Linux 下的 TCP 编程
2019-10-07
关键字:
TCP 网络通信模型中通常都都采用 C/S架构。
所谓 C/S架构 即通信双方一方是客户端 Client,另一方是服务端 Server。
服务端的整体流程如下:
1、socket()
2、bind()
3、listen()
4、accept()
5、write()
6、close()
客户端的整体流程如下:
1、socket()
2、connect()
3、read()
4、close()
socket() 函数:
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
函数执行成功时会返回一个代表着这个 socket 通信的文件描述符,失败时返回 EOF 并设置对应 errno。
参数 domain 表示通信协议类型,一般可以填以下几个值:
1、AF_INET;
IPV4
2、AF_INET6;
IPV6
3、AF_UNIX, AF_LOCAL;
本地通信。
4、AF_NETLINK;
内核与用户空间的通信,一般在做设备通信的时候会用上。
5、AF_PACKET
参数 type 指 socket 的类型,一般可以填 SOCK_STREAM 或 SOCK_DGRAM 或 SOCK_RAW。
参数 protocol 一般填 0 即可。但若 type 是 SOCK_RAW 时才需要按需填写这个参数的值。
bind() 函数:
#include <sys/types.h>
#include <sys/socket.h>
int bind(int sockid, const struct sockaddr *addr, socklen_t addrlen);
执行成功时返回值 0,失败时返回 EOF 并设置对应的 errno。
参数 sockId 表示通过 socket() 函数拿到的文件描述符。
参数 addr 所指代的结构体原型为:
struct sockaddr {
sa_family_t sa_family;
char sa_data[14];
}
但对于 IPV4 网络编程来讲,它实际上填充的数据是:
struct sockaddr_in {
sa_family_t sin_family; /* address family: AF_INET */
in_port_t sin_port; /* port in network byte order */
struct in_addr sin_addr; /* internet address */
};
struct in_addr {
uint32_t s_addr; /* address in network byte order */
};
所以这个参数在 IPV4 下是将上面的 sockaddr_in 强转成 sockaddr 来填入。由于两个结构体长度不一致,因此要在 sockaddr_in 末尾填充 0。
参数 addrlen 就是参数 2 的结构体的长度。
listen() 函数:
#include <sys/types.h>
#include <sys/socket.h>
int listen(int sockId, int backlog);
执行成功返回值 0,失败返回 EOF 并设置相应的 errno。
参数 sockId 是 socket 的文件描述符。
参数 backlog 一般填 5。这个参数表示同时允许多少个客户端和服务器进行三次握手的连接过程。
accept() 函数:
它的作用就是阻塞并等待客户端的连接。
#include <sys/types.h>
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
执行成功时会返回成功进行连接的 socket 的文件描述符,这是一个新的 socket 文件描述符,换句话说,当有客户端成功连接以后,与这个客户端通信所使用的 sockfd 就是这个新的 socket 文件描述符,旧的 sockfd 将继续用来等待下一个客户端的连接。函数执行失败时返回 EOF 并设置相应的 errno。
参数 addr 与 addrlen 这两个参数是用来保存连接进来的客户端的信息的。例如客户端的网络地址、端口号等信息。如果不想关心客户端的这些信息,就可以直接填 NULL 只靠新的 sockfd 来与客户端进行基础的 IO 通信。
connect() 函数:
#include <sys/types.h>
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
执行成功时返回值 0,失败时返回 EOF 并设置相应的 errno。
这几个参数与上面介绍的几个函数几乎是一样的,就不再解释了。
write()、read() 与 close() 函数:
当客户端与服务端成功建立连接以后,就可以通过对应的 sockfd 或 newfd 来进行标准 IO 通信了。说白了就是就把这个网络通信过程当成是普通文件 IO 过程就好。因此关于这里的 IO 函数就不再赘述了。
send()、recv() 函数:
#include <sys/types.h>
#include <sys/socket.h>
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
这两个函数与 write()、read() 大同小异。参数列表就多了一个 flags 参数而已。不过这两个函数的参数 flags 通常都填值 0,当填值 0 时,它们的作用与 write()、read() 是一样的。
实例:
以下贴出一个最简单的实现了 tcp 通信的服务端与客户端功能的实例代码:
服务端代码如下:
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <errno.h> #include <sys/socket.h> #include <sys/types.h> #include <netinet/in.h> #include <netinet/ip.h> #define IP_ADDR "192.168.77.200" int main() { int sockfd = -1; //对于 IPV4 来讲绑定时用的结构体就是这个。 struct sockaddr_in sin; // 1. 经典的创建 socket 函数调用方式。 if((sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0) { perror("socket"); exit(-1); } /* 由于 bind() 函数中参数 addr 所代表的通用结构体 sockaddr 的长度与 IPV4 下的 sockaddr_in 结构体不一致,因此需要将多余的数据部分清零。 */ bzero(&sin, sizeof(sin)); sin.sin_family = AF_INET; //对于 IPV4 来讲这个变量的值恒填 AF_INET。 sin.sin_port = htons(17173);//端口号,要求要网络字节序,因此需要作转换。 //sin.sin_addr.s_addr = inet_addr(IP_ADDR);//这种将字符串形式IPV4地址转换成整数形式 //的函数比较落后且有缺陷,一般不建议使用。 //可靠的 inet_pton() 转换函数 if(inet_pton(AF_INET, IP_ADDR, (void *)&sin.sin_addr.s_addr) != 1) //inet_pton() 的返回值较特殊。 { perror("inet_pton"); exit(-1); } // 2. 绑定操作。 if(bind(sockfd, (struct sockaddr *)&sin, sizeof(sin)) < 0) { perror("bind"); exit(-1); } // 3. 开始监听 if(listen(sockfd, 5) < 0) { perror("listen"); exit(-1); } // 4. 等待连接 int newfd = -1; newfd = accept(sockfd, NULL, NULL); if(newfd < 0) { perror("accept"); exit(-1); } // 5. 读写 int ret = -1; char buf[64]; while(1) { bzero(buf, 64); do{ ret = read(newfd, buf, 64 - 1); }while(ret < 0 && EINTR == errno); if(ret < 0) { perror("read"); exit(-1); } if(ret == 0) { //客户端已经关闭连接。 printf("Client was exited!\n"); break; } printf("Received:%s\n", buf); } close(newfd); close(sockfd); printf("bye\n"); return 0; }
客户端代码如下:
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <string.h> #include <sys/socket.h> #include <sys/types.h> #include <netinet/in.h> #include <netinet/ip.h> #define IP_ADDR "192.168.77.200" int main() { int sockfd = -1; //对于 IPV4 来讲绑定时用的结构体就是这个。 struct sockaddr_in sin; // 1. 经典的创建 socket 函数调用方式。 if((sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0) { perror("socket"); exit(-1); } // 2. 连接到服务端。 bzero(&sin, sizeof(sin)); sin.sin_family = AF_INET; sin.sin_port = htons(17173); if(inet_pton(AF_INET, IP_ADDR, (void *)&sin.sin_addr.s_addr) != 1) { perror("inet_pton"); exit(-1); } if(connect(sockfd, (struct sockaddr *)&sin, sizeof(sin)) < 0) { perror("connect"); exit(-1); } // 3. IO 通信 char buf[64]; while(1) { bzero(buf, 64); printf("input > "); //fflush(); fgets(buf, 63, stdin); write(sockfd, buf, strlen(buf)); } printf("bye\n"); close(sockfd); return 0; }
实例代码优化:
在上面的实例代码中,服务端所绑定的 IP 地址是写死的,这不便于程序的移植运行。可以将代码修改一下,使得程序可以不必限定绑定某个具体的 IP 地址,以提高程序的移植性。
修改的方法如下,主要就是在 bind() 之前将 sockaddr_in 的参数修改一下:
bzero(&sin, sizeof(sin)); sin.sin_family = AF_INET; sin.sin_port = htons(17173); sin.sin_addr.s_addr = htonl(INADDR_ANY);
只需要将要绑定的 IP 地址按照上述设定即可。上述的 INADDY_ANY 是一个宏,它代表的值是 -1,将绑定的 IP 地址设成 -1 就是告诉系统我要不限定 IP 地址绑定。
在上面的实例代码中,服务端是没有取客户端的信息的。若要获取客户端的信息,可以按照如下修改,主要的修改是完善 accept() 的参数。
struct sockaddr_in cin; int newfd = -1; newfd = accept(sockfd, (struct sockaddr *)&cin, sizeof(cin)); if(newfd < 0) { perror("accept"); exit(-1); } char ipv4[16]; if(!inet_ntop(AF_INET, (void *)&cin.sin_addr.s_addr, ipv4, sizeof(cin))) { perror("inet_ntop"); exit(-1); } printf("The ip of client:%s:%d\n", ipv4, ntohs(cin.sin_port));
没什么难的,若对某些函数的含义拿捏不准,直接 man 一下即可。
实现服务器的并发:
上面的实例中,服务端仅支持一个客户端的连接。显然这种交互模式是不符合要求的。一个最基础的服务端都应该做到在接收到客户端的连接请求以后,将与客户端的通信交由后台处理,自己则立即进入等待下一个连接的状态中去。
要实现这一功能,服务端在接收到连接请求后可以通过创建新进程或者新线程的方式来实现。
通过新线程来实现:
其核心思想就是在 socket 的前期准备完成以后,在 accept() 函数上包上一个无限循环,每接收到一个客户端连接就通过 pthread_create() 函数创建并运行一个子线程,将服务端与客户端的通信交由这个新建的子线程去执行,而主线程则在创建并启动了子线程后立即进行下一次的 accept 操作。
通过新进程来实现:
其核心思想与通过新线程的方式差不多,都是使用一个无限循环包裹一下 accept() 函数。
当接收到新客户端连接时,通过 fork() 函数创建子进程。由于子进程会继承父进程的几乎所有数据,因为我们必须要分辨两个进程,并分别在父、子进程中将各自不需要的资源关闭掉,例如在父进程中将 newfd 关掉,在子进程中将 sockfd 关掉。
同时还要注意,由于子进程本质上也是被包裹在一个无限循环中的,因此若子进程中的通信完成以后,要及时退出程序,通过 return 或 exit() 来退出。
然后还要注意避免子进程变成僵尸进程。解决的办法就是通过 signal() 函数自定义信号响应方式来接收子进程退出的信号进而回收子进程。子进程在结束时会发出 SIGCHLD 信号,默认情况下父进程会忽略此信号,因此我们可以通过捕捉这个信号来实现回收子进程的目的。
至于捕捉到这个信号以后的处理方式就简单了,直接调用 wait(-1) 或 waitpid(-1...) 函数来回收子进程即可。因为正是有子进程结束了者触发到调用进程回收函数的,因此 wait() 函数的参数直接填 -1 就几乎可以肯定能回收掉刚结束掉的子进程。
设置服务端绑定地址快速重用:
默认情况下,当一个地址被服务端绑定过后,将服务端退出,需要隔一段时间才能再次绑定这个地址。
Linux C 可以通过一个函数来设定允许地址快速重复绑定操作。
做法就是在服务端 socket 创建以后绑定之前调用一个函数:
// 创建 socket int b_reuse = 1; setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &b_reuse, sizeof(int)); // 绑定 socket