02-粘包与拆包

一句话定位:TCP 是字节流协议,它不保留你 write 时的边界;所谓"粘包/拆包"从来不是 TCP 的 bug,而是应用层没定义消息边界。本篇把边界问题的成因、四种定界方案、手写解码器的完整代码、Netty 的标准做法,以及两个开源工业库(HslCommunication、iot-communication)的真实实现逐行拆给你看。

配套阅读:01-TCP协议深入(滑动窗口、Nagle、延迟 ACK)、03-IO模型与高并发(BIO/NIO/Netty)、03-协议篇(各协议的长度字段位置)。

本篇目录

  1. 本质:TCP 没有"包"
  2. 术语辨析:粘包、拆包、半包、分包
  3. 工业协议的边界方案全景
  4. 四种定界方案对比
  5. 手写 BIO 长度字段解码器(完整可运行)
  6. NIO 状态机版本:半包才是常态
  7. Netty 的 LengthFieldBasedFrameDecoder
  8. 源码对照:HslCommunication 的 INetMessage 两阶段读
  9. 源码对照:iot-communication 的做法
  10. 没有长度字段怎么办:RTU over TCP 与私有协议
  11. 并发、错帧与串行化
  12. 复现、验证与排查清单
  13. 小结与误区

一、本质:TCP 没有"包"

1.1 字节流 vs 数据报

TCP 提供给应用层的是一个有序、可靠、无边界的字节流(byte stream)。对比另两个常见传输层协议:

协议 边界 可靠性 顺序 应用层看到
TCP 无边界 可靠 有序 一串连续字节,像读一个文件
UDP 有边界 不可靠 不保证 一次 recv 拿到一个完整报文,不多不少
SCTP 有边界 可靠(可配) 可配 一次收一个 message

所以粘包这个词只可能出现在流式传输里。UDP 上不存在粘包问题——要么丢包,要么收到完整一包。

1.2 三次 write ≠ 三次 read

一次 write() 调用与一次 read() 调用之间没有任何对应关系。下面这段代码复现了这个事实:

发送端                          内核 TCP 发送缓冲区              接收端
────────────────────────────── ───────────────────────────── ────────────────────────────
write("AA")  ────────────────▶ │ AA │                        │
write("BB")  ────────────────▶ │ AA BB │                     │
write("CC")  ────────────────▶ │ AA BB CC │  ──segment──▶    │ 内核接收缓冲区 │ AA BB CC │
                                                              read(buf) 可能返回:
                                                              ┌──────────────────────┐
                                                              │ "AABBCC"   ← 粘包    │
                                                              │ "AAB"      ← 拆包    │
                                                              │ "AA" "BB" "CC" ← 正常│
                                                              │ "AABBC" "C" ← 混合   │
                                                              └──────────────────────┘

返回值取决于接收端此刻缓冲区里有多少字节,与你发了几次毫无关系。

1.3 造成"粘合"的 5 个原因(发送侧 + 接收侧)

# 原因 位置 说明 能否关闭
1 Nagle 算法 发送方内核 未确认的小包不能被发送,攒起来凑够 MSS 或收到 ACK 才发 TCP_NODELAY 可关,但关了只是减少,不能消除
2 延迟 ACK 接收方内核 收到数据后延迟 40ms 再回 ACK,间接导致发送方继续攒数据 内核级调优,不建议关
3 拥塞窗口 / 滑动窗口 发送方内核 cwnd 小的时候,内核会把大块数据按 MSS 分段并等待窗口 不能关
4 发送/接收缓冲区合并 两端内核 多个小帧在 SO_SNDBUF / SO_RCVBUF 里被合并成一个 TCP 段 不能关
5 应用层 read 时机 应用代码 你 read 得晚了,缓冲区里已经堆了好几帧 是代码问题

关键结论:TCP_NODELAY 只能削弱原因 1,剩下的 2/3/4 依然存在。这就是为什么"我关了 Nagle 还是粘包"。

1.4 一个可复现的实验

// 黏包复现:服务端一次 read 读出多帧
import java.io.*;
import java.net.*;
import java.util.Arrays;

public class StickyDemo {
    public static void main(String[] args) throws Exception {
        ServerSocket ss = new ServerSocket(0);
        int port = ss.getLocalPort();

        new Thread(() -> {
            try (Socket c = new Socket("127.0.0.1", port)) {
                OutputStream out = c.getOutputStream();
                out.write(new byte[]{1, 1, 1});                       // 帧1
                out.write(new byte[]{2, 2, 2});                       // 帧2
                out.write(new byte[]{3, 3, 3, 3, 3, 3, 3, 3, 3});     // 帧3
                out.flush();
                Thread.sleep(500);
            } catch (Exception ignored) { }
        }).start();

        try (Socket s = ss.accept();
             InputStream in = s.getInputStream()) {
            byte[] buf = new byte[1024];
            Thread.sleep(300);                        // 故意"读晚了",让三帧在缓冲区堆起来
            int n = in.read(buf);
            System.out.println("单次 read 收到 " + n + " 字节:"
                    + Arrays.toString(Arrays.copyOf(buf, n)));
            // 典型输出:单次 read 收到 15 字节:[1, 1, 1, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3]
        } finally {
            ss.close();
        }
    }
}

跑一次就能看到:三帧被一次 read 全部读出。如果去掉 Thread.sleep(300),在回环地址上大概率会读到 3 字节——同一份代码,行为不同,这就是粘包最让人头疼的地方:它非确定性,取决于时序。


二、术语辨析:粘包、拆包、半包、分包

业界这几个词经常被混用,这里统一约定:

术语 英文 现象 示例(发送 AA / BB)
粘包 sticky packet 多帧被一次 read 读出 read 得到 AABB
拆包 / 半包 half packet / split 一帧被多次 read 读出 read 得到 AAB + B
分包 framing 通用说法,泛指"如何切分消息" —
错帧 frame shift 帧与帧边界错位,导致后续全错 读到 AB 却当成一帧去解析

常见认知误区(重点)

误区 1:粘包是 TCP 的缺陷。 错。TCP 从规范上就没有承诺过边界。"保序字节流"是它的设计目标,边界是应用层协议该定义的东西。

误区 2:关掉 Nagle(TCP_NODELAY)就能解决粘包。 错。见 1.3 表格,原因 2/3/4/5 依然存在。真正解决粘包的是应用层定界。

