在学习了MOOC计算机网络课程(华南理工)中第六章附录中的套接字编程入门后(linux环境下的TCP套接字),将一些基础知识和学习笔记记录在这里,并对课程中的代码做了修改,实现一个服务端可以同时连接多个客户端。(原课程地址可以参考这里,这是上一次开课的视频,因为这学期最新的课程中还没有讲到这里。2020.5.11)
Linux中有着“一切皆文件”的思想:
即系统中的所有事物都可以当成文件(windows中不是文件的东西,比如进程、硬盘、显示器等,在linux中都被抽象成了文件)。这么做虽然对于普通用户来说不太友好,但是对开发者很方便,因为这样做屏蔽了硬件的区别,所有设备都抽象成文件,提供统一的接口给用户,你可以根据它们的目录,使用同一套API(open、close,write,read函数)来访问它们。
套接字(socket)是什么:
我们知道传输层实现端到端的通信,而套接字(socket)就作为传输层连接的这个端点,它有一个自己的主机IP地址和一个主机端口号。TCP/IP的核心内容被封装在操作系统中,我们如果要进行网络编程,就需要用到操作系统提供的套接字(socket)接口。
对于Unix/Linux而言,套接字(socket)可以理解成是Unix/Linux中设定的一种特殊的文件,我们可以用open()、close()、write()、read()这种对于文件而言统一的API来访问它们,操作系统还为我们提供了一些对于socket而言常用的接口函数,如socket()、bind()、listen()、connect()等。
总结:套接字(socket)可以理解成是网络编程中的API。具体到Linux系统中,它被封装成一种特殊的文件来使用,并提供了一系列操作这种文件的函数。
(说一句题外话,socket一词在计算机硬件领域中泛指插座,在软件领域中就指我们上文所讲的内容了,有一定的比喻的意思,表达建立网络连接就跟插插座一样。只是我觉得把socket翻译成“套接字”过于僵硬了。。具体关于socket名字的来源,可以参考这个回答。
文件描述符是什么:
对于 Linux 的每一个进程,在内核空间都有一个与之对应的 PCB 进程控制块,PCB 内部有一个文件描述符表(File descriptor table),记录着当前进程所有可用的文件描述符,也即当前进程所有打开的文件。除了文件描述符表,系统还需要维护另外两张表:打开文件表(Open file table)和 i-node 表(i-node table)。
文件描述符表每个进程都有一个,打开文件表和 i-node 表整个系统只有一个,它们三者之间的关系如下图所示(左边是文件描述符表)。
如图可以发现这三种表都是结构体数组,0,1,2,3等是数组的下标。表头只是作者自己添加的注释,数组本身是没有的。实线箭头表示指针的指向。
通过上图我们可以发现,文件描述符就是一个数组的下标,是文件描述符表的索引。通过文件描述符这个下标,可以找到对应的文件指针,通过该指针可以进入打开文件表。该表存储了以下信息:
1.文件偏移量,也就是文件内部指针偏移量。调用 read() 或者 write() 函数时,文件偏移量会自动更新,当然也可以使用 lseek() 直接修改。
2.状态标志,比如只读模式、读写模式、追加模式、覆盖模式等。
3.i-node 表指针。
然而,要想真正读写文件,还得通过打开文件表的 i-node 指针进入 i-node 表,该表包含了诸如以下的信息:
1.文件类型,例如常规文件、套接字或 FIFO。
2.文件大小。
3.时间戳,比如创建时间、更新时间。
4.文件锁。
(这部分参考来自于https://blog.youkuaiyun.com/wan13141/article/details/89433379)
课件视频中的内容:
课件中简单介绍了socket的几种类型、客户端和服务端要完成的工作、函数调用的顺序等,在这里粘贴过来了一些关键部分的截图,具体可参考课件视频的讲解。
socket的类型:
服务器和客户端要做的工作如下:
对应的函数调用顺序如下:
要用到的函数:
这里简单介绍一下C语言中操作socket文件的一些函数,以及select函数。
socket函数:
int socket (int domain, int type, int protocol)
该函数类似普通文件的open操作,该函数返回打开的socket的文件描述符,后续的各种操作几乎都要用到这个描述符。
第一个参数domain,取值AF_INET时代表IPv4协议,取值AF_INET6时代表IPv6协议。
第二个参数type,表示套接字的类型。取值SOCK_STREAM时表示字节流套接字,取值SOCK_DGRAM时表示数据报套接字,取值SOCK_RAW时表示原始套接字。
第三个参数protocol,取值IPPROTO_TCP时表示TCP传输协议,取值IPPROTO_UDP时表示UDP传输协议。
返回值:成功时返回一个非负整数值,表示socket的文件描述符。
bind函数:
int bind(int fd, const struct sockaddr *addr, socklen_t addrlen);
将协议地址(32位的IPv4地址/或128位的IPv6地址 和 16位的端口号)与套接字绑定在一起,即把地址赋予给套接字。该函数一般在服务器端使用。
第一个参数fd,是套接字socket的文件描述符。
第二个参数addr,是一个指向sockaddr结构类型的地址变量的指针
第三个参数addrlen,是这个地址变量的长度。
返回值:返回0表示成功,返回-1表示不成功。
注意:这里要说一下第二个参数里的地址结构类型。对于IPv4而言,我们用的是struct sockaddr_in类型的变量,该结构定义在头文件<netinet/in.h>中,具体如下:
struct sockaddr_in {
uint8_t sin_len;
sa_family_t sin_family;
in_port_t sin_port;
struct in_addr sin_addr;
char sin_zero[8];
};
struct in_addr {
in_addr_t s_addr;
};
下面解释下ipv4套接字地址结构中每个字段的意义:
sin_len:无符号短整数,用来指明套接字地址结构的长度。
sin_family:无符号整数,用来指明套接字地址结构的地址族。 需要主动设置。AF_INET时表示采用IPv4协议。
sin_port:无符号的16位整数,用来指明TCP或UDP的端口号. 需要主动设置。
sin_addr:下面声明的结构体,用来存放IPV4的32-bit地址. 需要主动设置。
sin_zero[8]:char类型。填充位,用来保证struct sockaddr_in和struct sockaddr size一致。编程时不需要填写。
我们首先需要定义这个sockaddr_in类型的地址变量,如下图代码。
由于输入32位ip地址太复杂了,直接输入点分十进制的ip地址较为简便,所以我们可以将我们输入的点分十进制地址(argv[1])通过inet_addr函数转化为32位地址。
还有一个问题就是字节序的问题。字节序就是我们平常说的大端和小端模式,小端模式就是低位字节排放在内存的低地址端,大端模式反之。由于TCP/IP首部中所有的二进制整数在网络中传输时都是以大端模式的字节序(因此网络字节序是大端模式),所以我们要将主机字节序的端口号转换成为网络字节序的大端模式(如果你不确定你的主机字节序与网络字节序相同的话)。转换的方法是:首先argv[2]是我们输入的第二个参数即端口号,这是一个字符数组的类型,首先通过atoi函数将其转化成int类型,然后再通过htons函数将它从主机字节序转变成网络字节序(如下图)。
struct sockaddr_in ser;
ser.sin_family=AF_INET;//采用ipv4协议
ser.sin_port=htons(atoi(argv[2]));
ser.sin_addr.s_addr=inet_addr(argv[1]);
在我们的ipv4套接字地址结构中,用到的是struct sockaddr_in类型,而其它的协议会有不同的地址结构类型,也就是说存在多种地址结构类型,而bind函数中的第二个参数指定的只有一种结构类型,是sockaddr结构类型(这是一种通用的结构类型),因此我们用到bind函数时需要对第二个参数进行强制类型转换。这样设计的意义就在于可以处理各种协议类型的地址结构,从而做到“协议无关性”。
listen函数:
int listen (int fd, int n)
该函数只在服务器调用,该函数可以把套接字从CLOSED状态变成LISTEN状态,指示内核应接受指向该套接字的连接请求,此时该套接字就准备好监听来自客户端的请求了。
第一个参数fd,是要进行监听的socket的文件描述符。
第二个参数n,规定了内核应该为相应套接字socket排队的最大连接个数。具体来讲,内核为每个监听套接字维护两个队列:未完成连接队列(每个正处于三次握手中的TCP连接,套接字处于SYN_RCVD状态)和已完成连接队列(每个已经完成三次握手的TCP连接,套接字处于ESTABLISHED状态)。这两个队列之和不能超过n,否则无法建立TCP连接。SYN Flood攻击就是利用的这一点。
返回值:成功返回0,失败返回-1
connect函数:
int connect (int fd, const struct sockaddr * addr, socklen_t addrlen)
该函数在客户端使用,客户端通过该函数建立和TCP服务器的连接。一个注意的点是该函数参数中的地址要用服务器的地址。那么客户端本机地址什么时候输入呢?这个地址一般是由内核自动确定的,而不需要再用bind函数。
第一个参数fd,是套接字的文件描述符
第二个参数addr,是指向结构地址的指针,这里跟bind函数那里一样,需要做强制类型转换。
第三个参数addrlen,是这个地址变量的长度。
返回值:成功返回0,失败返回-1
accept函数:
int accept (in fd, struct sockaddr * addr, socklen_t *addrlen)
该函数的功能是返回已完成的连接。在客户端依次调用socket()、connect()之后就向服务器发送了一个连接请求。TCP服务器监听到这个请求并完成连接之后,accept()函数就可以返回这个已完成的连接。如果此时的已完成连接队列为空,即如果没有已完成的连接,那么会进入阻塞状态,直到有连接已完成。
第一个参数fd,是服务器套接字的文件描述符
第二个参数addr,是指向struct sockaddr结构类型的一个指针,这个结构会用来装等会返回的客户端的协议地址(该结构需要提前声明定义一下,内容可以不用填。注意声明的时候用IPv4地址结构的类型,即sockaddr_in类型的地址变量,然后在accept函数中强制转换成sockaddr类型)。
第三个参数addrlen,这是一个地址长度的变量的指针,该指针会用来指向等会返回的客户端的地址的长度。如果对返回的客户端的协议地址不感兴趣,那么可以把第二个和第三个参数设成NULL。在课件视频中其实并没有用到返回的客户端的地址,所以视频中代码处可以改成NULL。
返回值:如果成功则返回一个非负整数,是一个新的已连接套接字的文件描述符,这个新的套接字代表服务端与客户端之间的连接。失败时返回-1。
注意:服务器一开始通过socket函数建立的那个套接字,是监听套接字,它只负责监听有无客户端的连接请求,该套接字只有一个。如果accept函数成功返回,则新创建一个已连接套接字,它表示服务端与客户端的连接,这种套接字可以有很多个,取决于有多少个客户端连接到服务器。
close函数:
int close (int fd)
参数fd是文件描述符。
该函数可以用来关闭套接字,终止TCP连接。在多进程并发的服务器中,该函数只是将套接字的描述符引用计数减1。
那么引用计数是什么呢?如果在父进程里创建一个新的描述符,那么它的引用计数是1,如果有一个子进程继承了父进程的这个描述符的话,那么引用计数就会增加1。
如果只在父进程中创建了这个描述符而没有子进程继承的话,那么它的引用计数是1,此时调用一次close函数就会把引用计数减1变成0,只有当引用计数为0的时候,才会触发TCP客户端向服务器发送终止连接请求。
所以要尤其注意子进程会继承父进程的描述符,所以在多进程编程中可能需要多次使count–,或者使用shutdown()函数直接执行TCP终止。
返回值:成功返回0,失败返回-1。
通过前面的那几个函数,我们已经可以建立服务器和客户端之间的连接了,那么就可以进行网络I/O读写操作了,进行读写操作主要用到的是read()、write()等函数,在这里作简要介绍。
read函数:
ssize_t read(int fd, void *buf, size_t count);
其中fd是要读取文件的文件描述符,buf是把文件内容要读到的缓冲区,是一个指针,count是请求读取的字节数。正常情况下会返回读到的字节数,是ssize_t类型,即有符号整型(在32位机器上等同与int,在64位机器上等同与long int);如果返回0则说明读取时,读写指针已经达到文件末尾;如果返回-1则读取失败。
注意:对于这类函数,读写时,文件读写指针( 记录文件中读到的位置 )会随读到的字节移动,如果读完一个文件之后,读写指针就会移动到文件末尾,如果要再次重头读的话,需要用fseek函数来把读写指针移动到文件的开头处。
write函数:
ssize_t write(int fd, const void *buf, size_t count);
其中fd是要进行写操作的文件的文件描述符,buf是需要输出的缓冲区,是一个指针,count是写入的字节数。正常情况下返回写入的字节数,出错则返回-1。
recv和send函数:
它们提供了和read和write差不多的功能,不过它们提供了第四个参数来控制读写操作,更适用于套接字的通信。(课件视频中用到的就是recv和send函数,如果把它们替换成read和write函数也是完全可以的)
int recv(int sockfd,void *buf,int len,int flags)
int send(int sockfd,void *buf,int len,int flags)
前面的三个参数和read,write一样,第四个参数可以是0或者是以下的组合:
MSG_DONTROUTE 不查找表
MSG_OOB 接受或者发送带外数据
MSG_PEEK 查看数据,并不从系统缓冲区移走数据
MSG_WAITALL 等待所有数据
如果flags为0,则和read,write一样的操作(read对应于recv,write对应于send)。
注意一下返回值,recv函数正常情况下跟read函数一样返回读取的字节数,但如果recv返回0说明在等待协议接收数据时网络中断了,这是与read函数所不同的地方。对于send函数,正常情况下返回值是跟write函数一样的,如果返回0说明写入了0个字节,即写入的内容为空。
最后说一下select函数(较为复杂)
select函数:
该函数主要用于我们的socket通信当中,这个函数可以监控指定文件的读写情况。比如当服务器给客户端发了一条消息,客户端的select函数就监控到这个套接字文件可以读了(因为服务器要想发消息给客户端,就要调用send函数对套接字文件进行写操作,套接字文件被写入了东西就可以读了,于是被select监控到)。
int select (int maxfdp, fd_set* readfds, fd_set* writefds, fd_set* errorfds, struct timeval* timeout)
该函数一共有五个参数。
第一个参数是一个int类型,它要比你要监控的最大的描述符至少大1(等会会讲为什么)。
第二、三、四个参数,代表集合,即三个集合,这些集合用来装你要监控的文件描述符。如果要监控一个文件是否可读,就把它的描述符放到第一个集合readfds中;如果要监控一个文件是否可写,就把它的描述符放到第二个集合writefds中;如果要监控一个文件是否错误异常,就把它的描述符放到第三个集合errorfds中。比如在这次的代码中我们只需要监控文件描述符是否可读,而不需要关注是否可写是否异常,所以第三个和第四个参数不需要,设置成NULL即可。
第五个参数timeout,表示监控时间。如果监控时间设置成0s 0ms,那么调用这个函数时就会立即返回(非阻塞函数),告诉你是否有文件可读或可写;如果设置成某一个具体的时间,那么如果有文件可读或可写就会返回,如果没有的话就一直等待直到超时再返回;如果把这个参数设置成NULL,那么就相当于把时间设置成无穷大(阻塞状态),如果没有文件可读可写的话,程序就会一直卡在这里等待,直到有文件可读或可写了再返回。
第一个参数为什么要比监控的最大的描述符至少大1呢?第一个参数其实是指select能感知到的文件描述符的个数,如果它是10,那么select就能感知到0~ 9这十个文件描述符;如果它是15,那么select就能感知到0~14这十五个文件描述符。因此你应该明白了,如果你要监控的描述符最大是9,那么第一个参数就要至少比9大1,才能让select能感知到的范围包含你所有要监控的文件描述符。(讲道理我觉得linux中设计成这样挺鸡肋的,事实上windows中并不需要这么做)
第二、三、四这三个参数,是一个定义的结构体struct fd_set,该结构体可以用来装文件描述符,可以理解成是一个集合。下面来简单介绍一下这个集合的数据结构,这个集合通常是用一个整数数组来实现,假设数组中的每一个整数都能转换成32位二进制数,那么每个整数都可以对应32个描述符,即如果这个描述符存在,那么二进制对应的位就是1,如果不存在就是0。那么数组的第一个元素对应于描述符0~ 31,第二个元素对应于描述符32~63,以此类推。
在头文件中定义了一些宏函数,用来操作这个集合。结合刚才讲到的集合的数据结构,我们可以理解到操作集合的本质其实就是把数组中文件描述符对应的二进制位设置成0或1。
FD_ZERO(fd_set *fdset):清空fdset指向的集合(所有位设置成0&