linux下socket编程-TCP

时间:2022-12-17 22:42:08

网络字节序

发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出,接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存,因此,网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址

linux下socket编程-TCP

为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转换。

#include <arpa/inet.h>

uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);

例如将IP地址转换后准备发送。如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回,如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回。 

Socket地址的数据类型 

linux下socket编程-TCP

具体细节:

linux下socket编程-TCP

sockaddr的缺陷:sa_data把目标地址和端口信息混在一起了。

linux下socket编程-TCP

sockaddr_in结构体解决了sockaddr的缺陷,把port和addr 分开储存在两个变量中。

对于struct in_addr还有另一种形式的实现:

struct in_addr
{
    union
    {
        struct{unsigned char s_b1,s_b2,s_b3,s_b4;} S_un_b;
        struct{unsigned short s_w1,s_w2;} S_un_w;
        unsigned long S_addr;//4字节,32位,按照网络字节顺序存储IP地址
    } S_un;
};

只要取得某种sockaddr结构体的首地址,不需要知道具体是哪种类型的sockaddr结构体,就可以根据地址类型字段确定结构体中的内容。因此,socket API可以接受各种类型的sockaddr结构体指针做参数,例如bind、accept、connect等函数,这些函数的参数应该设计成void *类型以便接受各种类型的指针,但是sock API的实现早于ANSI C标准化,那时还没有void *类型,因此这些函数的参数都用struct sockaddr *类型表示,在传递参数之前要强制类型转换一下,例如:

struct sockaddr_in servaddr;
/* initialize servaddr */
bind(listen_fd, (struct sockaddr *)&servaddr, sizeof(servaddr));

sockaddr_in中的成员struct in_addr sin_addr表示32位的IP地址。但是我们通常用点分十进制的字符串表示IP地址,以下函数可以在字符串表示和in_addr表示之间转换。

字符串转in_addr的函数:

#include <arpa/inet.h>

int inet_aton(const char *strptr, struct in_addr *addrptr);
in_addr_t inet_addr(const char *strptr);
int inet_pton(int family, const char *strptr, void *addrptr);

in_addr转字符串的函数:

char *inet_ntoa(struct in_addr inaddr);
const char *inet_ntop(int family, const void *addrptr, char *strptr, size_t len);

其中inet_pton和inet_ntop不仅可以转换IPv4的in_addr,还可以转换IPv6的in6_addr,因此函数接口是void *addrptr。

TCP协议通信流程

linux下socket编程-TCP

如果客户端没有更多的请求了,就调用close()关闭连接,就像写端关闭的管道一样,服务器的read()返回0,这样服务器就知道客户端关闭了连接,也调用close()关闭连接。注意,任何一方调用close()后,连接的两个传输方向都关闭,不能再发送数据了。如果一方调用shutdown()则连接处于半关闭状态,仍可接收对方发来的数据。

提示:read()返回0就表明收到了FIN段。

最简单的TCP网络程序

/*server.c*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <ctype.h>
#include <arpa/inet.h>

#define MAXLINE 80
#define SERV_PORT 8000

int main(void)
{
	//IP地址+端口号就是一个sokcet,唯一标识网络通信的一个进程
	struct sockaddr_in servaddr, cliaddr;
	socklen_t cliaddr_len;

	int listenfd, connfd;

	char buf[MAXLINE];
	//ipv4的地址长度
	char str[INET_ADDRSTRLEN];

	int i, n;

	listenfd = socket(AF_INET, SOCK_STREAM, 0);

	bzero(&servaddr, sizeof(servaddr));
	servaddr.sin_family = AF_INET;
	servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
	servaddr.sin_port = htons(SERV_PORT);

	bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr));

	listen(listenfd, 20);

	printf("Accepting connections ...\n");
	

	while(1)
	{
		cliaddr_len = sizeof(cliaddr);
		connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &cliaddr_len);

		n = read(connfd, buf, MAXLINE);
		printf("received from %s at port %d\n", 
			inet_ntop(AF_INET, &cliaddr.sin_addr, str, sizeof(str)), 
			ntohs(cliaddr.sin_port));

		for(i = 0; i < n; ++i)
		{
			buf[i] = toupper(buf[i]);
		}//for

		write(connfd, buf, n);

		close(connfd);

	}//while

}

服务器的网络地址为INADDR_ANY,这个宏表示本地的任意IP地址,因为服务器可能有多个网卡,每个网卡也可能绑定多个IP地址,这样设置可以在所有的IP地址上监听,直到与某个客户端建立了连接时才确定下来到底用哪个IP地址,端口号为SERV_PORT,我们定义为8000。

在accept函数中,cliaddr是一个传出参数,accept()返回时传出客户端的地址和端口号。addrlen参数是一个传入传出参数(value-result argument),传入的是调用者提供的缓冲区cliaddr的长度以避免缓冲区溢出问题,传出的是客户端地址结构体的实际长度(有可能没有占满调用者提供的缓冲区)。如果给cliaddr参数传NULL,表示不关心客户端的地址。

/* client.c */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define MAXLINE 80
#define SERV_PORT 8000