误区 3:短一点的包就不会粘。 错。盈利能力取决于时序;小包恰恰是 Nagle 最喜欢攒的东西。

误区 4:UDP 也要处理粘包。 错。UDP 是数据报,一次 recv 一个完整报文;UDP 的麻烦是丢包、乱序、MTU 分片,是另一类问题。

误区 5:read 一次不够就 sleep 一下再读第二次。 错且危险。sleep 只是把概率提高,没有消除竞态;网络抖动时照样出错,还会引入固定延迟。正确做法是循环读直到满足"协议定义的完整条件"。


三、工业协议的边界方案全景

工业协议基本都是**「固定头 + 长度字段」**结构,这决定了它们天生好处理。以下是本篇会反复引用的三张表(完整协议细节见 03-协议篇)。

3.1 三大 PLC 协议的长度字段

协议 边界依据 长度字段偏移 长度字段字节数 字节序 长度是否含自身头部 总帧长
Modbus TCP MBAP 的 length 4 2 大端 不含(值为 UnitId + PDU) 6 + length
S7comm TPKT 的 length 2 2 大端 含(值为整帧长度) length
MC 3E 请求数据长度 7 2 小端 值为 CPU 监视定时器及其后所有字节 9 + length
MC 4E 请求数据长度 9 2 小端 同 3E(4E 访问路由更长) 11 + length(需实测确认)

逐字节位置(记住这三张图,后面所有代码都在操作它们):

Modbus TCP(MBAP 7 字节 + PDU)
┌────────┬────────┬────────┬──────┬─────────────────────────┐
│ 事务ID │ 协议ID │ 长度   │ 单元 │ PDU                     │
│ 2 字节 │ 2 字节 │ 2 字节 │ 1字节│ N 字节                  │
├────────┴────────┼────────┼──────┼─────────────────────────┤
│ 偏移 0..3       │ 4..5   │ 6    │ 7..                     │
└─────────────────┴────────┴──────┴─────────────────────────┘
                    ↑ length 的值 = 后续字节数 = 1 + N
                      所以总帧长 = 6 + length

S7comm(TPKT 4 字节 + COTP + S7 PDU)
┌──────┬──────┬────────┬──────────────────────────────────┐
│ 0x03 │ 0x00 │ 长度   │ COTP(3) + S7 Header + Param+Data │
│ 版本 │ 保留 │ 2 字节 │                                  │
├──────┴──────┼────────┼──────────────────────────────────┤
│ 偏移 0..1   │ 2..3   │ 4..                              │
└─────────────┴────────┴──────────────────────────────────┘
                 ↑ length 的值 = 整帧总长(含自身 4 字节)

MC QnA 3E 帧
┌──────┬────┬────┬──────┬────┬──────────┬──────────┬──────────────────┐
│50 00 │网络│PLC │IO号  │站号│ 数据长度 │CPU定时器 │ 命令+子命令+数据 │
│副头  │ 1  │ 1  │  2   │ 1  │  2(小端) │   2      │                  │
├──────┴────┴────┴──────┴────┼──────────┼──────────┼──────────────────┤
│ 偏移 0..1                  │ 7..8     │ 9..10    │ 11..             │
└────────────────────────────┴──────────┴──────────┴──────────────────┘
                               ↑ length 的值 = CPU 定时器 + 后续所有数据
                                 所以请求体长度 = length - 2

3.2 其它常见协议的边界方案

协议 定界方式 备注
Modbus RTU(串口) 时间间隔(3.5 字符静默) 半双工 RS485,靠"没声音了"判定帧结束
Modbus ASCII 起始符 : + 结束符 CR LF 分隔符法
Modbus RTU over TCP 无长度字段 最麻烦,见第十章
MQTT v3.1.1 首字节高位 + 剩余长度(变长整数,最多 4 字节) 变长长度字段
HTTP/1.1 Content-Length 或 Transfer-Encoding: chunked 长度 + 分隔符混合
Redis RESP 行首类型符 + CRLF 分隔符
很多国产私有 TCP 协议 魔数 + 长度 + 校验 复合

四、四种定界方案对比

方案 原理 优点 缺点 典型协议
① 固定长度 每帧都是 N 字节 实现最简单,零解析成本 浪费带宽、不能变长、无法扩展 某些单片机的定长上报帧
② 分隔符 遇到 \r\n / \0 等结束 简单、人可读、便于调试 数据中含分隔符要转义,否则错帧;无法预分配缓冲区 Modbus ASCII、NMEA-0183、Redis RESP
③ 长度字段(头+长度) 先读固定长度的头,从中取出 body 长度,再读 body 精确、高效、可预分配、可校验 头被污染就没救;需要处理长度字段的上限防护 Modbus TCP / S7 / MC,绝大多数工业协议
④ 超时/间隔判定 read 直到超时认为帧结束 协议无需任何字段 不可靠,依赖网络抖动;引入固定延迟 Modbus RTU、RTU over TCP

结论:工业采集场景无脑选 ③,协议原生就带了长度字段。只有当对接的是"既无长度字段也无分隔符"的私有协议时,才退而求其次用 ④。

4.1 长度字段方案的三个坑

这三个坑几乎每个手写过解码器的人都踩过:

坑 说明 后果
长度是否含头部 Modbus 的 length 不含前 6 字节,S7 的 length 含自身 4 字节,MC 的又不一样 少读/多读几字节,后续帧全部错位
字节序 S7/Modbus 是大端,MC 3E 是小端 长度算出一个天文数字,要么 OOM 要么永远等不到数据
长度上限未防护 读到脏数据长度 = 60000,直接 new byte[60000] 然后阻塞等待 内存被打满 / 线程永久卡死

第三个坑尤其致命。任何长度字段解码器必须设 maxFrameLength,超出直接报错断连:

if (bodyLength < 0 || bodyLength > MAX_FRAME_LENGTH) {
    throw new IOException("非法帧长度: " + bodyLength + ",丢弃连接");
}

五、手写 BIO 长度字段解码器

5.1 先把 readFully 写对

InputStream.read() 可能只读到你想要的一半——这是拆包的直接表现。所以读固定长度必须循环:

/**
 * 从流中读取恰好 length 个字节,读满才返回。
 * 这是所有长度字段解码器的地基——90% 的粘包 bug 源于没写这个循环。
 */
