一句话说清楚:TCP 是流式协议,根本没有"消息"这个概念,连续发两条数据,接收方可能一次全收到(粘包)、也可能分两次各收一半(半包),这不是 bug,是协议设计。解决办法是在应用层自己划边界——工程上用得最多的是长度前缀法:固定帧头 + 长度字段 + 负载,接收端累积数据、按长度切帧。
01粘包、半包到底是什么现象?
假设发送端连续发两条消息,每条逻辑上长这样(字母是一条消息的内容):
接收端实际看到的,可能是下面任意一种,且每次运行都不一样:
| 情况 | 一次 Receive 返回 | 叫法 |
|---|---|---|
| 理想 | AAA,下次 BBB | — |
| 粘在一起 | AAABBB | 粘包 |
| 切开了 | AA,下次 ABBB | 半包 |
| 更狠 | AAAB,下次 BB | 又粘又半 |
如果你的代码是"Read 一次 = 收到一条消息",那在局域网低负载时可能碰巧能跑,一上公网、一发快就乱:解析错位、字段离奇、数据时好时坏。这类问题最折磨人,因为它不稳定复现。
02TCP 为什么不给你保留边界?
- TCP 只保证字节按序、可靠到达,它眼里是一长串字节,不知道你在哪断句;
- 发送端:Nagle 算法会把小包攒一起发;一次 Write 的数据也可能被拆进多个 TCP 段(受 MSS 限制);
- 接收端:数据先进内核接收缓冲区,你的 Read 是从缓冲区里"舀",舀多少取决于当前剩多少、你给多大的数组。
所以你看,两次 Send 对应一次 Recv 太正常了。想靠关掉 Nagle(TCP_NODELAY)解决?没用,它只管发送端合不合并,接收缓冲的拼接和网络分段照样发生。边界只能在应用层自己加。
03三种封包方式,分别适合什么场景?
| 方式 | 怎么划边界 | 适用 / 毛病 |
|---|---|---|
| 固定长度 | 每条消息定长,如永远 64 字节 | 简单;但浪费带宽、负载一变就废 |
| 分隔符 | 约定结束符,如 \r\n(HTTP 头就这么干) | 文本协议好用;负载里出现分隔符要转义 |
| 长度前缀 | 帧头里写死负载长度,按长度取 | 二进制协议首选,灵活、无歧义 |
工业设备和上位机通信基本都是二进制,负载里什么字节都可能有,分隔符法和固定长度都别扭,长度前缀几乎是默认答案。
04自定义协议帧怎么设计?
给一个我常用的帧结构,字段各有各的职责:
- 魔数 0xAA55:用来"找帧头"。流中间错位时,逐字节搜到这两个字节能重新对齐;
- 长度字段:建议只表示负载长度,帧头固定多长写死,解析简单;
- 命令字:这条消息干什么,响应帧原样带回;
- 序列号:请求响应对得上号,重发、超时判断靠它;
- CRC:校验整帧,网线偶尔错码、缓冲区被踩,能拦住脏数据。
05拆包状态机怎么走?
核心思路:收到的字节先全攒进一个累积缓冲,再反复尝试从里面切完整帧。每次循环按顺序判断:
- 缓冲字节数 < 帧头长度(如 4)→ 数据不够,等下次数据(半包);
- 前两字节不是魔数 → 错位了,丢弃第一个字节继续搜(重新对齐);
- 读出负载长度 N,算出整帧总长;缓冲不够总长 → 半包,继续等;
- 整帧够了:校验 CRC,取出一帧交给业务处理;
- 从缓冲删掉这一帧,while 回到第 1 步——一次 Receive 里可能粘着好几帧。
这个 while 循环是关键中的关键。只切一帧就等下次 Read,缓冲尾部会不断积压数据,延迟和内存都会出问题。
06C# 拆包器完整代码
// 帧:AA 55 + 长度(2) + 命令(1) + 序列号(2) + 负载N + CRC(2) private readonly MemoryStream _buf = new(); public void OnReceive(byte[] data, int count) { _buf.Write(data, 0, count); while (TryReadFrame(out var frame)) Dispatch(frame); // 一缓冲区里可能有多帧 } private bool TryReadFrame(out byte[] frame) { frame = null; byte[] all = _buf.GetBuffer(); int len = (int)_buf.Length; if (len < 5) return false; // 帧头都不够 if (all[0] != 0xAA || all[1] != 0x55) { Resync(all, len); return false; // 找魔数对齐 } ushort payload = (ushort)((all[2] << 8) | all[3]); int total = 7 + payload + 2; // 头5 + 序列号2 + CRC2 if (len < total) return false; // 半包 frame = new byte[total]; Buffer.BlockCopy(all, 0, frame, 0, total); // TODO: CRC 校验失败应丢弃并计数,不要 Dispatch _buf.SetLength(0); if (len > total) _buf.Write(all, total, len - total); // 剩余字节留着 return true; }
注意网络字段统一大端,长度用移位拼,别用 BitConverter——两端字节序不一定相同。
07半包、超大包与内存膨胀怎么防?
- 半包不用特殊处理:状态机"数据不够就 return false"天然解决,关键是收到的字节要原样保留在缓冲头部;
- 压缩累积缓冲:切完帧要把剩余字节挪回头部,长时间运行缓冲不能只增不减;
- 大对象别每次 new:示例图省事每帧 new 数组,高并发下会给 GC 添堵,可用 ArrayPool 或复用缓冲;
- 收和发分两个连接或两套序列号:响应和主动推送混在一条连接时,靠命令字区分,别让序列号串了。
08发送端怎么封包?怎么验证整套逻辑?
发送端就是拆包的逆过程:按帧结构依次写字节——魔数、大端长度、命令、序列号、负载、CRC。建议封成一个 BuildFrame(cmd, seq, payload) 函数,全工程只走这一个出口,协议一改只动一处。
- 单元测试造脏数据:故意把一帧切成三段喂给 OnReceive,断言最终只收到一条完整消息;再把两帧拼成一次喂入,断言收到两条;
- 抓包对照:Wireshark/tcpdump 看字节,确认帧头、长度、大端序与协议一致;
- 压测长时间跑:小帧高速连发几小时,监控累积缓冲长度和内存,验证压缩逻辑;
- 故障注入:随机丢字节、错 CRC,看状态机能否靠魔数重新对齐、恢复正常。
今天就可以动手:先在纸上把你的帧结构定下来(字段、长度、谁大端、CRC 覆盖范围),写进注释;然后实现 BuildFrame 和上面的拆包器,写一个"切三段再拼两帧"的自测。说实话,这个自测过了,你的通信层在生产环境基本不会再因为边界问题翻车。粘包半包不可怕,可怕的是假装 TCP 会替你断句。
