"设备明明一次发了一整包,上位机收到的却是半截"——这不是设备的问题,是 TCP 的天性:TCP 是字节流,没有消息边界。你 Send 三次,对端可能一次 Receive 全收到(粘包);你 Send 一次,对端可能分三次才收齐(拆包)。解决办法只有一个方向:应用层自己定义帧结构,收端按帧切分。这篇把我们用了多年的帧结构和拆包代码完整放出来。

先搞明白:粘包拆包到底是怎么发生的

TCP 发送端有 Nagle 算法,小包会被攒一攒再发;接收端内核缓冲区按字节流交给应用层,Receive 一次读多少完全看缓冲区里当时有多少、以及你给的 buffer 多大。于是三种情况都会出现:

现象原因后果(不做帧处理时)
粘包两帧数据被合并成一次 Receive 返回只解析了前一帧,后一帧被当垃圾丢弃
拆包一帧数据被拆成多次 Receive 才收齐半帧解析失败,或解析出离谱数值
粘+拆混合一次收到"一帧半"最隐蔽:第一帧正常,半帧留在缓冲区污染下一轮

注意一个常见误区:UDP 没有粘包问题,因为 UDP 是数据报协议,一次 Recv 对应一次 Send(但 UDP 有丢包乱序问题,是另一套麻烦)。另外"把 Receive 的返回值当成一帧的长度"也是错的——返回值只是"这次读到了几个字节",跟业务消息长度毫无关系。

帧结构怎么定?我们固定用这五段

帧头2字节 0xAA55
长度2字节 载荷字节数
命令字1字节 功能码
载荷N字节 业务数据
校验2字节 CRC16

▲ 帧头用"一奇一偶"的字节组合(如 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),超了直接清空重同步——防止设备异常狂发垃圾数据把内存吃满。
发送端同样要讲帧结构。上位机下发命令时按同样的"帧头+长度+命令+载荷+校验"组包,并且一帧一次 Send。虽然 TCP 不保证 Send 的边界,但完整组包后发送能最大限度避免自己这边产生拆包;接收端按上面的逻辑处理,双向都稳。

调试技巧:别猜,把字节打出来

粘包拆包问题靠猜是猜不明白的,我们的标准动作:收发两个方向都记十六进制流水日志,每笔带时间戳(毫秒级),出问题时把日志和协议文档对着看,"这一笔为什么解析失败"一目了然。进阶一点用 Wireshark 抓 102 或自定义端口,能直接看到 TCP 分段情况——你会发现"设备发一包、上位机收两笔"在抓包里是完全正常的现象,不是故障。

还有一个验收动作建议固化下来:让设备以最高频率连发 1 万帧,上位机侧统计"解析成功帧数",必须等于 10000,一帧不多一帧不少。差一帧就说明拆包逻辑有洞,上线前必须抓住。

说到底,TCP 粘包拆包不是 bug,是协议特性。帧结构定清楚、缓冲区攒够再切、错位能重同步,这三件事做到位,字节流就老实了。如果你的程序现在还是"Receive 到啥解析啥",先别加功能,把拆包层补上再说。