public static byte[] readFully(InputStream in, byte[] buf, int offset, int length)
        throws IOException {
    int read = 0;
    while (read < length) {
        int n = in.read(buf, offset + read, length - read);
        if (n < 0) {
            throw new EOFException("连接被关闭,期望 " + length + " 字节,实际读到 " + read);
        }
        read += n;
    }
    return buf;
}

注意 n == 0 的情况不需要处理(TCP 流的 read 在 length > 0 时不会返回 0),但 n < 0(EOF,对端关闭)必须处理,否则会陷入死循环。

5.2 完整解码器:头 + 长度 + 体

下面这个类是可以直接用于生产的最小实现,覆盖三种长度语义:

import java.io.*;
import java.nio.ByteOrder;

/**
 * 通用「头 + 长度字段」帧解码器(BIO)。
 * 支持三种长度语义,覆盖 Modbus TCP / S7comm / MC 3E 三种协议。
 */
public class FrameReader {

    /** 长度语义:长度字段的值是相对于哪里计数的 */
    public enum LengthMode {
        /** 值 = 后续剩余字节数(不含字段前的头,含字段后的所有内容)—— Modbus TCP / MC 3E */
        REMAIN_AFTER_FIELD,
        /** 值 = 整帧总长(含头部自身)—— S7comm TPKT */
        WHOLE_FRAME
    }

    private final InputStream in;
    private final int lengthFieldOffset;      // 长度字段起始偏移
    private final int lengthFieldLength;      // 长度字段字节数(1/2/4)
    private final ByteOrder order;            // 长度字段字节序
    private final LengthMode mode;
    private final int maxFrameLength;         // 防护:单帧最大允许长度

    public FrameReader(InputStream in, int lengthFieldOffset, int lengthFieldLength,
                       ByteOrder order, LengthMode mode, int maxFrameLength) {
        this.in = in;
        this.lengthFieldOffset = lengthFieldOffset;
        this.lengthFieldLength = lengthFieldLength;
        this.order = order;
        this.mode = mode;
        this.maxFrameLength = maxFrameLength;
    }

    /**
     * 读取一帧完整数据。粘包时多余的数据会留在内核缓冲区,留给下一次调用。
     * @return 完整的一帧;返回 null 表示对端已关闭
     */
    public byte[] readFrame() throws IOException {
        // ── 阶段 1:读固定头部(含长度字段)
        int headLen = lengthFieldOffset + lengthFieldLength;
        byte[] head = new byte[headLen];
        try {
            readFully(in, head, 0, headLen);
        } catch (EOFException e) {
            return null;                                  // 一字节都没读到 = 连接关闭
        }

        // ── 阶段 2:解析长度字段
        int lenValue = readLength(head, lengthFieldOffset, lengthFieldLength, order);

        // ── 阶段 3:根据语义计算还要读多少 body
        int bodyLen;
        if (mode == LengthMode.WHOLE_FRAME) {
            bodyLen = lenValue - headLen;                 // S7:值是整帧长
        } else {
            bodyLen = lenValue;                           // Modbus/MC:值是字段后的剩余
        }

        // ── 阶段 4:防护 + 读 body
        int total = headLen + bodyLen;
        if (bodyLen < 0 || total > maxFrameLength) {
            throw new IOException("非法帧长度 body=" + bodyLen
                    + " total=" + total + " > max=" + maxFrameLength);
        }
        if (bodyLen == 0) return head;

        byte[] body = new byte[bodyLen];
        readFully(in, body, 0, bodyLen);

        // ── 阶段 5:拼帧返回
        byte[] frame = new byte[total];
        System.arraycopy(head, 0, frame, 0, headLen);
        System.arraycopy(body, 0, frame, headLen, bodyLen);
        return frame;
    }

    private static int readLength(byte[] data, int off, int len, ByteOrder order) {
        int v = 0;
        if (order == ByteOrder.BIG_ENDIAN) {
            for (int i = 0; i < len; i++) v = (v << 8) | (data[off + i] & 0xFF);
        } else {
            for (int i = len - 1; i >= 0; i--) v = (v << 8) | (data[off + i] & 0xFF);
        }
        return v;
    }

    private static void readFully(InputStream in, byte[] buf, int offset, int length)
            throws IOException {
        int read = 0;
        while (read < length) {
            int n = in.read(buf, offset + read, length - read);
            if (n < 0) throw new EOFException("EOF at " + read + "/" + length);
            read += n;
        }
    }
}

5.3 三个协议怎么用

// Modbus TCP:长度字段在偏移 4,2 字节大端,值 = 后续字节数
FrameReader modbus = new FrameReader(in, 4, 2, ByteOrder.BIG_ENDIAN,
                                     FrameReader.LengthMode.REMAIN_AFTER_FIELD, 260);
// → 读 [00 01][00 00][00 06][11] 03 006B 0003,length=6,再读 6 字节 body

// S7comm:TPKT 长度在偏移 2,2 字节大端,值 = 整帧长(含 4 字节 TPKT)
FrameReader s7 = new FrameReader(in, 2, 2, ByteOrder.BIG_ENDIAN,
                                 FrameReader.LengthMode.WHOLE_FRAME, 1024);
// → 读 [03 00][00 1F],length=31,再读 27 字节 body

// MC QnA 3E:数据长度在偏移 7,2 字节【小端】,值 = CPU定时器 + 后续
FrameReader mc = new FrameReader(in, 7, 2, ByteOrder.LITTLE_ENDIAN,
                                 FrameReader.LengthMode.REMAIN_AFTER_FIELD, 2048);
// → 读头 9 字节(7+2),length=0x0C00 小端 → 12,再读 12 字节 body

为什么多余的数据不会丢? 因为 readFully 只读恰好需要的字节数,缓冲区里剩下的属于下一帧,下一次 readFrame() 自然会从那里继续。这就是粘包被"自动消化"的原理——你不需要手动做缓冲区管理,内核缓冲区天然充当了暂存陈本。

5.4 但 BIO 方案有个前提:一连接一请求

上面的 FrameReader 假设:发一帧 → 收一帧 → 再发。如果两个线程共用一个 socket 同时读写,各自的 readFrame 会互相抢字节,导致解析错位。串行化手段见第十一章。


