Linux网络编程 - 套接字与协议族

一 理解网络编程和套接字(socket)

1.1 网络编程和套接字概要

        网络编程就是编程程序使两台连网的计算机可以互相交换数据。那么,这两台计算机之间用什么传输数据呢?首先需要物理连接。如今大部分计算机都已连接到庞大的互联网,因此不用担心这点。再此基础上,只需考虑如何编写数据传输软件。但实际上这也不用愁,因为操作系统会提供名为“套接字(socket)”的部件。套接字是网络数据传输时用的软件设备,我们可以通过套接字完成数据的网络传输,因此网络编程又称为套接字编程。

        那为什么要用“套接字(socket)”这个词呢?这其实是一种形象的类比,socket 的本意是插座的意思,我们把电器设备的插头插到插座上就能从电网获得电力供给,同样的道理,为了与远程计算机进行数据传输,需要连接到互联网上,而网络编程中的“套接字”就是计算机用来连接互联网的“插座”。套接字本身就带有“连接”的含义,如果将其引申,则还表示两台计算机之间的网络连接。

1.2 构建接电话套接字

        套接字大致分为两种,其中,先要讨论的TCP套接字可以比喻成电话机。实际上,电话机也是通过固定电话网(telephone network)完成语音数据交换的。因此,我们熟悉的固定电话与套接字实际并无太大区别。下面利用电话机讲套接字的创建及使用方法。

        电话机可以同时用来拨打或接听,但对套接字而言,拨打和接听是有区别的。我们先讨论用于接听的套接字创建过程。

1、调用socket函数(安装电话机)时进行的对话

  • 问:“接电话需要准备什么?”
  • 答:“当然是电话机!”

        有了电话机才能接听电话,接下来,我们首先需要安装一部电话机。下列函数创建过程就是相当于电话机的套接字。

  • socket() — 创建一个套接字
#include <sys/socket.h>

int socket(int af, int type, int protocol);

//函数参数说明
//af: 套接字中使用的协议族(Protocol family)信息
//type: 套接字数据传输类型信息,即套接字类型
//protocol: 计算机间通信中使用的传输协议信息,一般是TCP/UDP传输协议

//返回值: 成功时返回文件描述符,失败时返回-1

 《函数说明》

1、socket()函数的第1个参数af,表示套接字使用的是哪种地址族,也就是IP地址类型,常用的有 AF_INET 和 AF_INET6。AF 是“Address Family”的简写,INET是“Inetnet”的简写。AF_INET 表示 IPv4 地址,例如:127.0.0.1;AF_INET6 表示 IPv6 地址,例如:1030::C9B4:FF12:48AA:1A2B。

2、socket()函数的第2个参数type,表示数据传输方式/套接字类型,常用的有 SOCK_STREAM(流格式套接字/面向连接的套接字) 和 SOCK_DGRAM(数据报套接字/无连接的套接字)。

3、socket()函数的第3个参数protocol,表示数据传输方式,常用的有 IPPROTO_TCP 和 IPPTOTO_UDP,分别表示 TCP 传输协议和 UDP 传输协议。

int tcp_socket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);  //IPPROTO_TCP表示TCP协议,这种套接字称为 TCP 套接字
int udp_socket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);  //IPPROTO_UDP表示UDP协议,这种套接字称为 UDP 套接字

问题:有了地址类型和数据传输方式,还不足以决定采用哪种协议吗?为什么还需要第三个参数呢?

:一般情况下有了 af 和 type 两个参数就可以创建套接字了,操作系统会自动推演出传输协议类型,除非遇到这样的情况:有两种不同的协议支持同一种地址类型和数据传输类型。如果我们不指明使用哪种协议,操作系统是没办法自动推演的。如果 协议族af+套接字类型type 能唯一确定要使用的数据传输协议,则可以将第3个参数 protocol 的值设为 0,操作系统会自动推断出应该使用何种协议。代码如下:

int tcp_socket = socket(AF_INET, SOCK_STREAM, 0);  //创建TCP套接字
int udp_socket = socket(AF_INET, SOCK_DGRAM, 0);   //创建UDP套接字

《参考链接》

socket(2) - Linux man page

socket()函数用法详解:创建套接字

socket()函数介绍

Socket详解

        另外,我们用户只需要购买电话机,剩下的安装和分配电话号码等工作都由电信局的工作人员完成。而套接字需要我们自己安装,这也是套接字编程难点所在,但多安装几次就会发现其实不难。准备好电话机后要考虑分配电话号码的问题,这样别人才能联系到自己。

2、调用 bind()函数(分配电话号码)时进行对话

  • 问:“请问您的电话号码是多少?”
  • 答:“我的电话号码是123-1234。”

        套接字同样如此。就像给电话机分配电话号码一样(虽然不是真的把电话号码给了电话机),利用 bind()函数给创建好的套接字分配网络地址信息(IP地址和端口号)。

  • bind() — 给套接字分配网络地址信息(IP地址和端口号)
#include <sys/socket.h>

int bind(int sockfd, struct sockaddr *myaddr, socklen_t len);

//函数参数说明
//sockfd: 创建好的套接字对应的文件描述符,其值是操作系统分配的,具有唯一性
//myaddr: 指向描述网络地址信息结构体变量的指针
//len: 第二个参数指向的结构体变量的内存空间大小

//返回值: 成功时返回0,失败时返回-1

        调用bind()函数给套接字分配网络通信地址后,就基本完成了接电话的所有准备工作。接下来需要连接电话线并等待来电。

3、调用listen()函数(相当于连接电话线)时进行的对话

  • 问:“已架设完电话机后是否只需连接电话线?”
  • 答:“对,只需连接就能接听电话。”

        一旦连接好电话线,电话机就转为可接听状态,这时其他人可以拨打电话请求连接到该电话机。同样,我们编程人员需要手动把套接字转化为可接收连接的状态,这就需要使用 listen()函数来完成。

  • listen() — 将套接字设置为监听状态,即监听可能到来的连接请求。
