02-粘包与拆包
一句话定位:TCP 是字节流协议,它不保留你 write 时的边界;所谓"粘包/拆包"从来不是 TCP 的 bug,而是应用层没定义消息边界。本篇把边界问题的成因、四种定界方案、手写解码器的完整代码、Netty 的标准做法,以及两个开源工业库(HslCommunication、iot-communication)的真实实现逐行拆给你看。
配套阅读:
01-TCP协议深入(滑动窗口、Nagle、延迟 ACK)、03-IO模型与高并发(BIO/NIO/Netty)、03-协议篇(各协议的长度字段位置)。
本篇目录
- 本质:TCP 没有"包"
- 术语辨析:粘包、拆包、半包、分包
- 工业协议的边界方案全景
- 四种定界方案对比
- 手写 BIO 长度字段解码器(完整可运行)
- NIO 状态机版本:半包才是常态
- Netty 的 LengthFieldBasedFrameDecoder
- 源码对照:HslCommunication 的 INetMessage 两阶段读
- 源码对照:iot-communication 的做法
- 没有长度字段怎么办:RTU over TCP 与私有协议
- 并发、错帧与串行化
- 复现、验证与排查清单
- 小结与误区
一、本质: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 的三个易错点:
compact()必须调用——否则未消费完的半包字节会在下一次clear()时被丢弃。这是 NIO 粘包处理最常见的 bug。- 缓冲区是每连接独占的——如果做成单例共享,多连接的字节会串味。
- 别用
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); // 请求/响应是否配对
}
ProtocolHeadBytesLength()返回 -1 时表示"没有固定头,用结束符判定"——一个接口兼容了长度字段法和分隔符法。- 每种协议一个实现类,共 30 个(Modbus / S7 / Melsec / Fins / Fanuc / DLT645 …)。
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) 是上限防护
}
...
}
}
三个值得学的细节:
Math.min(length - 2, 300)——即使长度字段给出天文数字,也只按上限 300 读,避免内存/阻塞失控。这是第四章"坑 3"的现场答案。length == 0的自愈——发现长度字段为 0,怀疑整帧偏移了 1 字节(前面可能粘了一个乱码字节),主动丢 1 字节重算。这是多年现场经验沉淀。- 用请求报文反算期望长度:
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 行):
waitForMoreIf the data is not enough, whether to wait for more data, most of them are not waiting, waiting is suitable for subcontracting sticky packages(若数据不够,是否等待,等待更多数据,大部分都是不等待的,等待都适用于分包粘包的情况)
语义拆开看:
waitForMore = false(默认):一次read返回的数据少于请求长度就认为"收完了"。适用于长度未知的协议(如 Modbus RTU over TCP、1E 帧),本质是超时/短读判定帧结束。waitForMore = true:必须读满length才返回。适用于已知长度的协议(长度字段法的第二阶段)。
一个 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 匹配。两库的实际做法是:
- HSL:
SimpleHybirdLock锁 socket;同时提供CheckMessageMatch允许"收到非匹配帧继续等"——为流水线留了门。 - iot-communication:
locker.lock()把 write+read 整段包起来,纯串行。
串行化示意(每设备一个连接 + 一把锁)
线程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 排查清单
- [ ]
readFully写了循环吗?还是只调用一次read? - [ ] 长度字段的字节序核对了吗?(MC 是小端!)
- [ ] 长度含不含头部确认了吗?(Modbus 不含,S7 含)
- [ ] 有没有
maxFrameLength上限防护? - [ ]
SO_TIMEOUT设了吗?(不设 = 可能永久阻塞,线程泄漏) - [ ] NIO 场景调用
compact()了吗? - [ ] 多线程共享 socket 时用锁包裹完整往返了吗?
- [ ] 校验失败时断开连接了吗?还是在脏流里继续硬读?
- [ ] 网关/网口 interneicine 多主站访问同一从站时,事务 ID 会不会冲撞?
十三、小结与误区
要点回顾
- TCP是无边界字节流,"粘包/拆包"是应用层没定义消息边界的结果,不是 TCP 的 bug。
- 关 Nagle 解决不了粘包——真正解决的是应用层定界。
- 四种定界方案:固定长度、分隔符、长度字段、超时。工业协议一律优先用长度字段。
- 长度字段的三个坑:含不含头部、字节序、上限防护。少一样都会出事。
- 模板流程:读头 → 解析长度 → 读体 → 拼帧 → 校验。多出的数据留在缓冲区给下一帧,这就是粘包被消化的原理。
- HSL 用 INetMessage 抽象边界规则,一次写流程,30 种协议复用;还带长度上限自愈和请求反算。
- iot-communication 用 waitForMore 开关:
true读满(长度已知),false短读即止(长度未知)。 - 校验失败立刻断连重连,不要在脏字节流里试图重新同步。
- 流水线并发要谨慎:Modbus 有事务 ID 但很多设备不认,S7/MC 基本不支持。默认串行最稳。
- 几百台设备用 BIO 采集器就够,别为了"高性能"引入 Netty 的复杂度。
还需要警惕的误区
- ❌ "读一次不够,sleep 一下再读"→ 竞态没消除,还引入延迟
- ❌ "用
available()判断有多少字节可读"→ 这只反映此刻缓冲区里的量,不代表帧是否完整 - ❌ "用 BufferedReader.readLine() 读二进制"→ 会做字符集解码,
0x0A/0x0D会被当换行,二进制数据必被破坏 - ❌ "DataInputStream.readFully 是线程安全的"→ 它不是;多线程竞争会交错字节
- ❌ "SO_TIMEOUT 设了就万事大吉"→ 超时会抛异常,必须配合重连逻辑
- ❌ "Netty 的解码器能处理所有协议"→ 遇到 RTU over TCP 这类无长度字段的,仍然要自己写
ReplayingDecoder/自定义解码器
下一篇:
03-IO模型与高并发——把单条连接的 IO 放到几百台设备的并发模型里去看。 配套源码:04-源码分析-HslCommunication/02-通信内核NetworkBase.md、05-源码分析-iot-communication/02-网络层与粘包处理.md
本文是《工业通信与数据采集》栏目第 5/22 篇 · 上一篇:TCP 协议深入 | 栏目总览 | 下一篇:IO 模型与高并发