Linux 网络编程复习总结
覆盖范围:协议与分层 → 数据封装与地址解析 → IP/TCP → B/S 与 C/S → 套接字核心函数 → TCP 客户端/服务器实战 → 信号量生产者消费者复习
一、协议与分层模型
1.1 什么是协议
协议是一组通信规则,约定双方"如何开始、如何表示数据、何时结束"。没有协议,两台主机即使物理连通,也无法理解彼此发送的比特流。
协议的三要素可以记为:语法(数据格式)、语义(每个字段的含义与应答动作)、时序(谁先发、什么时候发)。
1.2 OSI 七层与 TCP/IP 四层
OSI 是教学参考模型,TCP/IP 是实际落地模型。对应关系如下:
| OSI 七层 | TCP/IP 四层 | 代表协议 |
|---|---|---|
| 应用层 / 表示层 / 会话层 | 应用层 | HTTP、FTP、SSH、Telnet |
| 传输层 | 传输层 | TCP、UDP |
| 网络层 | 网络层 | IP、ICMP、IGMP |
| 数据链路层 / 物理层 | 网络接口层(链路层) | 以太网帧、ARP |
Linux 应用层网络编程主要工作在传输层及以下。调用 socket API 时,传输层的 TCP/UDP 头、网络层的 IP 头、链路层的帧头都由内核(含网卡驱动)处理,应用程序只面对字节流或数据报。
二、数据封装流程:从应用到比特流
数据不能"裸奔"在网络中传递,必须逐层加上头部。发送方向的封装顺序为:
应用数据 → 传输层(TCP/UDP 头)→ 网络层(IP 头)→ 链路层(以太网帧头 + 帧尾)→ 网卡发送
接收方向则反向逐层剥离头部,最终把应用数据交给对应的 socket 缓冲区。
两点补充:
- 每层只负责加自己的头,接收端按相反顺序逐层剥掉,任何一层都不关心上层的载荷内容。
- 以太网帧尾的 FCS 校验一般由网卡硬件计算并附加,软件构造帧时只管帧头。
三、以太网帧、ARP 与 IP 协议
3.1 以太网帧与 ARP 的分工
以太网帧:依据 MAC 地址在局域网内完成数据包的传输。
ARP(地址解析协议):已知 IP 地址,查询对应的 MAC 地址。只有拿到 MAC,以太网帧才能在链路层发出。
ARP 请求以广播方式发出(目的 MAC 全 F),目标主机收到后以单播应答;结果会存进本机的 ARP 缓存,并带老化时间,过期后重新查询。
3.2 IP 协议要点
IP 协议负责逻辑寻址和路由。IPv4 头部中需要关注的字段:
| 字段 | 作用 |
|---|---|
| 版本 | IPv4 / IPv6 |
| TTL | 数据包允许经过的路由跳数上限,每经一个路由减 1,减到 0 时丢弃,防止环路 |
| 源 IP / 目的 IP | 各 32 位(4 字节),点分十进制表示(如 192.168.1.108) |
IP 地址标识主机,端口号标识该主机上的进程,二者合起来确定一个通信端点。严格地说,一条 TCP 连接由四元组唯一确定:源 IP、源端口、目的 IP、目的端口。服务器能用同一个 8080 端口同时服务多个客户端,靠的就是四元组不同。
四、TCP 协议核心特征
TCP 是面向连接的、可靠的、基于字节流的传输层协议,提供全双工通信。TCP 头部关键字段:
- 16 位源端口号 / 16 位目的端口号:端口范围 0~65535,其中 0 保留,1~1023 是知名端口(绑定通常需要 root)。
- 32 位序号 / 32 位确认序号:用于可靠传输、去重、按序重组。
- 6 个标志位:SYN、ACK、FIN、RST、PSH、URG,控制连接的建立、拆除与数据传输。
- 16 位窗口大小:流量控制,告知对方当前可接收的数据量。
TCP 是一对一的连接,天然属于 C/S 模型:服务器必须先存在并监听,客户端主动连接。也因为是字节流,TCP 不保留应用层的消息边界,一次 read 可能读到半条消息,也可能读到两条半,收发双方要自己约定切分方式。
五、B/S 与 C/S 模型比对
| 维度 | C/S(Client/Server) | B/S(Browser/Server) |
|---|---|---|
| 客户端 | 需安装专用客户端程序 | 只需浏览器,零维护 |
| 协议 | 可自定义,灵活 | 应用层只认 HTTP/HTTPS |
| 响应速度 | 快,可缓存大量数据 | 相对较慢,不能缓存大量数据 |
| 安全性 | 较高,可自定义加密,局域网内可控 | 依赖 HTTPS 与浏览器安全模型 |
| 开发维护成本 | 较高(两端都要维护) | 较低(仅维护服务端) |
| 适用场景 | 局域网、高性能交互 | 广域网、跨平台通用访问 |
B/S 本质上是 C/S 的一种特化形态——客户端固定为浏览器,协议固定为 HTTP/HTTPS。
六、套接字(Socket)本质
套接字是一个文件描述符,指向内核中的一个套接字对象。该对象内部由两个缓冲区(发送缓冲区、接收缓冲区)实现双向通信。通信过程中,套接字一定是成对出现的:一端的发送缓冲区对应另一端的接收缓冲区。
所以对 socket 的 read/write 和读写普通文件很像,区别在于套接字不支持 lseek,没有文件偏移的概念;而且收发可以同时进行,两个方向互不影响。
七、网络字节序与地址转换函数
7.1 为什么需要字节序转换
主机字节序:x86 多为小端(低位字节存低地址)。网络字节序:TCP/IP 规定使用大端(高位字节存低地址)。因此,端口号和 IP 地址在写入 sockaddr_in 之前必须转为网络字节序。漏掉转换不会报错,但会连到一个莫名其妙的端口上,属于最难查的一类 bug。
7.2 字节序转换函数(<arpa/inet.h>)
函数命名规则:h = host,n = network,l = long(32 位,用于 IP),s = short(16 位,用于端口)。
uint32_t htonl(uint32_t hostlong); // 主机字节序 32 位 → 网络字节序(IP)
uint16_t htons(uint16_t hostshort); // 主机字节序 16 位 → 网络字节序(端口)
uint32_t ntohl(uint32_t netlong); // 网络字节序 32 位 → 主机字节序(IP)
uint16_t ntohs(uint16_t netshort); // 网络字节序 16 位 → 主机字节序(端口)7.3 IP 地址转换函数:字符串 ↔ 二进制
inet_pton 和 inet_ntop 是可重入、同时支持 IPv4/IPv6 的现代接口,新代码优先用它们。
注释:p 代表的是 “Presentation to Network” 呈现给人看的形式,适合人阅读和书写的地址表示形式。
注释:n 代表的是 “Network” 是网络协议内部使用的二进制数值形式,并且是网络字节序
inet_pton:点分十进制字符串 → 网络字节序二进制
int inet_pton(int af, const char *src, void *dst);| 参数 | 说明 |
|---|---|
| af | AF_INET(IPv4)或 AF_INET6 |
| src | 源字符串,如 "192.168.1.100" |
| dst | 转换结果写入的内存(IPv4 应传入 struct in_addr * 的地址) |
| 返回值 | 成功返回 1;格式无效返回 0;af 不支持返回 -1 |
注意返回值的三种情况:0 表示字符串不是合法地址,-1 表示地址族不支持,只有 1 才是成功。用 if (ret < 0) 判断会把格式错误漏掉。
inet_ntop:网络字节序二进制 → 点分十进制字符串
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);| 参数 | 说明 |
|---|---|
| af | AF_INET / AF_INET6 |
| src | 指向二进制 IP 的指针(如 &addr.sin_addr) |
| dst | 存放结果字符串的缓冲区 |
| size | 缓冲区大小,IPv4 建议至少 INET_ADDRSTRLEN(16) |
| 返回值 | 成功返回 dst;失败返回 NULL |
7.4 小例子:把转换结果打印成字节
光看函数说明不容易建立直觉,下面这段程序把三种表示的实际内存字节打出来。在小端机器上运行,能直接看到"主机序"和"网络序"的差别。
/* endian.c */
#include <arpa/inet.h>
#include <stdio.h>
static void dump(const char *tag, const void *p, size_t n)
{
const unsigned char *b = p;
size_t i;
printf("%s:", tag);
for (i = 0; i < n; i++)
printf(" %02x", b[i]);
printf("\n");
}
int main(void)
{
uint16_t host_port = 8080;
uint16_t net_port = htons(host_port);
struct in_addr ip;
char str[INET_ADDRSTRLEN];
if (inet_pton(AF_INET, "192.168.1.108", &ip) != 1) {
fprintf(stderr, "inet_pton failed\n");
return 1;
}
dump("host order", &host_port, 2); /* 小端机器:90 1f */
dump("network order", &net_port, 2); /* 1f 90,即 0x1f90 = 8080 */
dump("ip address", &ip, 4); /* c0 a8 01 6c */
inet_ntop(AF_INET, &ip, str, sizeof(str));
printf("inet_ntop -> %s\n", str);
return 0;
}编译运行:
gcc -Wall -Wextra -o endian endian.c && ./endian八、sockaddr 地址结构
8.1 通用结构 vs IPv4 专用结构
struct sockaddr 是通用"壳",struct sockaddr_in 是 IPv4 的实际填充结构。构造时用 sockaddr_in,传给函数时强制转换为 (struct sockaddr *),这样同一套函数签名可以兼容 IPv4、IPv6、Unix 域套接字。
struct sockaddr {
sa_family_t sa_family; // 地址族
char sa_data[14]; // 地址数据(协议相关)
};
struct sockaddr_in {
sa_family_t sin_family; // 地址族,IPv4 填 AF_INET
in_port_t sin_port; // 端口号,必须是网络字节序
struct in_addr sin_addr; // IPv4 地址,必须是网络字节序
};
struct in_addr {
uint32_t s_addr; // 32 位 IPv4 地址,网络字节序
};8.2 关键字段使用规则
sin_family不要做字节序转换,直接赋AF_INET。sin_port必须经过htons()转换为网络字节序。sin_addr.s_addr必须经过htonl()/inet_pton()转换,或直接使用INADDR_ANY。- 端口写 0 表示让内核分配一个临时端口,客户端通常不 bind,内核会自动做这件事。
- 结构体里没有显式赋值的填充字节用
memset清零,避免把栈上的垃圾值传给内核。
九、套接字核心函数详解
9.1 socket:创建套接字
#include <sys/socket.h>
int socket(int domain, int type, int protocol);| 参数 | 说明 |
|---|---|
| domain | 协议族:AF_INET(IPv4)、AF_INET6、AF_UNIX 等 |
| type | SOCK_STREAM(TCP,流式)、SOCK_DGRAM(UDP,报式) |
| protocol | 通常填 0,表示默认协议;也可显式填 IPPROTO_TCP / IPPROTO_UDP |
| 返回值 | 成功返回套接字文件描述符(非负);失败返回 -1,设置 errno |
需要纠正一个常见说法:socket() 返回时只是创建了一个空的套接字对象,此时既没有本地 IP 和端口(除非之后 bind),也没有对端地址。执行 bind 后本地端点才确定;执行 connect 成功或 accept 返回后,描述符才与"本地 IP、本地端口、对端 IP、对端端口"这个四元组绑定。所以"描述符记录目的 IP 和端口"这句话只对已连接的套接字成立。
9.2 bind:绑定本地地址
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);| 参数 | 说明 |
|---|---|
| sockfd | socket() 返回的描述符 |
| addr | 填充好的地址结构,需强转为 (const struct sockaddr *) |
| addrlen | 地址结构的大小,通常 sizeof(struct sockaddr_in) |
| 返回值 | 成功返回 0;失败返回 -1 |
服务器需要绑定一个众所周知的端口供客户端连接。若只关心端口、不关心具体网卡,sin_addr.s_addr 可设为 INADDR_ANY,表示绑定本机所有网卡地址。
失败最常见的原因是 EADDRINUSE:端口被占用,或者上一次运行留下的连接处于 TIME_WAIT。服务器加 SO_REUSEADDR 可以避开后一种情况。
9.3 listen:将套接字设为监听状态
int listen(int sockfd, int backlog);| 参数 | 说明 |
|---|---|
| sockfd | 已 bind 的套接字描述符 |
| backlog | 已完成连接队列(accept 队列)的长度上限 |
| 返回值 | 成功返回 0;失败返回 -1 |
调用 listen 后,套接字从主动套接字变为被动套接字,内核开始接受该端口上的 SYN 请求,完成三次握手的连接进入已完成队列,等待 accept 取走。
关于 backlog 的两点澄清:
- 它限制的是 accept 队列,不是"同时能有多少个客户端",更不是端口上能建立连接的总数。队列满了之后,新完成握手的连接可能被丢弃或忽略,客户端表现为连接慢、超时。
- 内核实际取
min(backlog, /proc/sys/net/core/somaxconn),所以写 20 不保证真能排 20 个。想调大要先改 somaxconn。
9.4 accept:从已完成队列取出一个连接
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);| 参数 | 说明 |
|---|---|
| sockfd | 监听套接字描述符 |
| addr | 传出参数:调用成功后,内核填入客户端的地址信息 |
| addrlen | 传入传出参数:调用前设为 sizeof(struct sockaddr_in);调用后内核更新为实际写入的字节数 |
| 返回值 | 成功返回新的已连接套接字描述符(用于与该客户端通信);失败返回 -1 |
accept 的关键理解:监听套接字不变,每接受一个客户端,就返回一个新的已连接描述符。服务器需要为每个客户端维护一个已连接描述符,而监听套接字继续留在 accept 循环中接待后续连接。addr 和 addrlen 都可以传 NULL,表示不关心对端地址;但只要想打印客户端 IP 和端口,就必须传。
9.5 connect:客户端发起连接
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);| 参数 | 说明 |
|---|---|
| sockfd | 客户端 socket() 返回的描述符 |
| addr | 服务器的地址结构 |
| addrlen | 地址结构大小 |
| 返回值 | 成功返回 0;失败返回 -1 |
connect 触发 TCP 三次握手:客户端发送 SYN,服务器回应 SYN-ACK,客户端再回 ACK。三次握手完成后,connect 返回,双方进入 ESTABLISHED 状态。
常见的失败原因:ECONNREFUSED(服务器没启动、端口写错、被防火墙拒绝)、ETIMEDOUT(网络不通,SYN 没有回应)、ENETUNREACH(没有路由)。服务器必须先于客户端启动,否则客户端 connect 就会拿到 ECONNREFUSED。
9.6 数据收发与关闭
TCP 套接字可以直接使用文件 I/O 风格的 read / write,也可以使用 send / recv。
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
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);几个容易考也容易错的点:
read返回 0 表示对端已关闭(收到了 FIN),不是读到空数据;返回 -1 才是出错,且要排除errno == EINTR的情况。- 返回值可能小于请求的长度。TCP 是字节流,一次 read 读多少由内核缓冲区里现有数据决定;要读满 n 字节,必须循环。
- 向一个已经收到 RST 的连接 write,内核会给进程发 SIGPIPE,默认动作是终止进程。忽略它:
signal(SIGPIPE, SIG_IGN),或者send(..., MSG_NOSIGNAL)。
关闭时:
int close(int fd); // 关闭描述符,引用计数减到 0 时发送 FIN
int shutdown(int sock, int howto); // 半关闭,howto: SHUT_RD / SHUT_WR / SHUT_RDWRclose 只是把本进程的引用计数减一,减到 0 时内核才发 FIN,函数本身立刻返回。如果只想告诉对方"我不再发送了",但还要继续接收,用 shutdown(fd, SHUT_WR),它不受引用计数影响。
十、C/S 模型 TCP 通信流程
10.1 服务器端完整流程
socket(AF_INET, SOCK_STREAM, 0)—— 创建 TCP 套接字。bind(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr))—— 绑定 IP 和端口。listen(sockfd, backlog)—— 进入监听状态。accept(sockfd, (struct sockaddr *)&cli_addr, &addrlen)—— 阻塞等待客户端连接;返回新的已连接描述符 connfd。- 使用 connfd 与客户端收发数据(read / write 或 recv / send)。
close(connfd)结束与该客户端的通信;监听套接字继续留在 accept 循环中。
服务器必须先于客户端启动,否则客户端 connect 会因连接被拒绝而失败。
10.2 客户端完整流程
socket(AF_INET, SOCK_STREAM, 0)—— 创建 TCP 套接字。- 填充 serv_addr:
sin_family = AF_INET,sin_port = htons(port),sin_addr 设为服务器 IP(inet_pton或inet_addr)。 connect(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr))—— 发起连接,阻塞直到三次握手完成或失败。- 使用 sockfd 收发数据。
close(sockfd)。
inet_addr 是老接口,能用但有两个坑:输入非法时返回 INADDR_NONE,而广播地址 255.255.255.255 转换后也是 INADDR_NONE,没法区分;另外它不支持 IPv6。新代码统一用 inet_pton。
10.3 最小案例框架
下面是骨架,省略了返回值检查,完整可编译版本见 10.4 和 10.5。
服务器端(核心片段)
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in serv_addr = {0};
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(8080);
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
bind(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
listen(sockfd, 20);
struct sockaddr_in cli_addr;
socklen_t addrlen = sizeof(cli_addr);
int connfd = accept(sockfd, (struct sockaddr *)&cli_addr, &addrlen);
char buf[1024];
ssize_t n = read(connfd, buf, sizeof(buf));
if (n > 0)
write(connfd, "OK", 2); // 这里回固定字符串;真实服务器一般原样回显收到的 n 个字节
close(connfd);
close(sockfd);客户端(核心片段)
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in serv_addr = {0};
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(8080);
inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr);
connect(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
write(sockfd, "hello", 5);
char buf[1024];
ssize_t n = read(sockfd, buf, sizeof(buf));
close(sockfd);上述框架与标准 TCP 服务器/客户端创建步骤完全对应:服务器依次执行 socket → bind → listen → accept → 收发 → close,客户端依次执行 socket → connect → 收发 → close。
10.4 服务器完整示例(回显服务器)
在骨架上补齐三件事:检查每个返回值、允许重启时立即重新绑定端口、打印客户端地址。协议很简单:收到什么就回什么,对方关闭连接后回到 accept 等下一个客户端。
/* server.c —— 单连接回显服务器,编译:gcc -Wall -Wextra -o server server.c */
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#define PORT 8080
#define BACKLOG 20
#define BUF_SIZE 1024
static void die(const char *msg)
{
perror(msg);
exit(EXIT_FAILURE);
}
/* write 可能一次写不完,循环直到写完或出错 */
static ssize_t writen(int fd, const char *buf, size_t n)
{
size_t done = 0;
while (done < n) {
ssize_t w = write(fd, buf + done, n - done);
// w 是返回已经写入的值。
if (w < 0) {
if (errno == EINTR)
continue;
return -1;
}
if (w == 0)
return -1; /* 防止死循环,实际可设 errno = EIO */
done += (size_t)w;
}
return (ssize_t)n;
}
int main(void)
{
int listen_fd, conn_fd;
struct sockaddr_in serv_addr, cli_addr;
socklen_t cli_len;
char cli_ip[INET_ADDRSTRLEN];
char buf[BUF_SIZE];
ssize_t n;
int on = 1;
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0)
die("socket");
/* 重启服务器时端口可能还在 TIME_WAIT,加上这个选项可以立即重新 bind */
if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)) < 0)
die("setsockopt");
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(PORT);
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(listen_fd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0)
die("bind");
if (listen(listen_fd, BACKLOG) < 0)
die("listen");
printf("server listening on 0.0.0.0:%d\n", PORT);
for (;;) {
cli_len = sizeof(cli_addr);
conn_fd = accept(listen_fd, (struct sockaddr *)&cli_addr, &cli_len);
if (conn_fd < 0) {
if (errno == EINTR) /* 被信号打断,重试即可 */
continue;
die("accept");
}
if (inet_ntop(AF_INET, &cli_addr.sin_addr, cli_ip, sizeof(cli_ip)) == NULL)
snprintf(cli_ip, sizeof(cli_ip), "unknown");
printf("accept %s:%d\n", cli_ip, ntohs(cli_addr.sin_port));
while ((n = read(conn_fd, buf, sizeof(buf))) > 0) {
if (writen(conn_fd, buf, (size_t)n) < 0) {
perror("write");
break;
}
}
if (n < 0)
perror("read");
close(conn_fd);
printf("close %s:%d\n", cli_ip, ntohs(cli_addr.sin_port));
}
close(listen_fd);
return 0;
}10.5 客户端完整示例
客户端从标准输入读一行,发给服务器,再把回显打印出来。连上之后可以一直发,按 Ctrl-D 结束。
/* client.c —— 编译:gcc -Wall -Wextra -o client client.c */
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#define PORT 8080
#define BUF_SIZE 1024
static void die(const char *msg)
{
perror(msg);
exit(EXIT_FAILURE);
}
int main(int argc, char *argv[])
{
const char *ip = (argc > 1) ? argv[1] : "127.0.0.1";
struct sockaddr_in serv_addr;
char sendbuf[BUF_SIZE];
char recvbuf[BUF_SIZE];
ssize_t n;
int fd;
fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0)
die("socket");
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(PORT);
/* inet_pton 成功返回 1,格式错误返回 0,地址族不支持返回 -1 */
if (inet_pton(AF_INET, ip, &serv_addr.sin_addr) != 1) {
fprintf(stderr, "invalid address: %s\n", ip);
exit(EXIT_FAILURE);
}
if (connect(fd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0)
die("connect");
printf("connected to %s:%d, 输入一行回车发送,Ctrl-D 退出\n", ip, PORT);
while (fgets(sendbuf, sizeof(sendbuf), stdin) != NULL) {
size_t len = strlen(sendbuf);
if (send(fd, sendbuf, len, 0) < 0) {
perror("send");
break;
}
n = recv(fd, recvbuf, sizeof(recvbuf) - 1, 0);
if (n < 0) {
perror("recv");
break;
}
if (n == 0) {
printf("server closed\n");
break;
}
recvbuf[n] = '\0';
printf("echo: %s", recvbuf);
}
close(fd);
return 0;
}10.6 编译与运行
gcc -Wall -Wextra -O2 -o server server.c
gcc -Wall -Wextra -O2 -o client client.c
# 终端 1
./server
# 终端 2
./client # 默认连 127.0.0.1:8080
./client 192.168.1.108验证时可以对照以下几点:
- 先启动服务器再启动客户端;反过来启动,客户端会打印
connect: Connection refused。 - 客户端输入一行,服务器原样回显,两边都正常打印。
- 客户端按 Ctrl-D,服务器的 read 返回 0,打印 close 日志,回到 accept 等待下一个连接。
ss -ltnp | grep 8080可以看到监听套接字及其 accept 队列,ss -tnp可以看到已建立的连接和四元组。
另外,accept 返回的 connfd 本地端口仍然是 8080,对端端口才是客户端那个随机端口。用 ss -tnp 看一条连接的两端,能直接对上这一点。
10.7 多客户端:每个连接一个子进程
10.4 的服务器一次只服务一个客户端,第二个人连上来要排队。改成 fork 之后,主进程只负责 accept,子进程负责收发。
/* server_fork.c —— 编译:gcc -Wall -Wextra -o server_fork server_fork.c */
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#define PORT 8080
#define BACKLOG 20
#define BUF_SIZE 1024
static void die(const char *msg)
{
perror(msg);
exit(EXIT_FAILURE);
}
static ssize_t writen(int fd, const char *buf, size_t n)
{
size_t left = n;
while (left > 0) {
ssize_t w = write(fd, buf, left);
if (w < 0) {
if (errno == EINTR)
continue;
return -1;
}
left -= (size_t)w;
buf += w;
}
return (ssize_t)n;
}
/* 子进程里的全部工作:打印对端地址,然后把收到的内容回显回去 */
static void serve(int conn_fd)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
char ip[INET_ADDRSTRLEN];
char buf[BUF_SIZE];
ssize_t n;
if (getpeername(conn_fd, (struct sockaddr *)&peer, &len) == 0 &&
inet_ntop(AF_INET, &peer.sin_addr, ip, sizeof(ip)) != NULL)
printf("[pid %d] serve %s:%d\n", (int)getpid(), ip, ntohs(peer.sin_port));
while ((n = read(conn_fd, buf, sizeof(buf))) > 0) {
if (writen(conn_fd, buf, (size_t)n) < 0)
break;
}
}
int main(void)
{
int listen_fd, conn_fd;
struct sockaddr_in serv_addr, cli_addr;
socklen_t cli_len;
int on = 1;
pid_t pid;
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0)
die("socket");
if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)) < 0)
die("setsockopt");
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(PORT);
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(listen_fd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0)
die("bind");
if (listen(listen_fd, BACKLOG) < 0)
die("listen");
/* Linux 下把 SIGCHLD 设为忽略,子进程退出后由内核自动回收,不留僵尸进程。
如果不这么写,就要在 SIGCHLD 处理函数里循环 waitpid(-1, NULL, WNOHANG)。 */
signal(SIGCHLD, SIG_IGN);
for (;;) {
cli_len = sizeof(cli_addr);
conn_fd = accept(listen_fd, (struct sockaddr *)&cli_addr, &cli_len);
if (conn_fd < 0) {
if (errno == EINTR)
continue;
die("accept");
}
pid = fork();
if (pid < 0) {
perror("fork");
close(conn_fd);
continue; /* fork 失败不影响已有连接,继续接待下一个 */
}
if (pid == 0) {
close(listen_fd); /* 子进程不 accept,监听描述符用不上 */
serve(conn_fd); /* 子进程负责这条连接 */
close(conn_fd);
_exit(0); /* 用 _exit 而不是 exit,避免刷新父进程的 stdio 缓冲区 */
}
close(conn_fd); /* 父进程不参与通信,及时关掉,否则对端 EOF 收不到 */
}
close(listen_fd);
return 0;
}两个细节值得记:
- 父进程 accept 之后必须 close(conn_fd)。描述符是引用计数,父进程不关,子进程关掉之后连接也不会真正释放,客户端会一直等不到 FIN。
- 子进程要关掉 listen_fd。否则子进程退出前,监听套接字一直有引用,服务器重启会出现端口占用。
10.8 常见错误对照
| 现象 | 原因 |
|---|---|
| bind/listen 成功,客户端连不上 | 绑定到了错误地址,或客户端 IP/端口写错;用 ss -ltn 核对监听地址 |
| 连接到一个奇怪端口 | 忘了 htons,8080 被当成主机序解释 |
bind 返回 EADDRINUSE | 端口被占用,或旧连接处于 TIME_WAIT,加 SO_REUSEADDR |
| accept 之后服务器还收不到数据 | 用 listen_fd 而不是 conn_fd 收发 |
| 一次 read 只读到半条消息 | TCP 是字节流,没有消息边界,需要自己定长度或分隔符 |
| 客户端断开后服务器进程被杀 | 向已关闭的连接 write 触发 SIGPIPE,忽略它或改用 MSG_NOSIGNAL |
| 服务器一重启就 bind 失败 | 子进程没关 listen_fd,或缺少 SO_REUSEADDR |
inet_addr 判断失败失效 | 255.255.255.255 与错误返回值都是 INADDR_NONE,改用 inet_pton |
附:函数速查
| 阶段 | 服务器 | 客户端 |
|---|---|---|
| 创建 | socket(AF_INET, SOCK_STREAM, 0) | 同左 |
| 地址 | bind 到 INADDR_ANY:port | inet_pton + htons 填服务器地址 |
| 连接 | listen + accept 循环 | connect |
| 收发 | read / write(或 recv / send) | 同左 |
| 关闭 | close(connfd),监听套接字保留 | close(fd) |