#include <sys/socket.h>

int listen(int sockfd, int backlog);

//参数说明
//sockfd: 希望进入等待连接请求状态的套接字文件描述符,传递的描述符套接字参数成为服务器端套接字(即监听套接字)
//backlog: 最大连接请求等待队列(Queue)的长度,若为5,则等待队列长度为5,表示最多可以让5个连接请求进入等待队列

//返回值: 成功时返回0,失败时返回-1

        连接好电话线后,如果有人拨打电话就会响铃,拿起话筒就能接听电话。

4、调用 accept()函数(相当于拿起话筒)时进行的对话

  • 问:“电话铃响了,我该怎么办?”
  • 答:“难道你还不知道吗?拿起电话筒接听啊!”

        拿起电话筒意味着接受了对方的连接请求。套接字同样如此,如果有人为了完成数据传输而请求连接,就需要调用 accept()函数进行受理连接请求的操作。

  • accept() — 服务器端受理客户端连接请求
#include <sys/types.h>
#include <sys/socket.h>

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

//参数说明
//sockfd: 套接字对应的文件描述符
//addr: 保存发起连接请求的客户端地址信息的变量地址值,调用函数后向传递来的地址变量实参填充客户端地址信息
//addrlen: 第2个参数addr结构体的长度,指向存有长度变量的地址。函数调用后,该变量即被填入客户端地址长度

//返回值: 成功时返回0,失败时返回-1

网络编程中接受连接请求的套接字创建过程可整理如下:

第一步:调用socket()函数创建套接字。

第二步:调用bind()函数分配网络通信地址,即IP地址和端口号。

第三步:调用listen()函数转为可接收连接请求的状态。

第四步:调用accept()函数受理到来的连接请求。

掌握了这些步骤就相当于为套接字编程勾勒好了轮廓,后续步骤就是为此轮廓进一步着色了。

1.3 编写 “Hello,world” 服务器端程序

        服务器端(Server)是能够受理连接请求的程序。下面构建一个服务器端程序以验证之前提到的函数调用过程,该服务器端收到连接请求后向请求者返回 “Hello,world” 答复信息。hello_server.c 源程序代码如下:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

void error_handling(char *message);

int main(int argc, char *argv[])
{
    int serv_sock;
    int clnt_sock;

    struct sockaddr_in serv_addr;
    struct sockaddr_in clnt_addr;
    socklen_t clnt_addr_size;

    char message[]="Hello World!";
    
    if(argc!=2){
        printf("Usage : %s <port>\n", argv[0]);
        exit(1);
    }
    
    serv_sock=socket(PF_INET, SOCK_STREAM, 0);  //创建服务器端套接字,该套接字用于监听连接请求
    if(serv_sock == -1)
        error_handling("socket() error");
    
    //填充服务器端serv_addr网络地址信息
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family=AF_INET;
    serv_addr.sin_addr.s_addr=htonl(INADDR_ANY);
    serv_addr.sin_port=htons(atoi(argv[1]));
    
    if(bind(serv_sock, (struct sockaddr*) &serv_addr, sizeof(serv_addr))==-1 )
        error_handling("bind() error"); 
    
    if(listen(serv_sock, 5)==-1)           //最大监听连接数为5
        error_handling("listen() error");
    
    clnt_addr_size=sizeof(clnt_addr);  
    clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_addr,&clnt_addr_size);  //返回一个新的套接字文件描述符,这个套接字用于数据的收发处理
    if(clnt_sock==-1)
        error_handling("accept() error");  
    
    write(clnt_sock, message, sizeof(message));  //发送数据信息
    close(clnt_sock);                            //关闭收发套接字
    close(serv_sock);                            //关闭监听套接字
    return 0;
}

void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

《程序说明》

1、服务器端实现过程中先要创建套接字,但此时的套接字尚非真正的服务器端套接字。

2、为了完成套接字通信地址分配,需要初始化结构体变量并调用bind函数。

3、调用listen函数进入等待连接请求状态。连接请求等待队列的长度设置为5。此时的套接字才是服务器端TCP套接字。

4、调用accept函数从等待队列的对头取出一个客户端连接请求与其建立连接,并返回新创建的套接字文件描述符。另外,调用accept函数时若等待队列为空,则accept函数不会返回,直到队列中出现新的客户端连接请求。

5、调用write函数向客户端发送数据,注意此时使用的是新创建的套接字来完成数据传输操作的。

6、调用close函数关闭连接,监听套接字serv_sock和新创建的套接字clnt_sock都需要关闭。

1.4 构建打电话套接字

        服务器端创建的套接字又称为服务器端套接字或监听(listening)套接字。接下里介绍的套接字是用于请求连接的客户端套接字。客户端套接字的创建过程比创建服务器端套接字简单。

       拨打电话的操作,相当于客户端套接字请求连接的过程,使用的是 connect()函数来完成的。

  • connect() — 客户端向服务器端发起连接请求
#include <sys/types.h>
#include <sys/socket.h>

int connect(int sockfd, const struct sockaddr *serv_addr, socklen_t addrlen);

//参数说明
//sockfd: 套接字对应的文件描述符
//serv_addr: 保存目标服务器端网络地址信息的结构体变量的地址值
//addrlen: 以字节为单位传递已传递给第2个结构体参数serv_addr的地址变量长度

//返回值: 成功时返回0,失败时返回-1

 《函数说明》客户端调用 connect 函数后,发生以下情况之一才会返回(完成函数调用)

  • 服务器端接收了连接请求。
  • 发生断网等异常情况而中断连接请求。

        需要注意的是,所谓“接收连接”并不意味着服务器端调用了accept函数,其实是服务器端把连接请求信息记录到等待队列。因此connect函数返回后,并不会立即进行数据交换。

        客户端程序只有“调用socket()函数创建客户端套接字” 和 “调用connect()函数向服务器端发送连接请求” 这两个步骤,因此比服务器端简单。下面给出客户端的 hello_client.c 源程序,代码如下:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

