$ tcpdump -i eth0 -nn port 8080 -X  # 抓包看看边界

TCP 粘包半包怎么破?长度前缀法封包拆包:自定义协议帧、接收状态机与 C# 完整代码

2026-09-25  //  纯技术 · Socket 网络编程 · C#
首页 / 行业资讯 / TCP粘包半包怎么破

一句话说清楚:TCP 是流式协议,根本没有"消息"这个概念,连续发两条数据,接收方可能一次全收到(粘包)、也可能分两次各收一半(半包),这不是 bug,是协议设计。解决办法是在应用层自己划边界——工程上用得最多的是长度前缀法:固定帧头 + 长度字段 + 负载,接收端累积数据、按长度切帧。

01粘包、半包到底是什么现象?

假设发送端连续发两条消息,每条逻辑上长这样(字母是一条消息的内容):

A A A消息①
B B B消息②

接收端实际看到的,可能是下面任意一种,且每次运行都不一样:

情况一次 Receive 返回叫法
理想AAA,下次 BBB—
粘在一起AAABBB粘包
切开了AA,下次 ABBB半包
更狠AAAB,下次 BB又粘又半

如果你的代码是"Read 一次 = 收到一条消息",那在局域网低负载时可能碰巧能跑,一上公网、一发快就乱:解析错位、字段离奇、数据时好时坏。这类问题最折磨人,因为它不稳定复现。

02TCP 为什么不给你保留边界?

所以你看,两次 Send 对应一次 Recv 太正常了。想靠关掉 Nagle(TCP_NODELAY)解决?没用,它只管发送端合不合并,接收缓冲的拼接和网络分段照样发生。边界只能在应用层自己加。

03三种封包方式,分别适合什么场景?

方式怎么划边界适用 / 毛病
固定长度每条消息定长,如永远 64 字节简单;但浪费带宽、负载一变就废
分隔符约定结束符,如 \r\n(HTTP 头就这么干)文本协议好用;负载里出现分隔符要转义
长度前缀帧头里写死负载长度,按长度取二进制协议首选,灵活、无歧义

工业设备和上位机通信基本都是二进制,负载里什么字节都可能有,分隔符法和固定长度都别扭,长度前缀几乎是默认答案。

04自定义协议帧怎么设计?

给一个我常用的帧结构,字段各有各的职责:

AA 55帧头魔数
00 20负载长度
01命令字
.. ..序列号
N字节负载payload
.. ..CRC16
TIP // 长度只算负载还是全帧一定要在协议文档里写死,并保持收发一致。建议"长度 = 负载字节数",固定字段长度双方写常量,改帧结构时不容易牵连。

05拆包状态机怎么走?

核心思路:收到的字节先全攒进一个累积缓冲,再反复尝试从里面切完整帧。每次循环按顺序判断:

  1. 缓冲字节数 < 帧头长度(如 4)→ 数据不够,等下次数据(半包);
  2. 前两字节不是魔数 → 错位了,丢弃第一个字节继续搜(重新对齐);
  3. 读出负载长度 N,算出整帧总长;缓冲不够总长 → 半包,继续等;
  4. 整帧够了:校验 CRC,取出一帧交给业务处理;
  5. 从缓冲删掉这一帧,while 回到第 1 步——一次 Receive 里可能粘着好几帧。

这个 while 循环是关键中的关键。只切一帧就等下次 Read,缓冲尾部会不断积压数据,延迟和内存都会出问题。

06C# 拆包器完整代码

FrameDecoder.cs
// 帧: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半包、超大包与内存膨胀怎么防?

WARN // 长度字段必须设上限读出负载长度后,先判断是否超过约定最大值(比如 1MB),超过直接断连或丢弃重同步。否则对端一个错误字节让长度变成 0xFFFF,你就傻傻等一个永远等不到的 64KB,累积缓冲还可能被恶意撑爆。

08发送端怎么封包?怎么验证整套逻辑?

发送端就是拆包的逆过程:按帧结构依次写字节——魔数、大端长度、命令、序列号、负载、CRC。建议封成一个 BuildFrame(cmd, seq, payload) 函数,全工程只走这一个出口,协议一改只动一处。

  1. 单元测试造脏数据:故意把一帧切成三段喂给 OnReceive,断言最终只收到一条完整消息;再把两帧拼成一次喂入,断言收到两条;
  2. 抓包对照:Wireshark/tcpdump 看字节,确认帧头、长度、大端序与协议一致;
  3. 压测长时间跑:小帧高速连发几小时,监控累积缓冲长度和内存,验证压缩逻辑;
  4. 故障注入:随机丢字节、错 CRC,看状态机能否靠魔数重新对齐、恢复正常。
TIP // 心跳也是帧心跳包就是一条负载为空、长度为 0 的普通帧,别为它另开旁路。收到空负载帧直接回空响应即可,连接死活、NAT 保活全靠它。

今天就可以动手:先在纸上把你的帧结构定下来(字段、长度、谁大端、CRC 覆盖范围),写进注释;然后实现 BuildFrame 和上面的拆包器,写一个"切三段再拼两帧"的自测。说实话,这个自测过了,你的通信层在生产环境基本不会再因为边界问题翻车。粘包半包不可怕,可怕的是假装 TCP 会替你断句。

// tags: tcp · sticky-packet · length-prefix · framing · socket · state-machine · csharp