int main(int argc, char *argv[])
{
	struct sockaddr_in servaddr;

	char buf[MAXLINE];

	int sockfd, n;

	char *str;

	if(argc != 2)
	{
		fputs("usage: ./client message\n", stderr);
		exit(1);
	}
	str = argv[1];

	sockfd = socket(AF_INET, SOCK_STREAM, 0);

	bzero(&servaddr, sizeof(servaddr));
	servaddr.sin_family = AF_INET;
	inet_pton(AF_INET, "127.0.0.1", &servaddr.sin_addr);
	servaddr.sin_port = htons(SERV_PORT);

	connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr));

	write(sockfd, str, strlen(str));

	n = read(sockfd, buf, MAXLINE);
	printf("Response from server:\n");
	write(STDOUT_FILENO, buf, n);

	close(sockfd);
	return 0;
}

先编译运行服务器:

$ ./server
 Accepting connections ...

然后在另一个终端里用netstat命令查看:

$ netstat -apn|grep 8000
 tcp        0      0 0.0.0.0:8000            0.0.0.0:*               LISTEN     8148/server

可以看到server程序监听8000端口,IP地址还没确定下来。

现在编译运行客户端:

$ ./client abcd
Response from server:
ABCD

回到server所在的终端,看看server的输出:

$ ./server
 Accepting connections ...
 received from 127.0.0.1 at PORT 59757

再做一个小实验,在客户端的connect()代码之后插一个while(1);死循环,使客户端和服务器都处于连接中的状态,用netstat命令查看:

$ ./server &
[1] 8343
$ Accepting connections ...
./client abcd &
[2] 8344
$ netstat -apn|grep 8000
tcp        0      0 0.0.0.0:8000            0.0.0.0:*               LISTEN     8343/server         
tcp        0      0 127.0.0.1:44406         127.0.0.1:8000          ESTABLISHED8344/client         
tcp        0      0 127.0.0.1:8000          127.0.0.1:44406         ESTABLISHED8343/server

应用程序中的一个socket文件描述符对应一个socket pair,也就是源地址:源端口号和目的地址:目的端口号,也对应一个TCP连接。

linux下socket编程-TCP

错误处理与读写控制

系统调用不能保证每次都成功,必须进行出错处理,这样一方面可以保证程序逻辑正常,另一方面可以迅速得到故障信息。

为使错误处理的代码不影响主程序的可读性,我们把与socket相关的一些系统函数加上错误处理代码包装成新的函数,做成一个模块wrap.c:

#include <stdlib.h>
#include <errno.h>
#include <sys/socket.h>

void perr_exit(const char *s)
{
	perror(s);
	exit(1);
}

int wrap_accept(int fd, struct sockaddr *sa, socklen_t *salenptr)
{
	int n;

again:
	if((n = accept(fd, sa, salenptr)) < 0)
	{
		if((errno == ECONNABORTED) || (errno == EINTR))
			goto again;
		else 
			perr_exit("accept error");
	}

	return n;
}

void wrap_bind(int fd, const struct sockaddr *sa, socklen_t salen)
{
	if(bind(fd, sa, salen) < 0)
		perr_exit("bind error");
}

void wrap_connect(int fd, const struct sockaddr *sa, socklen_t salen)
{
	if(connect(fd, sa, salen) < 0)
		perr_exit("connect error");
}

void wrap_listen(int fd, int backlog)
{
	if(listen(fd, backlog) < 0)
		perr_exit("listen error");
}

int wrap_socket(int family, int type, int protocol)
{
	int n;
	if((n = socket(family, type, protocol)) < 0)
		perr_exit("socket error");

	return n;
}