void error_handling(char *message);

int main(int argc, char* argv[])
{
    int sock;
    struct sockaddr_in serv_addr;
    char message[30];
    int str_len;
    
    if(argc!=3){
        printf("Usage : %s <IP> <port>\n", argv[0]);
        exit(1);
    }
    
    sock=socket(PF_INET, SOCK_STREAM, 0);      //创建客户端套接字,用于连接请求和收发数据
    if(sock == -1)
        error_handling("socket() error");
    
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family=AF_INET;
    serv_addr.sin_addr.s_addr=inet_addr(argv[1]);
    serv_addr.sin_port=htons(atoi(argv[2]));
        
    if(connect(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr))==-1)   //向服务器端发起连接请求
        error_handling("connect() error!");
    
    str_len=read(sock, message, sizeof(message)-1);                          //接收来自服务器端的回复信息
    if(str_len==-1)
        error_handling("read() error!");
    
    printf("Message from server: %s \n", message);  
    close(sock);                                                             //关闭客户端套接字
    return 0;
}

void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

《程序说明》

1、客户端首先也是需要创建一个准备连接服务器端的套接字,此时创建的是TCP套接字。

2、结构体变量serv_addr中初始化IP地址和端口号。初始化值为目标服务器端套接字的IP地址和端口号。

3、调用connect()函数向服务器端发送连接请求。

4、成功建立TCP连接后,接收服务器端的数据。

5、接收数据完成后,调用close()函数关闭套接字,结束语服务器端的TCP连接。

1.5 在Linux平台下运行

在Linux平台下的C语言编译器是GCC(GNU Compiler Collection,GNU编译器集合)。

1、我们先对 hello_server.c 源文件进行编译。

gcc hello_server.c -o hserver

        编译 hello_server.c 源文件并生成可执行文件 hserver。该命令中的 -o 是用来指定可执行文件名的可选参数,因此,编译后将生成可执行文件 hserver。

2、再对 hello_client.c 源文件进行编译。

gcc hello_client.c -o hclient

3、运行上面两个可执行文件。

# 先执行服务器端程序
./hserver 9190
# 正常情况下,服务器端程序将停留在此状态,因为服务器端调用的accept()函数还未返回,进程处于挂起状态

# 然后在另一个终端窗口执行客户端程序
./hclient 127.0.0.1 9190
Message from server: Hello, world!
# 由此可查看到客户端接收到服务器端发来的回复消息,完成消息传输后,服务器端和客户端都停止运行。
# 执行过程中输入的 127.0.0.1 是运行示例用的本地计算机的IP地址,也叫回送地址。如果在同一台计算机中同
# 时运行服务器端和客户端程序,将采用这种连接方式。但果服务器端与客户端在不同的计算机中运行,则应采用
# 所在计算机的IP地址。例如,服务器端IP地址为 192.168.1.120,则客户端的执行命令为: ./hclient 192.168.1.120 9190

二 基于Linux的文件操作

        讨论套接字编程的过程中,突然谈及文件操作也许有些奇怪。但对Linux操作系统而言,套接字操作与文件操作没有区别,因为在Linux

系统中,一切皆文件,因而有必要了解文件操作。在Linux系统中,socket 也被认为是文件的一种,因此在网络数据传输过程中自然可以使用文件I/O的相关函数。Windows系统则与Linux系统不同,它是要区分socket和文件的。因此在WIndows系统中需要调用特殊的数据传输相关函数。

2.1 底层文件访问 和 文件描述符

  • 底层文件访问(Low-Level File Access),文件描述符(File Descriptor)

        “底层” 这个表达可以理解为“与标准无关的操作系统独立提供的”。如果想要使用Linux提供的文件I/O函数,首先应理解好文件描述符的概念。

        此处的文件描述符是操作系统分配给文件或套接字的整型值。实际上,学习C语言过程中用过的标准输入输出及标准错误在Linux系统中也被分配如下表中的文件描述符。

表2-1 分配给标准输入输出及标准错误的文件描述符
文件描述符对象
0标准输入:Standard Input
1标准输出:Standard Output
2标准错误:Standard Error

        文件和套接字一般都要经过创建过程才会被分配文件描述符。而表2-1中的3种输入输出对象即使未经过特殊的创建,程序开始运行后也会被自动分配文件描述符。

《知识延伸》文件描述符(文件句柄)

        学校附近有一个服务站,只需打一个电话就能复印所需论文。服务站有位常客叫小明,他每次都要求复印同一篇论文的其中一部分内容。

        “大叔你好!请帮我复印一下《关于随着高度信息化社会而逐步提升地位的触觉、知觉、思维、性格、智力等人类生活质量相关问题特性的人类学研究》这篇论文第26页到第30页。”

        这位小明同学每天这样打好几次电话,更雪上加霜的是语速还特别慢。终于有一天大叔说:“从现在开始,那篇论文就编号为第18号!你就说帮我复印第18号论文的第26页到第30页!”

        之后小明也是只复印超过50字标题的论文,大叔也会给每天论文分配无重复的新号(数字编号)。这就不会头疼于于小明的对话,且不影响正常的业务。

        在上面的示例中,大叔相当于操作系统,小明相当于程序员,论文编号相当于文件描述符,论文相当于文件或套接字,论文与论文号是一一对应的关系。也就是说,每当生成文件或套接字,操作系统将返回分配给它们的整数编号。这个整数编号将成为程序员与操作系统之间良好沟通的渠道。实际上,文件描述符只不过是为了方便称呼操作系统创建的文件或套接字而赋予的数而已。

        文件描述符有时也称为文件句柄,但“句柄”主要是Windows中的术语,其实二者是同一个概念,只是叫法不同而已。

