工业通信与数据采集:从字节序到自研采集器的一套知识体系
把一套 22 篇的工业通信资料整理成栏目发布:本篇是总览与浓缩导读——float 为什么乱码、TCP 为什么粘包、三种协议的报文骨架差在哪、两大开源库源码的取舍,以及自研采集器值得落地的几条结论。全集 13-34 篇可在栏目内按序阅读。
更新于 2026-10-31。内容基于 HslCommunication(Java 版 3.9.3)与 iot-communication 两个开源库的本地源码逐行研读整理,十六进制示例与代码引用均可对照源码复现;工程上尚未实测确认的内容按整理约定标注"(需实测确认)",现场行为差异极大,请以实机为准。
这套知识的组织逻辑
整理时遵循一条主线:先看懂 byte(字节序)→ 再看懂 frame(粘包)→ 再看懂 protocol(报文)→ 再看懂别人的代码(源码)→ 最后写自己的(实战)。这个顺序不是随意的:采集开发里最高频的故障,恰好沿着这条线分布。
| 篇章 | 内容 | 对应的工程问题 |
|---|---|---|
| 基础篇 | 采集全景、协议栈、字节序 | 点表怎么建、值为什么读错 |
| 网络原理篇 | TCP 深入、粘包与拆包、IO 模型 | 帧怎么切、并发怎么做 |
| 协议篇 | Modbus、S7、MC | 报文怎么手工构造和解析 |
| 源码分析 | HslCommunication、iot-communication | 开源库怎么实现、能不能用 |
| 对比与实战 | 横向选型、自研采集器设计 | 用哪个库、自己写怎么写 |
| 附录 | 速查手册、FAQ 32 条 | 现场连不上、数据不对 |
与站内文章的关系:多协议采集平台的架构设计讲的是我自己采集系统的配置模型与运行链路,协议设备模拟器设计讲不接真机时如何演练协议链路。这篇补的是更底层的通信原理与开源库源码视角,也是那两篇文章背后依赖的知识基础。
字节序:16 位整数都对,一读 float 就乱
采集项目里最经典的报障是:点表里 16 位整数全部正常,一加 float 点位就出天文数字或近零小数。根因在于 FLOAT32 占 4 字节 = 2 个 16 位寄存器,而 Modbus 只规定了寄存器内部大端,两个寄存器以什么顺序拼成 32 位完全没规定。字序 × 字节序共有 4 种组合,对不上就乱码。
以 12.5f(0x41480000)为例,四种排列在同一解析器下的结果:
| 字序 | 线上字节 | 按 ABCD 解析 |
|---|---|---|
| ABCD | 41 48 00 00 |
12.5(正确) |
| BADC | 48 41 00 00 |
197632.0 |
| CDAB | 00 00 41 48 |
≈2.34e-41 |
| DCBA | 00 00 48 41 |
≈2.59e-41 |
现象可以直接反推字序:读到 1e5~1e38 量级先试 BADC,读到 1e-41 量级的次正规数试 CDAB/DCBA。验证时必须用 12.5、1.0 这类已知值写入反推——用 0 验证是无效的,四种排列解析出来全是 0。结论确定后应写进点表 byte_order 字段,作为测点级配置而不是采集器全局开关,因为同一个站里不同品牌设备、甚至同一设备经不同映射功能块暴露的数据都可能不一致。
协议层面的对照也值得记住:S7 内部是大端(ABCD);三菱/欧姆龙数据多为小端,但 MC 报文的头部字段又是大端;网络字节序统一大端。类型上还有几个高频坑:UINT32 必须用 long 承接(0xFFFFFFFF 存 int 就是 -1,"电表累计量变负"就是这么来的);Java 的 byte 有符号,组报文必须 & 0xFF;RAW 值超出量程(如西门子满量程 27648)或出现 32767 这类哨兵值应标 BAD 质量戳,而不是截断输出。
粘包:不是 TCP 的 bug,是应用层没定义消息边界
粘包/拆包从来不是 TCP 的 bug,而是应用层没定义消息边界。
TCP 是字节流协议,一次 read() 读到的可能是半帧、一帧或两帧半。产生"粘合"的成因有五个:Nagle 算法在发送侧攒小包、延迟 ACK 让发送方继续攒(Linux 约 40ms)、MSS 分段、收发缓冲区把多个小帧并成一个段、以及应用层读得晚。
关 Nagle 只削弱原因一,剩下四个还在——这就是"关了 Nagle 还是粘包"的答案。更隐蔽的是 Nagle + 延迟 ACK 的 40ms 组合拳:触发条件是"一次请求分多次 write 且中间不读"。先写 7 字节 MBAP 再写 PDU 的写法正好中招。所以实践口径很简单:setTcpNoDelay(true) 一律打开,且一次 write 写完整帧。
应用层定界有四种方法,工业协议各有归属:
| 定界方法 | 典型使用者 |
|---|---|
| 定长帧 | 部分单片机私有协议 |
| 分隔符 | Modbus ASCII(CRLF)、NMEA-0183 |
| 长度字段 | Modbus TCP、S7、MC —— 工业采集首选 |
| 超时/静默判定 | Modbus RTU(3.5 字符静默,9600bps 下约 4.01ms) |
长度字段法看起来最省心,但三个协议的长度语义各不相同(下节展开),错配就是错帧。另外两件事必须做:解码器设 maxFrameLength,防止脏长度值打爆内存;校验失败立刻断连重连,不要试图在脏字节流里重新同步——错帧之后字节流已无对齐可言,继续读只会雪崩。
三种协议,三种长度语义
这是整套资料里我最想强调的一张表。三个最常用协议都靠长度字段定界,但位置、端序、"含不含头部"全都不同:
| 协议 | 长度字段位置 | 端序 | 长度语义 |
|---|---|---|---|
| Modbus TCP | MBAP 偏移 4-5 | 大端 | 不含前 6 字节(= UnitID + PDU),总帧长 = 6 + L |
| S7 | TPKT 偏移 2-3 | 大端 | 含自身 4 字节,接收侧还需读 = L − 4 |
| MC 3E | 偏移 7-8 | 小端 | 不含前 9 字节;应答自结束代码(偏移 9)算起、含 2 字节结束代码 |
落到 Netty 的 LengthFieldBasedFrameDecoder,就是三组不同的参数(frameLength = offset + len + adjustment + 长度值):
Modbus TCP (260, 4, 2, 0, 0)
S7 (1024, 2, 2, -4, 0) ← adjustment=-4 补偿"含自身头"
MC 3E (2048, 7, 2, 0, 0) ← 需覆写 getUnadjustedFrameLength 按小端取值
报文骨架的其余差异同样值得对照记忆:
- Modbus:MBAP 7 字节(事务 ID 2 + 协议 ID 2 + 长度 2 + 单元 ID 1)+ PDU,无握手。读保持寄存器的完整请求只有 12 字节:
00 01 00 00 00 06 11 03 00 6B 00 03(从站 17、地址 107 起 3 个)。典型坑:参考号 40001 对应报文地址 0(1 基转 0 基);RTU 的 CRC16 低字节先发;单次读保持寄存器上限 125 个。 - S7:TPKT(4B) + COTP + S7 PDU 三层封装,握手两步走——COTP CR/CC 协商 TSAP,再 Setup Communication(0xF0) 协商 PDU 尺寸(常见 240 字节,单次读上限 = PDU − 28)。握手完成后每帧前 7 字节恒为
03 00 xx xx 02 F0 80。典型坑:S7 地址是位地址(字节地址 ×8 + 位);S7-1200/1500 必须勾选"PUT/GET 通信访问",否则能连不能读——这是现场第一高频问题。 - MC(三菱):3E 帧请求头 11 字节,副头部
50 00/应答D0 00,全帧数据小端(与 Modbus 寄存器数据、S7 相反)。读 D100 起 10 字的完整请求:50 00 00 FF FF 03 00 0C 00 0A 00 01 04 00 00 64 00 00 A8 0A 00,其中偏移 7 的0C 00是小端长度,0A 00是监视定时器 2.5s(单位 250ms)。典型坑:X/Y/B/W/SB/SW/DX/DY 八类软元件地址是十六进制,其余十进制;位数据 1 字节存 2 位;单次超 950 字必须切片。
长度语义的错配还有一个后果链:S7 这类带握手的协议在短连接模式下,每次采集都要重跑 COTP + Setup 两次握手。HslCommunication 默认正是短连接,高频采集必须显式 SetPersistentConnection(),否则除了延迟,主动关闭方还会在内核里堆积 TIME_WAIT。这一点在源码分析篇里可以看得非常清楚:短连接路径下每个请求都走"新建 socket → 连接初始化钩子 → 收发 → 关闭"的完整循环。
读两个开源库的源码,学两种设计
HslCommunication:把"流怎么分帧"抽象成接口
HSL(Java 版 3.9.3,294 个 .java、约 6.2 万行)覆盖 24 个 PLC 品牌,全库零第三方依赖,风格是"工程派":所有调用返回 OperateResult 不抛异常,五层单向依赖,继承链四级(NetworkBase → NetworkDoubleBase → NetworkDeviceBase → 39 个设备类)。
它最值得学习的设计是 INetMessage 接口:33 个帧解析器共用 ReceiveByMessage 里同一份两阶段读循环,新增协议的边际成本只有约 25 行:
// NetworkBase.ReceiveByMessage 的骨架(简化)
byte[] head = ReadBytesFromSocket(socket, netMessage.ProtocolHeadBytesLength(), timeout); // 1) 精确读满定长头
netMessage.setHeadBytes(head);
int contentLength = netMessage.GetContentLengthByHeadBytes(); // 2) 协议对象自己回答"体多长"
byte[] content = ReadBytesFromSocket(socket, contentLength, timeout); // 3) 精确读满体
byte[] frame = SpliceTwoByteArray(head, content); // 4) 拼完整帧
底层 ReadBytesFromSocket 是 while 循环读满才返回:"只读 N 字节、余量留内核缓冲区"解决了粘包,"读满循环"解决了拆包。这套抽象强于 Netty 的 LengthFieldBasedFrameDecoder(仅 4 个 int 参数)之处在于:ASCII 十六进制长度域、无长度域靠请求报文反推响应长度、STX/ETX 结束符定帧——参数化解码器表达不了的规则,接口都能表达。接口还带 CheckMessageMatch(1 匹配 / 0 错误 / -1 继续收)三态校验,防止应答错配。
iot-communication:一个 boolean 的两种读取策略
iot-communication(master,463 个 .java,MIT 协议)是"直白派":直接返回裸值、抛 unchecked 异常,协议头逐字节注释。它的粘包处理核心是一行代码:
// SocketUtils.read
if (!waitForMore && num < len) break; // false=读短即止;true=死等读满
waitForMore 一个 boolean 同时支持"读短即止"与"死等读满"两种策略。可读性好,但代价是每个协议要重复手写一遍两阶段读,三处协议实现的传参还不一致——有长度字段的 Modbus/S7/MC 是对的,没有长度字段的 RTU over TCP 基本是"赌运气"。它也有独有亮点:注解驱动的 S7 数据区直映射(PLC 数据区 → Java Bean)、自描述报文契约 + selfCheck() 统一回填长度/CRC、131 个测试类(HSL 的测试目录是空的)。
选型:先定 SPI,再选库
| 维度 | HslCommunication | iot-communication |
|---|---|---|
| 架构抽象 | INetMessage 可复用定帧(明显领先) | 每协议手写两阶段读 |
| 协议覆盖 | 19+ 品牌目录、串口、机器人、电表 | Modbus/S7/MC + RTSP、828D 数控 |
| 工程化 | 零测试、无构建文件 | 131 个测试类、标准 Maven、融入 Spring Boot |
| 性能 | BIO 同一量级,"换库提升一个量级"是幻想 | 同左 |
| 授权 | 有商用风险(见下) | MIT |
HSL 的授权问题需要单独记录:仓库 LICENSE 文件是 LGPL-3.0,但版权年份止于 2017-2019;作者已声明 2019 年 8 月起不再开源、转订阅制;现行代码内置混淆的 Authorization.java 授权校验,未激活限时 8 小时。LGPL 不等于可自由商用,冲突时以作者现行声明为准——闭源商业产品引入前必须书面确认并留档,这是第一决策因素,优先级高于一切技术指标。
无论选哪个库,最重要的一条是:先定自己的 SPI 驱动接口,再选库。库做驱动实现层,第三方类型不得泄漏到业务层——将来授权问题、bug、性能问题,都降级为"换一个实现类"。
落到工程上的几条硬结论
最后是把 22 篇压成 8 条的结论清单,也是自研采集器设计实战篇(近 2000 行)反复论证过的取舍:
- 批量合并优先于一切 IO 优化。 读 10 个寄存器线上要 217 字节、有效载荷率仅 18.4%,读到 120 个升至 57.6%;逐点读取的每秒上万次请求,按 125 寄存器/次合批后只剩每秒一两百次。"批量合并是数量级收益,IO 模型优化通常只有百分比收益。"
- 几百台设备,BIO 就是最优解。 每设备一线程的账本:200 台约 100MB 线程栈(-Xss512k),周期 500ms、单次交互 2ms 时连接占用率仅 0.4%。上千连接或周期小于 200ms 才值得换 Netty,且只需重写驱动包。
- PLC 连接数是硬上限。 S7-1200 只有 8 个 S7 连接(与 PG/HMI 共享),Modbus 从站常见 1-8。连接应长期持有而非池化归还,单 IP 连接做预算(2-3 条),重连必须带随机抖动——防止掉电恢复后的重连风暴二次打满 PLC。
- 一条连接必须串行。 并发写会导致字节交错,更危险的是响应错配:"数据看起来正常,但值是错的"。事务 ID 流水线可把吞吐从 1/RTT 提到约 3/RTT,写操作绝不流水线。
- 心跳必须做在应用层。 TCP KeepAlive 默认约 2 小时 11 分才判死,且 PLC 处于 STOP 时通信处理器照样回探测包——TCP 层活着不等于业务活着。周期 1s、超时 2s、连续 3 次失败判离线,最坏 6 秒发现问题。
- 失败也要上报 BAD。 "数据不动"比"数据标记为坏"危险得多——前者会被当成工艺平稳。RAW 越界同样标 BAD 而非截断。
- 写操作三原则:幂等 requestId、回读校验、写读互斥。 读错最坏是图表难看,写错会让阀门动作。
- 采集周期不小于 PLC 扫描周期的两倍;设备、采集、服务器三个时间戳分列存储,默认按采集时间 ts_collect 归档,用服务器时间做工艺分析等于把网络抖动画进趋势曲线。
栏目篇目
全集按"原理 → 网络 → 协议 → 源码 → 实战 → 附录"排列,可在博客「栏目」入口按序阅读:
| 篇章 | 篇目 |
|---|---|
| 基础篇 | 工业数据采集全景与架构、协议栈与工业网络、字节序与数据类型编码 |
| 网络原理篇 | TCP 协议深入、粘包与拆包、IO 模型与高并发 |
| 协议篇 | Modbus 协议详解、S7 协议详解、三菱 MC 协议详解 |
| 源码分析(HslCommunication) | 项目总览与架构、通信内核 NetworkBase、INetMessage 与粘包解法、协议实现与字节转换 |
| 源码分析(iot-communication) | 项目总览与架构、网络层与粘包处理、协议实现详解(上)、协议实现详解(下)、基础设施与工程特性 |
| 对比与实战 | 两库横向对比与选型、自研采集器设计实战 |
| 附录 | 通信协议速查手册、常见问题排查 FAQ |
如果只读一篇
这套资料的阅读路径设计为三段:入门(全景 + 字节序 + Modbus + 速查手册,约 3 小时)、进阶(TCP + 粘包 + IO 模型 + S7/MC,约 6 小时)、精通(两库源码 + 选型 + 自研实战,约 10 小时)。基础与网络两段可以并行,源码段建议先读过网络原理。
如果只读一篇,读粘包与拆包篇——它一头连着 TCP 原理,一头连着三个协议的长度字段,中间还夹着两个开源库的解法对照。如果只做一件事,把两阶段读那 30 行代码(先读定长头、让协议对象回答体长、再读满体)读懂。剩下的,都能自己推出来。