ssize_t wrap_read(int fd, void *ptr, size_t nbytes)
{
	ssize_t n;
again:
	if((n = read(fd, ptr, nbytes)) == -1)
	{
		if(errno == EINTR)
			goto again;
		else 
			return -1;
	}

	return n;
}

ssize_t wrap_write(int fd, const void *ptr, size_t nbytes)
{
	ssize_t n;

again:
	if((n = write(fd, ptr, nbytes)) == -1)
	{
		if(errno == EINTR)
			goto again;
		else
			return -1;
	}

	return n;
}

void wrap_close(int fd)
{
	if(close(fd) == -1)
		perr_exit("close error");
}

慢系统调用accept、read和write被信号中断时应该重试。connect虽然也会阻塞,但是被信号中断时不能立刻重试。对于accept,如果errno是ECONNABORTED,也应该重试。

TCP协议是面向流的,read和write调用的返回值往往小于参数指定的字节数。对于read调用,如果接收缓冲区中有20字节,请求读100个字节,就会返回20。对于write调用,如果请求写100个字节,而发送缓冲区中只有20个字节的空闲位置,那么write会阻塞,直到把100个字节全部交给发送缓冲区才返回,但如果socket文件描述符有O_NONBLOCK标志,则write不阻塞,直接返回20。为避免这些情况干扰主程序的逻辑,确保读写我们所请求的字节数,我们实现了两个包装函数readn和writen,也放在wrap.c中:

ssize_t wrap_readn(int fd, void *vptr, size_t n)
{
	size_t nleft;
	ssize_t nread;
	char *ptr;

	ptr = vptr;
	nleft = n;
	while(nleft > 0)
	{
		if((nread = read(fd, ptr, nleft)) < 0)
		{
			if(errno == EINTR)
				nread = 0;
			else
				return -1;
		}
		else if(nread == 0)
		{
			break;
		}

		nleft -= nread;
		ptr += nread;
	}//while

	return n - nleft;
}

linux下socket编程-TCP

如果wrap_readn函数返回了负数,那么这个负数的绝对值就表示多读取了多少的字节数。

ssize_t wrap_writen(int fd, const void *vptr, size_t n)
{
	size_t nleft;
	ssize_t nwritten;
	const char *ptr;

	ptr = vptr;
	nleft = n;

	while(nleft > 0)
	{
		if((nwritten = write(fd, ptr, nleft)) <= 0)
		{
			if(nwritten < 0 && errno == EINTR)
				nwritten = 0;
			else
				return -1;
		}
		nleft -= nwritten;
		ptr += nwritten;
	}

	return n;
}

linux下socket编程-TCP

如果应用层协议的各字段长度固定,用readn来读是非常方便的。例如设计一种客户端上传文件的协议,规定前12字节表示文件名,超过12字节的文件名截断,不足12字节的文件名用'\0'补齐,从第13字节开始是文件内容,上传完所有文件内容后关闭连接,服务器可以先调用readn读12个字节,根据文件名创建文件,然后在一个循环中调用read读文件内容并存盘,循环结束的条件是read返回0。

字段长度固定的协议往往不够灵活,难以适应新的变化。如果新版本的协议要添加新的字段,比如规定前12字节是文件名,从13到16字节是文件类型说明,从第17字节开始才是文件内容,同样会造成和老版本的程序无法兼容的问题。

现在看一下TFTP协议是如何避免上述问题的:TFTPTFTP协议的各字段是可变长的,以'\0'为分隔符,文件名可以任意长,这样,以后添加新的选项仍然可以和老版本的程序兼容(老版本的程序只要忽略不认识的选项就行了)。因此,常见的应用层协议都是带有可变长字段的,字段之间的分隔符用换行的比用'\0'的更常见,例如本节后面要介绍的HTTP协议。可变长字段的协议用readn来读就很不方便了,为此我们实现一个类似于fgets的readline函数,也放在wrap.c中:

//每次调用one_char_read返回一个字节数据
//字节数据是暂存在静态的数组中的
static ssize_t one_char_read(int fd, char *ptr)
{
	static int read_cnt;
	static char *read_ptr;	//静态缓冲区的遍历指针
	static char read_buf[100];	//静态数据缓冲区

	if(read_cnt <= 0)
	{
	again:
		if((read_cnt = read(fd, read_buf, sizeof(read_buf))) < 0)
		{
			if(errno = EINTR)
				goto again
			return -1;
		}
		else if(read_cnt == 0)
			return 0;

		read_ptr = read_buf;
	}//if

	read_cnt--;
	*ptr = *read_ptr++;

	return 1;
}