2.2 打开文件

  • open() — 打开文件以读写数据的函数。
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int open(const char *pathname, int flags);

//参数说明
//pathname: 指向打开的目标文件名及路径信息的字符串地址
//flags: 文件打开模式信息

//返回值: 成功时返回文件描述符,失败时返回-1

表2-2 是此函数第二个参数flags可能的常量值及其含义。如果需要传递多个参数,则应通过位或运算(OR)符组合并传递。

表2-2 文件打开模式
打开模式含义
O_CREAT必要时创建文件
O_TRUNC删除全部现有数据
O_APPEND维持现有数据,追加保存到文件末尾
O_RDONLY只读打开
O_WRONLY只写打开
O_RDWR读写打开

2.3 关闭文件

  • close() — 关闭已打开的文件对应的文件描述符。
#include <unistd.h>

int close(int fd);

//参数说明
//fd: 需要关闭的文件或套接字的文件描述符

//返回值: 成功时返回0,失败时返回-1

《函数说明》调用此函数的同时传递文件描述符实参,则会关闭(终止)与该文件描述符相对应的文件。另外需要注意的是,此函数不仅可以关闭文件,还可以关闭套接字。这再次证明了“Linux操作系统不区分文件与套接字”的特点。

2.4 将数据写入文件

  • write() — 用于向文件写入数据。
#include <unistd.h>

ssize_t write(int fd, const void *buf, size_t nbytes);

//参数说明
//fd: 显示数据传输对象的文件描述符
//buf: 保存要写入的数据的缓存区地址
//nbytes: 要写入的数据字节数大小

//返回值: 成功时返回写入的字节数,失败时返回-1

        Linux系统中不区分文件和套接字,因此通过套接字向其他计算机传递数据时也会用到该函数。上面的“Hello,world”示例程序中也是通过调用该函数来传递字符串信息的。

《函数说明》此函数声明中,size_t 是通过 typedef 关键字声明的 unsigned int 类型;而 ssize_t 类型比 size_t 类型前面多加的一个字母 s 代表的是 signed,即 ssize_t 是通过 typedef 声明的 signed int 类型。

《知识补给》以 _t 为后缀的数据类型

        我们已经接触到 ssize_t、size_t、socklen_t 等陌生的数据类型。这些都是元数据类型(primitive),在 sys/types.h 头文件中一般由 typedef 关键字声明定义的,算是给大家熟悉的基本数据类型起了一个别名。既然已经有了基本数据类型,为何还要声明并使用这些新的数据类型别名呢?

        人们目前普遍认为 int 是32位的,因为主流操作系统和计算机仍采用32位。而在过去是16位操作系统,int 类型是16位的,而不久的将来64位的操作系统和CPU将成为主流。根据系统的不同、时代的发展,数据类型的表现形式也随之改变,需要修改程序中使用的数据类型。如果以前编写的代码中,已在需要声明4字节数据类型的地方使用了 size_t 或 ssize_t,则将大大减少代码的变动,因为我们只需要修改并编译 size_t 和 ssize_t 的 typedef 声明即可。在软件项目开发中,为了给基本数据类型赋予别名,一般会添加大量 typedef z声明。而为了与程序员定义的新数据类型加以区分,操作系统定义的数据类型会添加后缀 _t。

        一言以蔽之,使用 typedef 关键字声明基础数据类型的别名,是为了提高代码的兼容性、移植性和健壮性。

实例:下面通过一个示例程序帮助大家更好地理解前面讨论过的函数。此程序将创建一个新文件并将数据保存到文件中。

源文件:low_open.c

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>

void error_handling(char *message);

int main(int argc, char *argv[])
{
    int fd;
    char buf[] = "Let`s go!\n";

    fd = open("data.txt", O_CREAT|O_WRONLY|O_TRUNC);
    if(fd == -1){
        error_handling("open() error!");
    }
    printf("file descriptor: %d\n", fd);
    
    if(write(fd, buf, sizeof(buf)) == -1){
        error_handling("write() error!");
    }
    close(fd);
    return 0;
}

void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

编译程序:gcc low_open.c -o lopen

运行程序:./lopen

file descriptor: 3

查看文件内容:cat data.txt

Let`s go!

2.5 读取文件中的数据

  • read() — 用来读取文件(接收套接字)中的数据。
#include <unistd.h>

ssize_t read(int fd, void *buf, size_t nbytes);

//参数说明
//fd: 显示数据接收对象的文件描述符
//buf: 要保存接收数据的缓存区地址
//nbytes: 要接收数据的最大字节数

//返回值: 成功时返回实际接收到的字节数(但遇到文件结尾则返回0),失败时返回-1

实例:下面示例程序将通过read()函数读取 data.txt 文件中保存的数据内容。

源文件:low_read.c

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>

#define BUF_SIZE 100

void error_handling(char *message);

int main(int argc, char *argv[])
{
    int fd;
    char buf[BUF_SIZE]={0};

    fd = open("data.txt", O_RDONLY);
    if(fd == -1){
        error_handling("open() error!");
    }
    printf("file descriptor: %d\n", fd);
    
    if(read(fd, buf, sizeof(buf)) == -1){
        error_handling("read() error!");
    }
    printf("file data: %s", buf);
    close(fd);
    return 0;
}

void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

编译程序:gcc low_read.c -o lread

运行程序:./lread

file descriptor: 3

file data: Let`s go!

需要注意的是,Linux系统中基于文件描述符的I/O操作函数,同样使用于套接字。

2.6 文件描述符 与 套接字

实例:下面将同时创建文件和套接字,并用整数值形态比较返回的文件描述符的值。

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/socket.h>