六、NIO 状态机版本:半包才是常态

NIO 的场景不同:Selector 就绪不代表数据完整,read() 一次可能只读到半帧。此时必须自己维护缓冲区和状态。

NIO 解码状态机
        ┌──────────────┐
        │  READ_HEAD   │  累积到 lengthFieldOffset+lengthFieldLength 字节
        └──────┬───────┘
               │ 头读完 → 解析出 bodyLen
               ▼
        ┌──────────────┐
        │  READ_BODY   │  继续累积到 headLen + bodyLen
        └──────┬───────┘
               │ 整帧完成 → 输出帧 → 回到 READ_HEAD
               ▼
        ┌──────────────┐
        │  余留数据保留 │  compact 后继续下一轮
        └──────────────┘

核心代码:

import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;
import java.util.ArrayList;
import java.util.List;

/**
 * NIO 长度字段解码状态机。每连接一个实例。
 */
public class NioFrameDecoder {
    private static final int MAX_FRAME = 4096;

    private final ByteBuffer buffer = ByteBuffer.allocate(8192);   // 累积缓冲区(每连接独占)
    private final int lengthFieldOffset;
    private final int lengthFieldLength;
    private int pendingBodyLen = -1;                               // -1 表示还没解析出长度

    public NioFrameDecoder(int lengthFieldOffset, int lengthFieldLength) {
        this.lengthFieldOffset = lengthFieldOffset;
        this.lengthFieldLength = lengthFieldLength;
    }

    /**
     * 数据到达时调用,返回本次解出的所有完整帧(可能为 0 个)。
     */
    public List<byte[]> onData(SocketChannel ch) throws Exception {
        List<byte[]> frames = new ArrayList<>();
        buffer.clear();                       // 注意:这里假设上一次已把残留数据 compact 保留
        int n = ch.read(buffer);
        if (n < 0) return null;               // 对端关闭

        buffer.flip();                        // 切换为读模式
        while (true) {
            if (pendingBodyLen < 0) {
                int headLen = lengthFieldOffset + lengthFieldLength;
                if (buffer.remaining() < headLen) break;           // 半包:头都没齐
                int savedPos = buffer.position();
                int v = 0;
                for (int i = 0; i < lengthFieldLength; i++) {
                    v = (v << 8) | (buffer.get(lengthFieldOffset + i) & 0xFF);
                }
                buffer.position(savedPos);
                pendingBodyLen = v;                                 // 示例:Modbus 语义
                if (pendingBodyLen < 0 || headLen + pendingBodyLen > MAX_FRAME) {
                    throw new IllegalStateException("非法帧长度 " + pendingBodyLen);
                }
            }
            int total = lengthFieldOffset + lengthFieldLength + pendingBodyLen;
            if (buffer.remaining() < total) break;                  // 半包:body 没齐

            byte[] frame = new byte[total];
            buffer.get(frame);                                      // 消费掉一帧
            frames.add(frame);
            pendingBodyLen = -1;                                    // 复位,准备下一帧
        }

        // 关键:把没消费完的残留数据挪到缓冲区头部,留给下次
        buffer.compact();
        return frames;
    }
}

NIO 的三个易错点:

  1. compact() 必须调用——否则未消费完的半包字节会在下一次 clear() 时被丢弃。这是 NIO 粘包处理最常见的 bug。
  2. 缓冲区是每连接独占的——如果做成单例共享,多连接的字节会串味。
  3. 别用 remaining() == 0 当循环条件——就绪事件可能只有半帧,状态要跨事件保持,这正是 pendingBodyLen 字段存在的意义。

现实建议:除非你要支撑数千并发连接,否则工业采集不要手写 NIO——直接用 Netty。


七、Netty 的 LengthFieldBasedFrameDecoder

Netty 把这个模板方法固化成了一个解码器,四个参数解决所有"头 + 长度"协议。

7.1 四个参数的语义

new LengthFieldBasedFrameDecoder(
    int maxFrameLength,      // 单帧最大长度,超过抛 TooLongFrameException
    int lengthFieldOffset,   // 长度字段起始偏移
    int lengthFieldLength,   // 长度字段字节数(1/2/3/4/8)
    int lengthAdjustment,    // 长度补偿值
    int initialBytesToStrip  // 解码后跳过多少字节(通常 0,也可跳过头部只留 PDU)
);

抽取帧长的计算公式(务必背下来):

frameLength = lengthFieldOffset + lengthFieldLength + lengthAdjustment + lengthFieldValue
              └──────────── 头部 ────────────┘   └── 补偿 ──┘   └── 长度字段的值 ──┘

7.2 三大协议的配置

协议 maxFrameLength lengthFieldOffset lengthFieldLength lengthAdjustment initialBytesToStrip 推导
Modbus TCP 260 4 2 0 0 目标 = 6 + value = offset(4)+fieldLen(2)+adj+value → adj = 0
S7comm 1024(按 PDU 协商结果) 2 2 -4 0 目标 = value(整帧)= 2+2+adj+value → adj = -4
MC 3E 2048 7 2 0 0 目标 = 9 + value = 7+2+adj+value → adj = 0(需额外设小端)
// Modbus TCP 粘包处理:一行解决
ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(260, 4, 2, 0, 0));

// S7comm
ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(1024, 2, 2, -4, 0));

// MC 3E —— 注意字节序!长度字段是小端
ByteBuf buf = ByteBufAllocator.DEFAULT.buffer();
LengthFieldBasedFrameDecoder decoder =
        new LengthFieldBasedFrameDecoder(2048, 7, 2, 0, 0, true);
// 默认情况下 LengthFieldBasedFrameDecoder 按大端解析,MC 需自行处理:
// 做法是把 ByteBuf 的 order 设为 LITTLE_ENDIAN,或继承该类覆写 getUnadjustedFrameLength()
public class McLittleEndianDecoder extends LengthFieldBasedFrameDecoder {
    public McLittleEndianDecoder() { super(2048, 7, 2, 0, 0); }

    @Override
    protected long getUnadjustedFrameLength(ByteBuf buf, int offset, int length, ByteOrder order) {
        return super.getUnadjustedFrameLength(buf, offset, length, ByteOrder.LITTLE_ENDIAN);
    }
}

7.3 与手写方案的对比

