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/。

本篇目录

  1. 协议概述:1979、主从、长盛不衰与先天缺陷
  2. 三种传输模式:RTU / ASCII / TCP
  3. 存储区模型与地址表达:0x/1x/3x/4x 与 1 基 0 基
  4. PDU 结构:请求 / 正常响应 / 异常响应
  5. MBAP 头:事务 ID、协议标识、长度、单元标识
  6. 功能码全表与各功能码报文结构
  7. 异常响应:功能码最高位置 1 与异常码全表
  8. CRC16 与 LRC:原理、Java 实现、验证用例
  9. RTU 帧间隔:3.5 字符时间的来源与超时处理
  10. 报文实例专辑(6 例,含完整十六进制)
  11. 32 位数据:一个 float 占两个寄存器
  12. 协议限制与优化:125 / 2000 从哪来,如何自动分包
  13. 源码对照:HSL 与 IOT 的实现差异
  14. 小结
  15. 常见误区与现场坑清单

第 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)       │              │
       <─────────────────────└──────────────┘

规则三条:

  1. 只有主站能发起通信,从站永远被动回应,绝不主动上报。
  2. 同一时刻总线上只有一个主站发号施令,从站最多 247 个(站号 1~247;0 是广播地址,248~255 保留)。
  3. 纯轮询:一问一答算一个事务。

广播(站号 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

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


第 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 地址编号混乱的根源

混乱来自两套并行体系:

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 三条硬性要求

  1. 帧间必须留 ≥ 3.5 字符静默(t3.5),这是唯一的帧结束判据。
  2. 帧内字节间隔不得 > 1.5 字符(t1.5),否则接收方会当作新帧,前一帧被判不完整而丢弃。
  3. 发送必须是连贯字节流。很多 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);        // 读一次就走

三个问题:

  1. 不知道该读多少——读指令可算期望长度,但异常响应只有 5 字节,广播写没有响应。
  2. 串口数据是流式的——一次 read() 可能返回不足一帧,也可能含下一帧的一半。这不是异常而是常态。
  3. 干扰字节——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 三个必须建立的直觉

  1. Modbus 报文本身不含语义。 读到的 0x0258 是 600 还是 60.0,取决于点表约定而非协议。协议只保证"这两个字节确实来自那个地址"。
  2. 一次失败往往不是"协议错"而是"配置错"。 站号、波特率、校验位、停止位、地址偏移这五项占了现场 Modbus 问题的绝大多数。
  3. 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)详解