int main(void)
{
    int fd1, fd2, fd3;
    fd1 = socket(PF_INET, SOCK_STREAM, 0);
    fd2 = open("test.dat", O_CREAT|O_WRONLY|O_TRUNC);
    fd3 = socket(PF_INET, SOCK_DGRAM, 0);
    
    printf("file descriptor 1: %d\n", fd1);
    printf("file descriptor 2: %d\n", fd2);
    printf("file descriptor 3: %d\n", fd3);
    
    close(fd1);
    close(fd2);
    close(fd3);
    
    return 0;
}

编译程序:gcc fd_seri.c -o fds

运行程序:./fds

file descriptor 1: 3

file descriptor 1: 4

file descriptor 1: 5

《程序说明》程序中创建了1个文件和2个套接字,从输出的文件描述符整数值可以看出,描述符从3开始以由小到大的顺序编号(numbering),因为 0、1、2 是分配给标准I/O的文件描述符。(见表2-1所示)

2.7 练习题

1、套接字在网络编程中的作用是什么?为何称它为套接字?

:网络编程就是编写程序让两台连网的计算机能相互交换数据。在我们不需要考虑物理连接的情况下,开发者只需要考虑如何编写数据传输的软件。操作系统提供了名为“套接字”的部件,它是网络进行数据传输时用到的软件设备。

socket 的英文原意是插座,我们把插头插到插座上就能从电网获得电力供给,同样的道理,为了与远程计算机进行数据交互,本地计算机需要连接到互联网上,而网络编程中的“套接字”就是用来连接互联网的工具,所以又称它为套接字。

2、在服务器端创建套接字后,会依次调用 listen 函数和 accept 函数。请比较并说明二者作用。

:listen()函数:将套接字转换为可接收连接的状态,此时服务器端程序处于监听状态。

accept()函数:受理连接请求,并且在没有接收到连接请求的情况下,该函数不会返回,此时调用该函数的进程处于挂起态,直到有连接请求到来时,该函数才会返回,进程恢复到运行态。二者的调用存在逻辑上的先后顺序:先执行listen()函数,然后再执行accept()函数。

3、Linux中,对套接字数据进行I/O时可以直接使用I/O相关函数;而在Windows中则不可以。原因为何?

:在Linux系统中,它是把套接字也看作是一种文件类型,所以可以使用文件I/O相关函数。而在Windows系统中,它是区分套接字和文件的,二者是属于两种不同的类型,因此在Windows系统中需要调用特殊的数据传输相关函数。

4、创建套接字后一般会给它分配地址,为什么?为了完成地址分配需要调用哪个函数?

:因为在计算机网络通信中需要识别出与之通信的目的主机的套接字,所以需要分配地址,包括IP地址和端口号,其中IP地址用于找到目的主机,端口号用于找到目的套接字。需要调用 bind()函数来完成网络地址信息的分配。

5、Linux中的文件描述符与Windows的句柄实际上非常类似。请以套接字为对象说明他们的含义。

:Linux系统中的文件描述符是操作系统为了区分指定套接字而分配给套接字的一个整数型编号;Windows系统中的句柄,也是操作系统为了区分不同系统资源而分配给指定资源的一个整数型编号,二者的含义相同,只是叫法不同而已。

6、底层文件I/O函数与ANSI标准定义的文件I/O函数之间有何区别?

:底层文件I/O函数,又称为系统I/O函数,它是由操作系统直接提供的I/O接口函数,不同的操作系统在具体实现上可能存在不同,系统I/O函数不带缓存机制,系统I/O函数可以操作所有文件类型,比如说套接字文件。

ANSI标准定义的文件I/O函数,又称为标准I/O函数,它是与操作系统无关的以标准C语言库函数的形式提供的I/O接口函数(通过封装底层操作系统提供的系统I/O函数,再给用户使用),标准I/O函数带缓存机制,标准I/O只可以操作普通文件。

7、参考本书给出的示例 low_open.c 和 low_read.c,分别利用底层文件I/O和ANSI标准I/O编写文件复制程序。可任意指定复制程序的使用方法。

(1)利用底层文件I/O编写文件复制程序。源文件:low_cpy.c

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

#define BUF_SIZE 100

int main(int argc, char *argv[]) 
{
    int src, dst;
    int read_cnt;
    char buf[BUF_SIZE];
    
    src=open("src.dat", O_RDONLY);                    //只读模式打开原始文件
    dst=open("dst.dat", O_CREAT|O_WRONLY|O_TRUNC);    //创建|只写|删除全部现有数据模式打开文件
    if(src==-1||dst==-1)
    {
        puts("file open error");
        return -1;
    }
    
    while((read_cnt=read(src, buf, BUF_SIZE))!=0)     //将原文件内容读取到buf缓存区
        write(dst, buf, read_cnt);                    //将buf缓存区中的内容写入目标文件

    close(src);                                       //关闭原文件文件描述符
    close(dst);                                       //关闭目标文件文件描述符
    return 0;
}

(2)利用 ANSI 标准I/O函数编写文件复制程序。源文件:ansi_cpy.c

#include <stdio.h>

#define BUF_SIZE  30

int main(void)
{
    char buf[BUF_SIZE];
    int readCnt;

    FILE *src=fopen("src.dat", "rb");    //以只读模式打开二进制文件(原文件)
    FILE *des=fopen("dst.dat", "wb");    //以只写模式打开二进制文件,如果文件不存在则新建(目标文件)
    
    if(src==NULL || des==NULL)
    {
        puts("file open error");
        return -1;
    }

    while(1)
    {
        readCnt=fread((void*)buf, 1, BUF_SIZE, src);   //读取BUF_SIZE个元素到buf内存空间中,其中每个元素的大小为1字节

        if(readCnt<BUF_SIZE)                           //如果实际读取的元素个数小于BUF_SIZE,执行if语句块
        {
            if(feof(src)!=0)                           //如果读到文件末尾,则退出while循环,否则继续写入
            {
                fwrite((void*)buf, 1, readCnt, des);   //将buf中的内容希尔写入到目标文件中,每次写入readCnt个元素,每个元素大小为1字节
                break;
            }
            else
                puts("file cpy error()");

            break;
        }
        fwrite((void*)buf, 1, BUF_SIZE, des);
    }

    fclose(src);
    fclose(des);
    return 0;
}