维度 手写 BIO FrameReader Netty LengthFieldBasedFrameDecoder
代码量 ~80 行 1 行配置
半包状态管理 依赖内核缓冲区(天然支持) 依赖 Cumulator 累积 ByteBuf
内存 每帧 new 数组,有 GC 压力 ByteBuf 池化 + 引用计数
上限防护 自己写 内置 TooLongFrameException
适用连接数 几十~几百(需要线程模型配合) 数千~数万
调试难度 低,就是同步代码 高,异步 + Pipeline 调用栈难读
工业采集推荐度 ★★★★(设备数 < 200) ★★★(设备数 > 500,或已有 Netty 栈)

一个务实的判断:几百台设备的采集器,每设备一线程 + BIO FrameReader 完全够用,代码简单到不可能出错;上 Netty 带来的复杂度要用"连接数 > 1000"或"团队已有 Netty 能力"来交换。


八、源码对照:HslCommunication 的两阶段读

HslCommunication(Java 版 3.9.3)把"如何判定一帧结束"抽象成了 INetMessage 接口,这是整个库最值得学习的设计。

8.1 INetMessage:把"边界规则"抽象出来

文件:_source/HslJavaLatest/HslCommunication/Core/IMessage/INetMessage.java

public interface INetMessage {
    int ProtocolHeadBytesLength();              // 第一次要读多少字节(头长度)
    int GetContentLengthByHeadBytes();          // 从头部算出 body 长度
    boolean CheckHeadBytesLegal(byte[] token);  // 头合法性校验(排除脏数据)
    int PependedUselesByteLength(byte[] h);     // 头部前面有几字节垃圾要丢
    int GetHeadBytesIdentity();                 // 消息标识(用于匹配请求)
    void setHeadBytes(byte[] h);   byte[] getHeadBytes();
    void setContentBytes(byte[] c); byte[] getContentBytes();
    void setSendBytes(byte[] s);    byte[] getSendBytes();
    boolean CheckReceiveDataComplete(byte[] send, MemoryStream ms);  // 动态判定是否完整
    int CheckMessageMatch(byte[] send, byte[] receive);              // 请求/响应是否配对
}

8.2 模板方法:协议无关的接收流程

文件:_source/HslJavaLatest/HslCommunication/Core/Net/NetworkBase/NetworkBase.java,第 290~331 行

protected OperateResultExOne<byte[]> ReceiveByMessage(Socket socket, int timeOut,
                                     INetMessage netMessage, ActionOperateExTwo<Long, Long> reportProgress) {
    if (netMessage == null) return Receive(socket, -1, timeOut);

    if (netMessage.ProtocolHeadBytesLength() < 0) {
        // ① 结束符模式:一直读到结束符为止
        ...
    } else {
        // ② 长度字段模式:先读头 → 解析长度 → 再读体
        OperateResultExOne<byte[]> headResult = Receive(socket, netMessage.ProtocolHeadBytesLength(), timeOut);
        if (!headResult.IsSuccess) return headResult;

        netMessage.setHeadBytes(headResult.Content);
        int contentLength = netMessage.GetContentLengthByHeadBytes();     // ← 协议自己决定怎么算
        if (contentLength <= 0) return headResult;

        OperateResultExOne<byte[]> contentResult = Receive(socket, contentLength, timeOut, reportProgress);
        if (!contentResult.IsSuccess) return contentResult;

        netMessage.setContentBytes(contentResult.Content);
        return OperateResultExOne.CreateSuccessResult(
                SoftBasic.SpliceTwoByteArray(netMessage.getHeadBytes(), contentResult.Content));
    }
}

这就是教科书式的模板方法模式:流程(两阶段读)在基类里写死,怎么算长度由子类(INetMessage 实现)决定。新增一种协议 = 新增一个 Message 类,通信内核一行不改。

8.3 底层:读满为止

文件:_source/HslJavaLatest/HslCommunication/Core/Net/NetSupport.java,第 35~55 行

public static byte[] ReadBytesFromSocket(Socket socket, int receive, ActionOperateExTwo<Long,Long> reportProgress)
        throws IOException, RemoteCloseException {
    byte[] bytes_receive = new byte[receive];
    int count_receive = 0;
    while (count_receive < receive) {
        // 分割成8KB来接收数据
        int receive_length = Math.min((receive - count_receive), SocketBufferSize);
        InputStream input = socket.getInputStream();
        int count = input.read(bytes_receive, count_receive, receive_length);
        if (count < 0) throw new RemoteCloseException();
        count_receive += count;
        if (count <= 0) throw new RemoteCloseException();
        if (reportProgress != null) reportProgress.Action((long) count_receive, (long) receive);
    }
    return bytes_receive;
}

注意细节:按 8KB 分块读(避免单次申请超大数据导致 read 语义歧义),count < 0 抛 RemoteCloseException(对端关闭要立刻感知)。

8.4 S7 消息:长度含自身头

文件:_source/HslJavaLatest/HslCommunication/Core/IMessage/S7Message.java

public class S7Message extends NetMessageBase implements INetMessage {
    public int ProtocolHeadBytesLength() { return 4; }              // TPKT 4 字节

    public int GetContentLengthByHeadBytes() {
        byte[] HeadBytes = getHeadBytes();
        if (HeadBytes == null) return 0;
        return (HeadBytes[2] & 0xff) * 256 + (HeadBytes[3] & 0xff) - 4;   // ← 减 4:长度含 TPKT 自身
    }

    public boolean CheckHeadBytesLegal(byte[] token) {
        byte[] HeadBytes = getHeadBytes();
        if (HeadBytes == null) return false;
        return HeadBytes[0] == 0x03 && HeadBytes[1] == 0x00;              // ← 校验 TPKT 魔数
    }
}

- 4 正是 S7 长度字段语义的体现(Netty 里对应 lengthAdjustment = -4)。CheckHeadBytesLegal 是第一道防线——万一读到错帧,至少能识别出"这不是头"。

8.5 Modbus TCP 消息:更小心,带自愈逻辑

文件:_source/HslJavaLatest/HslCommunication/Core/IMessage/ModbusTcpMessage.java,第 10~55 行

public int ProtocolHeadBytesLength() { return 8; }        // MBAP(7) + 功能码(1) = 8

