"设备明明一次发了一整包,上位机收到的却是半截"——这不是设备的问题,是 TCP 的天性:TCP 是字节流,没有消息边界。你 Send 三次,对端可能一次 Receive 全收到(粘包);你 Send 一次,对端可能分三次才收齐(拆包)。解决办法只有一个方向:应用层自己定义帧结构,收端按帧切分。这篇把我们用了多年的帧结构和拆包代码完整放出来。
先搞明白:粘包拆包到底是怎么发生的
TCP 发送端有 Nagle 算法,小包会被攒一攒再发;接收端内核缓冲区按字节流交给应用层,Receive 一次读多少完全看缓冲区里当时有多少、以及你给的 buffer 多大。于是三种情况都会出现:
| 现象 | 原因 | 后果(不做帧处理时) |
|---|---|---|
| 粘包 | 两帧数据被合并成一次 Receive 返回 | 只解析了前一帧,后一帧被当垃圾丢弃 |
| 拆包 | 一帧数据被拆成多次 Receive 才收齐 | 半帧解析失败,或解析出离谱数值 |
| 粘+拆混合 | 一次收到"一帧半" | 最隐蔽:第一帧正常,半帧留在缓冲区污染下一轮 |
注意一个常见误区:UDP 没有粘包问题,因为 UDP 是数据报协议,一次 Recv 对应一次 Send(但 UDP 有丢包乱序问题,是另一套麻烦)。另外"把 Receive 的返回值当成一帧的长度"也是错的——返回值只是"这次读到了几个字节",跟业务消息长度毫无关系。
帧结构怎么定?我们固定用这五段
▲ 帧头用"一奇一偶"的字节组合(如 0xAA 0x55),降低数据区碰巧撞出帧头的概率;长度字段只算载荷,不含帧头校验,双方约定必须一致。
定界方案其实就三种,各自适用场景很清楚:
| 方案 | 原理 | 优点 | 缺点 / 注意 |
|---|---|---|---|
| 固定长度 | 每帧固定 N 字节 | 实现最简单,无需拼帧逻辑 | 浪费带宽,载荷变长就没法用 |
| 分隔符 | 特殊字符结尾(如 \r\n) | 文本协议友好,调试直观 | 载荷里出现分隔符就炸,需转义;二进制数据慎用 |
| 长度字段 | 帧头后带长度字节 | 二进制任意载荷,工业协议主流 | 要先收到长度才知道一帧多长,逻辑稍复杂 |
工业设备二进制通信,我们一律选长度字段方案。Modbus、西门子 S7 这些成熟协议本质上也是这么干的。
拆包核心代码:缓冲区攒字节,够一帧切一帧
思路一句话:收到的字节全部进缓冲区,循环检查"够不够一帧",够就切出来解析,不够就等下次 Receive。完整骨架:
private readonly List<byte> _buf = new(); private const int HeadLen = 4; // 帧头2 + 长度2 private void OnReceive(byte[] data, int count) { lock (_buf) _buf.AddRange(data.Take(count)); while (true) { byte[] frame; lock (_buf) { // ① 找帧头:缓冲区前面的垃圾字节全部丢弃(重同步) int hi = IndexOfHeader(_buf); if (hi > 0) _buf.RemoveRange(0, hi); if (hi < 0 || _buf.Count < HeadLen) return; // 不够帧头,等 // ② 读长度字段(注意大小端与设备约定一致) int len = _buf[2] << 8 | _buf[3]; int total = HeadLen + 1 + len + 2; // 头+命令+载荷+CRC if (len > 1024) // 长度明显非法 → 帧头是假的,跳过一个字节重找 { _buf.RemoveAt(0); continue; } if (_buf.Count < total) return; // 半帧,等下次Receive frame = _buf.Take(total).ToArray(); _buf.RemoveRange(0, total); } // ③ 校验通过才交给业务,CRC错直接丢帧并计数 if (Crc16Ok(frame)) Dispatch(frame); else _stat.BadCrc++; } }
这段代码里有四个细节,每一个都是现场事故换来的:
- 重同步机制:缓冲区开头不是帧头时,逐字节往后找真正的 0xAA55,把前面的脏数据丢掉。没有这一步,一旦错位就永远错位,只能重启程序;
- 长度合法性检查:读出的长度超过协议上限(比如 1024),说明这个"帧头"是数据区碰巧撞上的假帧头,跳过一个字节继续找;
- 大小端:长度字段和载荷里的多字节数值,高低字节顺序必须和设备约定一致,这是"长度读出来是 21845 这种怪数"的元凶;
- 缓冲区上限:给 _buf 设个上限(比如 64KB),超了直接清空重同步——防止设备异常狂发垃圾数据把内存吃满。
调试技巧:别猜,把字节打出来
粘包拆包问题靠猜是猜不明白的,我们的标准动作:收发两个方向都记十六进制流水日志,每笔带时间戳(毫秒级),出问题时把日志和协议文档对着看,"这一笔为什么解析失败"一目了然。进阶一点用 Wireshark 抓 102 或自定义端口,能直接看到 TCP 分段情况——你会发现"设备发一包、上位机收两笔"在抓包里是完全正常的现象,不是故障。
还有一个验收动作建议固化下来:让设备以最高频率连发 1 万帧,上位机侧统计"解析成功帧数",必须等于 10000,一帧不多一帧不少。差一帧就说明拆包逻辑有洞,上线前必须抓住。
说到底,TCP 粘包拆包不是 bug,是协议特性。帧结构定清楚、缓冲区攒够再切、错位能重同步,这三件事做到位,字节流就老实了。如果你的程序现在还是"Receive 到啥解析啥",先别加功能,把拆包层补上再说。