三 套接字协议及其数据传输特性

3.1 关于协议的理解

  • 协议(Protocol)

        如果相隔很远的两个人想要展开对话,必须先决定对话方式。如果一方使用电话,那么另一方也只能使用电话,而不是书信。可以说,电话就是两人对话的协议。协议就是对话中使用的通信规则,把上述概念拓展到计算机网络领域可理解为“计算机间进行对话的通信规则”。

        简而言之,协议就是控制两个对等实体(或多个实体)进行通信(数据交换)而约定好的规则的集合。网络协议由语法、语义和同步三个要输组成的,其各自的含义如下:

  • 语法:即数据与控制信息的结构或格式。语法方面的规则定义了所交换的信息的格式。
  • 语义:即需要发出何种控制信息,完成何种操作以及做出何种响应。语义方面的规则就定义了发送者或接收者所要完成的操作,例如,在何种情况下:数据必须重传或丢弃。
  • 同步:即事件实现顺序的详细说明。

        在协议的控制下,两个对等实体间的通信使得本层能够向上一层提供服务(Service)。要实现本层协议,还需要使用下面一层所提供的服务。

        一定要弄清楚,协议和服务在概念上是很不一样的。协议与服务的概念区别如下:

  • 首先,协议的实现保证了能够向上一层提供服务。使用本层服务的实体只能看见服务而无法看见下面的协议。下面的协议对上面的实体是透明的。
  • 其次,协议是“水平的”,即协议是控制对等实体之间通信的规则。但服务是“垂直的”,即服务是由下层向上层通过层间接口提供的。上层使用下层所提供的服务必须通过与下层交换一些命令,这些命令在 OSI(Open Systems Interconnection,开放系统互连) 中称为服务原语。另外,需要注意的是,并非在一个层内完成的全部功能都称为服务,只有那些能够被高一层实体“看得见”的功能才能称之为“服务”。

        计算机网络的协议还有一个很重要的特点,就是协议必须把所有不利的条件事先都考虑到,而不能假定一切都是正常的和非常理想的情况。看一个计算机网络协议是否正确,不能只看在正常情况下是否正确,而且还必须非常仔细地检查这个协议能否应付各种异常情况

3.2 协议族(Protocol Family)

        套接字通信中的协议是有分类的,通过 socket() 函数的第一个参数传递套接字中使用的协议分类信息。此协议分配信息称为协议族,可分成如下几类。

表3-3 头文件sys/socket.h中声明的协议族
名称协议族
PF_INETIPv4 互联网协议族
PF_INET6IPv6 互联网协议族
PF_LOCAL本地通信的UNIX协议族
PF_PACKET底层套接字的协议族
PF_IPXIPX Novell 协议族

《说明1》UNIX系统中的sys/socket.h头文件是以 PF_ 为前缀的,而Linux系统中的sys/socket.h头文件是以 AF_ 为前缀的,但含义是相同的。

《说明2》我们着重了解的是表3-3中 PF_INET 对应的IPv4互联网协议族,当然IPv6协议族也很重要。另外,套接字中实际采用的最终传输协议信息是通过socket()函数的第三个参数传递的。详情请参见上文的socket()函数介绍。

3.3 套接字类型(Type)

        套接字类型,指的是套接字的数据传输方式,通过 socket()函数的第2个参数传递,只有这样才能决定创建的套接字的数据传输方式。这种说法可能会使各位感到疑惑,已通过第1个参数传递了协议族信息,还要决定数据传输方式?问题在于,决定了协议族并不能同时决定数据传输方式,换言之,socket()函数第1个参数 PF_INET 协议族中也存在多种数据传输方式。

        常见套接字类型及其含义,请见下表所示。

表3-4 套接字类型及其含义
名称含义
SOCK_STREAMTCP连接,提供序列化的、可靠的、全双工的,基于连接的字节流。可以支持带外数据传输机制
SOCK_DGRAM支持UDP(固定最大长度的无连接、不可靠消息)
SOCK_SEQPACKET为固定最大长度的数据报提供有序的、可靠的、基于双向连接的数据传输路径;使用者需要在每次输入系统调用时读取整个数据包。
SOCK_RAWRAW类型,提供原始网络协议访问
SOCK_RDM提供不保证排序的可靠数据报连接
SOCK_PACKET用于直接从设备驱动程序接收原始数据包,已过时,不应在新程序中使用

下面我们主要介绍最常用的两种数据传输方式,SOCK_STREAM 和 SOCK_DGRAM。

  • 套接字类型1:面向连接的套接字(SOCK_STREAM)

        如果向socket()函数的第2个参数传递 SOCK_STREAM,将创建面向连接的套接字。面向连接的套接字到底具有哪些特点呢?

  • 提供可靠的数据传输服务,能够确保数据传输的正确性
  • 数据按序到达,不会出现丢失或乱序
  • 采用字节流的方式,即以字节为单位传输字节序列,因此传输的数据不存在数据边界(Boundary)

        例如,发送数据一方的计算机通过3次调用write函数传递了100字节的数据,但接收数据一方的计算机仅通过一次read函数调用就可以接收全部100个字节。

        收发数据的套接字内部有缓冲区(buffer),简言之就是字节数组。通过套接字传输的数据保存到该数组中。因此,收到数据并不意味着马上调用read函数。只要不超过数组容量大小,则有可能在数据填充满缓冲区后只需通过1次read函数调用就能读取全部数据,也有可能分成多次read函数调用进行读取。也就是说,在面向连接的套接字中,read函数和write函数的调用次数并无太大意义。所以说,面向连接的套接字不存在数据边界。