public int GetContentLengthByHeadBytes() {
    byte[] HeadBytes = getHeadBytes();
    byte[] SendBytes = getSendBytes();
    if (HeadBytes.length >= ProtocolHeadBytesLength()) {
        int length = (HeadBytes[4] & 0xff) * 256 + (HeadBytes[5] & 0xff);

        int remainLength = 0;
        if (length == 0) {
            // ← 长度字段是 0?说明整帧偏移了 1 字节,丢掉第一个字节重算
            HeadBytes = SoftBasic.BytesArrayRemoveBegin(HeadBytes, 1);
            setHeadBytes(HeadBytes);
            remainLength = (HeadBytes[4] & 0xff) * 256 + (HeadBytes[5] & 0xff) - 1;
        } else {
            remainLength = Math.min(length - 2, 300);     // ← Math.min(...,300) 是上限防护
        }
        ...
    }
}

三个值得学的细节:

  1. Math.min(length - 2, 300)——即使长度字段给出天文数字,也只按上限 300 读,避免内存/阻塞失控。这是第四章"坑 3"的现场答案。
  2. length == 0 的自愈——发现长度字段为 0,怀疑整帧偏移了 1 字节(前面可能粘了一个乱码字节),主动丢 1 字节重算。这是多年现场经验沉淀。
  3. 用请求报文反算期望长度:
if (SendBytes != null && SendBytes.length == 12) {
    if (SendBytes[7] == ModbusInfo.ReadRegister || SendBytes[7] == ModbusInfo.ReadInputRegister) {
        int readLength = (SendBytes[10] & 0xff) * 256 + (SendBytes[11] & 0xff);
        int expectLength = readLength * 2 + 1;      // 字节数=n*2, +1 是字节计数字段
        if (expectLength != remainLength && length == 6) {
            remainLength = expectLength;            // 以"请求了几个寄存器"反算的长度为准
        }
    }
}

源码注释原文(第 36~38 行):

// 如果读取的长度和剩余的长度不一致,则说明数据有问题,以哪个为准呢?
// 2025年7月23日 00:15:45  此处以读取的长度为准,剩余长度需要重新计算

为什么这个设计很聪明:Modbus 是"请求-响应"协议,响应长度可以从请求推导(读 N 个寄存器 → 响应就是 1 + 2N 字节)。当长度字段本身不可信时,用请求反算是更可靠的 fallback。这是纯 bean counter 的工程智慧。

8.6 错帧的最后防线:事务 ID 匹配

文件:_source/HslJavaLatest/HslCommunication/Core/IMessage/ModbusTcpMessage.java,第 70~86 行

@Override
public int CheckMessageMatch(byte[] send, byte[] receive) {
    if (send.length < 8 || receive.length < 8) return 1;
    if (IsCheckMessageId) {
        if (send[0] != receive[0] || send[1] != receive[1]) return -1;  // 事务ID不一致 → 继续接收
    }
    if (StationCheckMatch) {
        if (send[6] != receive[6]) return -1;                           // 站号不一致 → 继续接收
    }
    return 1;
}

返回 -1 表示"这帧不是我要的,继续收"——用于流水线并发(同一连接上多个请求在飞)时把响应交给对的请求。详见第十一章。

8.7 对 HSL 方案的评价

维度 评价
优点 ① 把"边界规则"抽象成接口,协议实现与通信内核彻底解耦;② 模板方法使新增协议成本极低;③ 带长度上限防护和长度字段自愈,现场经验足;④ 支持"事务 ID 匹配"为流水线留了口子
缺点 ① 仍是 BIO,一条连接同一时刻只能处理一次交互;② 每次请求 new 多个 byte[],GC 压力不小;③ ModbusTcpMessage 里硬通配来形容 modem 特例(300、length==0 自愈),可读性和可维护性一般;④ 默认短连接(不调用 SetPersistentConnection 时),S7 每次采集都要重付 COTP + Setup 握手,高频场景下非常不划算

九、源码对照:iot-communication 的做法

iot-communication(xingshuangs)的风格不同:不抽象消息层,而是让每个协议自己写两阶段读,网络层只提供带 waitForMore 开关的工具方法。

9.1 网络层:waitForMore 是关键开关

文件:_source/iot-communication/src/main/java/com/github/xingshuangs/iot/net/SocketUtils.java,第 146~176 行

public static int read(final Socket socket, final byte[] data, final int offset, final int length,
                       final int maxLength, final int timeout, final boolean waitForMore) throws IOException {
    socket.setSoTimeout(timeout);
    int count = 0;
    int off = offset;
    InputStream in = socket.getInputStream();
    while (count < length) {
        int len = maxLength <= 0 ? length - count : Math.min(maxLength, length - count);
        int num = in.read(data, off, len);
        if (num < 0) {
            throw new SocketException("The end of the stream has been reached, and disconnected");
        }
        count += num;
        off += num;
        // 实际读取的数据长度比期望还小,则证明已经读取完了
        if (!waitForMore && num < len) {
            break;                    // ← 短读即止:这次读到的数据比期望少,认为帧已结束
        }
    }
    return count;
}

方法 javadoc 原文(第 142 行):

waitForMore If the data is not enough, whether to wait for more data, most of them are not waiting, waiting is suitable for subcontracting sticky packages(若数据不够,是否等待,等待更多数据,大部分都是不等待的,等待都适用于分包粘包的情况)

语义拆开看:

一个 boolean 同时支持了"定长读"和"短读即止"两种策略,这是这个库的设计亮点。

9.2 S7 的两阶段读

文件:_source/iot-communication/src/main/java/com/github/xingshuangs/iot/protocol/s7/service/PLCNetwork.java,第 243~282 行

private byte[] readFromServer(byte[] sendData) {
    ...
    TPKT tpkt;
    int len;
    byte[] total;
    try {
        this.locker.lock();                      // ← ① 串行化:同一时刻只允许一次交互
        this.write(sendData);

        byte[] data = new byte[TPKT.BYTE_LENGTH];// ← ② 先读 4 字节 TPKT
        len = this.read(data);
        if (len < TPKT.BYTE_LENGTH) {
            throw new S7CommException("The TPKT is invalid and the length is inconsistent");
        }
        tpkt = TPKT.fromBytes(data);             // ← ③ 解析出整帧长度
        total = new byte[tpkt.getLength()];
        System.arraycopy(data, 0, total, 0, data.length);
        len = this.read(total, TPKT.BYTE_LENGTH, tpkt.getLength() - TPKT.BYTE_LENGTH); // ← ④ 再读剩余
    } finally {
        this.locker.unlock();
    }
    if (len < total.length - TPKT.BYTE_LENGTH) {
        throw new S7CommException("The length of the data after TPKT is inconsistent");
    }
    return total;
}