linux下socket编程-TCP

当把静态缓冲区的字节全部返回后,read_cnt=0,下次在调用one_char_read的时候,就会再次利用read函数读取数据。read_ptr重新回到数组首地址,read_cnt中保存的是这次读取到的字节数目。

ssize_t wrap_readline(int fd, void *vptr, size_t maxlen)
{
	ssize_t n, rc;
	char c, *ptr;

	ptr = vptr;
	for(n = 1; n < maxlen; n++)
	{
		if((rc = one_char_read(fd, &c)) == 1)
		{
			*ptr++ = c;
			if(c == '\n')
				break;
		}
		else if(rc == 0)
		{
			*ptr = 0;
			return n - 1
		}
		else
			return -1;
	}//for

	*ptr = 0;
	return n;
}

到这里为止,就可以在client和server中添加错误控制了,这时不再直接使用原来的系统调用,而是使用wrap.c中封装过的Socket API接口。

2.3. 把client改为交互式输入

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#include "wrap.h"

#define MAXLINE 80
#define SERV_PORT 8000

int main(int argc, char *argv[])
{
	struct sockaddr_in servaddr;
	char buf[MAXLINE];
	int sockfd, n;

	sockfd = wrap_socket(AF_INET, SOCK_STREAM, 0);

	bzero(&servaddr, sizeof(servaddr));
	servaddr.sin_family = AF_INET;
	inet_pton(AF_INET, "127.0.0.1", &servaddr.sin_addr);
	servaddr.sin_port = htons(SERV_PORT);

	wrap_connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr));

	while(fgets(buf, MAXLINE, stdin) != NULL)
	{
		wrap_write(sockfd, buf, strlen(buf));
		n = wrap_read(sockfd, buf, MAXLINE);

		if(n == 0)
			printf("the other side has been closed.\n");
		else
			wrap_write(STDOUT_FILENO, buf, n);
	}//while

	wrap_close(sockfd);

	return 0;
}

编译并运行server和client,看看是否达到了你预想的结果。

$ ./client
haha1
HAHA1 
haha2
the other side has been closed.
haha3
$

这时server仍在运行,但是client的运行结果并不正确。原因是什么呢?仔细查看server.c可以发现,server对每个请求只处理一次,应答后就关闭连接,client不能继续使用这个连接发送数据。但是client下次循环时又调用write发数据给server,write调用只负责把数据交给TCP发送缓冲区就可以成功返回了,所以不会出错,而server收到数据后应答一个RST段,client收到RST段后无法立刻通知应用层,只把这个状态保存在TCP协议层。client下次循环又调用write发数据给server,由于TCP协议层已经处于RST状态了,因此不会将数据发出,而是发一个SIGPIPE信号给应用层,SIGPIPE信号的缺省处理动作是终止程序,所以看到上面的现象。

linux下socket编程-TCP

为了避免client异常退出,上面的代码应该在判断对方关闭了连接后break出循环,而不是继续write。另外,有时候代码中需要连续多次调用write,可能还来不及调用read得知对方已关闭了连接就被SIGPIPE信号终止掉了,这就需要在初始化时调用sigaction处理SIGPIPE信号,如果SIGPIPE信号没有导致进程异常退出,write返回-1并且errno为EPIPE

下面修改server,使它可以多次处理同一客户端的请求:

 

/* wrap_server.c */
#include <stdio.h>
#include <string.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <ctype.h>

#include "wrap.h"

#define MAXLINE 80
#define SERV_PORT 8000