《知识补充》套接字缓冲区已满是否意味着数据丢失?

问题:为了接收数据,套接字内部有一个有字节数组构成的缓冲区。如果这个缓冲区被接收的数据填满了会发生什么事情?之后传递过来的数据是否会丢失?

:首先,调用read函数会从缓冲区中读走部分数据,因此缓冲区并不总是满的。但如果read函数读取速度比接收数据的速度慢,则缓冲区有可能被填满。此时套接字无法再接收数据,但即使这样也不会发生数据丢失,因为发送端套接字将停止发送数据。也就是说,面向连接的套接字会根据接收端的状态发送数据,如果发送出错还会提供重传服务。因此,面向连接的套接字除特殊情况外,不会发生数据丢失。

        还有一点需要强调的是,面向连接的套接字只支持一对一的连接方式,即面向连接的套接字只能与另外一个同样特性的套接字连接。用一句话概括面向连接的套接字就是:“可靠的、按序到达的、基于字节流的面向连接的数据传输方式的套接字”。

  • 套接字类型2:面向消息的套接字(SOCK_DGRAM)

        如果向socket()函数的第2个参数传递 SOCK_DGRAM,则将创建面向消息的套接字。面向消息的套接字可以比喻成高速移动的摩托车快递。它的特点如下:

  • 强调快速传输而非按序传输
  • 传输的数据可能丢失也有可能损坏
  • 传输的数据有数据边界
  • 限制每次传输的数据大小

        众所周知,快递行业的速度就是生命。用摩托车发送同一目的地的2件包裹无需保证顺序达到,只要以最快速度交给客户即可。这种方式存在丢失或损坏的风险,而且包裹大小有一定限制。因此,若要传递大量包裹,则需分批发送。另外,如果用两辆摩托车分别发送两件包裹,则接收者也需要分2次接收。这种特性就是 “传输的数据具有数据边界”。

        面向消息的套接字比面向连接的套接字具有更快的传输速度,但无法避免数据的丢失或损坏。另外,每次传输的数据大小具有一定限制,并存在数据边界。存在数据边界意味着数据接收的次数应和数据发送次数相同。用一句概括面向消息的套接字就是:“不可靠的、不按序到达的、以数据的高速传输为目的的套接字”。

        另外,需要注意的是,面向消息的套接字虽然不存在连接的概念的,但是仍然支持使用连接的方式进行通信。

3.4 协议的最终选择

        下面介绍的是socket()函数的第3个参数,该参数决定了最终采用的传输协议。我们可能会有疑问,前面已经通过socket()函数的前两个参数传递了协议族信息和套接字数据传输方式(套接字类型),这两个参数信息还不足以决定采用的传输协议吗?为什么还需要传递第3个参数呢?

        正如我们所想,传递前两个参数即可创建所需的套接字。所以大部分情况下可以向第3个参数传递0,除非遇到这种情况:“同一协议族中存在多个传输方式相同的协议”。数据传输方式相同,但协议不同。此时就需要通过第3个参数具体指定协议信息了。

  • 创建 IPv4协议族中面向连接的套接字
int tcp_socket = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);

《代码说明》实参 PF_INET 表示使用的IPv4网络协议族,SOCK_STREAM 表示面向连接的数据传输方式。满足这两个条件的传输协议只有 IPPROTO_TCP,因此,上面的语句也可以写成:int tcp_socket = socket(PF_INET, SOCK_STREAM, 0);  操作系统会自动推断出应该使用何种协议。使用TCP传输协议的套接字称为TCP套接字。

  • 创建 IPv4协议族中面向消息的套接字
int udp_socket = socket(PF_INET, SOCK_DGRAM, IPPROTO_UDP);

《代码说明》SOCK_DGRAM 表示的是面向消息的数据传输方式,满足上述条件的协议只有 IPPROTO_UDP。因此,上面的语句也可以写成:

int udp_socket = socket(PF_INET, SOCK_DGRAM, 0);

3.5 面向连接的套接字:TCP套接字示例

将上文中的1.3节中的“Hello,world”示例程序作简要修改即可。

  • hello_server.c  —> tcp_server.c        无变化!
  • hello_client.c   —> tcp_client.c          更改read函数调用方式!

上文1.3节中的hello_server.c 和 hello_client.c 是基于TCP套接字的示例,现调整其中一部分代码,以验证TCP套接字的如下特性:

“传输的数据不存在边界。”

为验证这一点,需要让write函数的调用次数不同于read函数的调用次数。因此,在客户端中分多次调用read函数以接收服务器端发送的全部数据。

  • tcp_client.c 源文件的代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/types.h>
#include <sys/socket.h>

void error_handling(char *message);

int main(int argc, char* argv[])
{
    int sock;
    struct sockaddr_in serv_addr;
    char message[30];
    int str_len=0;
    int idx=0, read_len=0;
    
    if(argc!=3){
        printf("Usage : %s <IP> <port>\n", argv[0]);
        exit(1);
    }
    
    sock=socket(PF_INET, SOCK_STREAM, 0);            //创建TCP套接字
    if(sock == -1)
        error_handling("socket() error");
    
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family=AF_INET;
    serv_addr.sin_addr.s_addr=inet_addr(argv[1]);
    serv_addr.sin_port=htons(atoi(argv[2]));
        
    if(connect(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr))==-1) 
        error_handling("connect() error!");

    while(read_len=read(sock, &message[idx++], 1))   //每次调用read函数,从套接字接收缓冲区中读取1个字节的数据到message字符数组中
    {
        if(read_len==-1)
            error_handling("read() error!");
        
        str_len+=read_len;
    }

    printf("Message from server: %s \n", message);
    printf("Function read call count: %d \n", str_len);
    close(sock);
    return 0;
}

