我们将从以下3方面讨论Linux网络API:
1.socket地址API。socket最开始的含义是一个IP地址和端口对(ip,port),它唯一表示了使用TCP通信的一端,本书称其为socket地址。
2.socket基础API。socket的主要API都定义在sys/socket.h头文件中,包括创建socket、命名socket、监听socket、接受连接、发起连接、读写数据、获取地址信息、检测带外标记、读取和设置socket选项。
3.网络信息API。Linux提供了一套网络信息API,以实现主机名和IP地址之间的转换,以及服务名和端口号之间的转换,这些API都定义在netdb.h头文件中。
现代CPU的累加器一次能装载至少4字节(32位机),即一个整型数。这4个字节在内存中排列的顺序将影响它被累加器装载成的整数的值,这就是字节序问题。字节序分为大端字节序(big endian)和小端字节序(little endian)。大端字节序指整数的高位字节存储在内存的低地址处,小端字节序指整数的高位字节存放在内存的高地址处。以下代码用于检查机器的字节序:
#include <stdio.h>
void byteorder() {
union {
short value;
char union_bytes[sizeof(short)];
} test;
test.value = 0x0102;
if (test.union_bytes[0] == 1 && test.union_bytes[1] == 2) {
printf("big endian\n");
} else if (test.union_bytes[0] == 2 && test.union_bytes[0] == 1) {
printf("little endian\n");
} else {
printf("unknown...\n");
}
}
现代PC大多采用小端字节序,因此小端字节序又被称为主机字节序。
当格式化的数据(如32 bit整型数和16 bit短整型数)在两台使用不同主机序的主机之间直接传递时,接收端会错误解释它。解决问题的方法是发送端总是把要发送的数据转化成大端字节序数据后再发送,而接收端知道对方传送过来的数据总是大端字节序,所以接收端可以根据自身采用的字节序决定是否对接收到的数据进行转换(小端机转换,大端机不转换)。因此大端字节序也称为网络字节序。
即使是同一台机器上的两个进程(如一个用C语言编写,一个用JAVA编写)通信,也需考虑字节序问题(JAVA虚拟机采用大端字节序)。
Linux提供了以下4个函数完成主机字节序和网络字节序之间的转换:
上图作者给出的函数原型有误,函数htonl和ntohl的参数作者认为是long类型,long类型在32位机器上和64位机器上长度不同,这两个函数是用于转换IP地址的字节序的,而IP地址的长度只有32位,在我的机器上(Linux rh 2.6.39-400.17.1.el6uek.x86_64 #1 SMP Fri Feb 22 18:16:18 PST 2013 x86_64 x86_64 x86_64 GNU/Linux
)这两个函数的原型为:
这4个函数中,h表示主机字节序,n表示网络字节序,l结尾的用于转换IP地址,s结尾的用来转换端口号。任何格式化的数据通过网络传输时,都应使用这些函数转换字节序。
socket网络编程接口用sockaddr结构表示socket地址:
sa_family_t成员表示地址族,地址族通常与协议族类型对应。常见的协议族(protocol family,也称domain)和对应的地址族见下表:
宏PF_*
和AF_*
都定义在bits/socket.h头文件中,且后者与前者有完全相同的值,因此二者通常混用。
sa_data成员用于存放socket地址值,但不同的协议族的地址有不同的含义和长度:
由上表可见,14字节的sa_data成员无法容纳多数协议族的地址值,因此Linux定义了下面这个新的通用socket地址结构:
sockaddr_storage结构体提供了足够大的空间用于存放地址值,而且是内存对齐的(这就是__ss_align成员的作用)。
上面这两个通用socket地址结构体显然不好用,比如设置和获取IP地址和端口号需要执行繁琐的位操作,所以Linux为各个协议族提供了专门的socket地址结构。
UNIX本地域协议族使用以下专用socket地址结构:
TCP/IP协议族有sockaddr_in(IPv4)和sockaddr_in6(IPv6)两个专用socket地址结构:
所有socket地址(包括sockaddr_storage)类型的变量在传给socket编程接口时都需要强制转换为sockaddr类型,因为所有socket编程接口使用的地址参数的类型都是sockaddr。
人们习惯用可读性好的字符串来表示IP地址,如用点分十进制字符串表示IPv4地址,以及用十六进制字符串表示IPv6地址,但编程中我们需要将这些字符串转换为整数(二进制数)才能使用,而记录日志时则相反,我们要把整数表示的IP地址转换为可读的字符串。下面3个函数用于点分十进制字符串表示的IPv4地址和用网络字节序整数表示的IPv4地址之间的转换:
inet_addr函数用于将点分十进制字符串表示的IPv4地址转换为用网络字节序整数表示IPv4地址,它失败时返回INADDR_NONE。
inet_aton函数完成和inet_addr和相同的功能,但它将转换的结果存储于参数inp指向的地址结构中,它成功时返回1,失败时返回0。
inet_ntoa函数用于将网络字节序整数表示的IPv4地址转换为点分十进制字符串表示的IPv4地址。需要注意的是,该函数内部用一个静态变量存储转换结果,函数的返回值指向该静态内存,因此inet_ntoa函数是不可重入的,以下代码展示其不可重入性:
运行这段代码,得到的结果是:
作者提供的代码清单5-2是错的,inet_ntoa函数的参数类型是in_addr,而非char *,正确的代码应该是:
#include <stdio.h>
#include <arpa/inet.h>
int main() {
struct in_addr inAddr1, inAddr2;
inAddr1.s_addr = inet_addr("1.2.3.4");
inAddr2.s_addr = inet_addr("10.194.71.60");
char *szValue1 = inet_ntoa(inAddr1);
char *szValue2 = inet_ntoa(inAddr2);
printf("address 1: %s\n", szValue1);
printf("address 2: %s\n", szValue2);
}
以下函数同样也能完成IP地址的字符串表示和二进制表示之间的转换,它们更现代,能同时适用于IPv4和IPv6地址:
inet_pton函数的src参数是字符串表示的IP地址(点分十进制表示的IPv4地址或十六进制字符串表示的IPv6地址),dst参数用于存储转换后的二进制格式地址,af参数用于指定地址族,可以是AF_INET或AF_INET6,该函数成功时返回1,失败返回0并设置errno。
inet_ntop函数用于将IP地址的二进制格式转换为字符串表示,它的前3个参数与inet_pton函数的相同,最后一个参数cnt指定dst参数指针指向的地址的长度,我们可用以下两个宏确定字符串表示的最大长度:
inet_ntop函数成功时返回目标存储单元的地址(dst参数),失败则返回NULL并设置errno。
UNIX/Linux的一个哲学是:所有东西都是文件。socket也不例外,它就是可读、可写、可控制、可关闭的描述符。下面的系统调用创建一个套接字描述符:
domain参数告诉系统使用哪个协议族,对于TCP/IP协议族而言,该参数应设置为PF_INET或PF_INET6;对于UNIX本地域协议族而言,该参数应设置为PF_UNIX。可通过man socket
查看系统支持的所有协议族。
type参数指定服务类型,服务类型主要有SOCK_STREAM(流服务)和SOCK_DGRAM(数据报)服务。对TCP/IP协议族而言,SOCK_STREAM表示传输层使用TCP协议,SOCK_DGRAM表示传输层使用UDP协议。
Linux内核版本自2.6.17起,type参数可以是服务类型和SOCK_NONBLOCK(将新建的socket设为非阻塞的)、SOCK_CLOEXEC(调用exec时关闭该描述符)标志相与的值,在此版本前,文件描述符的这两个属性需要使用额外的系统调用(如fcntl还是)来设置。
protocol参数是在前两个参数构成的协议集合下,再选择一个具体的协议,但这个集合中通常只有一个协议,此时我们可将该参数设为0,表示使用该唯一的协议。
socket系统调用成功时返回一个socket文件描述符,失败时返回-1并设置errno。
创建socket时,我们指定了地址族,但未指定使用该地址族中哪个具体socket地址,将一个socket与socket地址绑定称为给socket命名。在服务器程序中,我们通常要命名socket,因为只有命名后客户端才能知道如何连接它。客户端则通常不需要命名socket,而是采用匿名方式,即使用操作系统自动分配的socket地址。命名socket的系统调用是bind:
bind函数将my_addr参数指针所指的socket地址分配给未命名的sockfd文件描述符,addrlen参数指出该socket地址的长度。
bind函数成功时返回0,失败返回-1并设置errno,其中常见的errno:
1.EACCES:被绑定的地址是受保护的地址,仅超级用户能访问,普通用户将socket绑定到端口0~1023上时,bind函数将返回EACCES错误。
2.EADDRINUSE:被绑定的地址正在使用中,比如将socket绑定到一个处于TIME_WAIT状态的socket地址。
socket被命名后,还不能接受客户连接,我们需要以下系统调用创建一个监听队列以存放待处理的客户连接:
sockfd参数指定被监听的socket。backlog参数提示内核监听队列的最大长度,监听队列的长度如果已满,服务器将不受理新的客户连接,客户端也将收到ECONNREFUSED错误信息。作者对于监听队列满时,客户连接到来时的描述是错的,一个客户的SYN到达时,如果队列是满的,TCP就忽略该分节,即不发送RST,这是因为这种情况是暂时的,客户TCP将重发SYN,我们期望不久这些队列中就有可用空间。在内核版本2.2前的Linux中,backlog参数是指所有处于SYN_RCVD和ESTABLISHED的socket的上限,但自内核版本2.2后,它只表示处于ESTABLISHED状态的socket的上限,处于SYN_RCVD的socket的上限由/proc/sys/net/ipv4/tcp_max_syn_backlog内核参数定义。
listen函数成功时返回0,失败时返回-1并设置errno。
编写一个服务器程序,研究backlog参数对listen系统调用的实际影响:
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include <stdbool.h>
#include <libgen.h>
static bool stop = false;
// SIGTERM信号的处理函数,用于结束main中的循环
static void handle_term(int sig) {
stop = true;
}
int main(int argc, char *argv[]) {
signal(SIGTERM, handle_term);
if (argc <= 3) {
printf("usage: %s ip_address port_number backlog\n", basename(argv[0]));
return 1;
}
const char *ip = argv[1];
int port = atoi(argv[2]);
int backlog = atoi(argv[2]);
int sock = socket(PF_INET, SOCK_STREAM, 0);
assert(sock >= 0);
struct sockaddr_in address;
bzero(&address, sizeof(address));
address.sin_family = AF_INET;
inet_pton(AF_INET, ip, &address.sin_addr);
address.sin_port = htons(port);
int ret = bind(sock, (struct sockaddr *)&address, sizeof(address));
assert(ret != -1);
ret = listen(sock, backlog);
assert(ret != -1);
// 循环等待连接,直到SIGTERM信号将它中断
while (!stop) {
sleep(1);
}
close(sock);
return 0;
}
以上服务器程序(名为testlisten)接收3个参数,IP地址、端口号、backlog值。我们在Kongming20上运行该服务器程序,并在ernest-laptop上执行多次telnet命令连接该服务器,同时,每使用telnet命令建立一个连接,就执行一次netstat命令查看服务器上连接的状态,具体操作如下:
以下是netstat命令某次输出的内容,它显示了这一时刻listen监听队列的内容:
可见监听队列中,处于ESTABLISHED状态的连接有6个(此实现中,ESTABLISHED状态的连接在监听队列中最多有backlog值加1个),其他连接处于SYN_RCVD状态。我们改变backlog参数再次运行服务器,完整连接还是最多只能有backlog加1个,在不同系统上,运行结果会有差别,但监听队列中完整连接的上限通常比backlog值大。
以下系统调用从监听队列中接受一个连接:
sockfd参数是执行过listen系统调用的监听socket(我们把执行过listen调用、处于LISTEN状态的socket称为监听socket,而所有处于ESTABLISHED状态的socket称为连接socket)。addr参数是用来获取被接受连接的远端socket地址,该socket地址的长度由addrlen参数指出。accept函数成功时返回一个新的连接socket,该socket在服务器端唯一标识了这个被接受的连接,服务器可通过读写该socket来与被接受连接对应的客户端通信。accept函数失败时返回-1并设置errno。
现考虑如下情况:如果监听队列中处于ESTABLISHED状态的连接对应的客户端出现网络异常(如断开网线),或提前退出,那么服务器的accept函数是否成功?下面编写一个简单的服务器程序测试它:
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <libgen.h>
int main(int argc, char *argv[]) {
if (argc <= 2) {
printf("usage: %s ip_address port_number\n", basename(argv[0]));
return 1;
}
const char *ip = argv[1];
int port = atoi(argv[2]);
struct sockaddr_in address;
bzero(&address, sizeof(address));
address.sin_family = AF_INET;
inet_pton(AF_INET, ip, &address.sin_addr);
address.sin_port = htons(port);
int sock = socket(PF_INET, SOCK_STREAM, 0);
assert(sock >= 0);
int ret = bind(sock, (struct sockddr *)&address, sizeof(address));
assert(ret != 1);
ret = listen(sock, 5);
assert(ret != -1);
// 等待20秒,使得客户连接和相关操作(断开网线或退出)完成
sleep(20);
struct sockaddr_in client;
socklen_t client_addrlength = sizeof(client);
int connfd = accept(sock, (struct sockaddr *)&client, &client_addrlength);
if (connfd < 0) {
printf("errno is: %d\n", errno);
} else {
char remote[INET_ADDRSTRLEN];
// 连接成功则打印客户的IP和端口号
printf("connected with ip: %s and port: %d\n",
inet_ntop(AF_INET, &client.sin_addr, remote, INET_ADDRSTRLEN), ntohs(client.sin_port));
close(connfd);
}
close(sock);
return 0;
}
我们在Kongming20上运行以上服务器程序(名为testaccept),并在ernest-laptop上执行telnet命令来连接该服务器,具体操作如下:
启动telnet客户端后,立即断开该客户主机的网线(建立和断开网线的过程要在服务器启动20秒内完成),结果发现accept函数能正常返回,服务器输出如下:
接着在服务器上运行netstat命令以查看accept函数返回的连接的状态:
netstat命令的输出说明,accept函数对于客户端网络断开毫不知情,下面重新执行上述过程,但这次不断开客户主机网线,而是建立连接后立即退出客户进程,这次accept函数同样正常返回,服务器输出如下:
再次在服务器上运行netstat命令:
由此可见,accept函数只是从监听队列中取出连接,而不论连接处于何种状态,更不关心网络状况的变化。
服务器通过listen函数来被动接受连接,客户需要通过以下系统调用主动与服务器建立连接:
sockfd参数是由socket函数返回的一个socket。serv_addr参数是服务器监听的socket地址,addrlen参数指定这个地址的长度。
connect函数成功时返回0,一旦成功建立连接,sockfd参数就唯一地标识了这个连接,客户就可通过读写sockfd来与服务器通信。connect函数失败时返回-1并设置errno,两种常见的errno:
1.ECONNREFUSED:目标端口不存在,连接被拒绝。
2.ETIMEDOUT:连接超时。
关闭一个连接实际上就是关闭该连接对应的socket,这可通过以下关闭普通文件描述符的系统调用来完成:
fd参数是待关闭的socket,但close系统调用并不总是立即关闭一个连接,而是将fd参数的引用计数减1,只有当fd参数的引用计数为0时,才真正关闭连接。多进程程序中,一次fork系统调用使父进程中打开的socket的引用计数加1,因此我们必须在父进程和子进程中都对该socket执行close函数才能将连接关闭。
如果要立即终止连接,而不是将socket的引用计数减1,可用以下shutdown系统调用(相比close函数,它是专门为网络编程设计的):
sockfd参数是待关闭的socket。howto参数决定了shutdown函数的行为,可取值如下:
由上表可见,shutdown函数能分别关闭socket上的读或写,或者都关闭,而close函数在关闭连接时只能将socket上的读和写同时关闭。
shutdown函数成功时返回0,失败时返回-1并设置errno。
对文件的读写函数read和write同样适用于socket,但socket编程接口提供了专门用于socket数据读写的系统调用,它们增加了对数据读写的控制,以下系统调用用于TCP流数据读写:
recv函数读取sockfd上的数据,buf和len参数分别指定读缓冲区的位置和大小。recv函数成功时返回实际读到的数据的长度,它可能小于我们期望的长度len,因此我们可能要多次调用recv才能读到完整的数据。recv函数可能返回0,这意味着对端关闭连接了。recv函数出错时返回-1并设置errno。
send函数往sockfd上写入数据,buf和len参数分别指定写缓冲区的位置和带下。send函数成功时返回实际写入的数据长度,失败则返回-1并设置errno。
recv和send函数的flags参数为数据收发提供了额外的控制,它可以是下表中一个或几个选项的逻辑或:
以下代码演示MSG_OOB选项的使用,它给程序提供了发送和接收带外数据的方法,以下是发送程序:
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <libgen.h>
int main(int argc, char *argv[]) {
if (argc <= 2) {
printf("usage: %s ip_address port_number\n", basename(argv[0]));
return 1;
}
const char *ip = argv[1];
int port = atoi(argv[2]);
struct sockaddr_in server_address;
bzero(&server_address, sizeof(server_address));
server_address.sin_family = AF_INET;
inet_pton(AF_INET, ip, &server_address.sin_addr);
server_address.sin_port = htons(port);
int sockfd = socket(PF_INET, SOCK_STREAM, 0);
assert(sockfd >= 0);
if (connect(sockfd, (struct sockaddr *)&server_address, sizeof(server_address)) < 0) {
printf("connection failed\n");
} else {
const char *oob_data = "abc";
const char *normal_data = "123";
send(sockfd, normal_data, strlen(normal_data), 0);
send(sockfd, oob_data, strlen(oob_data), MSG_OOB);
send(sockfd, normal_data, strlen(normal_data), 0);
}
close(sockfd);
return 0;
}
以下是带外数据的接收程序:
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <libgen.h>
#define BUF_SIZE 1024
int main(int argc, char *argv[]) {
if (argc <= 2) {
printf("usage: %s ip_address port_number\n", basename(argv[0]));
return 1;
}
const char *ip = argv[1];
int port = atoi(argv[2]);
struct sockaddr_in address;
bzero(&address, sizeof(address));
address.sin_family = AF_INET;
inet_pton(AF_INET, ip, &address.sin_addr);
address.sin_port = htons(port);
int sock = socket(PF_INET, SOCK_STREAM, 0);
assert(sock >= 0);
int ret = bind(sock, (struct sockaddr *)&address, sizeof(address));
assert(ret != 1);
ret = listen(sock, 5);
assert(ret != 1);
struct sockaddr_in client;
socklen_t client_addrlength = sizeof(client);
int connfd = accept(sock, (struct sockaddr *)&client, &client_addrlength);
if (connfd < 0) {
printf("errno is: %d\n", errno);
} else {
char buffer[BUF_SIZE];
// 等待客户把数据都发完,否则可能我们第一个recv函数读不到发送端第二次send发送的内容
// 从而第二个recv函数也读不到带外数据,这种情况下第二个recv函数应该接收"ab"
// 但由于我们的第二个recv函数指定了MSG_OOB,因此读不到带外数据,会返回-1
// 从而第三个recv函数读到"ab"
sleep(2);
memset(buffer, '\0', BUF_SIZE);
ret = recv(connfd, buffer, BUF_SIZE - 1, 0);
printf("get %d bytes of normal data '%s'\n", ret, buffer);
memset(buffer, '\0', BUF_SIZE);
ret = recv(connfd, buffer, BUF_SIZE - 1, MSG_OOB);
printf("get %d bytes of oob data '%s'\n", ret, buffer);
memset(buffer, '\0', BUF_SIZE);
ret = recv(connfd, buffer, BUF_SIZE - 1, 0);
printf("get %d bytes of normal data '%s'\n", ret, buffer);
close(connfd);
}
close(sock);
return 0;
}
我们先在Kongming20上启动带外数据接收程序(名为testoobrecv),然后在ernest-laptop上启动带外数据发送程序(名为testoobsend)来向服务器发送带外数据,同时用tcpdump抓取这一过程中客户和服务器交换的TCP报文段,具体操作如下:
服务器程序的输出如下:
由上图,客户的以MSG_OOB调用的send中,要输出的字符有3个(abc),仅有最有1个字符被服务器当成真正的带外数据接收。服务器对正常数据的接收被带外数据截断,即前一部分正常数据123ab和后续的正常数据123是不能被一个recv调用全部读出的。
以下是tcpdump的输出中,和带外数据相关的报文段:
上图中可以看到tcpdump的输出标志U,这表示该TCP报文段的头部被设置了紧急标志,urg 3
是紧急偏移值,它指出带外数据在字节流中的位置的后一位置是7(3+4,4是该TCP报文段的序号值相对初始序号值的偏移),因此,带外数据是字节流中的第6字节,即字符c。
flags参数只对当前send、recv调用生效。
socket编程接口中用于UDP数据报读写的系统调用是:
recvfrom函数读取sockfd参数上的数据,buf和len参数分别指定读缓冲区的位置和大小。由于UDP没有连接的概念,我们每次读取数据都需要获取发送端socket地址,即参数src_addr所指的内容,addrlen参数指定该地址的长度。
sendto函数往sockfd参数上写入数据,buf和len参数分别指定写缓冲区的位置和大小。dest_addr参数指定接收端的socket地址,addrlen参数指定该地址长度。
recvfrom和sendto系统调用的flags参数和返回值的含义与send/recv系统调用的flags参数及返回值相同。
recvfrom/sendto系统调用也可用于面向连接的socket的数据读写,只需要把最后两个参数都设为NULL以忽略发送端/接收端的socket地址(因为我们已经和对方建立了连接,所以已经知道其socket地址了)。
socket编程接口还提供了一对通用的数据读写系统调用,它们不仅能用于TCP流数据,也能用于UDP数据报:
sockfd参数指定被操作的目标socket。msg参数是msghdr结构类型的指针:
msg_name成员指向一个socket地址结构变量,它指定通信对方的socket地址,对于面向连接的TCP协议,该成员没有意义,必须被设置为NULL,这是因为对TCP socket而言,对方的地址已经知道。msg_namelen成员指定了msg_name成员所指socket地址的长度。
msg_iov成员是iovec结构类型指针:
iovec结构封装了一块内存的起始位置和长度,msg_iovlen成员指定这样的iovec结构对象有多少个。对于recvmsg函数而言,数据将被读取并存放在msg_iovlen块分散的内存中,这些内存的位置和长度由msg_iov成员指向的数组指定,这称为分散读。对于sendmsg函数而言,会将msg_iovlen成员表示的,分散在内存中的数据一并发送,这称为聚集写。
msg_control和msg_controllen成员用于辅助数据的发送。
msg_flags成员无需设置,它会复制recvmsg/sendmsg函数的flags参数的内容,以影响读写过程,在recvmsg函数返回前,会将某些更新后的标志设置到msg_flags成员中。
recvmsg/sendmsg函数的flags参数和返回值的含义均与send/recv函数的flags参数和返回值相同。
在实际应用中,我们通常无法预期带外数据何时到来,好在Linux内核检测到TCP紧急标志时,将通知应用进程有带外数据需要接收。内核通知应用进程带外数据到达的常见方式是:IO复用产生异常事件和SIGURG信号。即使应用进程得到了有带外数据需要接收的通知,还需要知道带外数据在数据流中的具体位置,才能准确接收带外数据,这一点可通过以下系统调用实现:
sockatmark函数判断sockfd参数是否处于带外标记,即下一个被读取到的数据是否是带外数据,如果是,sockatmark函数返回1,此时我们就可以利用带MSG_OOB标志的recv函数来接收带外数据,如果不是,sockatmark函数返回0。
我们有时想知道一个socket的本端socket地址,以及远端的socket地址,以下函数用于解决这个问题:
getsockname函数获取sockfd参数对应的本端socket地址,并将其存储到address参数指定的内存中,该socket地址的长度存储于address_len参数指向的变量中。如果实际socket地址的长度大于address参数所指内存区大小,该socket地址将被截断。getsockname函数成功时返回0,失败返回-1并设置errno。
getpeername函数获取sockfd参数对应的远端socket地址,其参数和返回值的含义与getsockname函数的参数及返回值相同。
fcntl系统调用是控制文件描述符属性的通用POSIX方法,下面两个系统调用是专门用来读取和设置socket文件描述符属性的方法:
sockfd参数指定被操作的目标socket。level参数指定要操作哪个协议的选项(即属性),比如IPv4、IPv6、TCP等。option_name参数指定选项名,下表中是常用的socket选项:
option_value和option_len参数分别是被操作选项的值和长度,不同的选项具有不同类型的值,如上表数据类型列所示。
getsockopt和setsockopt函数成功时返回0,失败时返回-1并设置errno。
对服务器而言,有部分socket选项只能在调用listen系统调用前针对监听socket设置才有效,因为连接socket只能由accept函数返回,而accept函数从监听队列中接受的连接至少已经完成了TCP三次握手的前2个步骤(listen监听队列中的连接至少已进入SYN_RCVD状态),这说明服务器已经往被接受连接上发送出了TCP同步报文段,但有些socket选项影响TCP同步报文段中的设置,如TCP最大报文段选项,对于这种情况,Linux提供的解决方案是,对监听socket设置的以下socket选项,accept函数返回的连接socket将自动继承这些选项设置:SO_DEBUG、GO_DONTROUTE、SO_KEEPALIVE、SO_LINGER、SO_OOBINLINE、SO_RCVBUF、SO_RCVLOWAT、SO_SNDBUF、SO_SNDLOWAT、TCP_MAXSEG、TCP_NODELAY。对于客户端而言,这些会影响TCP同步报文中的设置的TCP选项,应该在调用connect前设置,因为connect函数成功返回后,SYN报文段已发出。
服务器程序可通过设置socket选项SO_REUSEADDR来强制使用被处于TIME_WAIT状态的连接占用的socket地址,具体实现方法如下:
int sock = socket(PF_INET, SOCK_STREAM, 0);
assert(sock >= 0);
int reuse = 1;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
struct sockaddr_in address;
bzero(&address, sizeof(address));
address.sin_family = AF_INET;
inet_pton(AF_INET, ip, &address.sin_addr);
address.sin_port = htons(port);
int ret = bind(sock, (struct sockaddr *)&address, sizeof(address));
设置SO_REUSEADDR套接字选项后,即使sock处于TIME_WAIT状态,与之绑定的socket地址也可立即被重用。我们也可通过修改内核参数/proc/sys/net/ipv4/tcp_tw_recycle来快速回收被关闭的socket,从而使TCP连接根本不进入TIME_WAIT状态,进而允许应用进程立即重用本地socket地址。
SO_RCVBUF和SO_SNDBUF选项分别表示TCP接收缓冲区和发送缓冲区的大小,当我们用setsockopt回收来设置TCP的接收缓冲区和发送缓冲区的大小时,内核会将此值加倍(为bookkeeping开销留出空间),接收缓冲区加倍后的最小值为256字节,发送缓冲区加倍后的最小值为2048字节,但不同系统可能有不同最小值,如果加倍后的值还小于最小值,则将其设为最小值。最小值限制的目的主要是确保一个TCP连接拥有足够的空闲缓冲区来处理拥塞(如快速重传算法就期望TCP接收缓冲区至少容纳4个大小为MSS的TCP报文段)。我们可以修改内核参数/proc/sys/net/ipv4/tcp_rmem和/proc/sys/net/ipv4/tcp_wmem来强制TCP接收缓冲区和发送缓冲区的大小没有最小值限制。
以下客户端和服务器程序分别修改了TCP发送缓冲区和接收缓冲区的大小,以下是修改发送缓冲区大小的客户端程序:
#include <sys/socket.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <libgen.h>
#define BUFFER_SIZE 512
int main(int argc, char *argv[]) {
if (argc != 4) {
printf("usage: %s ip_address port_number send_buffer_size\n", basename(argv[0]));
return 1;
}
const char *ip = argv[1];
int port = atoi(argv[2]);
struct sockaddr_in server_address;
bzero(&server_address, sizeof(server_address));
server_address.sin_family = AF_INET;
inet_pton(AF_INET, ip, &server_address.sin_addr);
server_address.sin_port = htons(port);
int sock = socket(PF_INET, SOCK_STREAM, 0);
assert(sock >= 0);
int sendbuf = atoi(argv[3]);
int len = sizeof(sendbuf);
setsockopt(sock, SOL_SOCKET, SO_SNDBUF, &sendbuf, sizeof(sendbuf));
getsockopt(sock, SOL_SOCKET, SO_SNDBUF, &sendbuf, (socklen_t *)&len);
printf("the tcp send buffer size after setting is %d\n", sendbuf);
if (connect(sock, (struct sockaddr *)&server_address, sizeof(server_address)) != -1) {
char buffer[BUFFER_SIZE];
memset(buffer, 'a', BUFFER_SIZE);
send(sock, buffer, BUFFER_SIZE, 0);
}
close(sock);
return 0;
}
以下是修改接收缓冲区的服务器程序:
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <libgen.h>
#define BUFFER_SIZE 1024
int main(int argc, char *argv[]) {
if (argc != 4) {
printf("usage: %s ip_address port_number recv_buffer_size\n", basename(argv[0]));
return 1;
}
const char *ip = argv[1];
int port = atoi(argv[2]);
struct sockaddr_in address;
bzero(&address, sizeof(address));
address.sin_family = AF_INET;
inet_pton(AF_INET, ip, &address.sin_addr);
address.sin_port = htons(port);
int sock = socket(PF_INET, SOCK_STREAM, 0);
assert(sock >= 0);
int recvbuf = atoi(argv[3]);
int len = sizeof(recvbuf);
setsockopt(sock, SOL_SOCKET, SO_RCVBUF, &recvbuf, sizeof(recvbuf));
getsockopt(sock, SOL_SOCKET, SO_RCVBUF, &recvbuf, (socklen_t *)&len);
printf("the tcp receive buffer size after setting is %d\n", recvbuf);
int ret = bind(sock, (struct sockaddr *)&address, sizeof(address));
assert(ret != -1);
ret = listen(sock, 5);
assert(ret != -1);
struct sockaddr_in client;
socklen_t client_addrlength = sizeof(client);
int connfd = accept(sock, (struct sockaddr *)&client, &client_addrlength);
if (connfd < 0) {
printf("errno is: %d", errno);
} else {
char buffer[BUFFER_SIZE];
memset(buffer, '\0', BUFFER_SIZE);
while (recv(connfd, buffer, BUFFER_SIZE - 1, 0) > 0) { }
close(connfd);
}
close(sock);
return 0;
}
我们在ernest-laptop上运行修改接收缓冲区大小的服务器程序(名为set_recv_buffer),然后在Kongming20上运行修改发送缓冲区大小的客户程序(名为set_send_buffer)向服务器发送512字节的数据,用tcpdump抓取这一过程中双方交换的TCP报文段,具体操作如下:
从服务器的输出来看,该系统允许的TCP接收缓冲区最小为256字节,当我们设置TCP接收缓冲区为50字节时,系统忽略了我们的设置。从客户端的输出来看,我们设置的TCP发送缓冲区的大小被系统增加了一倍。以下是此次通信的tcpdump输出:
第2个报文段指出,服务器的通告窗口大小为192字节,该值小于接收缓冲区的256字节,是合理的,同时该SYN报文段还指出服务器采用的窗口扩大因子是6,因此服务器后续发送的报文段6、8、10、12的通告窗口大小都是3*2
6
^{6}
6=192字节,因此客户端每次最多给服务器发送192字节的数据。客户端一共给服务器发送了512字节的数据,这些数据必须被分到3个报文段(4、7、9)中发送。
上例中,当服务器收到客户端发送过来的第一批数据(报文段4)时,服务器用报文段5给予了确认,但该确认报文段的通告窗口大小为0,这说明TCP模块发送该确认报文段时,服务器进程还没来得及将数据从TCP接收缓冲区中读出,所以此时客户端是不能发送数据给服务器的,直到服务器发送一个重复的确认报文段6来扩大其接受通告窗口。
对于TCP发送和接收缓冲区,在我的机器上(Linux rh 2.6.39-400.17.1.el6uek.x86_64 #1 SMP Fri Feb 22 18:16:18 PST 2013 x86_64 x86_64 x86_64 GNU/Linux
),发送缓冲区没有最小大小限制,且不会乘2:
接收缓冲区有2288字节的最小限制,且会乘2:
SO_RCVLOWAT和SO_SNDLOWAT套接字选项分别表示TCP接收缓冲区和发送缓冲区的低水位标记,它们一般被IO复用系统调用用来判断socket是否可读或可写。当TCP接收缓冲区中可读数据的总数大于其低水位标记时,IO复用系统调用将通知应用进程可以从对应的socket上读取数据;当TCP发送缓冲区中的空闲空间(可以写入数据的空间)大于其低水位标记时,IO复用系统调用将通知应用进程可以向对应的socket上写入数据。
默认,TCP接收缓冲区和发送缓冲区的低水位标记均为1。
SO_LINGER选项用于控制close系统调用在关闭TCP连接时的行为,默认,当使用close系统调用关闭一个socket时,close函数将立即返回,TCP模块随后负责把该socket对应的TCP发送缓冲区中残留的数据发送给对方。
SO_LINGER选项对应的值的类型是linger:
根据linger结构体中两个成员的不同取值,close系统调用可能产生以下3种行为之一:
1.l_onoff为0。此时SO_LINGER选项不起作用,close函数用默认行为来关闭socket。
2.l_onoff不为0,l_linger为0。此时close系统调用立即返回,TCP模块将丢弃被关闭的socket对应的TCP发送缓冲区中残留的数据,同时给对方发送一个RST报文段。这种情况给服务器提供了一种异常终止一个连接的方法。
3.l_onoff不为0,l_linger大于0。此时close函数的行为取决于两个条件:一是被关闭的socket对应的TCP发送缓冲区中是否还有残留的数据;二是该socket是阻塞的还是非阻塞的。对于阻塞的socket,close函数将等待长为l_linger成员的时间,直到TCP模块发送完所有残留数据并得到对方的确认,如果这段时间内TCP模块没有发送完残留数据并得到对方确认,那么close函数将返回-1并设置errno为EWOULDBLOCK。如果socket是非阻塞的,close函数将立即返回,此时我们需要根据其返回值和errno来判断数据是否已经发送完毕。
IP地址和端口号都是数值表示的,不便于记忆,也不便于扩展(如从IPv4转移到IPv6),因此前面我们用主机名访问一台机器,而避免直接使用其IP地址。同样,我们也可用服务名代替端口号。下面两条telnet命令有完全相同的作用:
上例中,telnet客户进程通过调用某些网络信息API来实现主机名到IP地址的转换,以及服务名到端口号的转换。
gethostbyname函数根据主机名获取主机的完整信息,gethostbyaddr函数根据IP地址获取主机的完整信息。gethostbyname函数通常先在本地的/etc/hosts配置文件中查找主机,如果没有找到,再去访问DNS服务器。这两个函数定义如下:
gethostbyname函数的name参数指定目标主机的主机名。gethostbyaddr函数的addr参数指定目标主机的IP地址,len参数指定addr参数所指IP地址的长度,type参数指定addr参数所指IP地址的类型(可取值为AF_INET、AF_INET6)。
以上两个函数返回的都是hostent类型的指针:
getservbyname函数根据名称获取某个服务的完整信息。getservbyport函数根据端口号获取某个服务的完整信息,它们实际上都是通过读取/etc/services文件来获取服务的信息的:
name参数指定目标服务的名字,port参数指定目标服务对应的端口号。proto参数指定服务类型,可传tcp和udp,也可传NULL表示获取所有类型的服务。
以上两个函数返回的都是servent类型的指针:
通过主机名和服务名访问目标机器上的daytime服务,以获取该机器的系统时间:
#include <sys/socket.h>
#include <netinet/in.h>
#include <netdb.h>
#include <stdio.h>
#include <unistd.h>
#include <assert.h>
int main(int argc, char *argv[]) {
assert(argc == 2);
char *host = argv[1];
// 获取目标主机地址信息
struct hostent *hostinfo = gethostbyname(host);
assert(hostinfo);
// 获取daytime服务信息
struct servent *servinfo = getservbyname("daytime", "tcp");
assert(servinfo);
printf("daytime port is %d\n", ntohs(servinfo->s_port));
struct sockaddr_in address;
address.sin_family = AF_INET;
address.sin_port = servinfo->s_port;
// 由于h_addr_list本身是使用网络字节组的地址列表,所以使用其中IP地址时,无序转换字节序
address.sin_addr = *(struct in_addr *)*hostinfo->h_addr_list;
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
int result = connect(sockfd, (struct sockaddr *)&address, sizeof(address));
assert(result != -1);
char buffer[128];
result = read(sockfd, buffer, sizeof(buffer));
assert(result > 0);
buffer[result] = '\0';
printf("the day time is: %s", buffer);
close(sockfd);
return 0;
}
获取目标主机地址信息和获取服务信息的4个函数是不可重入的,即非线程安全的,但netdb.h头文件中给出了它们的可重入版本,正如Linux下所有其他函数的可重入版本那样,这些函数的函数名是在原函数名尾部加上_r。
getaddrinfo函数既能通过主机名获得IP地址,也能通过服务名获得端口号,它是可重入函数:
hostname参数可以是主机名,也可以是字符串表示的IP地址(IPv4采用点分十进制字符串,IPv6采用十六进制字符串)。service参数可以是服务名,也可以是字符串表示的十进制端口号。hints参数是应用进程给getaddrinfo函数的一个提示,以对getaddrinfo函数的输出进行更精确的控制,hints参数也可被设置为NULL,表示允许getaddrinfo函数返回所有可用的结果。result参数是一个链表,用于存储getaddrinfo函数返回的结果。
getaddrinfo函数返回的每条结果都是一个addrinfo结构对象:
ai_protocol成员指具体的网络协议,其含义与socket系统调用的通常被设为0的第3个参数相同。ai_flags参数可以是下表中标志的按位或:
当使用hints参数时,我们可以设置其ai_flags、ai_family、ai_socktype、ai_protocol字段,其他字段必须设为NULL,例如,下例使用hints参数获取主机ernest-laptop上的daytime流服务信息:
struct addrinfo hints;
struct addrinfo *res;
bzero(&hints, sizeof(hints));
hints.ai_socktype = SOCK_STREAM;
getaddrinfo("ernest-laptop", "daytime", &hints, &res);
从上例代码可见,getaddrinfo函数会隐式分配堆内存,因为res指针原本没有指向一块合法内存,所以用完这块内存后,我们需要使用以下函数释放这块内存:
getnameinfo函数能通过socket地址结构同时获取以字符串表示的主机名和服务名,它是可重入的:
getnameinfo函数将返回的主机名存储在host参数指向的缓存中,将服务名存储在serv参数指向的缓冲中,hostlen和servlen参数分别指定这两块缓存的长度。flags参数控制getnameinfo函数的行为,它可以是以下标志的逻辑或:
getaddrinfo和getnameinfo函数成功时返回0,失败则返回错误码,可能的错误码见下表:
Linux下strerror函数能将数值错误码errno转换成易读的字符串形式,同理,以下函数能将上表中错误码转换成字符串形式: