TCP/IP 协议栈与 Socket 编程
TCP/IP 协议栈与 Socket 编程
一、概述
TCP/IP 是互联网的通信协议族总称,定义了数据如何在网络上传输。Socket 是操作系统提供给应用访问 TCP/IP 的编程接口,应用通过 Socket 收发数据,无需关心底层细节。
一句话核心:TCP/IP 解决"数据怎么从一台设备可靠/不可靠地传到另一台",Socket 是应用与协议栈之间的"门"——应用建 Socket、绑定、连接、收发,协议栈负责把数据封装成 IP 包经网络送达。
二、四层模型
┌─────────────────────────────────────────┐
│ 应用层 HTTP/FTP/MQTT/CoAP... │ 业务数据
├─────────────────────────────────────────┤
│ 传输层 TCP(可靠)/UDP(不可靠) │ 端口区分应用
├─────────────────────────────────────────┤
│ 网络层 IP(寻址/路由) ICMP │ IP地址
├─────────────────────────────────────────┤
│ 网络接口层 以太网/WiFi/PPP... │ MAC地址
└─────────────────────────────────────────┘| 层 | 职责 | 关键协议 |
|---|---|---|
| 应用层 | 业务数据格式 | HTTP、MQTT、CoAP |
| 传输层 | 端到端传输,端口区分应用 | TCP、UDP |
| 网络层 | 寻址与路由 | IP、ICMP |
| 网络接口层 | 物理传输 | 以太网、WiFi |
2.1 封装过程(发送数据)
问题场景:应用层产生的数据(一段 JSON、一帧传感器数据……)要经过网络到达对端,但各层协议并不认识"业务数据":传输层需要知道"交给本机哪个应用"(端口号),网络层需要知道"发给哪台主机"(IP 地址),网络接口层需要知道"从哪块网卡发出、下一跳的物理地址"(MAC 地址)。因此发送方从应用层向下,每经过一层就在数据前面追加一个"头(Header)",携带本层需要的信息——这个过程叫封装(Encapsulation)。
为什么必须分层加头:
| 层 | 追加的头 | 携带的关键信息 | 解决什么问题 |
|---|---|---|---|
| 传输层 | TCP 头 | 源/目的端口、序列号、窗口 | 数据交给本机哪个应用、传输是否可靠 |
| 网络层 | IP 头 | 源/目的 IP、协议号、TTL | 数据发给哪台主机、上层是什么协议 |
| 网络接口层 | MAC 头 | 目的/源 MAC、类型 | 数据从哪块网卡发出、下一跳物理地址 |
各层头部结构(以最常见的基础头为例,不含可选项):
TCP 头(20 字节,无选项):
| TCP 头字段 | 长度 | 作用 |
|---|---|---|
| 源端口 | 2B | 源端口号:本机应用 |
| 目的端口 | 2B | 目的端口号:对端应用 |
| 序号(SEQ) | 4B | 本段首字节在字节流中的位置 |
| 确认号(ACK) | 4B | 期望收到的下一个字节序号 |
| 数据偏移/保留/标志 | 2B | 高 4 位头长(单位 4B,无选项 = 5 → 20B)+ 保留位 + 控制标志(SYN/ACK/FIN/PSH/RST/URG) |
| 窗口(Window) | 2B | 接收方还能收多少字节(流量控制) |
| 校验和(Checksum) | 2B | 覆盖伪头 + TCP 头 + 数据 |
| 紧急指针(Urgent Pointer) | 2B | URG 标志置位时有效 |
IP 头(20 字节,无选项):
| IP 头字段 | 长度 | 作用 |
|---|---|---|
| 版本/头长(Ver/IHL) | 1B | 高 4 位版本(IPv4 = 4);低 4 位头长(单位 4B,无选项 = 5 → 20B) |
| 服务类型(TOS) | 1B | 服务类型(QoS 区分服务) |
| 总长度(Total Length) | 2B | IP 包总长:头 + 数据 |
| 标识(Identification) | 2B | 分片重组时用于归类 |
| 标志/片偏移(Flags/Frag Offset) | 2B | 3 位标志(DF 不分片 / MF 更多分片)+ 13 位片偏移(单位 8B) |
| 生存时间(TTL) | 1B | 每过一个路由器减 1,减到 0 丢弃(防止环路),常见默认值 64/128/255 |
| 协议(Protocol) | 1B | 上层协议号:6 = TCP、17 = UDP、1 = ICMP |
| 头校验和(Header Checksum) | 2B | IP 头校验和(只校验头部本身) |
| 源 IP 地址 | 4B | 发送方 IP |
| 目的 IP 地址 | 4B | 接收方 IP |
以太网头(14 字节):
| 以太网头字段 | 长度 | 作用 |
|---|---|---|
| 目的 MAC | 6B | 本段链路的下一跳 |
| 源 MAC | 6B | 本机网卡 |
| 类型/长度 | 2B | 载荷类型:0x0800 = IPv4、0x0806 = ARP、0x86DD = IPv6 |
封装过程示意(发送 100 字节应用数据):
发送方向(自应用层向下,逐层加头):
应用层 业务数据(100B)
↓ 传输层加 TCP 头(20B)
传输层 [TCP头(20B) | 业务数据(100B)] = TCP段(120B)
↓ 网络层加 IP 头(20B)
网络层 [IP头(20B) | TCP头(20B) | 数据(100B)] = IP包(140B)
↓ 接口层加 MAC 头(14B)+ 尾部 FCS(4B)
接口层 [MAC头(14B) | IP头 | TCP头 | 数据(100B)] = 以太网帧(158B)示例数据(全小节统一基准,后续演算均基于此):
| 项目 | 示例值 |
|---|---|
| 应用数据 | 100B(一段 JSON:{"temp":26.5,"hum":60,"id":1001}) |
| 源端口 / 目的端口 | 50000 / 8080 |
| 源 IP / 目的 IP | 192.168.1.100 / 192.168.1.200 |
| 源 MAC / 目的 MAC | 00:11:22:33:44:55 / AA:BB:CC:DD:EE:FF |
| TTL / 协议号 | 64 / 6(TCP) |
数值演算(封装后各层长度,基准:应用数据 100 字节):
| 计算项 | 代入计算 | 结果 |
|---|---|---|
| TCP 头长(无选项) | 固定 | 20 B |
| TCP 段长度 | 20 + 100 | 120 B |
| IP 头长(无选项) | 固定 | 20 B |
| IP 包长度 | 20 + 120 | 140 B |
| 以太网头长 | 固定 | 14 B |
| 以太网帧长度(不含 FCS) | 14 + 140 | 154 B |
| 帧校验序列 FCS(IEEE 802.3) | 固定 | 4 B |
| 线上实际长度 | 154 + 4 | 158 B |
示例报文关键字段(把上面的示例数据填入各头部后,按网络字节序大端):
| 层 | 字段 | 示例值 | 字节视图(十六进制) |
|---|---|---|---|
| TCP 头 | 源端口 | 50000 | C3 50 |
| TCP 头 | 目的端口 | 8080 | 1F 90 |
| IP 头 | 版本/头长 | IPv4 / 5(= 20B) | 45 |
| IP 头 | 总长度 | 140 | 00 8C |
| IP 头 | TTL / 协议 | 64 / TCP(6) | 40 06 |
| IP 头 | 源 IP | 192.168.1.100 | C0 A8 01 64 |
| IP 头 | 目的 IP | 192.168.1.200 | C0 A8 01 C8 |
| MAC 头 | 目的 MAC | AA:BB:CC:DD:EE:FF | AA BB CC DD EE FF |
| MAC 头 | 源 MAC | 00:11:22:33:44:55 | 00 11 22 33 44 55 |
| MAC 头 | 类型 | IPv4 | 08 00 |
与后文第五章"TCP 头开销"演算统一口径:TCP 头 20B + IP 头 20B = 40B 协议开销,
效率 = 100 / (100 + 40) = 71.4%;此处再计入 MAC 头 14B 与 FCS 4B,帧长 158B,链路效率 = 100 / 158 ≈ 63.3%。
分步详解(发送路径,自应用层向下):
| 步骤 | 所在层 | 做什么 | 追加的头 | 结果 | 示例数据(代入) |
|---|---|---|---|---|---|
| 第 1 步 | 应用层 | 产生业务数据(如 100B 的 JSON/传感器数据) | 无 | 100B 数据 | 原数据 {"temp":26.5,"hum":60,"id":1001}(100B) |
| 第 2 步 | 传输层 | 追加 TCP 头:填源/目的端口、序列号、窗口、校验和 | 20B | TCP 段 120B | 100B 数据 + TCP 头(端口 50000→8080)= 120B |
| 第 3 步 | 网络层 | 追加 IP 头:填源/目的 IP、协议号(TCP = 6)、TTL | 20B | IP 包 140B | 120B 段 + IP 头(192.168.1.100→200,协议 6,TTL 64)= 140B |
| 第 4 步 | 网络接口层 | 追加 MAC 头:填目的/源 MAC、类型(IPv4 = 0x0800),帧尾补 FCS | 14B + 4B | 以太网帧 158B | 140B 包 + MAC 头(00:11…55→AA:BB…FF,类型 IPv4)+ FCS= 158B |
关键点:
- 各层只关心自己的头:对端对应层只需剥掉自己的头、把剩余部分交给上层,实现"分层透明",任何一层的头变更不影响其他层。
- 封装在内存中是"向前扩展":发送时在缓冲区头部预留空间逐层加头,避免数据整体搬移。
- 头部字段是网络字节序(大端):16/32 位多字节字段(端口、序号、IP 地址等)在网络上以大端传输,编程时写入前需做字节序转换,Wireshark 按大端解读。
- 易错点:
- 目的 MAC 是下一跳(网关)的 MAC,不是最终目的主机的 MAC——跨网段时需先经网关转发;
- IP 头的"协议"字段告诉接收方上层协议(TCP = 6 / UDP = 17 / ICMP = 1);
- 以太网"类型/长度"字段
0x0800表示载荷是 IPv4、0x0806表示 ARP; - TCP 头与 IP 头无选项时都是 20B;带选项时头长字段以 4 字节为单位计数(如 IP 头长字段值 5 表示 5 × 4 = 20B),实际头长 = 字段值 × 4。
三、IP 地址与子网
3.1 IPv4 地址
32 位,4 个 8 位段,点分十进制。例:192.168.1.100
3.2 子网掩码
区分网络位和主机位:
IP: 192.168.1.100 = 11000000.10101000.00000001.01100100
掩码: 255.255.255.0 = 11111111.11111111.11111111.00000000
网络地址: 192.168.1.0 (IP & 掩码)
主机地址: 0.0.0.100数值演算(同一子网判断):192.168.1.100 和 192.168.1.200,掩码 255.255.255.0:
192.168.1.100 & 255.255.255.0 = 192.168.1.0
192.168.1.200 & 255.255.255.0 = 192.168.1.0
→ 同一子网,可直连3.3 常用特殊地址
127.0.0.1:回环(本机);0.0.0.0:任意地址(监听所有网卡);- 私有地址:
10.x、172.16~31.x、192.168.x。
四、TCP 可靠传输
TCP 提供面向连接、可靠、字节流传输。核心机制:
| 机制 | 作用 |
|---|---|
| 三次握手 | 建立连接,协商序列号 |
| 四次挥手 | 关闭连接 |
| 确认(ACK)+重传 | 数据可靠性 |
| 滑动窗口/流量控制 | 防止接收方过载 |
| 拥塞控制 | 防止网络过载 |
| 序列号/排序 | 字节流有序 |
4.1 三次握手(建立连接)
客户端 服务器
SYN(seq=x) ──────────────►
◄──── SYN+ACK(seq=y,ack=x+1)
ACK(ack=y+1) ───────────►
=========== 连接建立 ===========具体示例(客户端 192.168.1.100:50000 连接服务器 192.168.1.200:8080,双方各自随机生成初始序列号 ISN:客户端 = 1000,服务器 = 5000):
# 第 1 次:客户端 → 服务器
SYN(seq=1000, ack 不生效) 标志位: SYN=1, ACK=0
# 第 2 次:服务器 → 客户端
SYN+ACK(seq=5000, ack=1001) 标志位: SYN=1, ACK=1
# 第 3 次:客户端 → 服务器
ACK(seq=1001, ack=5001) 标志位: SYN=0, ACK=1- 报文 1(SYN):客户端把
SYN置 1,随机生成自己的初始序列号seq=1000(真实场景为随机 ISN),ack字段无意义。表明"我要建立连接,我的起始序号是 1000"。 - 报文 2(SYN+ACK):服务器同意连接,发回
SYN=1、ACK=1,seq=5000是服务器自己的初始序列号(与客户端的 1000 不同),ack=1001表示"已收到你的 1000 号字节,请从 1001 号开始发"。同时服务器为连接分配缓冲。 - 报文 3(ACK):客户端发
ACK=1、seq=1001,ack=5001确认服务器的seq=5000。此后双方都知道对方的初始序列号,连接进入ESTABLISHED。
序列号含义:seq 是本报文首字节的字节流序号,ack 是期望对端下一个发送的字节序号(即已收到的最后一个序号 +1)。握手后数据从 seq=1001 起递送,接收方据此排序与去重。
为什么是 3 次:确保双方都确认"能收能发",并同步初始序列号(防止失效旧连接请求)。
4.2 四次挥手(关闭连接)
客户端 服务器
FIN ───────────────────► (我方不再发)
◄──── ACK
◄──── FIN (对方也不再发)
ACK ───────────────────►
=========== 连接关闭 ===========具体示例(沿用 4.1 的连接:客户端 ISN=1000、服务器 ISN=5000;设双方数据均收完后,客户端先主动关闭,此时双方序列号都推进到了 2000/6000):
# 第 1 次:客户端 → 服务器
FIN(seq=2000) 标志位: FIN=1, ACK=0 (声明"我不再发数据")
# 第 2 次:服务器 → 客户端
ACK(seq=6000, ack=2001) 标志位: FIN=0, ACK=1 (确认收到 FIN)
# 第 3 次:服务器 → 客户端
FIN(seq=6000, ack=2001) 标志位: FIN=1, ACK=1 (服务器也发完,声明不再发)
# 第 4 次:客户端 → 服务器
ACK(seq=2001, ack=6001) 标志位: FIN=0, ACK=1 (确认收到 FIN)
=========== 连接关闭 ===========- 报文 1(FIN):客户端发
FIN=1,seq=2000。表示"我要关闭了,不再发送数据"。此后客户端仍可接收数据。 - 报文 2(ACK):服务器确认
ack=2001。此时连接变为半关闭:客户端→服务器方向已关闭,但服务器→客户端方向仍可继续发数据(FIN 与 ACK 分开发送,正是全双工的体现)。 - 报文 3(FIN):服务器发完自己的数据后,回
FIN=1、ACK=1,seq=6000, ack=2001。表示"我的数据也发完了,关闭这一方向"。 - 报文 4(ACK):客户端确认
ack=6001。双方都完成各自的"发 FIN + 收 ACK",连接彻底关闭,进入TIME_WAIT。
为什么是 4 次:TCP 是全双工,每一方向关闭都需独立确认(发 FIN + 收 ACK),所以两方向各 2 次 = 4 次。
4.3 状态机
CLOSED → LISTEN → SYN_RCVD → ESTABLISHED(连接) → FIN_WAIT_1→2→TIME_WAIT → CLOSEDTIME_WAIT:主动关闭方在最后等 2×MSL,确保最后一个 ACK 送达(否则重发),此时端口不可立即复用(常见"端口被占用")。
五、TCP vs UDP
| 维度 | TCP | UDP |
|---|---|---|
| 连接 | 面向连接(三次握手) | 无连接 |
| 可靠性 | 可靠(ACK/重传) | 不可靠 |
| 有序性 | 有序(序列号) | 无序 |
| 数据边界 | 字节流(无边界) | 报文(有边界) |
| 开销 | 大(头20B+握手) | 小(头8B) |
| 应用 | 文件、HTTP、MQTT | 音视频、DNS、CoAP |
数值演算(TCP 头开销):TCP 头 20 字节 + IP 头 20 字节 = 40 字节开销。发 100 字节应用数据:
效率 = 100 / (100 + 40) = 71.4%关键理解:TCP 用可靠性和有序性换取额外开销;UDP 轻量但需应用自己处理丢包/乱序。
六、Socket 编程
Socket 是应用访问 TCP/IP 的接口。关键概念:IP 定位主机,端口定位主机上的应用,IP:端口 组成套接字地址。
6.1 Socket API 总览
| API | 作用 |
|---|---|
socket() | 创建套接字 |
bind() | 绑定本地 IP/端口 |
listen() | 监听(TCP 服务端) |
connect() | 连接(TCP 客户端) |
accept() | 接受连接(TCP 服务端) |
send()/recv() | TCP 收发 |
sendto()/recvfrom() | UDP 收发 |
close() | 关闭 |
6.2 TCP 服务端流程
socket() → bind() → listen() → accept() ← 无连接时阻塞等待
│ (有连接到达后返回新套接字)
▼
recv/send (处理该连接)
│
close()说明:
accept()的"阻塞等待"是正确的——服务端调用accept()后,若尚无客户端发起连接,会一直阻塞;直到某个客户端的connect()完成三次握手,accept()才从已完成连接队列取回该连接并返回新的套接字,此后recv/send都基于这个新套接字进行(原监听套接字继续等待下一个连接)。
6.3 TCP 客户端流程
socket() → connect() → send/recv → close()6.4 UDP 流程
socket() → bind() → sendto/recvfrom → close()
(UDP 无连接,收发都带对方地址)七、阻塞 vs 非阻塞 Socket
| 模式 | 行为 | 应用 |
|---|---|---|
| 阻塞 | 操作未完成前一直等待 | 简单单任务 |
| 非阻塞 | 立即返回(未就绪返回错误) | 需轮询 |
| 超时 | 设置最大等待时间 | 常用 |
阻塞 recv 问题:线程卡在 recv 无法退出 → 用非阻塞 + select 或超时解决(见下)。
八、select 多路复用
8.1 它要解决什么问题
若用阻塞方式同时处理多个连接,只能每连接开一个线程,一个线程一个 recv 堵着——连接多、线程多、资源浪费且难管理(尤其嵌入式环境资源有限)。
select 让单线程同时监控多个 Socket:先把关心的一堆 socket 交给内核,让内核"帮你盯着",只要有任何一个有数据可读(或可写/异常),select 就返回并告诉你"哪个就绪了",你再针对性处理。
8.2 生活类比(前台叫号)
想象银行一个窗口的柜员同时接待多个人:
- 把多个人的取号单交给叫号系统(
select),登记你关心的每个人; - 柜员坐着等(
select阻塞),谁叫到号(有数据来了)就处理谁; - 叫号系统一次只报"哪几个号就绪"(返回),柜员逐个处理(
FD_ISSET判断)。
对比:如果"一个人一个窗口",就要开很多窗口(线程),成本高。select 就是"一个窗口管所有人的叫号"。
8.3 关键 API 四件套
| API | 作用 |
|---|---|
FD_ZERO(&fds) | 清空集合 |
FD_SET(fd, &fds) | 把 fd 加进集合(登记关心它) |
select(...) | 阻塞等待,直到集合中任一 fd 就绪或超时 |
FD_ISSET(fd, &fds) | 判断 select 返回后,该 fd 是否就绪 |
8.4 完整示例(单线程监听两个 socket)
fd_set readfds;
int maxfd = (sock1 > sock2) ? sock1 : sock2; // 取最大 fd
struct timeval timeout = {5, 0}; // 最多等 5 秒
while (1) {
FD_ZERO(&readfds); // 1. 先清空(每次循环都要重置)
FD_SET(sock1, &readfds); // 2. 登记关心的 socket
FD_SET(sock2, &readfds);
/* 3. 阻塞等待:直到 sock1/sock2 任一可读,或 5 秒超时 */
int n = select(maxfd + 1, &readfds, NULL, NULL, &timeout);
if (n == -1) { /* 出错,如信号中断 */ }
else if (n == 0) { /* 超时:5 秒内没有任何就绪 */ }
else {
/* 4. 用 FD_ISSET 逐个判断谁就绪了 */
if (FD_ISSET(sock1, &readfds)) 读 sock1;
if (FD_ISSET(sock2, &readfds)) 读 sock2;
}
}8.5 三个容易混的点
- 第一个参数是
maxfd + 1,不是连接数:它是"最大 fd 值 +1",表示内核要扫描的 fd 编号范围(fd 从 0 开始,所以 +1)。写错(如写连接数 2)会导致部分 fd 监控不到。 select是"三路监控",本示例只用了"可读"这一路:select(n, &readfds, &writefds, &exceptfds, &timeout)——依次是读、写、异常集合,不用就填NULL。单路时可把它看作"读就绪列表"。fd_set会被select就地改写:select返回后,集合里只保留已就绪的 fd,其余被清掉。所以每次循环必须FD_ZERO重建,否则第二次循环就丢了登记。返回值n= 就绪的 fd 总数。
timeout:传非空可避免永久阻塞(超时返回 0);想一直等就传 NULL。设置 timeout = {0,0} 则立即返回(非阻塞轮询)。
九、粘包与分包(TCP 常见问题)
9.1 为什么会出现
TCP 是字节流协议:send 写入的数据会被打散进发送缓冲,按 TCP 分段、IP 分包在网络上传输,接收方 recv 读到的只是连续的字节,没有"一条消息"的边界概念。于是:
- 粘包:发送端多次
send(多条消息),接收端一次recv读到多段粘在一起; - 分包:发送端一条消息过大或被拆分,接收端多次
recv才凑齐一条。
加剧因素:发送缓冲/接收缓冲的拼接、Nagle 算法把小包合并发送、以及单次 recv 读不满缓冲区等,都会让"发了几条"和"读了几次"对不上。
发送端 send("ABC") send("DEF") → 网络字节流 ABCDEF
接收端 recv 一次 "ABCDEF" (粘包:两条并成一条读)
recv 两次 "AB" + "CDEF" (分包:一条被拆成多段读)关键结论:send/recv 的次数、边界与消息的边界不保证一致,必须由应用层自己约定"消息边界"来切分字节流。
9.2 三种解决办法对比
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 固定长度 | 每条消息长度固定,收满 N 字节算一条 | 实现最简单 | 短消息也占满 N 字节,浪费带宽;消息变长需重新定长 |
| 长度前缀 | 每条消息 = [4 字节长度][数据] | 灵活、节省、最常用 | 实现稍复杂,需先读长度再读数据 |
| 特殊分隔符 | 用 \r\n / \0 等结束符切分 | 直观、适合文本 | 消息内容不能含分隔符,需转义;二进制数据不适用 |
9.3 方案一:固定长度
#define MSG_LEN 32 // 每条固定 32 字节
char msg[MSG_LEN];
// 发送:不足补 0 到 32 字节
send(fd, payload, MSG_LEN, 0);
// 接收:必须收满 32 字节才算一条完整消息
int got = 0;
while (got < MSG_LEN)
got += recv(fd, msg + got, MSG_LEN - got, 0);
handle(msg); // 此时才是完整一条9.4 方案二:长度前缀(推荐)
// —— 发送端 ——
uint32_t len = (uint32_t)strlen(payload);
uint32_t net_len = htonl(len); // 转网络字节序(大端)
send(fd, &net_len, 4); // 先发长度
send(fd, payload, len); // 再发数据
// —— 接收端 ——
uint32_t len;
read_full(fd, (char *)&len, 4); // 先收 4 字节长度(需"读满")
len = ntohl(len); // 转回主机字节序
read_full(fd, buf, len); // 再按长度收完整数据
handle(buf, len);注意:长度字段也要走字节序转换(
htonl/ntohl),否则跨大小端主机解析会出错。
9.5 方案三:特殊分隔符
// 发送:每条末尾加 '\n'
send(fd, "msg1\n", 5, 0);
send(fd, "msg2\n", 5, 0);
// 接收:用循环逐个字符找 '\n' 切分,并处理跨 recv 的分段
// (简化示意,实际需配合缓冲区累计再扫描)9.6 接收端通用套路:缓冲区 + 按边界切分
无论哪种方案,接收端都要解决"一次 recv 可能只读半条、或一次读到多条"的问题。通用做法:维护一个收包缓冲,recv 到的字节先累加进去,再循环按边界切出完整消息。
缓冲区模型:把缓冲看作"一个累积中的字节堆",用 used 记录里面已有多少有效字节。每次 recv 都追加到缓冲末尾(buf + used 处),读多少 used 就加多少。切出一条消息后,把剩余字节前移到缓冲头部,为下一次追加腾出空间。
char buf[4096]; int used = 0; // 缓冲 + 已用字节数
while (1) {
/* 1. 把新读到的数据累进缓冲末尾(追加,而非覆盖) */
int n = recv(fd, buf + used, sizeof(buf) - used, 0);
if (n <= 0) break; // 断开或出错,结束
used += n; // 有效字节数增加
/* 2. 反复尝试从缓冲头部切出完整消息(长度前缀协议) */
while (used >= 4) { // 至少够读 4 字节"长度字段"
uint32_t len = ntohl(*(uint32_t *)buf); // 解析本条消息长度
if (used < 4 + len) break; // 数据还没凑齐,等下一批 recv
handle(buf + 4, len); // 切出并处理一条完整消息
/* 把剩余数据从末尾前移到头部,为下一条腾位置 */
memmove(buf, buf + 4 + len, used - 4 - len);
used -= 4 + len; // 减掉已消耗的字节数
}
}为什么用 memmove 而不是直接改指针:因为缓冲要反复循环使用,每次都要把"还没被处理完的残余字节"移回头部,让头部始终对齐新数据的写入位置;否则用久了会"越攒越靠后",最终溢出或写越界。
分步演算(直观理解循环怎么转)
假设发送端一次发了三条消息:M1(10B)、M2(5B)、M3(20B),每条都是 [4B 长度][数据]。网络上可能任意拼装,下面演示一种"分包+粘包混合"的到达方式:
| 时刻 | recv 收到 | 缓冲内容(括号内为解析出的消息) | 动作 |
|---|---|---|---|
| 1 | M1 的前 6B | [len=10][M1前2B...](6B) | used=6<4+10,切不出来,等待 |
| 2 | M1 后 8B | (M1整条 14B) + 剩余 0B | 凑齐 M1,切出并 used=0 |
| 3 | M2(9B)+M3 前 3B | (M2整条 9B) [len=20][M3前...](12B) | M2 完整→切出;M3 才 3B,used=3<4,等待 |
| 4 | M3 剩余 20B | [len=20][M3...] 先累积到 23B | 外循环 used=23,内循环凑齐 M3,切出,used=0 |
可见:无论网络怎么拆拼,只要每次把收到的字节累进缓冲、再循环切分,最终都能完整取出每条消息,不会丢消息也不会错位。
封装成可复用的"读满"与"取消息"函数
上面 recv 一次可能没读够,recv 返回的字节数也不保证等于请求数。可以把"凑齐 N 字节"抽成工具函数:
/* 从 fd 读到恰好 n 字节为止(失败/断开返回 -1) */
int read_full(int fd, char *dst, int n) {
int got = 0;
while (got < n) {
int r = recv(fd, dst + got, n - got, 0);
if (r <= 0) return -1; // 出错或对端关闭
got += r;
}
return 0;
}
/* 用 read_full 实现"先读长度、再读数据",天然避免分包 */
uint32_t len;
if (read_full(fd, (char *)&len, 4) < 0) break;
len = ntohl(len);
char *data = malloc(len);
if (read_full(fd, data, len) < 0) break;
handle(data, len);核心思路:凑不齐就等,凑齐一条切一条,余下字节留在缓冲里继续累计,直到下次
recv补齐。两种写法(缓冲循环 /read_full读满)效果等价,后者更简单直观,前者更省拷贝、适合高频大流量。
9.7 小结
- 粘包/分包的根源是 TCP 字节流无消息边界,不是 bug,需要应用层处理;
- 固定长度适合场景简单、长度已知;长度前缀通用性最好;分隔符适合文本协议;
- 接收端务必读满 + 缓冲累计 + 按边界切分,避免一次
recv多读/少读导致丢消息或错位。
十、常见坑与排查
| 问题 | 可能原因 | 排查 |
|---|---|---|
| 连接被拒绝 | 端口没监听、防火墙 | 确认服务端 listen,查端口 |
| 连接超时 | IP 不可达、路由 | ping 测试,查路由 |
| TIME_WAIT 端口占用 | 主动关闭后端口复用 | 设 SO_REUSEADDR |
| 粘包/分包 | TCP 字节流无边界 | 加消息边界(长度前缀) |
| recv 卡死 | 阻塞无超时 | 用 select 或超时 |
| 收不到数据 | IP/端口错、字节序 | 核对地址,检查大小端 |