void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

 与该示例配套的服务器端程序 tcp_server.c 与 hello_server.c 完全相同,故省略其源代码。执行方式也与hello_server.c 和 hello_client.c 相同。

编译程序:gcc tcp_client.c -o hclient

运行程序:./hclient 127.0.0.1 9190

Message from server: Hello World!

Function read call count: 13

《结果分析》从运行结果可以看出,服务器端发送了13个字节的数据,客户端调用了13次read函数进行读取。由此我们可以知道面向连接的数据传输方式,其传输的数据是不存在数据边界的。

四 习题

1、什么是协议?在收发数据中定义协议有何意义?

:协议是为了完成数据交换而约定好的通信规则的集合。定义协议的意义是定义发送和接收数据时所需的承诺。

2、面向连接的TCP套接字传输特性有3点,请分别说明。

:(1)提供可靠的传输服务;(2)数据按序到达;(3)传输的数据不存在数据边界。

3、下面那些是面向消息的套接字的特性?

a. 传输数据可能丢失

b. 没有数据边界(Boundary)

c. 以快速传递为目标

d. 不限制每次传递数据的大小

e. 与面向连接的套接字不同,不存在连接概念

:a, c, e

4、下列数据适合用哪类套接字进行传输?并给出原因。

a. 演唱会现场直播的多媒体数据

b. 某人压缩过的文本文件

c. 网上银行用户与银行之间的数据传递

:a(UDP):因为多媒体数据即视频数据,它需要实时快速地传输数据,因此发送方的发送速率必须是恒定的,所以使用UDP更合适。

b(TCP):压缩过的文本文件,为了保证传输过程中数据的正确性和完整性,需要使用面向连接的TCP协议。

c(TCP):用户与银行的数据传递,数据内容非常重要,为了保证传输的数据不会出现丢失或损坏的情况,需要使用面向连接的TCP协议。

5、何种类型的套接字不存在数据边界?这类套接字接收数据时需要注意什么?

:面向连接的套接字(TCP套接字)不存在数据边界。由于面向连接的套接字传输的数据没有边界的限制,因此收发函数的调用次数不具有任何意义。重要的不是函数的调用次数,而是数据的发送量。因此,程序员必须编写代码,确保发送的数据量和接收的数据量是一致的,特别是不能编写依赖函数调用次数的代码。

6、tcp_server.c 和 tcp_client.c 中需要多次调用read函数读取服务器端调用1次write函数传递的字符串。更改程序,使服务器端多次调用(次数自拟)write函数传递数据,客户端调用1次read函数进行读取。为达到这一目的,客户端需延迟调用read函数,因为客户端要等待服务器端传输完所有数据。Windows和Linux都通过下列代码延迟read或recv函数的调用。

for(i=0; i<3000; i++)
    printf("wait time %d\n", i);

让CPU执行多余任务以延迟代码运行的方式称为“忙等(Busy Waiting)”。使用得当即可推迟函数调用。

:(1)修改后的服务器端程序 tcp_serv.c,代码如下:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/types.h>
#include <sys/socket.h>

void error_handling(char *message);   //实现代码同上,故省略

int main(int argc, char *argv[])
{
    int serv_sock;
    int clnt_sock;

    struct sockaddr_in serv_addr;
    struct sockaddr_in clnt_addr;
    socklen_t clnt_addr_size;

    char message[]="Hello World!";
    
    if(argc!=2){
        printf("Usage : %s <port>\n", argv[0]);
        exit(1);
    }
    
    serv_sock=socket(PF_INET, SOCK_STREAM, 0);
    if(serv_sock == -1)
        error_handling("socket() error");
    
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family=AF_INET;
    serv_addr.sin_addr.s_addr=htonl(INADDR_ANY);
    serv_addr.sin_port=htons(atoi(argv[1]));
    
    if(bind(serv_sock, (struct sockaddr*) &serv_addr, sizeof(serv_addr))==-1)
        error_handling("bind() error"); 
    
    if(listen(serv_sock, 5)==-1)
        error_handling("listen() error");
    
    clnt_addr_size=sizeof(clnt_addr);  
    clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_addr,&clnt_addr_size);
    if(clnt_sock==-1)
        error_handling("accept() error");  
    
    //分4次发送,每次最多发送4字节数据
    write(clnt_sock, message, 4);
    write(clnt_sock, message+4, 4);
    write(clnt_sock, message+8, 4);
    write(clnt_sock, message+12, sizeof(message)-12);

    close(clnt_sock);
    return 0;
}

(2)修改后的客户端程序 tcp_clnt.c,代码如下:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/types.h>
#include <sys/socket.h>

void error_handling(char *message);   //实现代码同上,故省略

int main(int argc, char* argv[])
{
    int sock;
    struct sockaddr_in serv_addr;
    char message[30];
    int str_len=0;
    int idx=0, read_len=0, i;
    
    if(argc!=3){
        printf("Usage : %s <IP> <port>\n", argv[0]);
        exit(1);
    }
    
    sock=socket(PF_INET, SOCK_STREAM, 0);
    if(sock == -1)
        error_handling("socket() error");
    
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family=AF_INET;
    serv_addr.sin_addr.s_addr=inet_addr(argv[1]);
    serv_addr.sin_port=htons(atoi(argv[2]));
        
    if(connect(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr))==-1) 
        error_handling("connect() error!");

    for(i=0; i<100; i++)        // busy waiting!!
        printf("Wait time %d\n", i);

    read(sock, message, sizeof(message));
    printf("Message from server: %s \n", message);
    close(sock);
    return 0;
}

参考

《TCP-IP网络编程(尹圣雨)》第1、2章 - 理解网络编程和套接字、套接字类型与协议设置

《计算机网络(第7版-谢希仁)》第1章 - 概述

《计算机网络》谢希仁第七版课后答案完整版

《计算机网络》谢希仁第七版-博客专栏

  • 0
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值