int main(void)
{
	struct sockaddr_in servaddr, cliaddr;

	socklen_t cliaddr_len;
	int listenfd, connfd;

	char buf[MAXLINE];
	char str[INET_ADDRSTRLEN];

	int i, n;

	listenfd = wrap_socket(AF_INET, SOCK_STREAM, 0);

	bzero(&servaddr, sizeof(servaddr));
	servaddr.sin_family = AF_INET;
	servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
	servaddr.sin_port = htons(SERV_PORT);

	wrap_bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr));

	wrap_listen(listenfd, 20);

	printf("Accepting connections ...\n");
	while(1)
	{
		cliaddr_len = sizeof(cliaddr);
		connfd = wrap_accept(listenfd, (struct sockaddr *)&cliaddr, &cliaddr_len);

		while(1)
		{
			n = wrap_read(connfd, buf, MAXLINE);
			if(n == 0)
			{
				printf("the other side has been closed.\n");
				break;
			}

			printf("received from %s at port %d\n", 
				inet_ntop(AF_INET, &cliaddr.sin_addr, str, sizeof(str)), 
				ntohs(cliaddr.sin_port));
			for(i = 0; i < n; ++i)
				buf[i] = toupper(buf[i]);
			wrap_write(connfd, buf, n);
		}//while

		wrap_close(connfd);
	}//while
}

经过上面的修改后,客户端和服务器可以进行多次交互了。我们知道,服务器通常是要同时服务多个客户端的,运行上面的server和client之后,再开一个终端运行client试试,新的client能得到服务吗?想想为什么。 

使用fork并发处理多个client的请求

网络服务器通常用fork来同时服务多个客户端,父进程专门负责监听端口,每次accept一个新的客户端连接就fork出一个子进程专门服务这个客户端。但是子进程退出时会产生僵尸进程,父进程要注意处理SIGCHLD信号和调用wait清理僵尸进程。

一下给出代码框架:

listenfd = socket(...);
bind(listenfd, ...);
listen(listenfd, ...); 
while (1) {
	connfd = accept(listenfd, ...);
	n = fork();
	if (n == -1) {
		perror("call to fork");
		exit(1);
	} else if (n == 0) {
		close(listenfd);
		while (1) {
			read(connfd, ...);
			...
			write(connfd, ...);
		}
		close(connfd);
		exit(0);
	} else
		close(connfd);
}

setsockopt

现在做一个测试,首先启动server,然后启动client,然后用Ctrl-C使server终止,这时马上再运行server,结果是:

$ ./server
 bind error: Address already in use

这是因为,虽然server的应用程序终止了,但TCP协议层的连接并没有完全断开,因此不能再次监听同样的server端口。我们用netstat命令查看一下:

$ netstat -apn |grep 8000
 tcp        1      0 127.0.0.1:33498         127.0.0.1:8000          CLOSE_WAIT 10830/client        
 tcp        0      0 127.0.0.1:8000          127.0.0.1:33498         FIN_WAIT2  -

server终止时,socket描述符会自动关闭并发FIN段给client,client收到FIN后处于CLOSE_WAIT状态,但是client并没有终止,也没有关闭socket描述符,因此不会发FIN给server,因此server的TCP连接处于FIN_WAIT2状态。

现在用Ctrl-C把client也终止掉,再观察现象:

$ netstat -apn |grep 8000
 tcp        0      0 127.0.0.1:8000          127.0.0.1:44685         TIME_WAIT  -
 $ ./server
 bind error: Address already in use

client终止时自动关闭socket描述符,server的TCP连接收到client发的FIN段后处于TIME_WAIT状态。TCP协议规定,主动关闭连接的一方要处于TIME_WAIT状态,等待两个MSL(maximum segment lifetime)的时间后才能回到CLOSED状态,因为我们先Ctrl-C终止了server,所以server是主动关闭连接的一方,在TIME_WAIT期间仍然不能再次监听同样的server端口。MSL在RFC1122中规定为两分钟,但是各操作系统的实现不同,在Linux上一般经过半分钟后就可以再次启动server了。

在server的TCP连接没有完全断开之前不允许重新监听是不合理的,因为,TCP连接没有完全断开指的是connfd(127.0.0.1:8000)没有完全断开,而我们重新监听的是listenfd(0.0.0.0:8000),虽然是占用同一个端口,但IP地址不同,connfd对应的是与某个客户端通讯的一个具体的IP地址,而listenfd对应的是wildcard address。解决这个问题的方法是使用setsockopt()设置socket描述符的选项SO_REUSEADDR为1,表示允许创建端口号相同但IP地址不同的多个socket描述符。在server代码的socket()和bind()调用之间插入如下代码:

int opt = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

使用select

select是网络程序中很常用的一个系统调用,它可以同时监听多个阻塞的文件描述符(例如多个网络连接),哪个有数据到达就处理哪个,这样,不需要fork多进程就可以实现并发服务的server。