四个要素齐全:加锁串行化 → 读头 → 解析长度 → 读体。注意第 249 行还有发送端防护:if (this.pduLength > 0 && sendData.length - 7 > this.pduLength) throw ...——发之前先检查别超过 PDU。

9.3 MC 的两阶段读(3E / 4E)

文件:_source/iot-communication/src/main/java/com/github/xingshuangs/iot/protocol/melsec/service/McNetwork.java,第 181~209 行

protected byte[] readFromServer4E3E(byte[] req) {
    int remainLength;
    int len;
    byte[] total;
    try {
        this.locker.lock();
        this.write(req);

        int headerLength = this.frameType == EMcFrameType.FRAME_4E ? 15 : 11;   // ← 3E=11, 4E=15
        byte[] data = new byte[headerLength];
        len = this.read(data);
        if (len < headerLength) {
            throw new McCommException(" McHeader is invalid, read length is inconsistent");
        }
        McHeader3EAck header = (McHeader3EAck) McHeaderAck.fromBytes(data, this.frameType);
        remainLength = header.getDataLength() - 2;        // ← 数据长度含 CPU 监视定时器(2),扣掉
        total = new byte[headerLength + remainLength];
        System.arraycopy(data, 0, total, 0, data.length);
        len = this.read(total, data.length, remainLength, true);   // ← waitForMore = true!
    } finally {
        this.locker.unlock();
    }
    if (len < remainLength) {
        throw new McCommException("The length of the data behind the McHeader is inconsistent");
    }
    return total;
}

第 200 行的第四个参数 true 就是 waitForMore——第二阶段必须读满,不允许短读提前返回。

9.4 没有长度字段时:1E 帧的兜底

同文件第 155~172 行:

protected byte[] readFromServer1E(byte[] req) {
    int len;
    byte[] data = new byte[1024];            // ← 固定 1024 缓冲区
    try {
        this.locker.lock();
        this.write(req);
        len = this.read(data);               // ← 默认 waitForMore=false,短读即止
    } finally {
        this.locker.unlock();
    }
    if (len < 0) {
        throw new McCommException(" McHeader is invalid, read length is inconsistent");
    }
    byte[] total = new byte[len];
    System.arraycopy(data, 0, total, 0, len);
    return total;
}

这是"超时判定帧结束"的典型:申请 1024 字节,读到多少算多少(依赖下次 read 返回少于请求长度就 break)。它的可靠性依赖"PLC 一次把响应全发出来且不被拆分"——在局域网通常成立,在链路拥塞时存在半包风险。

9.5 两个库的方案对比

维度 HslCommunication iot-communication
边界规则的抽象 INetMessage 接口(30 个实现) 无抽象,每个协议自己写两阶段读
首发同一份二阶段代码 ✅ NetworkBase 模板方法 ❌ 每个协议重复一遍(S7/MC 各一份)
长度字段外的补救 length==0 自愈 + 请求反算 + 上限防护 waitForMore 开关 + 短读即止
无长度字段协议 靠 ProtocolHeadBytesLength() == -1 走结束符模式 直接 1024 缓冲 + 短读即止
套接字串行化 SimpleHybirdLock 保护 socket locker.lock()(ReentrantLock)包裹 write+read
事务 ID 匹配 ✅ CheckMessageMatch(支持流水线) ❌ 未做,纯串行
可读性 抽象多,跳几层才看到字节操作 直白,一个类里看完整个读写过程

一句话总结差异:HSL 追求"协议无关的统一框架",iot-communication 追求"每个协议清清楚楚、看得见摸得着"。对于学习者,后者更好读;对于要支撑几十种协议的团队,前者更省事。


十、没有长度字段怎么办

现实里总会遇到这类设备:一个固定格式的二进制包,既无长度字段也无结束符(典型:Modbus RTU over TCP、很多国产控制器/仪表)。三种办法:

方案 A:等待超时(短读即止)

socket.setSoTimeout(300);            // 300ms
InputStream in = socket.getInputStream();
byte[] buf = new byte[256];
int n = in.read(buf);                // 读到多少算多少
// 如果超过 300ms 还没新数据,read 返回已读到的部分
优点 缺点
实现最简单 每次采集固定多等一个超时窗口(300ms → 采集周期至少 300ms+)
无需协议配合 链路抖动导致响应被拆成两块时,只会拿到前半帧 → 丢数据

这是 iot-communication 处理 1E 帧的做法,属于"能用但不够严谨"。建议在局域网、非关键数据场合用。

方案 B:协议语义回溯(推荐)

即使没有长度字段,多数协议也能从内容反推长度。以 Modbus RTU 为例:

RTU 响应帧: [站号 1B][功能码 1B][字节数 1B][数据 N B][CRC 2B]
                                    ↑ 从第 3 个字节就能算出 N,从而算出整帧长度
// RTU over TCP 的正确读法:先读 3 字节,算出总长,再读剩余,最后校验 CRC
byte[] head = readFully(in, new byte[3], 0, 3);
if ((head[1] & 0x80) != 0) {
    // 异常响应:固定 5 字节(站号+功能码+异常码+CRC2)
    byte[] rest = readFully(in, new byte[2], 0, 2);
    return concat(head, rest);
}
int byteCount = head[2] & 0xFF;                     // 数据字节数
int restLen = byteCount + 2;                        // 数据 + CRC16
byte[] rest = readFully(in, new byte[restLen], 0, restLen);
return concat(head, rest);                          // 再 CRC 校验

只要能找到任何一个可推导长度的字段,就别用超时方案。

方案 C:3.5 字符时间(仅串口 RTU)

RTU 在 RS485 上靠"静默 3.5 个字符时间"判定帧结束。9600 波特率下:

