本文摘录自《UNIX网络编程 卷1》。
基本套接字函数
socket函数
为了执行网络I/O,一个进程必须做的第一件事就是调用socket函数,指定期望的通信协议类型。其定义如下:
#includeint socket(int family, int type, int protocol); // 返回:若成功则返回非负描述符,若失败则返回-1
其中:family参数指明协议族,它是图4-2中所示的某个常值。该参数也往往被称为协议域。
type指明套接字类型,它是图4-3中所示的某个常值。
protocol参数应设为图4-4所示的某个协议类型常值,或者设为0,以选择所给定family和type组合的系统默认值。
在《UNIX网络编程 卷1》中,作者将该函数进行了进一步的包装:
1 /* include Socket */ 2 int 3 Socket(int family, int type, int protocol) 4 { 5 int n; 6 7 if ( (n = socket(family, type, protocol)) < 0) 8 err_sys("socket error"); 9 return(n); 10 } 11 /* end Socket */
并非所有套接字family和type的组合都是有效的,图4-5给出了一些有效的组合和对应的真正协议。其中标“是”的项也是有效的,但还没找到便捷的缩略词。而空白项则是无效组合。
connect函数
TCP客户端用connect函数来建立与TCP服务器的连接。其定义如下:
#includeint connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen); // 返回:若成功返回0,若出错返回-1
其中:sockfd是由socket函数返回的套接字描述符。
第二个、第三个参数分别是一个指向套接字地址结构的指针和该结构的大小。关于套接字详细可见前边一博文套接字编程相关函数(1:套接字地址结构、字节序转换、IP地址转换)。
在《UNIX网络编程 卷1》中,作者将该函数进行了进一步的包装:
1 void 2 Connect(int fd, const struct sockaddr *sa, socklen_t salen) 3 { 4 if (connect(fd, sa, salen) < 0) 5 err_sys("connect error"); 6 }
客户端在调用函数connect前不必非得调用bind函数,因为如果需要的话,内核会确定源IP地址,并选择一个临时端口作为源端口。
如果是TCP套接字,调用connect函数将激发TCP的三路握手过程,而且仅在连接建立成功或出错时才返回。具体出错的几种情况请见书中4.3节。
bind函数
bind函数把一个本地协议地址赋予一个套接字。对于网际网协议,协议地址是32位的IPv4地址或128位的IPv6地址与16位的TCP或UDP端口号的组合。函数定义如下:
#includeint bind(int sockfd, const struct sockaddr *myaddr, socklen_t addrlen); // 返回:若成功则返回0,若出错则返回-1
其中,第二个参数是一个指向特定于协议的地址结构的指针,第三个参数是该地址结构的长度。
在《UNIX网络编程 卷1》中,作者将该函数进行了进一步的包装:
void Bind(int fd, const struct sockaddr *sa, socklen_t salen) { if (bind(fd, sa, salen) < 0) err_sys("bind error"); }
对于TCP,调用bind函数可以指定一个端口号,或指定一个IP地址,也可以两者都指定,还可以两者都不指定。
1)服务器在启动时会捆绑众所周知的端口。如果一个TCP客户或服务器未曾调用bind捆绑一个端口,当调用connect或listen时,内核就要为相应的套接字选择一个临时端口。对于TCP客户端来说,让内核来选择临时端口是正常的,除非应用需要预留端口;然而对于服务器来说却极为罕见,因为服务器是通过他们众所周知的端口被大家认识的。
2)进程可以把一个特定的IP地址捆绑到它的套接字上,不过这个IP地址必须属于其所在主机的网络接口之一。对于TCP客户,这就为该套接字上发送的IP数据报指派了源IP地址。对于TCP服务器,这就限定该套接字只接收那些目的地为这个IP地址的客户连接。TCP客户通常不把IP地址捆绑到它的套接字上。当连接套接字时,内核将根据所用外出网络接口来选择源IP地址,而所用外出接口则取决于到达服务器所需的路径。如果TCP服务器没有把IP地址捆绑到它的套接字上,内核就把客户端发送的SYN的目的IP地址作为服务器的源IP地址。
正如我们所说,调用bind可以指定IP地址或端口,可以两者都指定,也可以都不指定。图4-6汇总了如何根据预期的结果,设置sin_addr和sin_port或sin6_addr和sin6_port的值。
如果指定端口号为0,那么内核就在bind调用时选择一个临时端口。然而如果指定IP地址为通配地址,那么内核等到套接字已连接(TCP)或已在套接字上发出数据报(UDP)时才选择一个本地IP地址。
对于IPv4来说,通配地址由常值INADDR_ANY来指定,其值一般为0。它告知内核去选择IP地址。示例如下:
struct sockaddr_in servaddr; servaddr.sin_addr.s_addr = htonl(INADDR_ANY); /* wildcard */
如此赋值对IPv4是可行的,因为IP地址是一个32位的值,可以用一个简单的数字常值表示(上例中为0),对于IPv6,我们就不能这么做了,因为128位的IPv6地址是存放在一个结构中的。(在C语言中,赋值语句的右边无法表示常值结构。)为了解决这个问题,我们改写为:
struct sockaddr_in6 serv; serv.sin6_addr = in6addr_any; /* wildcard */ /* notes */ #includein.h> extern const struct in6_addr in6addr_any; /* :: */
系统预先分配in6addr_any变量并将其初始化为常值INT6ADDR_ANY_INIT。头文件
无论是网络字节序还是主机字节序,INADDR_ANY的值(为0)都一样,因此使用htonl并非必需。不过既然头文件
如果让内核来为套接字选择一个临时端口号,那么必须注意,函数bind并不返回所选择的值。实际上,由于bind函数的第二个参数有const限定词,它无法返回所选之值。为了得到内核所选择的这个临时端口值,必须调用函数getsockname来返回协议地址。
listen函数
listen函数仅由TCP服务器调用,它做两件事情:
1)当socket函数创建一个套接字时,它被假设为一个主动套接字,也就是说,它是一个将调用connect发起连接的客户端套接字。listen函数把一个未连接的套接字转换成一个被动套接字,指示内核应接受指向该套接字的连接请求。根据TCP状态转换图,调用listen函数导致套接字从CHLOSED状态转换到LISTEN状态。
2)本函数的第二个参数规定了内核应该为相应套接字排队的最大连接个数。
#includeint listen(int sockfd, int backlog); // 返回:若成功则返回0,若出错则返回-1
在《UNIX网络编程 卷1》中,作者将该函数进行了进一步的包装:
1 void 2 Listen(int fd, int backlog) 3 { 4 char *ptr; 5 6 /*4can override 2nd argument with environment variable */ 7 if ( (ptr = getenv("LISTENQ")) != NULL) 8 backlog = atoi(ptr); 9 10 if (listen(fd, backlog) < 0) 11 err_sys("listen error"); 12 }
本函数通常在调用socket和bind这两个函数之后,并在调用accept函数之前调用。
为了理解其中的backlog参数,我们必须认识到内核为任何一个给定的监听套接字两个队列:
1)未完成链接队列(incomplete connection queue),每个这样的SYN分节对应其中一项:已由某个客户端发出并到达服务器,而服务器正在等待完成相应的TCP三路握手过程。这些套接字处于SYN_RCVD状态。
2)已完成链接队列(completed connection quue),每个已完成TCP三路握手过程的客户端对应其中一项。这些套接字处于ESTABLISHED状态。
图4-7描绘了监听套接字的这两个队列:
每当在未完成连接队列中创建一项时,来自监听套接字的参数就复制到即将建立的连接中。连接的创建机制是完全自动的,无需服务器进程插手。图4-8展示了用这两个队列建立连接时所交换的分组。
当一个客户SYN到达时,若这些队列是满的,TCP就忽略该分节,也就是不发送RST。这么做是因为:这种情况是暂时的,客户TCP将重发SYN,期望不久能在这些队列中找到可用空间。要是服务器TCP立即响应以一个RST,客户端的connect调用就会立即返回一个错误,强制应用进程处理这种情况,而不是让TCP的正常重传机制来处理。另外,客户端无法区别响应SYN的RST究竟意味着“该端口没有服务器在监听”,还是意味着“该端口有服务器在监听,不过它的队列满了”。
accept函数
accept函数由TCP服务器调用,用于从已完成连接队列队头返回下一个已完成连接。如果已完成连接队列为空,那么进程被投入睡眠(假设套接字为默认的阻塞方式)。
#includeint accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen); // 返回:若成功则返回非负描述符,若失败则返回-1
参数cliaddr和addrlen用来返回已连接的对端进程(客户端)的协议地址。addrlen是值-结果参数(见本文最后解释):调用前,我们将由*addrlen所引用的整数值置为由cliaddr所指的套接字地址结构的长度,返回时,该整数值即为由内核存放在该套接字地址结构内的确切字节数。
如果accept成功,那么其返回值是由内核自动生成的一个全新描述符,代表与所返回客户端的TCP链接。在讨论accept函数时,我们称它的第一个参数为监听套接字(listening socket)描述符(由socket创建,随后用作bind和listen的第一个参数的描述符),称它的返回值为已连接套接字(connected socket)描述符。区分这两个套接字非常重要。一个服务器通常仅仅创建一个监听套接字,它在服务器的生命期一直存在。内核为每个由服务器进程接受的客户连接创建一个已连接套接字(也就是说对于它的TCP三路连接握手过程已经完成)。当服务器完成对某个给定客户端的服务时,相应的已连接套接字就被关闭。
本函数最多返回三个值:一个既可能是新套接字描述符也可能是出错指示的整数、客户端进程的协议地址(由cliaddr指针所指)以及该地址的大小(由addrlen指针所指)。如果我们对返回客户协议地址不感兴趣,那么可以把cliaddr和addrlen均置为空指针。
在《UNIX网络编程 卷1》中,作者将该函数进行了进一步的包装:
1 int 2 Accept(int fd, struct sockaddr *sa, socklen_t *salenptr) 3 { 4 int n; 5 6 again: 7 if ( (n = accept(fd, sa, salenptr)) < 0) { 8 #ifdef EPROTO 9 if (errno == EPROTO || errno == ECONNABORTED) 10 #else 11 if (errno == ECONNABORTED) 12 #endif 13 goto again; 14 else 15 err_sys("accept error"); 16 } 17 return(n); 18 }
疑问:我们知道,一般情况下(不考虑端口复用的情况),一个端口只能被一个套接字绑定,当很明显我们在这里就遇到了两个不同的套接字:监听套接字和已连接套接字,那这样不会互相矛盾么?
进一步的问题就是:若有多个客户端已连接到服务器(不考虑多线程的情况),那服务器是怎么处理这些连接的?
具体解答摘自一博文:
首先,一个端口肯定只能绑定一个socket。我认为,服务器端的端口在bind的时候已经绑定到了监听套接字socetfd所描述的对象上,accept函数新创建的socket对象其实并没有进行端口的占有,而是复制了socetfd的本地IP和端口号,并且记录了连接过来的客户端的IP和端口号。
那么,当客户端发送数据过来的时候,究竟是与哪一个socket对象通信呢?
客户端发送过来的数据可以分为2种,一种是连接请求,一种是已经建立好连接后的数据传输。
由于TCP/IP协议栈是维护着一个接收和发送缓冲区的。在接收到来自客户端的数据包后,服务器端的TCP/IP协议栈应该会做如下处理:如果收到的是请求连接的数据包,则传给监听着连接请求端口的socetfd套接字,进行accept处理;如果是已经建立过连接后的客户端数据包,则将数据放入接收缓冲区。这样,当服务器端需要读取指定客户端的数据时,则可以利用socketfd_new 套接字通过recv或者read函数到缓冲区里面去取指定的数据(因为socketfd_new代表的socket对象记录了客户端IP和端口,因此可以鉴别)。
close函数
close函数用来关闭套接字,并终止TCP连接。
#includeint close(int sockfd); // 返回:若成功则返回0,若出错则返回-1
在《UNIX网络编程 卷1》中,作者将该函数进行了进一步的包装:
1 void 2 Close(int fd) 3 { 4 if (close(fd) == -1) 5 err_sys("close error"); 6 }
close一个TCP套接字的默认行为是把该套接字标记为已关闭,然后立即返回到调用进程。该套接字描述符不能再由调用进程使用,也就是说它不能再作为read或write的第一个参数。然而TCP将尝试发送已排队等候发送到对端的任何数据,发送完毕后发送的是正常的TCP连接终止序列。
getsockname和getpeername函数
这两个函数或者返回与某个套接字关联的本地协议地址(getsockname),或者返回与某个套接字关联的外地协议地址(getpeername)。
#includeint getsockname(int sockfd, struct sockaddr *localaddr, socklen_t *addrlen); int getpeername(int sockfd, struct sockaddr *peeraddr, socklen_t *addrlen); // 返回:若成功则返回0,若出错则返回-1
这两个函数的最后一个参数是值-结果参数。这就是说,这两个函数都得装填由localaddr或peeraddr指针所指的套接字地址结构。
需要这两个函数的理由如下:
1)在一个没有调用bind的TCP客户上,connect成功返回后,getsockname用于返回由内核赋予该连接的本地IP地址和本地端口号。
2)在以端口号0调用(告知内核去选择本地端口号)后,getsockname用于返回由内核赋予的本地端口号。
3)getsockname可用于获取某个套接字的地址族,如下:
1 #include "unp.h" 2 3 int 4 sockfd_to_family(int sockfd) 5 { 6 struct sockaddr_storage ss; 7 socklen_t len; 8 9 len = sizeof(ss); 10 if(getsockname(sockfd, (SA *) &ss, &len)) 11 return (-1); 12 13 return (ss.ss_family); 14 }
4)在一个以通配IP地址调用bind的TCP服务器上,与某个客户端的连接一旦建立(accep成功返回),getsockname就可以用于返回由内核赋予该连接的本地IP地址。在这样的调用中,套接字描述符参数必须是已连接套接字的描述符,而不是监听套接字的描述符。
5)当一个服务器是由调用过accept的某个进程通过调用exec执行程序时,它能够获取客户身份的唯一途径便是调用getpeername。具体例子见书4.10节。
附:值-结果参数(书3.3节)
我们提到过,当往一个套接字函数传递一个套接字地址结构时,该结构总是以引用形式来传递,也就说传递的方向是指向该结构的一个指针。该结构的长度也作为一个参数来传递,不过其传递方式取决于该结构的传递方向:从进程到内核,还是从内核到进程。
1)从进程到内核传递套接字地址结构的函数有3个:bind、connect和sendto,这3个函数的一个参数是指向某个套接字地址结构的指针,另一个参数是该结构的整数大小,例如:
struct sockaddr_in serv; /* fill in serv{} */ connect( sockfd, (SA*)&serv, sizeof(serv) );
由于指针和指针所指结构的大小都传递给了内核,所以内核知道需要从进程拷贝多少数据进来,图3.7展示了这个情形:
套接字地址结构大小的数据类型实际上是socklen_t,而不是int,不过POSIX规范建议将socklen_t定义为uint32_t。
2)从内核到进程传递套接字地址结构的有4个函数:accept、recvfrom、getsockname 和 getpeername。这4个函数其中的两个参数是指向套接字地址结构的指针和指向表示该结构大小的整数变量的指针,例如:
struct sockaddr_un cli; /* Unix domain */ socklen_t len; len = sizeof(cli); getpeername(unixfd, (SA*) &cli, &len ) /* len may have changed */
把套接字地址结构大小这个参数从一个整数改为指向某个整数变量的指针,其原因在于:当函数被调用时,结构大小是一个值(value),它告诉内核该结构的大小,这样内核在写该结构时不至于越界;当函数返回时,结构大小又是一个结果(result),它告诉进程内核在该结构中究竟存储了多少信息。这种类型的参数成为值-结果(value-result)参数。图3-8展示了这个情形: