03 · INetMessage 与粘包解法:33 个帧解析器与 Netty 对比
本篇定位:回答一个问题——"这条 TCP 流,哪里是一帧的结尾?" HslCommunication 的答案是把这件事抽成
INetMessage接口,然后为每种协议写一个 20~100 行的小类。本篇逐个讲解接口方法、给 33 个实现类做全景分类,并与 Netty 的LengthFieldBasedFrameDecoder做正面对比。
目录
- 1. 为什么需要 INetMessage
- 2. 接口方法逐个解释
- 3. 默认实现 NetMessageBase
- 4. 33 个帧解析器全景表
- 5. 五类定帧模型逐类详解
- 6. 一个私有协议接入示例
- 7. 与 Netty LengthFieldBasedFrameDecoder 对比
- 8. 综合评价:什么时候该用哪套
- 设计优点 / 设计缺点 / 可借鉴点
1. 为什么需要 INetMessage
TCP 是字节流,没有消息边界。一次 InputStream.read() 可能返回:
- 半帧(拆包)
- 一帧半(粘包)
- 三帧加半帧
Core/IMessage/INetMessage.java(109 行)抽象了"如何认定一帧"这件事。它的使用者只有一个——NetworkBase.ReceiveByMessage(NetworkBase.java:290,已在第 02 篇详解):
阶段一 Receive(socket, N = netMessage.ProtocolHeadBytesLength(), timeout)
→ netMessage.setHeadBytes(头字节)
阶段二 M = netMessage.GetContentLengthByHeadBytes()
→ Receive(socket, M, timeout)
→ SpliceTwoByteArray(head, body)
这个抽象的核心价值:把"每种协议的帧边界怎么定"从通信引擎里彻底剥离出去。ReceiveByMessage 这一份代码服务了 24 个 PLC 品牌 + Modbus + MQTT + Redis + WebSocket 的自定义帧。
类比一下就很清楚:
| 方案 | 谁负责定帧 | 新增协议成本 |
|---|---|---|
| 写死在 Socket 代码里 | 通信引擎 | 改核心代码,风险高 |
Netty LengthFieldBasedFrameDecoder |
4 个 int 参数(offset/length/adjustment/strippedBytes) | 配参,但遇到非标准帧就配不出来 |
Hsl INetMessage |
一段 Java 代码 | 写一个 20~100 行的小类 |
2. 接口方法逐个解释
Core/IMessage/INetMessage.java:9-109,共 11 个方法。逐个解释:
| 方法 | 行号 | 返回值语义 | 调用时机 | 谁在调 |
|---|---|---|---|---|
ProtocolHeadBytesLength() |
15 | 第一次读多少字节。-1 = 开启流式模式;<-1 = 结束符模式(见第 02 篇 §3.4) |
每次收帧的第一阶段 | NetworkBase.java:318 |
GetContentLengthByHeadBytes() |
22 | 拿到头之后,算出还要读多少字节。返回 0 = 无体 | 两阶段读的中间 | NetworkBase.java:322 |
CheckHeadBytesLegal(byte[] token) |
30 | 头是否合法(含 magic 检查、token 校验) | 收完整帧后 | NetworkDoubleBase.java:646 |
PependedUselesByteLength(byte[] headByte) |
38 | 头前面有几字节是垃圾(用于重新对齐) | 定义了但未被调用 | 见下方说明 |
GetHeadBytesIdentity() |
45 | 从帧头提取消息 ID | 未被框架调用,由协议自己按需使用 | — |
setHeadBytes / getHeadBytes |
52 / 59 | 头的读写 | — | NetworkBase.java:321 |
getContentBytes / setContentBytes |
66 / 73 | 体的读写 | — | NetworkBase.java:328 |
getSendBytes / setSendBytes |
80 / 87 | 保存本次请求报文,供"依靠请求推断响应长度"使用 | 发送前设置 | NetworkDoubleBase.java:587 |
CheckReceiveDataComplete(byte[] send, MemoryStream ms) |
96 | 流式模式下判断是否收全 | 每追加一段就问一次 | NetworkBase.java:363 |
CheckMessageMatch(byte[] send, byte[] receive) |
107 | 1 = 匹配 / 0 = 明确错误 / -1 = 不是我这帧,继续收 | 收完一帧后 | NetworkDoubleBase.java:633 |
2.1 两个最有分量的方法
getSendBytes / setSendBytes:协议可以"回头看自己发了什么"
这是 INetMessage 最特别的设计。很多国产/日系老协议的响应帧根本不带长度字段,长度必须由请求推断。setSendBytes 就是为此存在的。典型使用者:
MelsecA1EBinaryMessage(行 17-21):请求的功能码决定响应长度FetchWriteMessage(行 14-18):按SendBytes[8]的命令类型算长度MelsecA1EAsciiMessage(第 10 行起):new String(SendBytes, 20, 2, US_ASCII)取请求里的长度
这是一种"有状态解码器"。它的代价是:一个 Message 实例必须对应一次请求-响应往返,不能复用。
CheckMessageMatch:三态返回值
// Modbus TCP 的实现:Core/IMessage/ModbusTcpMessage.java:71-86
public int CheckMessageMatch(byte[] send, byte[] receive) {
if (send == null) return 1;
if (receive == null) return 1;
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;
}
对应驱动者在 NetworkDoubleBase.java:630-643:匹配就 break,返回 -1 就 times++ 继续收下一帧,直到超时。这个能力是 Netty 的固定偏移解码器不具备的——Netty 假定"先到先服务"的严格顺序。
PependedUselesByteLength:一个定义了却没接线的接口方法
我看遍了 ReceiveByMessage 的全部分支(NetworkBase.java:290-331),没有一处调用过 PependedUselesByteLength。全库只有 DLT645Message.java:18 实现了它:
// Core/IMessage/DLT645Message.java:18-22
public int PependedUselesByteLength(byte[] headByte) {
int index = HslCommunication.Instrument.DLT.Helper.DLT645Helper.FindHeadCode68H(headByte);
if (index < 0) return 10;
return index;
}
这个方法本意是"帧前面有乱码时,告诉引擎跳过几字节重新对齐",但因为没被接线,DLT645 的前导垃圾字节实际上无法自动纠正。这是一个明确的半成品(未在本版本 ReceiveByMessage 中见到调用)。
3. 默认实现 NetMessageBase
Core/IMessage/NetMessageBase.java(56 行)是所有实现类的父类。它不是抽象类,也没有实现 INetMessage:
public class NetMessageBase {
public boolean CheckHeadBytesLegal(byte[] token) { return true; } // 行 8-11 默认放行
public int GetHeadBytesIdentity() { return 0; } // 行 13-15
public int PependedUselesByteLength(byte[] headByte) { return 0; } // 行 17
public byte[] getHeadBytes() { return HeadBytes; } // 行 19-21
public byte[] getContentBytes() { return ContentBytes; } // 行 23-25
public byte[] getSendBytes() { return SendBytes; } // 行 27-29
public void setHeadBytes(byte[] headBytes) { HeadBytes = headBytes; } // 行 31-33
public void setContentBytes(byte[] cb) { ContentBytes = cb; } // 行 35-37
public void setSendBytes(byte[] sendBytes) { SendBytes = sendBytes; } // 行 39-41
private byte[] HeadBytes = null; // 行 43
private byte[] ContentBytes = null; // 行 45
private byte[] SendBytes = null; // 行 47
public boolean CheckReceiveDataComplete(byte[] send, MemoryStream ms) { return true; } // 行 49
public int CheckMessageMatch(byte[] send, byte[] receive) { return 1; } // 行 51
}
所有 33 个实现类的写法一致:extends NetMessageBase implements INetMessage。
设计评价:
- 优点:
HeadBytes/ContentBytes/SendBytes三个字段集中管理,子类不必重复写样板;CheckHeadBytesLegal默认true意味着不校验,符合"能省就省"。 - 缺点:
NetMessageBase既不抽象也不实现接口,却包着一堆INetMessage的方法——子类只要漏写implements INetMessage,编译器不会报错(因为方法都在),但GetNewNetMessage()返回它时会 ClassCastException。这是一个类型安全漏洞。 更糟的是:由于继承链是NetMessageBase → 子类,而子类又implements INetMessage,Java 里SpecifiedCharacterMessage这类可以直接new,绕过 interface。
另外注意这类里有两个完全没用到的局部变量模式:几乎每个实现方法开头都会写
byte[] HeadBytes = getHeadBytes();
byte[] SendBytes = getSendBytes(); // ← 即使这个方法里根本用不到 SendBytes
这是从 C# 版逐行翻译时留下的(C# 版里有 #region,作者习惯开局把两个 local 取好)。属于无害但刺眼的噪音——Core/IMessage/ 包内就有 36 处 byte[] SendBytes = getSendBytes();,其中相当一部分在该方法里完全没被用到。
4. 33 个帧解析器全景表
Core/IMessage/ 共 34 个文件:1 个接口(INetMessage.java)+ 1 个基类(NetMessageBase.java,不算实现)+ 32 个实现类;此外 CNC/Fanuc/CNCFanucSeriesMessage.java 也在别处实现了同一接口,故全库共 33 个帧解析器。按"帧怎么定界"分成五类:
4.1 类别总览
| 类别 | 定界方式 | 数量 |
|---|---|---|
| A. 标准长度域 | 固定头 + 从头里拿长度 | 25 |
| B. 推断长度 | 要靠 getSendBytes() 反推 |
3 |
C. 流式(-1) |
收一段问一次 CheckReceiveDataComplete |
2 |
| D. 结束符 | SpecifiedCharacterMessage 的负数编码 |
1 |
| E. 长度域 + 流式兜底 | 两者结合 | 1 |
| — | 位于 CNC/Fanuc/ 包(结构不一致) |
1 |
| 合计 | 33 |
4.2 完整清单
| 实现类 | 文件:行数 | 帧头字节 | 长度怎么算 | 类别 | 服务于谁 |
|---|---|---|---|---|---|
HslMessage |
HslMessage.java:11-22 | 32 | getInt(head, 28) |
A | HSL 自有协议(NetSimplify 等) |
AlienMessage |
AlienMessage.java:7-16 | 5 | head[4] & 0xFFFF |
A | DTU/异形透传会话 |
AllenBradleyMessage |
AllenBradleyMessage.java:7 | 24 | getUShort(head, 2) |
A | AB PLC CIP(EtherNet/IP) |
AllenBradleySLCMessage |
AllenBradleySLCMessage.java | 28 | head[2]*256 + head[3] |
A | AB SLC / PCCC |
AdsNetMessage |
AdsNetMessage.java:7-22 | 6 | getInt(head,2),钳到 [0, 1e8] |
A | Beckhoff ADS |
DLT645Message |
DLT645Message.java:10-22 | 10 | head[9] & 0xff + 2 |
E | 电能表 DLT645(含 PependedUselesByteLength 与 CheckReceiveDataComplete) |
EFORTMessage |
EFORTMessage.java:7-18 | 18 | getShort(head,16) - 18 |
A | 埃夫特机器人 |
EFORTMessagePrevious |
EFORTMessagePrevious.java | 17 | 同上减去 17 | A | 埃夫特旧版固件 |
FanucRobotMessage |
FanucRobotMessage.java:7 | 56 | getUShort(head, 4) |
A | FANUC 机器人 |
FetchWriteMessage |
FetchWriteMessage.java:4-25 | 16 | 见 §5.4(靠 head[5] + SendBytes[8] 分支) | B | 西门子 Fetch/Write |
FinsMessage |
FinsMessage.java:6-34 | 8 | getInt(倒序4字节, 0),钳到 [0, 10000] |
A | 欧姆龙 FINS/TCP |
FujiCommandSettingTypeMessage |
FujiCommandSettingTypeMessage.java | 5 | head[4] & 0xff |
A | 富士 |
FujiSPBMessage |
FujiSPBMessage.java:12 | 5 | parseInt(ASCII(head,3,2),16)*2+2 |
A | 富士 SPB |
FujiSPHMessage |
FujiSPHMessage.java | 20 | getUShort(head, 18) |
A | 富士 SPH |
GeSRTPMessage |
GeSRTPMessage.java:4-13 | 56 | head[4] + head[5]*256 |
A | GE SRTP |
InovanceEasyNetMessage |
InovanceEasyNetMessage.java:11 | 2 | ToUInt16(head,0) - 2 |
A | 汇川 Easy |
KukaVarProxyMessage |
KukaVarProxyMessage.java:7-28 | 4 | head[2]*256 + head[3] |
A | KUKA 机器人 VarProxy |
LsisFastEnetMessage |
LsisFastEnetMessage.java:7-32 | 20 | getShort(head, 16) |
A | LS Electric FEnet |
MelsecQnA3EBinaryMessage |
MelsecQnA3EBinaryMessage.java:4-24 | 9 | head[7] + head[8]*256 |
A | 三菱 MC 二进制 |
MelsecQnA3EAsciiMessage |
MelsecQnA3EAsciiMessage.java:8-33 | 18 | parseInt(ASCII(head,14,4), 16) |
A | 三菱 MC ASCII |
MelsecA1EBinaryMessage |
MelsecA1EBinaryMessage.java:5-40 | 2 | 见 §5.3 | B | 三菱 A-1E 二进制 |
MelsecA1EAsciiMessage |
MelsecA1EAsciiMessage.java:9 | 4 | 见 §5.3 | B | 三菱 A-1E ASCII |
MemobusMessage |
MemobusMessage.java | 12 | ToUInt16(head,6) - 12 |
A | 安川 Memobus |
ModbusRtuMessage |
ModbusRtuMessage.java:16-38 | -1 | — | C | Modbus RTU Over TCP |
ModbusTcpMessage |
ModbusTcpMessage.java:10-55 | 8 | 见 §5.2 | A(带 B 倾向) | Modbus TCP |
OpenProtocolMessage |
OpenProtocolMessage.java:10-28 | 4 | ToInt32(ASCII(head,0,4)) - 4 + 1;异常时兜底 17 |
A | 阿特拉斯拧紧枪 OpenProtocol |
RkcTemperatureMessage |
RkcTemperatureMessage.java:8-26 | -1 | — | C | RKC 温控表(ASCII 控制符) |
SAMMessage |
SAMMessage.java | 7 | head[5]*256 + head[6] |
A | SAM 协议 |
S7Message |
S7Message.java:5-29 | 4 | head[2]*256 + head[3] - 4 |
A | 西门子 S7(ISO-on-TCP TPKT) |
ToyoPucMessage |
ToyoPucMessage.java | 4 | ToUInt16(head,2),try-catch 兜 0 |
A | 丰田 Toyopuc |
YokogawaLinkBinaryMessage |
YokogawaLinkBinaryMessage.java:4 | 4 | head[2]*256 + head[3] |
A | 横河 Link |
CNCFanucSeriesMessage* |
CNC/Fanuc/ 包下 | — | — | — | FANUC 0i 机床(与 FANUC 机器人不同) |
SpecifiedCharacterMessage |
SpecifiedCharacterMessage.java:5-65 | 负数 | 见第 02 篇 §3.4 | D | 所有"以某字符结尾"的文本协议 |
CNCFanucSeriesMessage.java位于CNC/Fanuc/包而非Core/IMessage/,但同样实现INetMessage。也就是说Core/IMessage/不是唯一的消息实现位置——这是包结构上的一个不一致。
5. 五类定帧模型逐类详解
5.1 类别 A:标准长度域(最干净的一类)
西门子 S7(Core/IMessage/S7Message.java:5-29)——ISO-on-TCP 的 TPKT 头:
public int ProtocolHeadBytesLength(){ return 4; } // 行 5-7 0x03 0x00 Len_Hi Len_Lo
public int GetContentLengthByHeadBytes(){
byte[] HeadBytes = getHeadBytes();
byte[] SendBytes = getSendBytes(); // ← 未被使用
if(HeadBytes == null) return 0;
return (HeadBytes[2] & 0xff) * 256 + (HeadBytes[3] & 0xff) - 4; // 行 14 注意减掉头自身 4 字节
}
public boolean CheckHeadBytesLegal(byte[] token)
{
byte[] HeadBytes = getHeadBytes();
byte[] SendBytes = getSendBytes(); // ← 未被使用
if(HeadBytes == null) return false;
if(HeadBytes[0]==0x03 && HeadBytes[1] == 0x00){ return true; } // 行 23 TPKT 版本号必须为 3
else { return false; }
}
-4 很关键:TPKT 的 length 字段描述的是包含自身 4 字节头在内的总长度,而 Hsl 的两阶段模型里"内容长度"必须是头之后的部分,所以要减 4。这个 "lengthAdjustment" 概念在 Netty 里需要一个显式的 lengthAdjustment 参数,在这里只是一行减法。
三菱 MC 二进制(Core/IMessage/MelsecQnA3EBinaryMessage.java:4-24):
public int ProtocolHeadBytesLength() { return 9; } // 行 4-6
public int GetContentLengthByHeadBytes() {
byte[] HeadBytes = getHeadBytes();
byte[] SendBytes = getSendBytes(); // ← 未被使用
if (HeadBytes == null) return 0;
return (HeadBytes[7] & 0xff) + (HeadBytes[8] & 0xff) * 256; // 行 12 小端
}
public boolean CheckHeadBytesLegal(byte[] token) {
byte[] HeadBytes = getHeadBytes();
byte[] SendBytes = getSendBytes(); // ← 未被使用
if (HeadBytes == null) return false;
if ((HeadBytes[0] & 0xff) == 0xd0 && HeadBytes[1] == 0x00) { return true; } else { return false; } // 行 19
}
注意:这里(HeadBytes[7] + HeadBytes[8]*256)是小端,而 S7 的(HeadBytes[2]*256 + HeadBytes[3])是大端。两种字节序在同一份代码里各自硬编码,没有任何工具方法辅助——于是每一处都要作者手工写 *256。仅在 Core/IMessage/ 包内,* 256 就出现了 16 次。这是 Low-level 代码的必然,但也意味着极易写错。
三菱 MC ASCII(Core/IMessage/MelsecQnA3EAsciiMessage.java:8-33)——长度是 ASCII 十六进制:
public int ProtocolHeadBytesLength() { return 18; } // 行 8-10
public int GetContentLengthByHeadBytes() {
byte[] HeadBytes = getHeadBytes();
if (HeadBytes == null) return 0;
byte[] buffer = new byte[4];
buffer[0] = HeadBytes[14]; buffer[1] = HeadBytes[15]; // 行 17-20
buffer[2] = HeadBytes[16]; buffer[3] = HeadBytes[17];
return Integer.parseInt(new String(buffer, StandardCharsets.US_ASCII), 16); // 行 22 ★ radix=16
}
这是 Netty 的 LengthFieldBasedFrameDecoder 完全做不到的场景:长度域是""0018" 这样的 ASCII 十六进制文本"。Netty 只有 lengthFieldLength 为 1/2/3/4/8 字节的二进制解码,且即便扩展也需要自定义解码器。而在 Hsl 的模型里,这只是一个 Integer.parseInt(s, 16)。
欧姆龙 FINS(Core/IMessage/FinsMessage.java:10-23)——长度字段字节序还得倒过来:
public int GetContentLengthByHeadBytes() {
byte[] HeadBytes = getHeadBytes();
byte[] SendBytes = getSendBytes(); // ← 未被使用
if (HeadBytes == null) return 0;
byte[] buffer = new byte[4];
buffer[0] = HeadBytes[7]; buffer[1] = HeadBytes[6]; // 行 15-18 ★ 倒序填充
buffer[2] = HeadBytes[5]; buffer[3] = HeadBytes[4];
int length = Utilities.getInt(buffer, 0);
if (length > 10000) length = 10000; // 行 20 钳上限
if (length < 0) length = 0; // 行 21 钳下限
return length;
}
注意行 20-21 的钳位——这是一个重要的防御性实践:如果帧头是脏的、长度字段被解析成一个巨大数字,Receive(socket, 巨大值) 会挂死到超时。钳位能快速失败。全库只有 6 个类做了这类保护(FinsMessage 行 20-21、AdsNetMessage 行 17-18、MemobusMessage、OpenProtocolMessage 行 22、ModbusTcpMessage 行 27、ToyoPucMessage 的 try-catch 兜 0)。其余 27 个类没有任何防护——这是不一致的安全实践。
5.2 类别 A 的一个特殊变体:Modbus TCP( ModbusTcpMessage)
Core/IMessage/ModbusTcpMessage.java 是 33 个类里最长、最有故事的一个(95 行)。它的 GetContentLengthByHeadBytes(行 14-55)做了三层递进:
int length = (HeadBytes[4] & 0xff) * 256 + (HeadBytes[5] & 0xff); // 行 19 MBAP 里的 length 字段
int remainLength = 0;
if (length == 0) {
HeadBytes = SoftBasic.BytesArrayRemoveBegin(HeadBytes, 1); // 行 23 ★ 丢掉头一个字节,重对齐
setHeadBytes(HeadBytes); // 行 24 ★ 写回自己的 head!
remainLength = (HeadBytes[4] & 0xff) * 256 + (HeadBytes[5] & 0xff) - 1;
} else {
remainLength = Math.min(length - 2, 300); // 行 27 ★ 上限 300
}
if (SendBytes != null && SendBytes.length == 12) { // 行 30
if (SendBytes[7] == ModbusInfo.ReadRegister || SendBytes[7] == ModbusInfo.ReadInputRegister) {
int readLength = (SendBytes[10] & 0xff) * 256 + (SendBytes[11] & 0xff);
if (readLength > 0 && readLength <= 127) {
int expectLength = readLength * 2 + 1; // 行 34 字节计数+长度字节
if (expectLength != remainLength && length == 6) {
// 2025年7月23日 00:15:45 此处以读取的长度为准,剩余长度需要重新计算
remainLength = expectLength; // 行 38
}
}
} else if (SendBytes[7] == ModbusInfo.ReadCoil || SendBytes[7] == ModbusInfo.ReadDiscrete) {
int readLength = (SendBytes[10] & 0xff) * 256 + (SendBytes[11] & 0xff);
if (readLength > 0 && readLength <= 2040) {
int expectLength = (readLength - 1) / 8 + 1 + 1; // 行 44
if (expectLength != remainLength && length == 6) {
remainLength = expectLength;
}
}
}
}
return remainLength;
这段代码讲的是血泪史:
- 行 19:读取 MBAP 里的 length 字段,标准做法。
- 行 22-25:遇到
length == 0——说明收到了 1 字节的脏数据,BytesArrayRemoveBegin(HeadBytes, 1)丢掉第一个字节并setHeadBytes重写自己的头。这是一个在现场对抗国产 Modbus 网关脏数据时打的补丁。 - 行 27:
Math.min(length - 2, 300)硬编码上限 300——防止长度字段异常导致读一个天文数字。 - 行 30-50:如果 MBAP 长度和实际请求推断的长度不一致(
length == 6是个可疑的默认值),以请求推断为准。注释里连时间戳都留下了:2025年7月23日 00:15:45。
评价:
- 从工程角度,这是对的——现场设备就是这么烂,
ModbusTcpMessage在努力健壮容错。 - 从设计角度,这违反了单一职责:一个"帧长度解析类"里塞了"兼容国产网关脏数据"的 heuristics,以及两个魔数
300和6。而且setHeadBytes的副作用(修改自己的 head)让ReceiveByMessage的行 329 用netMessage.getHeadBytes()拼出来的帧是被修改过的头——这是一处隐蔽的行为。
另外注意 CheckHeadBytesLegal(行 58-67)只检查 head[2] == 0 && head[3] == 0(MBAP 协议标识必须为 0),而没有检查 magic 是否含有未被 strip 的字节。
5.3 类别 B:靠请求推断长度(难度最高)
三菱 A-1E 二进制(Core/IMessage/MelsecA1EBinaryMessage.java:5-40)——它的帧头只有 2 字节,响应长度必须由请求的命令码决定:
public int ProtocolHeadBytesLength(){ return 2; } // 行 5-7 只有副标题 + 结束码
public int GetContentLengthByHeadBytes(){
byte[] HeadBytes = getHeadBytes();
byte[] SendBytes = getSendBytes();
if (HeadBytes[1] == 0x5B) return 2; // 行 12 CPU 异常:异常码 + 0x00
else if (HeadBytes[1] == 0x00)
{
switch (HeadBytes[0])
{
case (byte)0x80: return SendBytes[10] != 0x00 ? ((SendBytes[10] & 0xff) + 1) / 2 : 128; // 行 17 位批量读;请求未填点数时兜底 128
case (byte)0x81: return (SendBytes[10] & 0xff ) * 2; // 行 18 字批量读
case (byte)0x82: // 行 19 位批量写
case (byte)0x83: return 0; // 行 20 字批量写:无体
default: return 0;
}
}
else return 0;
// 注释:在A兼容1E协议中,写入值后,若不发生异常,只返回副标题 + 结束代码(0x00)
}
注意行 17 的 (SendBytes[10] + 1) / 2 与那个 : 128 兜底:请求里的"读取点数"决定响应字节数;若点是 0(请求里没写),就按最大 128 字节读。这是一处猜测。
CheckHeadBytesLegal(行 31-40)也很特别——它拿 HeadBytes[0] 和 SendBytes[0] 做减法验证:
if (((HeadBytes[0] & 0xff) - (SendBytes[0] & 0xff)) == 0x80) { return true; }
即响应副标题必须 = 请求副标题 + 0x80。这是唯一一个 CheckHeadBytesLegal 依赖 SendBytes 的实现。
5.4 再多一个 B 类例子:Siemens Fetch/Write
Core/IMessage/FetchWriteMessage.java:8-25 的逻辑更绕:不仅要看响应的 HeadBytes[5](命令类型),还要回头看请求的 SendBytes[8]:
public int GetContentLengthByHeadBytes() {
byte[] HeadBytes = getHeadBytes();
byte[] SendBytes = getSendBytes();
if (HeadBytes[5] == 0x05 || HeadBytes[5] == 0x04) return 0; // 行 12
if (HeadBytes[5] == 0x06) { // 行 13
if (SendBytes == null) return 0;
if (HeadBytes[8] != 0x00) return 0; // 行 15 发生了错误
if (SendBytes[8] == 0x01 || SendBytes[8] == 0x06 || SendBytes[8] == 0x07)
return ((SendBytes[12] & 0xff) * 256 + (SendBytes[13] & 0xff)) * 2; // 行 17 按字:×2
return (SendBytes[12] & 0xff) * 256 + (SendBytes[13] & 0xff); // 行 18 按字节
} else if (HeadBytes[5] == 0x03) {
if (HeadBytes[8] == 0x01 || HeadBytes[8] == 0x06 || HeadBytes[8] == 0x07)
return ((HeadBytes[12] & 0xff) * 256 + (HeadBytes[13] & 0xff)) * 2; // 行 21
return (HeadBytes[12] & 0xff) * 256 + (HeadBytes[13] & 0xff); // 行 22
}
return 0;
}
注意同一个任务在两处会切换数据源:HeadBytes[5] == 0x06 时看请求的 SendBytes,而 == 0x03 时看响应的 HeadBytes。这是把协议文档的手wave写成了代码。难读,但它确实在服务款奇形怪状的西门子 Fetch/Write。
5.5 类别 C:流式(-1)模式
ModbusRtuMessage(Core/IMessage/ModbusRtuMessage.java:16-38)是纯粹的流式:
public int ProtocolHeadBytesLength() { return -1; } // 行 16-18
public int GetContentLengthByHeadBytes() { return 0; } // 行 21-23
public boolean CheckReceiveDataComplete( byte[] send, MemoryStream ms ) {
return HslCommunication.ModBus.ModbusInfo.CheckRtuReceiveDataComplete(send, ms.ToArray()); // 行 27
}
public int CheckMessageMatch( byte[] send, byte[] receive ) {
if (!StationCheckMatch) return 1; // 行 33
return HslCommunication.ModBus.ModbusInfo.CheckRtuMessageMatch( send, receive ); // 行 34
}
注意行 27:Core/IMessage 反向依赖了 HslCommunication.ModBus。这是全库唯一一处 Core → 应用协议包的反向依赖,破坏了分层。
RkcTemperatureMessage(Core/IMessage/RkcTemperatureMessage.java:14-26)则是通过 ASCII 控制符判断:
public boolean CheckReceiveDataComplete( byte[] send, MemoryStream ms )
{
byte[] buffer = ms.ToArray( );
if (buffer.length == 1)
if (buffer[0] == AsciiControl.ACK || buffer[0] == AsciiControl.NAK) return true; // 行 19
else if (buffer.length == 6 && buffer[0] == AsciiControl.EOT
&& buffer[buffer.length - 1] == AsciiControl.ENQ) return true; // 行 21
if (buffer.length > 3 && buffer[0] == AsciiControl.STX
&& buffer[buffer.length - 2] == AsciiControl.ETX) return true; // 行 23
if (buffer.length > 6 && buffer[0] == AsciiControl.EOT && buffer[3] == AsciiControl.STX
&& buffer[buffer.length - 2] == AsciiControl.ETX) return true; // 行 24
return false;
}
温控表的 RESP 协议用 STX/ETX/EOT/ENQ/ACK/NAK 这些 1960 年代的电传 control code 做分界。这种"看开头是 STX、倒数第二位是 ETX"的判断逻辑,用参数化解码器表达不出来。
5.6 类别 E:长度域 + 流式兜底(DLT645Message)
// Core/IMessage/DLT645Message.java:10-26
public int ProtocolHeadBytesLength() { return 10; } // 行 10-12
public int GetContentLengthByHeadBytes() { return (getHeadBytes()[9] & 0xff) + 2; } // 行 14-16 数据域长度 + 2 字节校验+结束符
public int PependedUselesByteLength(byte[] headByte) { ... } // 行 18-22 (未被调用)
public boolean CheckReceiveDataComplete(byte[] send, MemoryStream ms) {
return DLT645Helper.CheckReceiveDataComplete(ms); // 行 24-26 再兜一层
}
DLT645 的帧头是 10 字节(68H addr[6] 68H ctrl len),第 10 字节(index 9)是数据域长度,+2 是 CS 校验和 16H 结束符。同时它还实现了 CheckReceiveDataComplete 做二次确认。
6. 一个私有协议接入示例
假设你要接一台国产温控器,协议是:0xAA 0x55 | [len_lo] [len_hi] | payload... | 0x0D。怎么接?只需要写这一个类(约 25 行):
public class MyTcMessage extends NetMessageBase implements INetMessage {
public int ProtocolHeadBytesLength() { return 4; } // AA 55 len_lo len_hi
public int GetContentLengthByHeadBytes() {
byte[] h = getHeadBytes();
if (h == null || h.length < 4) return 0;
if ((h[0] & 0xff) != 0xAA || (h[1] & 0xff) != 0x55) return 0; // magic 不对 ← 返回 0 意为"没有体"
return (h[2] & 0xff) + (h[3] & 0xff) * 256;
}
public boolean CheckHeadBytesLegal(byte[] token) {
byte[] h = getHeadBytes();
return h != null && (h[0] & 0xff) == 0xAA && (h[1] & 0xff) == 0x55;
}
}
然后在设备类里:
@Override protected INetMessage GetNewNetMessage() { return new MyTcMessage(); }
就这样。 不要再碰 NetworkBase.ReceiveByMessage 一个字。这就是这套抽象最大的价值——新增协议的边际成本被压到 25 行。
7. 与 Netty LengthFieldBasedFrameDecoder 对比
7.1 Netty 的做法(简述)
// new LengthFieldBasedFrameDecoder(maxFrameLength, lengthFieldOffset, lengthFieldLength,
// lengthAdjustment, initialBytesToStrip)
new LengthFieldBasedFrameDecoder(1024, 0, 2, 0, 0)
四个参数:长度域在哪、几个字节、长度值要不要修正、要不要剥掉头部字节。它在一个累积缓冲区上反复尝试解码,状态机是 Netty 管的。
7.2 异同对照表
| 维度 | Hsl INetMessage |
Netty LengthFieldBasedFrameDecoder |
|---|---|---|
| 定界表达力 | 任意 Java 代码 | 4 个 int 参数(二进制长度域) |
| 能否处理 ASCII 十六进制长度域 | ✅ Integer.parseInt(s, 16) |
❌ 需自定义解码器 |
| 能否处理"长度由请求推断" | ✅ getSendBytes() |
❌ 完全不行(解码器是单向的) |
| 能否处理"靠结束符" | ✅ CheckReceiveDataComplete 任意逻辑 |
❌ 需换 LineBasedFrameDecoder / DelimiterBasedFrameDecoder |
| 能否处理"多帧乱序应答" | ✅ CheckMessageMatch 三态 + 重试循环 |
❌ 假定严格 FIFO |
| 能否在解析过程中"修正头" | ✅ ModbusTcpMessage.java:24 重写 head |
❌ |
| 能否在失败前做特殊兼容 | ✅ 任意 if-else(ModbusTcpMessage 的脏数据分支) |
❌ 抛 TooLongFrameException 或等数据 |
| 是否需要处理半包 | 不需要(Receive 里有 while 循环,必然读满) |
需要(累积缓冲区 + 状态机) |
| 是否零拷贝 / 缓冲复用 | ❌ 每次 new byte[length] + SpliceTwoByteArray 再拷一遍 |
✅ ByteBuf 池化 + slice 零拷贝 |
| 是否异步 / 高并发 | ❌ 阻塞 BIO,一个连接同时只能处理一个请求 | ✅ NIO + EventLoop,一条连接可管线化 |
| 内存上限保护 | 靠每个 Message 类自己 Math.min(只有 5 个类做了) |
maxFrameLength 强制统一保护 |
| 新增协议成本 | 一个类,形态自由 | 配参数(能表达的场合很省),不能表达就得写 ByteToMessageDecoder |
| 依赖 | 零(只吃 JDK) | Netty(jar 体积 MB 级) |
| 学习曲线 | 低(写两段 int 返回方法) | 中(要理解 ByteBuf 生命周期与引用计数) |
7.3 Netty 解码器能表达、而 Hsl 需要多写几行的例子
标准帧 | HDR(2) | LEN(4) | BODY(LEN) |:
// Netty
new LengthFieldBasedFrameDecoder(65535, 2, 4, 0, 0)
// Hsl
public int ProtocolHeadBytesLength() { return 6; }
public int GetContentLengthByHeadBytes() { return Utilities.getInt(getHeadBytes(), 2); }
在这个最标准、最常见的场合,Netty 一行胜出,Hsl 需要写一个类。 这是 Netty 的优势区。
7.4 Hsl 能表达、Netty 需要换解码器或自写的例子
| 场景 | Netty | Hsl |
|---|---|---|
| 长度域是 ASCII 十六进制(三菱 MC ASCII 帧) | 写自定义 ByteToMessageDecoder |
Integer.parseInt(s,16),一行(已实现) |
| 响应长度由请求的命令码决定(三菱 A-1E) | 做不到,得在上层 Hack(记录最后一次请求) | getSendBytes() 天然支持(接口内置) |
协议靠 STX...ETX 分界(RKC 温控) |
换 DelimiterBasedFrameDecoder;多个结束符还需多实例 |
CheckReceiveDataComplete 随意组合判断 |
| 收到脏字节要重对齐(Modbus TCP 网关) | 无此概念 | BytesArrayRemoveBegin(HeadBytes,1) + setHeadBytes |
| 多主站共用链路、应答可能错序 | 无此概念 | CheckMessageMatch 返回 -1,继续收 |
7.5 一个本质差异:同步 vs 异步模型
这是最重要的对比,甚至比"表达力"更重要:
Netty (异步/事件驱动) Hsl (同步阻塞)
───────────────────────────────── ─────────────────────────────────
channel.read → 可能只有半帧 Receive(socket, N) → 必然返回 N 字节
↓ 累积到 ByteBuf ↓ 不到就死等(或 SoTimeout)
再 read → 拼出完整帧 → 触发 channelRead Receive(socket, M) → 必然返回 M 字节
↓ ↓
pipeline 继续处理下个 handler 拼接返回,调用方直接拿到完整帧
Netty 的模型适合"高并发、不确定何时到、可能同时有几十条连接";Hsl 的模型适合"我发一条命令,然后死等这一条应答"——后者恰恰是 PLC 主站采集的真实模型:严格的一问一答,重试由上层控制。
所以这两者不是"谁替代谁",而是解决不同规模的问题。 Hsl 用同步 IO 换取了:
- 极低的认知负担(调用方拿到的返回值就是一帧,不存在"还没收全"的状态)
- 零 ByteBuf 生命周期管理(不用
release(),没有内存泄漏风险) - 极低延迟抖动(没有 EventLoop 队列延迟)
换取这些的代价是:单连接吞吐被限制为 1/RTT(已在第 02 篇说明,这个取舍对 PLC 是正确的)。
7.6 一个双方都存在的缺陷
两者都不能优雅处理"长度字段本身损坏"。
- Netty:抛
TooLongFrameException(如果有maxFrameLength),否则读出一个巨帧直到超时。 - Hsl:
Receive(socket, 巨大值)会阻塞到SoTimeout超时,然后整条连接被CloseSocket(NetworkBase.java:151)——这条长连接就废了,下一次请求才重连。
全库只有 6 个 Message 类做了长度防护(FinsMessage 行 20-21、AdsNetMessage 行 17-18、MemobusMessage、OpenProtocolMessage 行 22、ModbusTcpMessage 行 27、ToyoPucMessage 的 try-catch)。这是一个应该做成框架级、而不是让每个协议各自考虑的能力:在 ReceiveByMessage 里加一个统一的 maxFrameLength 参数会是更安全的做法。
8. 综合评价:什么时候该用哪套
| 你的场景 | 推荐 |
|---|---|
| 高并发网关(几百条连接同时收发) | Netty |
| 标准 Modbus TCP / S7 单线采集 | 两者都行;Hsl 上手快(零依赖) |
| 国产/日系非标准私有协议(长度域奇怪、要靠请求推断) | Hsl INetMessage——写一个类,比自定义 Netty 解码器省一半代码 |
| 串口透传服务器(一 TCP 挂 N 台 RTU 从站) | Hsl(共享 PipeSocket + 请求串行化锁,已在第 02 篇讲过) |
| 需要 ByteBuf 零拷贝、大吞吐传输 | Netty |
| 目标环境是 Java 6 / 内存 64MB 的工控机 | Hsl(零第三方依赖,且没有 Netty 的堆外内存管理) |
一个务实的组合:如果你的系统同时有"高并发南向接入"和"各种奇怪北向设备",完全可以在 Netty 的 pipeline 里复用 INetMessage 的逻辑——把 ProtocolHeadBytesLength() 和 GetContentLengthByHeadBytes() 当成 ByteToMessageDecoder 的两个回调来调用。这两者的抽象其实很接近,只是 Netty 把状态机摆在了框架侧、Hsl 摆在了协议侧。
设计优点
- 这套抽象是全库最值得学习的设计。 33 个帧解析器共享同一份读取循环(
NetworkBase.java:290-331),新增协议边际成本约 25 行。 - 把"同步请求的上下文"显式建模成
getSendBytes():响应不含长度信息时可以回头看请求——这是 Netty 那种纯单向解码器做不到的,而这个接口把它设计成了一等公民。 CheckMessageMatch的三态设计(1/0/-1)解决了"多主站共用链路导致应答错配"这个真实存在的现场问题,且实现极简(一个 while 循环)。- 每一次读都是"必定读满",调用方不需要面对"半包"这个状态。极低的认知负担。
Math.min(length - 2, 300)、if (length > 10000) length = 10000这类钳位虽然只有 5 个类做了,但说明作者知道脏数据风险。- 注释里留下时间戳和现场日期(
ModbusTcpMessage.java:37的2025年7月23日 00:15:45):这是开源项目罕见的可追溯性,比"优化了一下"要有用得多。
设计缺点
PependedUselesByteLength定义了却从未被调用(只有DLT645Message实现它)——半成品能力,会误导使用者。NetMessageBase既不抽象、也不实现INetMessage,子类漏写implements INetMessage时编译器不报错,运行时才 ClassCastException。这是一个类型安全漏洞,应该让NetMessageBase直接implements INetMessage并保持三个方法为 abstract。- 每个实现类都有一行未使用的
byte[] SendBytes = getSendBytes();(约 20 处),是 C# 逐行翻译的残留噪音。 - 33 个类里只有 6 个做了长度防护。这是一个应该上升到框架层(在
ReceiveByMessage里加统一maxFrameLength)的保护,目前完全依赖每个协议作者的自觉。 ModbusTcpMessage.GetContentLengthByHeadBytes里有副作用(行 23-24 修改自身 head),违反"查询方法不应有副作用"的直觉;且塞了两个魔数300和6。Core/IMessage/ModbusRtuMessage.java:27反向依赖HslCommunication.ModBus,破坏 Core 层的封闭性。CNCFanucSeriesMessage不在Core/IMessage/包里,说明"消息帧解析器放哪"这条约定没有被严格遵守。- 每次接收调用两次
Receive,每次都新建结果数组并最后做一次SpliceTwoByteArray拷贝(NetworkBase.java:329),相比 Netty 的ByteBufslice 是明确的性能劣势(但对低频 PLC 采集影响有限)。 MelsecA1EBinaryMessage.java:17的: 128兜底是一处猜测:请求长度为 0 时直接假设要读 128 字节。(需实测确认该假设在真机上的正确性。)
可借鉴点
- 当"配置项"变得越来越结构各异时,就把它升级成"可执行代码"。 Netty 用 4 个 int 描述帧;一旦遇到 ASCII 长度域、推断长度、结束符三种情况就都得换类。Hsl 直接用代码,表达力是无限的。这个"参数 vs 代码"的取舍点很值得在自己设计接口时思考。
- 把上一次请求放进解码器上下文(
setSendBytes):任何"响应格式依赖请求内容"的协议(这在工业界非常普遍)都需要这个。Netty 社区常见的 workaround 是写一个LastRequestChannelHandler,丑陋且线程不安全;把它放进接口才是正解。 - 三态返回值替代布尔:
CheckMessageMatch的 1/0/-1 比boolean isMatch()多表达了"确定错误"和"继续等待"两种语义,驱动方代码反而更简单。 - 防御性钳位要写在解析层——虽然只有 5 个类做了,但这个模式本身是对的:在"长度"这个数字被交给下一层之前先夹住它。
- 注释里记录修改日期与现场背景(
ModbusTcpMessage.java:37)。这种"为什么"比任何方法注释都重要。 SpecifiedCharacterMessage是反例,不是正例:用int负值打包三个参数确实省了一个类,但可读性几乎为零。自己代码里请用一个record/小配置类。
上一篇:通信内核 NetworkBase:Socket 层、两阶段读与四级继承 | 下一篇:协议实现与字节转换:ModBus / Siemens / Melsec 与 Core.Transfer
本文是《工业通信与数据采集》栏目第 12/22 篇 · 上一篇:通信内核 NetworkBase:Socket 层、两阶段读与四级继承 | 栏目总览 | 下一篇:协议实现与字节转换:ModBus / Siemens / Melsec 与 Core.Transfer