1 个字符 = 起始位1 + 数据位8 + 校验位1 + 停止位1 = 11 bit
1 bit 时间 = 1 / 9600 s ≈ 0.104 ms
3.5 字符 = 3.5 × 11 × 0.104 ≈ 4.0 ms
波特率 3.5 字符时间(11bit/字符) 实用取值
9600 ≈ 4.01 ms 5 ms
19200 ≈ 2.00 ms 3 ms
38400 ≈ 1.00 ms 2 ms
115200 ≈ 0.33 ms 1 ms(很多实现取固定 1.75ms 上限)

在 TCP 上不要模拟这套逻辑——TCP 的到达时序与串口完全不同。


十一、并发、错帧与串行化

11.1 同一连接能否并发?

一条 TCP 连接理论上可以流水线(pipeline)并发多个请求,但工业协议的支持度不一:

协议 是否支持流水线 依据 风险
Modbus TCP ✅ 支持 MBAP 有 2 字节事务 ID 廉价设备/网关经常无视事务 ID,乱序回响应
S7comm ⚠️ 部分 PDU reference 字段 西门子 PLC 通常按序处理,个别设备忽略 reference
MC 3E ❌ 基本不支持 无事务标识 响应可能错位到别的请求

工程建议:默认串行,除非实测确认设备支持事务 ID 匹配。两库的实际做法是:

串行化示意(每设备一个连接 + 一把锁)
线程A ──┐
        ├─▶ [lock] write(req1) → read(res1) ── unlock ──▶ 结果返回 A
线程B ──┘   └─ 阻塞等待 ─────────────────────────────┘→ [lock] write(req2) → read(res2)

11.2 错帧的三种后果与防护

后果 现象 防护手段
当前帧解析失败 数据错位、类型不对 CheckHeadBytesLegal 校验魔数;CRC/校验和
后续帧全错(雪崩) 一旦边界错位,后面每一帧都按错的位置切 校验失败立刻断连重建,不要带着脏状态继续跑
内存/线程失控 长度字段给出超大值 长度上限防护 + 读超时 + 强制断连

最重要的原则:一旦校验失败,立刻关闭连接重连。试图在脏字节流里"重新同步"是徒劳的——你不知道下一帧从哪开始。这正是 HSL Receive() 里 catch 到 RemoteCloseException/IOException 后立即 CloseSocket(socket) 的原因(NetworkBase.java 第 147~152 行)。

11.3 多线程共用一个 socket 的绝对禁忌

// ✗ 错误:两个线程同时操作同一个 socket
thread1: synchronized ??? — 没有锁,write 和 read 会交错
thread2: 同时 write → 两个请求粘在一起发出去 → 对方可能回两帧 → 各自 readFrame 抢字节 → 全错

// ✓ 正确:锁包裹完整的「请求-响应」往返
private final ReentrantLock socketLock = new ReentrantLock();

public byte[] exchange(byte[] request) throws IOException {
    socketLock.lock();
    try {
        out.write(request);
        out.flush();
        return frameReader.readFrame();     // 完整的一来一回在锁内完成
    } finally {
        socketLock.unlock();
    }
}

注意:握手过程(如 S7 的 COTP + Setup)也必须在这把锁保护下完成,否则连接建立与数据请求会打架。


十二、复现验证与排查清单

12.1 用 Wireshark 看真相

过滤表达式 用途
tcp.len > 0 只看有载荷的 TCP 段
modbus Modbus TCP 报文(Wireshark 内置解析器)
tcp.flags.reset == 1 找 RST,定位异常断连
tcp.analysis.retransmission 重传包,看链路质量
tcp.analysis.flags && !tcp.analysis.window_update 各种 TCP 异常提示
tcp.stream eq 3 只看第 3 条连接(右键 Follow → TCP Stream)
s7comm 西门子 S7comm(需 Wireshark 支持该 dissector)
tcp.reassembled_in 看应用层数据被拆到哪个后续包才完整(直接观察拆包)

关键判断技巧:如果一个 TCP 段里出现了多个应用层请求(Wireshark 显示 "Reassembled PDU" 或同方向连着两个协议层条目),那就是粘包现场;如果应用层数据被标到另一个包里有 "TCP segment of a reassembled PDU",那就是拆包现场。

12.2 破坏性验证:一定要主动测试这些场景

场景 怎么造 合格标准
粘包 服务端连续发 3 帧不 sleep 客户端能正确拆出 3 帧
拆包 服务端每帧发时 Thread.sleep(50) 分两次 write 客户端能等到完整帧
超大长度 伪造长度字段 = 0xFFFF 立刻报"非法帧长度"并断连,不卡死不 OOM
脏头 发送一段随机字节 头校验失败,断连重连
半途断网 采集中拔网线 超时后报错并进入重连流程,不永久阻塞
慢响应 服务端延迟 5 秒 超时配置生效,不无限等待

12.3 排查清单


十三、小结与误区

要点回顾

  1. TCP是无边界字节流,"粘包/拆包"是应用层没定义消息边界的结果,不是 TCP 的 bug。
  2. 关 Nagle 解决不了粘包——真正解决的是应用层定界。
  3. 四种定界方案:固定长度、分隔符、长度字段、超时。工业协议一律优先用长度字段。
  4. 长度字段的三个坑:含不含头部、字节序、上限防护。少一样都会出事。
  5. 模板流程:读头 → 解析长度 → 读体 → 拼帧 → 校验。多出的数据留在缓冲区给下一帧,这就是粘包被消化的原理。
  6. HSL 用 INetMessage 抽象边界规则,一次写流程,30 种协议复用;还带长度上限自愈和请求反算。
  7. iot-communication 用 waitForMore 开关:true 读满(长度已知),false 短读即止(长度未知)。
  8. 校验失败立刻断连重连,不要在脏字节流里试图重新同步。
  9. 流水线并发要谨慎:Modbus 有事务 ID 但很多设备不认,S7/MC 基本不支持。默认串行最稳。
  10. 几百台设备用 BIO 采集器就够,别为了"高性能"引入 Netty 的复杂度。

还需要警惕的误区


下一篇:03-IO模型与高并发——把单条连接的 IO 放到几百台设备的并发模型里去看。 配套源码:04-源码分析-HslCommunication/02-通信内核NetworkBase.md、05-源码分析-iot-communication/02-网络层与粘包处理.md


本文是《工业通信与数据采集》栏目第 5/22 篇 · 上一篇:TCP 协议深入 | 栏目总览 | 下一篇:IO 模型与高并发