Modbus 协议详解
本篇定位:把 Modbus 这条至今仍在大量 RS485 现场与以太网网关上跑的"活化石"协议拆到字节粒度,让读者脱离任何库也能手工拼出一条合法的 RTU / ASCII / TCP 报文,并看懂抓包里的每一个字节。所有报文结构均以开源实现为锚点核实:HslCommunication Java(下称 HSL)
HslJavaLatest/HslCommunication/ModBus/ModbusInfo.java、Serial/SoftCRC16.java,以及 iot-communication(下称 IOT)com/github/xingshuangs/iot/protocol/modbus/。
本篇目录
- 协议概述:1979、主从、长盛不衰与先天缺陷
- 三种传输模式:RTU / ASCII / TCP
- 存储区模型与地址表达:0x/1x/3x/4x 与 1 基 0 基
- PDU 结构:请求 / 正常响应 / 异常响应
- MBAP 头:事务 ID、协议标识、长度、单元标识
- 功能码全表与各功能码报文结构
- 异常响应:功能码最高位置 1 与异常码全表
- CRC16 与 LRC:原理、Java 实现、验证用例
- RTU 帧间隔:3.5 字符时间的来源与超时处理
- 报文实例专辑(6 例,含完整十六进制)
- 32 位数据:一个 float 占两个寄存器
- 协议限制与优化:125 / 2000 从哪来,如何自动分包
- 源码对照:HSL 与 IOT 的实现差异
- 小结
- 常见误区与现场坑清单
第 1 章 协议概述
1.1 来历
1979 年 Modicon(后被施耐德收购)为自家 PLC 制定 Modbus,运行在 RS-232 上;1997 年拓展出 Modbus TCP(端口 502);2004 年后移交 Modbus-IDA,成为无专利、无授权费的公共标准。它是**应用层(OSI 第 7 层)**协议,只规定报文中每个字节的含义,不规定底层是 RS-485、RS-232 还是 TCP——这正是一个功能码 0x03 既能跑在 RTU 帧里也能跑在 TCP 帧里的原因。
1.2 主从架构
请求(Request) ┌──────────────┐
主站 ──────────────────────>│ 从站 0x01 │
Master │ │
响应(Response) │ │
<─────────────────────└──────────────┘
规则三条:
- 只有主站能发起通信,从站永远被动回应,绝不主动上报。
- 同一时刻总线上只有一个主站发号施令,从站最多 247 个(站号 1~247;0 是广播地址,248~255 保留)。
- 纯轮询:一问一答算一个事务。
广播(站号 0)只有写指令有意义,且从站不应答——发了广播却等回应必然超时。HSL 为此专门跳过接收,见
ModBus/ModbusTcpNet.java:147-151:站号等于BroadcastStation时走ReadFromCoreServer(send, false, true)。
1.3 为什么长盛不衰
完全开放(无专利、无授权费;任何 MCU 几百行 C 就能实现从站)+ 极简(核心功能码只有 8 个,最短 RTU 帧 8 字节)+ 无状态(服务端不保存会话,掉电重启、断线重连无需复位流程)+ 映射透明(不做语义抽象,直接暴露寄存器地址,抓包即用肉眼看懂)+ 生态惯性(二十年间积累的仪表、变频器、电表都带 RS-485 + Modbus)。
1.4 先天局限(必须提前认清)
| 局限 | 表现 | 工程后果 |
|---|---|---|
| 没有数据类型 | 寄存器只有"16 位无符号整数"一个概念 | 拿到 42 F6 E9 79 不知道是 float 还是 int32,必须查点表 |
| 没有时间戳 | 报文不含"数据产生时刻"字段 | 上位机只有"到达时刻",历史趋势丢精度 |
| 基于轮询 | 从站不能主动上报 | 周期 T 与站数 N、每站点数 M 成正比;需快慢分组轮询 |
| 单点读长有限 | 0x03 一次最多 125 寄存器 |
大点表必须分包,进一步拉长周期 |
| 无安全机制 | 无认证、无加密、地址即权限 | 暴露到公网 = 任何人可改写寄存器;必须配白名单/VPN |
术语:ADU(Application Data Unit)= 完整的线上帧;PDU(Protocol Data Unit)= ADU 去掉地址与校验后的部分;MBAP = TCP 模式专属的头部。关系:ADU = 传输头尾 + PDU。搞清这个关系,后面所有报文表就不会混淆。
第 2 章 三种传输模式:RTU / ASCII / TCP
2.1 对比总表
| 维度 | RTU | ASCII | TCP |
|---|---|---|---|
| 编码 | 二进制(原字节直接发) | 可打印 ASCII(每字节 → 2 字符) | 二进制 |
| 帧起始 | 3.5 字符静默间隔 | : (0x3A) |
无,靠长度字段定界 |
| 帧结束 | 3.5 字符静默间隔 | \r\n (0x0D 0x0A) |
无 |
| 校验 | CRC-16(2 字节,低字节先发) | LRC(1 字节 → 2 个 ASCII 字符) | 无(TCP 已保证) |
| 站点寻址 | 1 字节站号 (1~247) | 1 字节站号(2 个 ASCII 字符) | MBAP 中的单元标识符 |
| 物理层 | RS-485/232/422 | RS-485/232 | TCP/IP,端口 502 |
| 线上效率 | 高 | 低约 50% | 高(+7 字节开销) |
| 典型场景 | 现场总线首选 | 调试、老仪表 | 以太网网关、上位机直连 |
┌──────────────────── RTU ADU ────────────────────┐
│ 静默3.5 │ 站号 │ PDU(功能码+数据) │ CRC低 CRC高 │ 静默3.5 │
└─────────────────────────────────────────────────┘
┌──────────────────── ASCII ADU ──────────────────┐
│ ':' │ 站号(2字符) │ PDU(2N字符) │ LRC(2字符) │ CR LF │
└─────────────────────────────────────────────────┘
┌──────────────────── TCP ADU ────────────────────┐
│ 事务ID(2) │ 协议ID(2) │ 长度(2) │ 单元ID(1) │ PDU │
└─────────────────────────────────────────────────┘
2.2 RTU
- 没有起止符,靠"总线静默多久"判断帧边界 → 引出 第 9 章。
- CRC 是 2 字节且低字节在前(最易错点,见 8.4)。
- 每字节 11 bit(1 起始 + 8 数据 + 1 偶校验 + 1 停止),这是 t3.5 的计算基础。
2.3 ASCII:一个字节变两个字符
转化规则:把 1 个字节拆成高 4 位与低 4 位,各自转成 0~9 A~F 中的一个 ASCII 字符。
0x5B → 高4位 0x5='5' 低4位 0xB='B' → "5B"
0x6B → → "6B"
0x03 → → "03"
建议统一用大写十六进制字符。构造三件套见 ModBus/ModbusInfo.java:540-549:
public static byte[] TransRtuToAsciiPackCommand(byte[] value) {
byte[] modbus_lrc = SoftLRC.LRC(value); // ① 先加 LRC(1 字节)
byte[] modbus_ascii = SoftBasic.BytesToAsciiBytes(modbus_lrc); // ② 逐字节变两个 ASCII 字符
return SoftBasic.SpliceTwoByteArray(new byte[]{0x3A}, modbus_ascii, new byte[]{0x0D, 0x0A}); // ③ 加 ':' 与 CRLF
}
解包是逆过程(ModBus/ModbusInfo.java:557-574):第 560 行先校验首字节 0x3A、尾两字节 0x0D 0x0A;第 564 行去首尾并还原成字节;第 566 行 CheckLRC;第 570 行还要再删最后 1 字节,因为 LRC 本身也参与了 ASCII 转换。
完整 ASCII 帧(读 11 03 006B 0003):
3A 31 31 30 33 30 30 36 42 30 30 30 33 37 45 0D 0A
: 1 1 0 3 0 0 6 B 0 0 0 3 7 E CR LF
└─Station=0x11─┘└─FC=03─┘└─ 006B ─┘└─ 0003 ─┘└LRC┘
字符串形式::1103006B00037E\r\n(共 17 字节)。
2.4 TCP
- 端口 502。
- 不再需要 CRC:以太网的 FCS(CRC-32) 已挡住比特错误,TCP 已保证重传与顺序,Length 字段已定界。职责不重复。
- 事务 ID 承担 RTU 中"站号 + 时序"的部分职责。
第 3 章 存储区模型与地址表达
3.1 四张表
| 区号 | 名称 | 英文 | 位宽 | 访问 | 功能码 | Modicon 前缀 |
|---|---|---|---|---|---|---|
| 0x | 线圈 | Coil | 1 bit | 读/写 | 01 / 05 / 0F | 0xxxx |
| 1x | 离散量输入 | Discrete Input | 1 bit | 只读 | 02 | 1xxxx |
| 3x | 输入寄存器 | Input Register | 16 bit | 只读 | 04 | 3xxxx |
| 4x | 保持寄存器 | Holding Register | 16 bit | 读/写 | 03 / 06 / 10 | 4xxxx |
口诀:线圈/保持寄存器可写;带"输入"二字的只读。 "2x"的空缺是历史遗留(原预留给特殊线圈,未启用)。
3.2 地址编号混乱的根源
混乱来自两套并行体系:
- (A)参考号(Reference Number):手册上印的
40001/400001,规则是"区号 + 从 1 开始的编号"。 - (B)报文地址(Protocol Address):帧里那 2 个字节,规则是"从 0 开始的偏移"。
3.3 对照表
| 功能码 | 区 | 参考号(5 位) | 参考号(6 位) | 报文地址(0 基) |
|---|---|---|---|---|
| 01 | 线圈 | 00001 | 000001 | 0x0000 |
| 01 | 线圈 | 00100 | 000100 | 0x0063 |
| 02 | 离散输入 | 10001 | 100001 | 0x0000 |
| 04 | 输入寄存器 | 30001 | 300001 | 0x0000 |
| 03 | 保持寄存器 | 40001 | 400001 | 0x0000 |
| 03 | 保持寄存器 | 40100 | 400100 | 0x0063 |
5 位 vs 6 位:老设备内存小,用 5 位(上限 9999);突破后用 6 位(上限 65535)。永远不要假设——先读手册标明最靠前的那个地址,数据对得上再往下走。
3.4 库里的开关 AddressStartWithZero
HSL 用布尔开关统一处理(ModBus/ModbusTcpNet.java:69、75-81),组包前由 CheckModbusAddressStart 完成换算——ModBus/ModbusInfo.java:117-122:
private static void CheckModbusAddressStart(ModbusAddress mAddress, boolean isStartWithZero) throws Exception {
if (!isStartWithZero) {
if (mAddress.getAddress() < 1) throw new Exception(StringResources.Language.ModbusAddressMustMoreThanOne());
mAddress.setAddress(mAddress.getAddress() - 1); // ← 1 基转 0 基,就这一行
}
}
对应文案见 Language/DefaultLanguage.java:180:"地址值在起始地址为1的情况下,必须大于1"。
ModbusTcpNet bus = new ModbusTcpNet("192.168.1.10", 502, (byte)1);
bus.setAddressStartWithZero(true); // 地址写 0 → 报文里也是 0
// bus.setAddressStartWithZero(false); // 地址写 1 → 报文里变成 0(自动 -1)
3.5 位地址(寄存器内某一位)
Modbus 协议本身不支持"读 40001 的第 3 位"。库的做法是先读整个字,再在本地取位——ModBus/ModbusHelper.java:164-185:
if (modbusAddress.Content.indexOf('.') > 0) { // 地址带 .x
int bitIndex = Integer.parseInt(modbusSplits[1]); // 小数部分 = 位索引
short len = (short) ((length + bitIndex + 15) / 16); // 向上取整到整字
OperateResultExOne<byte[]> read = modbus.Read(addressSplits[0], len); // ① 先读字
boolean wordReverse = modbus.getDataFormat() == DataFormat.ABCD || modbus.getDataFormat() == DataFormat.CDAB;
if (wordReverse)
return BoolArraySelectMiddle(ByteToBoolArray(BytesReverseByWord(read.Content)), bitIndex, length);
else
return BoolArraySelectMiddle(ByteToBoolArray(read.Content), bitIndex, length); // ② 再取位
}
注意第 181-185 行:不同 DataFormat 下位序映射关系不同,这是"改了字节序之后位也跟着错位"的经典坑。
写入时优先用 0x16 屏蔽写(一条报文完成 Read-Modify-Write);设备不支持则退化为"读字→改位→写字",并把 EnableWriteMaskCode 永久关掉(ModBus/ModbusHelper.java:233-237)。
读-改-写不是原子操作。若同一寄存器还有别的写者(另一主站、PLC 自身)会产生竞态,控制字场景建议与工艺侧约定"独占寄存器"。
第 4 章 PDU 结构
4.1 定义与三种形态
PDU = 功能码(1 字节)+ 数据(0~252 字节),是与传输模式无关的那一部分。
┌──────────┬────────────────────┐
│ 功能码 │ 数据 │
│ 1 byte │ 0 ~ 252 bytes │
└──────────┴────────────────────┘
| 形态 | 偏移 0 | 偏移 1~ |
|---|---|---|
| 主站请求 | 功能码(01/03/06...) | 起始地址、数量;写操作时含待写数据 |
| 从站正常响应 | 功能码原值回显 | 字节数 + 数据;或回显地址 + 数量 |
| 从站异常响应 | 功能码 | 0x80 | 1 字节异常码(01~0B) |
判别口诀:响应第 2 字节(PDU 首字节)
>= 0x80即异常。HSL 就是这么写的——ModBus/ModbusInfo.java:475:if ((response[1] & 0xff) >= 0x80)。
4.2 为什么用"功能码最高位置 1"
经典的单字节错误标记技巧:从站不需要任何额外信号就能告诉主站"我出错的是哪个请求"。主站收到 0x83 立刻知道是 0x03 失败。代价是功能码空间只有 1~127——这也是 Modbus 的功能码永远看不到 0x81 这类"正常功能码"的原因。
第 5 章 MBAP 头
5.1 逐字节拆解
| 偏移 | 字节数 | 名称 | 示例值 | 含义 |
|---|---|---|---|---|
| 0 | 2 | Transaction Identifier 事务元标识符 | 00 01 |
主站生成的流水号,从站原样回显;用于把响应配回请求 |
| 2 | 2 | Protocol Identifier 协议标识符 | 00 00 |
恒为 0x0000 代表 Modbus;非 0 可丢弃 |
| 4 | 2 | Length 长度 | 00 06 |
本字段之后还剩多少字节(单元 ID + PDU),大端 |
| 6 | 1 | Unit Identifier 单元标识符 | 11 |
网关后挂 RTU 从站时填从站站号;直连 TCP 设备常固定 0x01 或 0xFF |
HSL 直接把 RTU 的"站号"字节复用为 Unit ID——
ModBus/ModbusInfo.java:493-502把整条核心命令(首字节即 station)拼在 6 字节 MBAP 之后。因此 HSL 组出的 length = 核心命令长度,恰好等于标准规定的1(UnitID) + PDU长度,两者等价。
5.2 Length 字段常见误解
00 01 | 00 00 | 00 06 | 11 | 03 00 6B 00 03
↑
本字段之后有 6 字节:11 03 00 6B 00 03
= UnitID(1) + FC(1) + Addr(2) + Qty(2)
Length 不含 MBAP 自己的前 6 字节;整帧总长 = 6 + Length。
HSL 接收侧把"头部"定义为 8 字节(MBAP 6 + station 1 + FC 1),故剩余内容 = Length - 2——Core/IMessage/ModbusTcpMessage.java:14-55:
int length = (HeadBytes[4] & 0xff) * 256 + (HeadBytes[5] & 0xff);
int remainLength = 0;
if (length == 0) { // 有些设备长度字段写 0,做兼容
HeadBytes = SoftBasic.BytesArrayRemoveBegin(HeadBytes, 1); // 丢掉一个多余字节重试
setHeadBytes(HeadBytes);
remainLength = (HeadBytes[4] & 0xff) * 256 + (HeadBytes[5] & 0xff) - 1;
} else {
remainLength = Math.min(length - 2, 300); // ← 头部已吃掉 station+FC 两字节
}
第 30-50 行还藏了"以请求长度为基准"的兜底:若 length == 6(请求长度)但与"功能码 + 数量"推出来的期望长度不符,就以请求的读取长度为准重算。这是被各种非标网关磨出来的经验式容错。
5.3 为什么 Modbus TCP 不需要 CRC
| 保证项 | 谁负责 |
|---|---|
| 比特差错检测 | 以太网 MAC 帧的 FCS (CRC-32) |
| 丢包重传、顺序 | TCP 的序号/确认/重传 |
| 帧定界 | MBAP 的 Length 字段(不用 3.5 字符静默) |
| 重复/迟到报文 | TCP 自身 + 事务标识符配对 |
在已有三重保证的通道上再加应用层 CRC 只浪费带宽与 CPU,还会引入"实现不一致"的新错误。职责不重复是协议分层的基本原则。
5.4 事务 ID 的价值:并发流水线
RTU 是严格一问一答(半双工总线),而 TCP 允许:
T=0ms 主站→从站 事务ID=0001 读 40001
T=2ms 主站→从站 事务ID=0002 读 40100
T=5ms 主站→从站 事务ID=0003 读 40200
T=12ms 主站←从站 事务ID=0002 ← 响应可能乱序到达!
T=15ms 主站←从站 事务ID=0001
T=18ms 主站←从站 事务ID=0003
TCP 只保证字节流有序,不保证"第 N 个请求的响应第 N 个到达"——尤其在多主站共享一个网关 socket、或网关内部乱序转发时。
HSL 的做法:生成用 SoftIncrementCount(65535, 0)(ModBus/ModbusTcpNet.java:25、123-125);校验在 Core/IMessage/ModbusTcpMessage.java:76-83,IsCheckMessageId(事务 ID)与 StationCheckMatch(单元 ID)默认均为 true,不匹配就跳过本帧继续等。
现场经验:若网关把多个串口从站混在一个 socket 上且响应会乱序,务必同时开两个校验;若网关不规范(事务 ID 恒为 0),则必须关掉
IsCheckMessageId,否则一直等超时。
IOT 的
protocol/modbus/model/MbapHeader.java:46定义BYTE_LENGTH = 7,把 Unit ID 也归入 MBAP(先写 7 字节 MBAP,再写不含站号的 PDU);HSL 是"MBAP 6 字节 + 含站号的核心命令"。两种写法最终字节流完全一致,只是代码组织视角不同。
第 6 章 功能码全表
6.1 主表
| 功能码 | 名称 | 对象 | 请求 PDU 数据 | 响应 PDU 数据 |
|---|---|---|---|---|
| 0x01 | Read Coils 读线圈 | 位 | 起始地址(2) + 数量(2) | 字节数(1) + 位状态(N) |
| 0x02 | Read Discrete Inputs 读离散输入 | 位,只读 | 起始地址(2) + 数量(2) | 字节数(1) + 位状态(N) |
| 0x03 | Read Holding Registers 读保持寄存器 | 字 | 起始地址(2) + 数量(2) | 字节数(1) + 寄存器值(2N) |
| 0x04 | Read Input Registers 读输入寄存器 | 字,只读 | 起始地址(2) + 数量(2) | 字节数(1) + 寄存器值(2N) |
| 0x05 | Write Single Coil 写单个线圈 | 位 | 地址(2) + 值(2) | 回显地址(2) + 值(2) |
| 0x06 | Write Single Register 写单个寄存器 | 字 | 地址(2) + 值(2) | 回显地址(2) + 值(2) |
| 0x0F | Write Multiple Coils 写多个线圈 | 位 | 地址(2) + 数量(2) + 字节数(1) + 值(N) | 地址(2) + 数量(2) |
| 0x10 | Write Multiple Registers 写多个寄存器 | 字 | 地址(2) + 数量(2) + 字节数(1) + 值(2N) | 地址(2) + 数量(2) |
| 0x14 | Read File Record 读文件记录 | 记录 | 字节数(1) + 子请求组(每组 7 字节) | 变长 |
| 0x15 | Write File Record 写文件记录 | 记录 | 字节数(1) + 子请求组 | 回显 |
| 0x16 | Mask Write Register 屏蔽写寄存器 | 字 | 地址(2) + And(2) + Or(2) | 回显同样 6 字节 |
| 0x17 | Read/Write Multiple Registers 读写多个 | 字 | 读起始(2)+读数量(2)+写起始(2)+写数量(2)+写字节数(1)+写值 | 字节数(1) + 读到的数据。极少数设备支持,HSL 自注"只有极个别的设备可能会支持"(:78-80) |
| 0x2B | Read Device Identification 读设备识别码 | — | MEI 等 | 厂商/型号字符串 |
HSL 常量行号:
ModBus/ModbusInfo.java:22(01)、:27(02)、:33(03)、:38(04)、:44(05)、:49(06)、:55(0F)、:60(10)、:65(14)、:70(15)、:75(16)、:80(17)。 IOT 额外定义了0x2B(enums/EMbFunctionCode.java:108),HSL 未定义。
6.2 0x01 / 0x02 读位
| 方向 | 偏移 | 字节数 | 示例值 | 含义 |
|---|---|---|---|---|
| 请求 | 0 | 1 | 01 / 02 |
功能码 |
| 请求 | 1~2 | 2 | 00 13 |
起始位地址 = 19(参考号 00020) |
| 请求 | 3~4 | 2 | 00 01 |
位数,1~2000 |
| 响应 | 0 | 1 | 01 |
功能码回显 |
| 响应 | 1 | 1 | 01 |
数据字节数 = ⌈数量/8⌉ |
| 响应 | 2~ | N | 03 |
位数据 |
位压缩规则:N 位 → ⌈N/8⌉ 字节,LSB 在前(字节的 bit0 对应起始位 +0)。
byte = 0x03 = 0b0000_0011
│ ││
│ │└─ bit0 = 1 → 线圈[起始+0] = 1
│ └── bit1 = 1 → 线圈[起始+1] = 1
└────────── bit2..7 = 0(未请求的位,忽略)
6.3 0x03 / 0x04 读字
| 方向 | 偏移 | 字节数 | 示例值 | 含义 |
|---|---|---|---|---|
| 请求 | 0 | 1 | 11 |
站号(TCP 里是 Unit ID) |
| 请求 | 1 | 1 | 03 |
功能码 |
| 请求 | 2~3 | 2 | 00 6B |
起始地址 = 107 |
| 请求 | 4~5 | 2 | 00 03 |
数量,1~125 |
| 响应 | 0~1 | 2 | 11 03 |
站号 + 功能码回显 |
| 响应 | 2 | 1 | 06 |
数据字节数 = 数量 × 2 |
| 响应 | 3~ | 2N | 02 2B 00 00 00 64 |
寄存器值,每字 2 字节大端 |
6.4 0x05 写单个线圈
| 偏移 | 字节数 | 值 | 含义 |
|---|---|---|---|
| 2~3 | 2 | 00 AC |
线圈地址 |
| 4~5 | 2 | FF 00 |
置 ON 必须是 0xFF00;OFF 是 00 00 |
坑:误发
00 01或FF FF,规范设备直接回异常码 03。HSL 只写FF 00/00 00两种组合(:273-287)。
6.5 0x06 写单个寄存器
| 偏移 | 字节数 | 示例值 | 含义 |
|---|---|---|---|
| 2~3 | 2 | 00 01 |
寄存器地址 |
| 4~5 | 2 | 00 03 |
待写值(大端),0x0000~0xFFFF |
响应把请求 PDU 原封不动回显。HSL 在写入 short 时有"用 0x06 还是拆成 0x10"的分支,见 :321-341。
6.6 0x0F 写多个线圈
| 偏移 | 字节数 | 示例值 | 含义 |
|---|---|---|---|
| 2~3 | 2 | 00 13 |
起始地址 |
| 4~5 | 2 | 00 0A |
线圈数量(1~1968) |
| 6 | 1 | 02 |
数据字节数 = ⌈数量/8⌉ |
| 7~ | N | CD 01 |
位数据,LSB 在前 |
响应:站号 + 0x0F + 地址(2) + 数量(2),共 6 字节。HSL 组包见 :248-264,第 256/258 行分别写"数量"与"字节数"两个不同的长度字段。
6.7 0x10 写多个寄存器
| 偏移 | 字节数 | 示例值 | 含义 |
|---|---|---|---|
| 2~3 | 2 | 00 01 |
起始地址 |
| 4~5 | 2 | 00 02 |
寄存器数量(不是字节数!) |
| 6 | 1 | 04 |
字节数 = 数量 × 2 |
| 7~ | 2N | 00 0A 01 02 |
待写数据,大端 |
响应:站号 + 0x10 + 地址(2) + 数量(2)。HSL 组包见 :373-387,第 382-384 行把字节数除以 2 得寄存器数量,正是为填这两个字段。
6.8 0x16 屏蔽写寄存器
用途:只改一个字里的某几位,不影响其他位(如"向控制字第 3 位置 1")。
NewValue = (Current AND AndMask) OR (OrMask AND (NOT AndMask)) // 规范定义
HSL 简化为 (Current AND AndMask) OR OrMask(:230-234,orMask = 1<<bitIndex; andMask = ~orMask),在"orMask 置位、andMask 清位"的典型用法下两者等价。
| 偏移 | 字节数 | 示例值 | 含义 |
|---|---|---|---|
| 2~3 | 2 | 00 04 |
寄存器地址 |
| 4~5 | 2 | F7 FF |
And 掩码(清第 11 位) |
| 6~7 | 2 | 08 00 |
Or 掩码(置第 11 位) |
兼容性警告:0x16 属"可选功能码",大量廉价仪表不支持。HSL 先试 0x16,若从站回"不支持该功能码(0x01)"就自动降级为读改写,并永久关闭
EnableWriteMaskCode(ModBus/ModbusHelper.java:233-237)。很值得借鉴的容错设计。
第 7 章 异常响应
7.1 判别与提取
// ModBus/ModbusInfo.java:473-484
public static OperateResultExOne<byte[]> ExtractActualData(byte[] response) {
if ((response[1] & 0xff) >= 0x80) // 功能码最高位为 1 → 异常
return new OperateResultExOne<byte[]>(response[2], // ErrorCode = 异常码
ModbusInfo.GetDescriptionByErrorCode(response[2]));
else if (response.length > 3)
return OperateResultExOne.CreateSuccessResult(SoftBasic.BytesArrayRemoveBegin(response, 3));
else
return OperateResultExOne.CreateSuccessResult(new byte[0]);
}
BytesArrayRemoveBegin(response, 3) 表示正常响应要扔掉前 3 字节(站号 + 功能码 + 字节数),剩下才是裸数据。
7.2 异常码全表
| 码 | 名称 | 中文解释 | 常见触发 / 处置 |
|---|---|---|---|
| 01 | Illegal Function | 非法功能码:从站不认识或不开放 | 设备只支持 03/06,你发了 04 → 换功能码 |
| 02 | Illegal Data Address | 非法数据地址:地址越界或地址+长度组合越界 | 手册说只有 100 个寄存器,你读 40100 起 10 个 → 末尾越界 |
| 03 | Illegal Data Value | 非法数据值 | 给 0x05 发了 00 01 而非 FF 00;写入值超量程 |
| 04 | Slave Device Failure | 从站设备故障:执行中发生不可恢复错误 | 读设备状态寄存器诊断 |
| 05 | Acknowledge | 已接收、正在处理、需较长时间 | 主站应继续轮询"程序完成"报文 |
| 06 | Slave Device Busy | 从站正忙 | 重试 + 退避,不要立即重发 |
| 08 | Memory Parity Error | 内存奇偶校验错:读记录文件时发现 | 重试;持续则硬件故障 |
| 0A | Gateway Path Unavailable | 网关无法分配内部通信路径 | 网关配置错误或过载 |
| 0B | Gateway Target Failed to Respond | 网关存在,但其后的从站未响应 | 检查网关后从站是否掉线/波特率是否对 |
IOT 的中文注释极其详尽,见
enums/EMbExceptionCode.java:47-118,其中 02 举了"100 个寄存器的控制器,偏移 96 长度 4 成功,偏移 96 长度 5 报 02"——这是对"组合越界"最直观的解释。 HSL 只实现 01/02/03/04 四个描述(:94-112+ 查表:605-618),其余落到UnknownError。
7.3 HSL 的中文文案
四个文案见 Language/DefaultLanguage.java:172-175:不支持的功能码 / 读取的数据越界 / 读取长度超过最大值 / 读写异常。注意 HSL 把 03 描述成"读取长度超过最大值",与标准的 Illegal Data Value 有出入,是作者按现场经验写的工程化文案。
第 8 章 CRC16 与 LRC
8.1 CRC-16/MODBUS 参数
| 参数 | 值 |
|---|---|
| 多项式(标准形式) | 0x8005(x¹⁶ + x¹⁵ + x² + 1) |
| 多项式(反转形式) | 0xA001 ← 实现里用这个 |
| 初始值 | 0xFFFF |
| 输入/输出是否反转 | 均是(RefIn = RefOut = true) |
| 结果异或值 | 0x0000 |
| 线上字节序 | 低字节先发(CRC 低 8 位在前) |
为什么有两个多项式:Modbus 采用 LSB-first(低位先发) 串行位序,等价于把 0x8005 的位反转(0x8005 反转 16 位 → 0xA001)。所以实现都用 0xA001。
8.2 完整 Java 实现
对照 Serial/SoftCRC16.java:72-115,下面给出等价且更易读的版本:
public final class ModbusCrc16 {
/** 多项式反转形式 0xA001,初值 0xFFFF,低字节先存;返回已附带 CRC 的数组 */
public static byte[] crc16(byte[] data) {
int crc = 0xFFFF; // ① 寄存器预置全 1
for (byte b : data) {
crc ^= (b & 0xFF); // ② 当前字节与 CRC 低 8 位异或
for (int i = 0; i < 8; i++) { // ③ 每字节处理 8 次
if ((crc & 0x0001) != 0) // 最低位为 1
crc = (crc >>> 1) ^ 0xA001; // 右移一位并与多项式异或
else
crc = (crc >>> 1); // 仅右移一位
}
}
byte[] out = new byte[data.length + 2];
System.arraycopy(data, 0, out, 0, data.length);
out[data.length] = (byte) (crc & 0xFF); // ④ 低字节在前
out[data.length + 1] = (byte) ((crc >> 8) & 0xFF); // ⑤ 高字节在后
return out;
}
/** 校验接收到的(含 CRC 的)报文 */
public static boolean check(byte[] frame) {
if (frame == null || frame.length < 2) return false;
byte[] recalc = crc16(java.util.Arrays.copyOf(frame, frame.length - 2));
return recalc[recalc.length - 2] == frame[frame.length - 2]
&& recalc[recalc.length - 1] == frame[frame.length - 1];
}
}
HSL 原始实现(
Serial/SoftCRC16.java:83-111)没用位运算便利,而用"高低两个字节寄存器 + 模拟移位",是从 8 位单片机经典 C 代码直译过来的:低 8 位异或 → 各寄存器右移 → 移出位为 1 则异或CH=0xA0 / CL=0x01。初值preH/preL = (0xff, 0xff)(:61)。结果完全一致。
8.4 验证用例(可直接抄去自测)
| PDU(十六进制) | CRC16 | RTU 帧末两字节(低在前) |
|---|---|---|
11 03 00 6B 00 03 |
0x7687 | 76 87 ← 官方规范样例 |
11 03 06 02 2B 00 00 00 64 |
0xBAC8 | C8 BA |
11 03 06 AE 41 56 52 43 40 |
0x49AD | 49 AD ← 官方规范样例 |
11 06 00 01 00 03 |
0x9B9A | 9A 9B |
11 01 00 13 00 01 |
0x9F0E | 0E 9F |
11 01 01 03 |
0x4915 | 15 49 |
11 10 00 01 00 02 04 00 0A 01 02 |
0xF0C6 | C6 F0 |
11 10 00 01 00 02 |
0x9812 | 12 98 |
11 83 02 |
0xC134 | C1 34 |
01 05 00 AC FF 00 |
0x4C1B | 4C 1B ← 官方规范样例 |
01 0F 00 13 00 0A 02 CD 01 |
0xCB72 | 72 CB |
01 03 00 00 00 02 |
0xC40B | C4 0B |
01 03 04 00 64 00 C8 |
0x7ABA | BA 7A |
表中与官方规范吻合的三条(
7687/49AD/4C1B)可作为算法正确性的判据。
8.5 LRC(ASCII 模式用)
LRC = 纵向冗余校验:所有字节求和 → 取低 8 位 → 用 256 减 → 结果作 1 字节校验和。对照 Serial/SoftLRC.java:19-32:
public static byte[] LRC(byte[] value) {
int sum = 0;
for (int i = 0; i < value.length; i++) sum += value[i] & 0xFF; // ① 求和
sum = sum % 256; // ② 截断到 8 位
sum = 256 - sum; // ③ 取补
return SoftBasic.SpliceTwoByteArray(value, new byte[]{(byte) sum}); // ④ 追加到尾
}
等价的现代写法:
public static byte[] lrc(byte[] data) {
int lrc = 0;
for (byte b : data) lrc += (b & 0xFF);
byte[] out = new byte[data.length + 1];
System.arraycopy(data, 0, out, 0, data.length);
out[data.length] = (byte) ((-lrc) & 0xFF); // 二补数取负,等价于 256 - (sum & 0xFF)
return out;
}
验证:核心报文 11 03 00 6B 00 03,和 = 0x82,256 - 0x82 = 0x7E,完整帧 :1103006B00037E\r\n。
LRC 的检错能力远弱于 CRC-16(漏检率高得多),这也是 ASCII 模式只用于低速/调试场合的原因之一。
第 9 章 RTU 帧间隔
9.1 三条硬性要求
- 帧间必须留 ≥ 3.5 字符静默(t3.5),这是唯一的帧结束判据。
- 帧内字节间隔不得 > 1.5 字符(t1.5),否则接收方会当作新帧,前一帧被判不完整而丢弃。
- 发送必须是连贯字节流。很多 USB 转串口芯片按 16ms 分包,直接把 RTU 帧切碎 → "明明发出去了但从站不回"。解法:把 FTDI/CH340 的 Latency Timer 调到 1ms,或换带硬件自动收发的模块。
9.2 t3.5 的计算
一个"字符"的位数取决于校验配置。Modbus RTU 规范默认是 1 起始 + 8 数据 + 1 偶校验 + 1 停止 = 11 bit(无校验 + 1 停止则是 10 bit)。
波特率 9600 下(11 bit/字符):
1 bit 时间 = 1 / 9600 s = 104.17 µs
1 字符时间 = 11 × 104.17 = 1.1458 ms
1.5 字符时间 = 1.7187 ms ≈ 1.72 ms
3.5 字符时间 = 4.0104 ms ≈ 4.01 ms
常用波特率速查(11 bit/字符)
| 波特率 | 1 字符 | t1.5 | t3.5 |
|---|---|---|---|
| 1200 | 9.167 ms | 13.75 ms | 32.08 ms |
| 2400 | 4.583 ms | 6.87 ms | 16.04 ms |
| 4800 | 2.292 ms | 3.44 ms | 8.02 ms |
| 9600 | 1.146 ms | 1.72 ms | 4.01 ms |
| 19200 | 0.573 ms | 0.859 ms | 2.01 ms |
| 38400 | 0.286 ms | 0.430 ms | 1.00 ms |
| 115200 | 0.0955 ms | 0.143 ms | 0.334 ms |
关键规则:波特率 > 19200 时不要用计算值,直接用固定值 t3.5 = 1750 µs、t1.5 = 750 µs。 原因:高波特率下算出的 t3.5 太短(115200 时仅 334 µs),PC 端定时器精度与 OS 调度延迟早已超过它,按公式算会频繁误判。这是规范明确给出的强行约定。
9.3 超时配置
帧结束判定 ≥ t3.5(9600 下建议 ≥ 4.5 ms);响应超时典型 300~1000 ms;重试 2~3 次并指数退避;轮询间隔 ≥ 各从站事务耗时之和 + 余量(不要上一轮没结束就发下一轮)。
9.4 为什么不能用"读满就好"
// 错误示范
byte[] buf = new byte[256];
int len = serial.read(buf); // 读一次就走
三个问题:
- 不知道该读多少——读指令可算期望长度,但异常响应只有 5 字节,广播写没有响应。
- 串口数据是流式的——一次
read()可能返回不足一帧,也可能含下一帧的一半。这不是异常而是常态。 - 干扰字节——RS-485 的反射、收发方向切换毛刺会在帧首插入垃圾字节。正解是按协议可识别性做滑动窗口。
正解是"接收 → 累积到缓冲区 → 按规则判定帧完整 → 取出 → 保留剩余"。HSL 按功能码分档计算期望长度(ModBus/ModbusInfo.java:666-715):
| 功能码 | 期望帧长(含 CRC) | 依据 |
|---|---|---|
| 05 / 06 / 0F / 10 | 8 | 站号+功能码+地址2+数量2 + CRC2 |
| 01 / 02 / 03 / 04 / 17 | 3 + response[2] + 2 |
站号+功能码+字节数N+N 字节 + CRC2 |
| 16 | 10 | 站号+功能码+地址2+And2+Or2 + CRC2 |
| 异常(功能码 ≥ 0x80) | 5 | 站号+异常功能码+异常码 + CRC2 |
| 未知功能码 | 仅靠 CRC 兜底 | 走到 :706-711 的 else 分支 |
外层再叠"滑动窗口 + CRC 降级"两遍扫描(ModBus/ModbusInfo.java:718-744):
int count = Math.min(response.length - 3, 3); // 最多允许前面 3 个干扰字节
for (int i = 0; i <= count; i++) { // 第一遍:站号功能码匹配 + CRC 严格校验
if (CheckModbusStationFunction(send, response, i))
if (CheckRtuReceiveDataComplete(response, i)) return true;
}
for (int i = 0; i < count; i++) { // 第二遍:忽略 CRC,只按长度判定
if (CheckModbusStationFunction(send, response, i))
if (CheckRtuReceiveDataComplete(response, i, false)) return true;
}
CheckModbusStationFunction(:746-750)判断"站号相同 且 功能码相同或为功能码 + 0x80"。
第 724-725 行的注释非常坦诚:"这里可能还是有一点点问题……虽然可能性非常非常小了"——这就是工程现实:没有接收算法能做到 100% 无歧义,只能把概率压到可接受。
9.5 TCP 为什么没有这个问题
TCP 用 MBAP 的 Length 定界,接收侧变成"先读 8 字节头 → 算出剩多少 → 再读这么多",无需任何静默判断。这也是 RS-485 → TCP 网关化带来的隐性收益。
第 10 章 报文实例专辑
每条都给出完整十六进制字节流、逐字段表格、说明。可直接对着串口/网络调试助手发送。
例 1:读保持寄存器 0x03(RTU / TCP 两种形态)
场景:从站 0x11 (17),从保持寄存器地址 0x006B (107) 起读 3 个寄存器(参考号 40108~40110)。
RTU 请求
11 03 00 6B 00 03 76 87
└┬┘ └┬┘ └──┬──┘ └──┬┘ └──┬┘
│ │ │ │ └─ CRC16 = 0x7687,低字节 76 先发
│ │ │ └──────── 数量 = 3
│ │ └──────────────── 起始地址 = 0x006B = 107
│ └────────────────────── 功能码 0x03 读保持寄存器
└────────────────────────── 从站地址 = 0x11 = 17
| 偏移 | 字节数 | 示例值 | 含义 |
|---|---|---|---|
| 0 | 1 | 11 |
从站地址 17 |
| 1 | 1 | 03 |
功能码:读保持寄存器 |
| 2~3 | 2 | 00 6B |
起始地址 0x006B = 107 |
| 4~5 | 2 | 00 03 |
读取数量 3 |
| 6~7 | 2 | 76 87 |
CRC16(0x7687,低位在前) |
RTU 响应(三个寄存器值为 0x022B=555、0x0000=0、0x0064=100)
11 03 06 02 2B 00 00 00 64 C8 BA
| 偏移 | 字节数 | 示例值 | 含义 |
|---|---|---|---|
| 0 | 1 | 11 |
站号回显 |
| 1 | 1 | 03 |
功能码回显 |
| 2 | 1 | 06 |
数据字节数 = 3 × 2 |
| 3~4 | 2 | 02 2B |
寄存器 40108 = 555 |
| 5~6 | 2 | 00 00 |
寄存器 40109 = 0 |
| 7~8 | 2 | 00 64 |
寄存器 40110 = 100 |
| 9~10 | 2 | C8 BA |
CRC16(0xBAC8,低位在前) |
TCP 请求(同样 PDU 前加 MBAP)
00 01 00 00 00 06 11 03 00 6B 00 03
└──┬──┘ └──┬──┘ └┬┘ │ └──── 与 RTU 的 PDU 完全一致(无 CRC)
│ │ │ └── 0x11 = 单元标识符(= RTU 站号)
│ │ └───── 长度 = 6(本字段之后的字节数)
│ └─────────── 协议标识符 = 0x0000
└─────────────────── 事务标识符 = 0x0001
| 偏移 | 字节数 | 示例值 | 含义 |
|---|---|---|---|
| 0~1 | 2 | 00 01 |
事务 ID = 1 |
| 2~3 | 2 | 00 00 |
协议 ID = 0 |
| 4~5 | 2 | 00 06 |
长度:后面还有 6 字节 |
| 6 | 1 | 11 |
单元标识符 = 17 |
| 7 | 1 | 03 |
功能码 |
| 8~9 | 2 | 00 6B |
起始地址 107 |
| 10~11 | 2 | 00 03 |
数量 3 |
TCP 响应:00 01 00 00 00 09 11 03 06 02 2B 00 00 00 64(长度 0x09 = UnitID 1 + PDU 8)
ASCII 形态已在 2.3 给出::1103006B00037E\r\n
例 2:写单个寄存器 0x06
场景:向从站 0x11 的保持寄存器 0x0001 写入 0x0003。
11 06 00 01 00 03 9A 9B
| 偏移 | 字节数 | 示例值 | 含义 |
|---|---|---|---|
| 0 | 1 | 11 |
从站地址 |
| 1 | 1 | 06 |
功能码:写单个寄存器 |
| 2~3 | 2 | 00 01 |
目标寄存器地址 = 1 |
| 4~5 | 2 | 00 03 |
待写值 = 3 |
| 6~7 | 2 | 9A 9B |
CRC16 = 0x9B9A(低位在前) |
响应:与请求逐字节完全一致(回显)。判成功只需确认 响应[0]==请求[0]、响应[1]==请求[1] 且未被置 0x80(HSL:ModBus/ModbusHelper.java:71-82)。
例 3:写多个寄存器 0x10
场景:向从站 0x11 的保持寄存器 0x0001 起写入 2 个寄存器,值 0x000A 与 0x0102。
请求
11 10 00 01 00 02 04 00 0A 01 02 C6 F0
| 偏移 | 字节数 | 示例值 | 含义 |
|---|---|---|---|
| 0 | 1 | 11 |
从站地址 |
| 1 | 1 | 10 |
功能码:写多个寄存器 |
| 2~3 | 2 | 00 01 |
起始地址 = 1 |
| 4~5 | 2 | 00 02 |
寄存器数量 = 2 |
| 6 | 1 | 04 |
字节数 = 2 × 2 = 4 |
| 7~8 | 2 | 00 0A |
第 1 个寄存器值 = 10 |
| 9~10 | 2 | 01 02 |
第 2 个寄存器值 = 258 |
| 11~12 | 2 | C6 F0 |
CRC16 = 0xF0C6(低位在前) |
响应
11 10 00 01 00 02 12 98
| 偏移 | 字节数 | 示例值 | 含义 |
|---|---|---|---|
| 0~3 | 4 | 11 10 00 01 |
站号 + 功能码 + 起始地址回显 |
| 4~5 | 2 | 00 02 |
成功写入的寄存器数量 |
| 6~7 | 2 | 12 98 |
CRC16 = 0x9812(低位在前) |
例 4:读线圈 0x01 与位压缩
场景:从站 0x11,从线圈地址 0x0013 (19) 起读 1 个线圈。
请求
11 01 00 13 00 01 0E 9F
| 偏移 | 字节数 | 示例值 | 含义 |
|---|---|---|---|
| 0 | 1 | 11 |
从站地址 |
| 1 | 1 | 01 |
功能码:读线圈 |
| 2~3 | 2 | 00 13 |
起始位地址 = 19(参考号 00020) |
| 4~5 | 2 | 00 01 |
读取位数 = 1 |
| 6~7 | 2 | 0E 9F |
CRC16 = 0x9F0E |
响应
11 01 01 03 15 49
| 偏移 | 字节数 | 示例值 | 含义 |
|---|---|---|---|
| 0 | 1 | 11 |
站号回显 |
| 1 | 1 | 01 |
功能码回显 |
| 2 | 1 | 01 |
数据字节数 = ⌈1/8⌉ = 1 |
| 3 | 1 | 03 |
位数据 = 00000011b |
| 4~5 | 2 | 15 49 |
CRC16 = 0x4915 |
多位示例(请求 10 个线圈,回 02 CD 01):
数量 = 10 → 字节数 = (10+7)/8 = 2
第1字节 CD = 1100_1101b LSB在前 → 位序 0..7 = 1,0,1,1,0,0,1,1
第2字节 01 = 0000_0001b 位序 8..9 = 1,0(高位为0是未请求的位)
最终位数组 = [1,0,1,1,0,0,1,1, 1,0]
关键:最后不足 8 位时多余高位必须为 0,且解析时按请求长度截断,否则会多解出伪位。HSL 显式传入 bitLength 截断(ModBus/ModbusHelper.java:196-197)。
例 5:异常响应
场景:主站向从站 0x11 发 03 读保持寄存器,地址越界。
11 83 02 C1 34
| 偏移 | 字节数 | 示例值 | 含义 |
|---|---|---|---|
| 0 | 1 | 11 |
从站地址 |
| 1 | 1 | 83 |
异常功能码 = 0x03 | 0x80 → "0x03 出错" |
| 2 | 1 | 02 |
异常码 02:非法数据地址(越界) |
| 3~4 | 2 | C1 34 |
CRC16 = 0x34C1(低位在前) |
TCP 形态:00 01 00 00 00 03 11 83 02(长度 3 = UnitID 1 + PDU 2)
处置:先把异常码映射成中文(:605-618),再把"本次请求的 地址 / 长度 / 功能码"一并打进日志——只打"读取失败"在现场等于没打。
例 6:写多寄存器完整 RTU 帧(CRC 字节序专项)
场景:向站号 1 的保持寄存器 0x0000 起写 2 个寄存器,值 0x0064(100) 与 0x00C8(200)。
01 10 00 00 00 02 04 00 64 00 C8 BA 7A
└┬┘ └┬┘ └──┬──┘ └──┬──┘ │ └────┬────┘ └──┬┘
│ │ │ │ │ │ └─ CRC16 = 0x7ABA → 线上 BA 7A(低位在前)
│ │ │ │ │ └──────────── 待写数据 2 个字
│ │ │ │ └─────────────────── 字节数 = 4
│ │ │ └──────────────────────── 寄存器数量 = 2
│ │ └──────────────────────────────── 起始地址 = 0
│ └────────────────────────────────────── 功能码 0x10
└────────────────────────────────────────── 站号 1
输入:01 10 00 00 00 02 04 00 64 00 C8
迭代结果:CRC = 0x7ABA
低字节 = 0xBA,高字节 = 0x7A
线上顺序(低在前)= BA 7A ← 与"0x7ABA"的书写顺序相反
这是最容易出错的一步:十六进制书写习惯是高位在前(0x7ABA),而 RTU 线上顺序是低位在前(BA 7A)。写代码时始终先算出一个
int crc,再显式写(byte)(crc & 0xFF)、(byte)(crc >> 8),不要在脑内做顺序转换。
第 11 章 32 位数据
一个 IEEE 754 单精度 float 占 4 字节 = 2 个 Modbus 寄存器,但"哪个字节在前"没有标准答案,形成四种排列。记法:内存中从左到右 4 字节标为 A B C D(A = 最高有效字节)。Modbus 传输顺序是"先传第一个寄存器,每个寄存器内先传高字节"。
| DataFormat | 线上字节顺序 | 常见叫法 | HSL 注释(Core/Transfer/DataFormat.java:6-23) |
|---|---|---|---|
| ABCD | A B C D |
大端 | 按照顺序 |
| BADC | B A D C |
字节交换,字不交换 | 按照单字反转 |
| CDAB | C D A B |
字交换,字节不交换 | 按照双字反转 |
| DCBA | D C B A |
完全小端 | 按照倒序排序 |
实例:123.456f 的 IEEE 754 为 0x42F6E979(A=0x42, B=0xF6, C=0xE9, D=0x79)
| DataFormat | 寄存器[40001] | 寄存器[40002] | 线上字节流 |
|---|---|---|---|
| ABCD | 0x42F6 | 0xE979 | 42 F6 E9 79 |
| BADC | 0xF642 | 0x79E9 | F6 42 79 E9 |
| CDAB | 0xE979 | 0x42F6 | E9 79 42 F6 |
| DCBA | 0x79E9 | 0xF642 | 79 E9 F6 42 |
怎么快速确定现场的字节序——不要猜,用已知值反推:① 在设备侧写入一个已知实数(推荐 1.0 或 123.456);② 用 Modbus 工具读出相邻 2 个寄存器的原始十六进制;③ 四种排列各转换一次,哪个得到正确值就是哪个;④ 记入点表。同一设备的不同数据类型(float / int32 / uint32)字节序通常一致但非必然,建议每种类型各验一次。
32 位整数同样适用这套规则;64 位(double/int64)扩展到 4 个寄存器,排列方式更多但处理完全类比。完整字节序专题(含三菱/欧姆龙等各家差异)详见
01-基础篇/03。
第 12 章 协议限制与优化
12.1 125 从哪来
规范规定 PDU 最大 253 字节。对 0x03 响应:
1(功能码) + 1(字节数) + 2N ≤ 253 → 2N ≤ 251 → N ≤ 125.5 → N = 125
若按"RTU ADU ≤ 256"算:1+1+1+2N+2 ≤ 256 → 同样得到 125。两个约束都指向 125,这使它成为业界常识。
12.2 2000 从哪来
对 0x01/0x02 响应:1 + 1 + ⌈N/8⌉ ≤ 253 → ⌈N/8⌉ ≤ 251 → N ≤ 2008。规范取整方便记忆,定为 2000 个位(250 字节)。
12.3 写操作的限制
| 功能码 | 上限 | 推导(RTU 请求 ADU ≤ 256) |
|---|---|---|
0x0F 写多线圈 |
1968 位 | 1+1+2+2+1+⌈N/8⌉+2 ≤ 256 → ⌈N/8⌉ ≤ 249 |
0x10 写多寄存器 |
123 个 | 1+1+2+2+1+2N+2 ≤ 256 → 2N ≤ 247 → N ≤ 123.5 |
读 125 / 写 123 不对称,因为响应和请求各自的固定开销不同。这个区别极少有人注意,但写 len=124 的组包在严格设备上会失败。
12.4 库的做法:自动分包
HSL 在 ModBus/ModbusInfo.java:155-172 实现(字 120 / 位 2000):
if (mAddress.getFunction() == ReadCoil || ... ReadRegister || ... ReadInputRegister || ...) {
// 支持自动切割读取,字读取 120 个字,位读取 2000 个位
OperateResultExTwo<int[], int[]> bytes = HslHelper.SplitReadLength(mAddress.getAddress(), length,
(mAddress.getFunction() == ReadCoil || mAddress.getFunction() == ReadDiscrete)
? (short) 2000 : (short) 120);
for (int i = 0; i < bytes.Content1.length; i++) {
byte[] buffer = new byte[6];
buffer[0] = (byte) mAddress.getStation();
buffer[1] = (byte) mAddress.getFunction();
buffer[2] = Utilities.getBytes(bytes.Content1[i])[1]; // 分包后起始地址 高字节
buffer[3] = Utilities.getBytes(bytes.Content1[i])[0]; // 低字节
buffer[4] = Utilities.getBytes(bytes.Content2[i])[1]; // 本包长度 高字节
buffer[5] = Utilities.getBytes(bytes.Content2[i])[0]; // 低字节
commands.add(buffer);
}
}
分包算法见 Core/Types/HslHelper.java:194-204:把 length 切成 [seg, seg, ..., 余数],各段起始地址按前一段长度递增。
为什么用 120 而不是 125? 125 是理论上限,现场大量设备实现得"擦边",120 留出 5 个寄存器安全余量——宁可多一次轮询,不要一次失败。
调用侧循环发出并顺序拼接,任一包失败即整体失败(ModBus/ModbusHelper.java:111-124)。
12.5 合并相邻地址(读优化核心)
问题:点表 40001, 40002, 40010, 40011, 40050 逐个读就是 5 个事务。合并后只需 3 个([40001,l=2] [40010,l=2] [40050,l=1]);若允许空洞还能把 40001~40010 一次性读完,再降为 2 个(代价是白读 7 个寄存器,换来 1 次 RTT)。
| 因素 | 倾向"大胆合并" | 倾向"保守分包" |
|---|---|---|
| 轮询周期紧张 | ✅ 少一次事务 = 少一轮 RTT | |
| 波特率低(1200/2400) | ✅ 多读空洞的字节也是时间 | |
| 设备不支持某些中间地址 | ✅ 合并后可能整包报异常 02 | |
| 点表密度高 | ✅ 收益巨大 | |
| 点表稀疏(40001, 45000) | ✅ 合并会把中间 5000 个寄存器全读出来 |
经验推荐:设置"允许的最大空洞 gap",例如 16——相邻两点距离 ≤ 16 就合并,否则拆开。
12.6 TCP 下限制是否放宽?
不会。 PDU ≤ 253 是应用层约束,与传输方式无关。即便 TCP 的 Length 字段是 16 位(理论 65535),从站内部缓冲区通常仍按 253 设计,强行发超长请求会得到异常码 03/02 或被静默丢弃。
12.7 轮询周期估算
单站单次事务 T ≈ T_req + T_gap + T_resp + T_slave;总线总轮询周期 = Σ(各站各事务 T) + 安全余量。
9600 bps、11 bit/字符 ⇒ 每字节约 1.146 ms。典型读 10 个寄存器的 RTU 事务:请求 8 字节 × 1.146 = 9.2 ms,t3.5 = 4.0 ms,响应 25 字节 × 1.146 = 28.6 ms,从站处理 ≈ 20 ms,合计 ≈ 61.8 ms。
9600 bps 下一个读 10 点的事务就吃掉 60 ms。若 30 个从站、每站 8 个点分组,总线周期轻松超过 5 秒。老现场"数据刷新慢"往往不是软件问题而是物理层带宽决定——要么提速到 19200/38400,要么拆成多条 RS-485 总线。
第 13 章 源码对照
13.1 HSL 关键位置速查
| 功能 | 文件 | 行号 |
|---|---|---|
| 功能码常量 | ModBus/ModbusInfo.java |
22、27、33、38、44、49、55、60、65、70、75、80 |
| 异常码常量 | ModBus/ModbusInfo.java |
97、102、107、112 |
| 1 基 → 0 基换算 | ModBus/ModbusInfo.java |
117-122 |
| 读命令自动切分(120 字 / 2000 位) | ModBus/ModbusInfo.java |
155-172 |
| 写多线圈组包 | ModBus/ModbusInfo.java |
248-264 |
| 写单个线圈组包(FF00/0000) | ModBus/ModbusInfo.java |
273-287 |
| 写多寄存器组包 | ModBus/ModbusInfo.java |
373-387 |
| 屏蔽写(0x16)组包 | ModBus/ModbusInfo.java |
397-408 |
| 0x17 读写组包 | ModBus/ModbusInfo.java |
439-466 |
| 数据提取(含异常判别) | ModBus/ModbusInfo.java |
473-484 |
| RTU → TCP 打包(MBAP) | ModBus/ModbusInfo.java |
493-502 |
| RTU → ASCII 打包 | ModBus/ModbusInfo.java |
540-549 |
| ASCII → RTU 解包 | ModBus/ModbusInfo.java |
557-574 |
| RTU 帧完整性判定 | ModBus/ModbusInfo.java |
666-715、718-744 |
| 站号/功能码匹配(含 +0x80 容错) | ModBus/ModbusInfo.java |
746-750 |
| 富地址解析 | Core/Address/ModbusAddress.java |
136-157 |
| 位读取(读整字后取位) | ModBus/ModbusHelper.java |
164-185 |
| 0x16 降级到读改写 | ModBus/ModbusHelper.java |
219-239 |
| CRC16 计算 | Serial/SoftCRC16.java |
72-115 |
| CRC16 校验 | Serial/SoftCRC16.java |
27-41 |
| LRC 计算 | Serial/SoftLRC.java |
19-32 |
| LRC 校验 | Serial/SoftLRC.java |
40-52 |
| 长度计算(接收驱动) | Core/IMessage/ModbusTcpMessage.java |
14-55 |
| MBAP 头合法性检查 | Core/IMessage/ModbusTcpMessage.java |
58-67 |
| 事务 ID / 单元 ID 匹配 | Core/IMessage/ModbusTcpMessage.java |
71-86 |
| 广播站跳过等待接收 | ModBus/ModbusTcpNet.java |
147-151 |
| 分包算法 | Core/Types/HslHelper.java |
194-204 |
| DataFormat 枚举 | Core/Transfer/DataFormat.java |
6-23 |
| 中文错误文案 | Language/DefaultLanguage.java |
172-175、180 |
13.2 IOT 关键位置速查
| 功能 | 文件 | 行号 |
|---|---|---|
| MBAP 头(7 字节定义) | protocol/modbus/model/MbapHeader.java |
46、54-78 |
| 事务 ID 自增 | protocol/modbus/model/MbapHeader.java |
108-115 |
| 功能码枚举(含 0x2B 与全部错误码) | protocol/modbus/enums/EMbFunctionCode.java |
43-175 |
| 异常码枚举(含详尽中文注释) | protocol/modbus/enums/EMbExceptionCode.java |
47-118 |
IOT 采用面向对象建模:MbPdu 基类 → MbReadHoldRegisterRequest / MbWriteMultipleRegisterRequest 等十几个子类各自实现 toByteArray() / fromBytes();再由 MbRtuRequest / MbAsciiRequest / MbTcpRequest 三种包装器叠加头尾。
选择建议:真正上产线时,HSL 那些"看起来很土"的容错代码(第 724-725 行那段自嘲注释就是证明)恰恰最有价值——它们每一个都对应一次真实的现场事故。IOT 的分层对象模型则更适合理解协议本身的结构。
第 14 章 小结
14.1 一页速记
Modbus = 主从轮询 + 四个存储区 + 功能码 + (传输粘合剂)
传输粘合剂三种:
RTU : 静默3.5定界 + 站号 + PDU + CRC16(低位在前)
ASCII : ':' + (站号+PDU+LRC)全部转ASCII + CRLF
TCP : MBAP(事务ID|0000|长度|单元ID) + PDU,无校验
四区:0x线圈(RW,bit) 1x离散输入(RO,bit) 3x输入寄存器(RO,word) 4x保持寄存器(RW,word)
八码:01/02 读位 03/04 读字 05/06 写单个 0F/10 写多个
异常判别:响应功能码 >= 0x80 → 下一字节是异常码
01非法功能 02非法地址 03非法值 04设备故障 06忙 0B网关后无响应
地址陷阱:参考号 - 1(或 -40001 / -400001) = 报文地址
限制 :读字125 / 读位2000 / 写字123 / 写位1968
字节序 :32 位数据要试 ABCD / BADC / CDAB / DCBA
14.2 三个必须建立的直觉
- Modbus 报文本身不含语义。 读到的
0x0258是 600 还是 60.0,取决于点表约定而非协议。协议只保证"这两个字节确实来自那个地址"。 - 一次失败往往不是"协议错"而是"配置错"。 站号、波特率、校验位、停止位、地址偏移这五项占了现场 Modbus 问题的绝大多数。
- 3.5 字符静默是协议的隐形规则。 它看不见摸不着,但 USB 转串口延迟、多主站争用、OS 调度都会破坏它。凡是"偶尔丢帧"的串口问题,八成在此。
第 15 章 常见误区与现场坑清单
15.1 地址类
| # | 坑 | 现象 | 处理 |
|---|---|---|---|
| 1 | 把参考号当初报文地址填 | 数据整体偏移一位,或报异常 02 | 明确是 -1(1 基)还是 -40001(参考号),固化到配置 |
| 2 | 5 位 / 6 位体系混淆 | 手册写 400001 你按 40001 减,偏移巨大 | 看手册地址范围上限,>9999 即 6 位体系 |
| 3 | 功能码与区不匹配 | 用 03 读输入寄存器(应 04)→ 异常 02 或读到垃圾 | 按 3.1 对照 |
| 4 | 地址 + 长度越界 | 读 40100 起 100 个但设备只有 150 个 → 异常 02 | 校验"起始 + 长度 ≤ 设备上限" |
| 5 | 位索引方向理解反了 | 40001.3 到底是从高位数还是低位数 |
各家不同,必须用已知值实测(见 3.6) |
15.2 通信参数类
| # | 坑 | 现象 | 处理 |
|---|---|---|---|
| 6 | 波特率/校验位不一致 | 完全无响应,或全是 CRC 错帧 | 逐项核对:9600,8,E,1 最常见;也有 8,N,2 与 8,N,1 |
| 7 | RS-485 的 A/B 接反 | 短距离能通,长距离或干扰大时丢包 | 两线对调一试即可;标识有 A+/B-,也有 D+/D- |
| 8 | 没接终端电阻 | 长距离(>100m)高速率误码 | 总线两端各加 120Ω |
| 9 | 没有共地/参考地 | 时好时坏,与设备启停相关 | 接信号地(SG),或用隔离型 RS-485 转换器 |
| 10 | USB 转串口延迟计时器 | PC 能发但从站不回,换台式机就好 | FTDI Latency Timer 设为 1ms;CH340 类似;或换硬件自动收发模块 |
| 11 | 多主站争用总线 | 随机 CRC 错、随机超时 | 同一 RS-485 总线只能有一个主站,多采集程序要互斥 |
| 12 | 广播写后又等响应 | 必然超时 | 广播(站号 0)无响应 |
15.3 报文构造类
| # | 坑 | 现象 | 处理 |
|---|---|---|---|
| 13 | CRC 字节序写反 | 从站完全不响应 | RTU 是低字节先发:0x7687 → 线上 76 87 |
| 14 | 0x10 两个长度字段填混 |
异常 03 或只写前半段 | 偏移 4-5 是寄存器数量,偏移 6 是字节数 |
| 15 | 0x05 写成 00 01 而非 FF 00 |
异常 03 | ON 必须 FF 00,OFF 是 00 00 |
| 16 | 单次读超过 125 / 2000 | 异常 03,或数据被截断 | 自动分包(HSL 用 120/2000) |
| 17 | 单次写超过 123 / 1968 | 同上,写操作更易被忽略 | 注意读 125 / 写 123 不对称 |
| 18 | 事务 ID 恒为 0 却开着 ID 校验 | 一直等到超时 | 要么让 ID 自增,要么关掉 IsCheckMessageId |
| 19 | TCP 下把 Unit ID 与站号混为一谈 | 网关后能通,直连不通 | 直连 TCP 设备常忽略 Unit ID(填 0x01/0xFF 均可);经网关必须填真实从站号 |
| 20 | 32 位数据跨分包边界 | 高低字分散到两包,拼起来错位 | 分包时保证 32 位数据不跨包(包长按偶数寄存器对齐) |
15.4 数据语义类
| # | 坑 | 现象 | 处理 |
|---|---|---|---|
| 21 | float 字节序没调对 | 读到天文数字(如 1.2e-38) | 用已知值反推 ABCD/BADC/CDAB/DCBA |
| 22 | 有符号/无符号没分清 | 温度显示 -46 或 65490 | 按点表选 ReadInt16 / ReadUInt16 |
| 23 | 工程量程换算漏乘 | 值"像"但差一个倍数 | 实际值 = 原始值 × 系数 + 偏移,有些还要取反 |
| 24 | 寄存器宽度用错长度参数 | 用 16 位宽度去读 32 位浮点的一半 | 明确每个变量的寄存器宽度 |
| 25 | 屏蔽写 And/Or 掩码算错 | 写位后其他位被清零 | AndMask = ~bit,OrMask = bit;非原子,注意竞态 |
排查顺序:① 物理层(线通不通、A/B 是否反、终端电阻、共地)→ ② 参数层(站号/波特率/数据位/校验/停止位)→ ③ 单帧验证(手发 11 03 00 6B 00 03 76 87,看有无回应)→ ④ 看异常码 → ⑤ 地址校准(读手册第一个地址,不对就 ±1)→ ⑥ 长度校准(先读 1 点,超过 120 要分包)→ ⑦ 字节序(读已知浮点,四种都试)→ ⑧ 抓包对比第 10 章实例。
最后一条经验:现场调试时先让最简单的那条报文通(读 1 个保持寄存器),再扩展到多点、多类型。任何"一次性配了 500 个点然后发现全错"的做法,排查成本都是前者的十倍以上。
本文是《工业通信与数据采集》栏目第 7/22 篇 · 上一篇:IO 模型与高并发 | 栏目总览 | 下一篇:S7 协议(西门子 S7comm)详解