设备通信与协议编程(七):Modbus RTU/TCP、功能码与寄存器映射

Modbus 看起来只是“读写寄存器”,真正的工程问题却集中在地址表示、数据类型、字节顺序、轮询一致性和异常响应上。若这些契约没有写清,两个都声称兼容 Modbus 的程序仍可能完全无法互通。

本文以 Modbus Application Protocol V1.1b3 和 Serial Line Protocol V1.02 为边界。

1. 先区分 PDU、ADU 和传输

Modbus 的核心协议数据单元(PDU)由功能码和数据组成:

1
PDU = Function Code + Data

不同传输再包一层 ADU:

1
2
RTU ADU = Server Address + PDU + CRC
TCP ADU = MBAP Header + PDU

Modbus TCP 的 MBAP 头包含 Transaction Identifier、Protocol Identifier、Length 和 Unit Identifier。TCP 传输不附加 RTU CRC;TCP 校验和也不提供应用层身份认证。TCP 传输默认使用 502 端口;Modbus 报文实现指南将其规定为强制默认监听端口。

2. 四类数据模型

数据块单元常用功能码
Coils1 bit,可读写01 (0x01)、05 (0x05)、15 (0x0F)
Discrete Inputs1 bit,只读02 (0x02)
Input Registers16 bit,只读04 (0x04)
Holding Registers16 bit,可读写03 (0x03)、06 (0x06)、16 (0x10)、22 (0x16)、23 (0x17)

只读/可写是协议数据模型语义,具体设备仍可能对某些地址施加额外限制。

3. 地址为什么总差 1

协议 PDU 中的起始地址是从 0 开始的 16 位地址。设备手册常用 40001400001 或“寄存器 1”等人类表示法标记 holding register;这些前缀和基数不直接在线路上传输。

例如手册中的 40001 经常对应 PDU 地址 0,但这不是所有厂商文档的强制格式。接入前必须记录:

1
手册标号 | 数据块 | PDU 地址 | 读写功能码 | 数据类型 | 单位 | 缩放 | 字节/字顺序

不要在代码中到处写 address - 1;在设备映射层一次性转换。

4. 16 位寄存器不等于业务类型

规范定义寄存器是 16 bit,并规定多字节量在 PDU 中高位字节先传。设备把 float32int32、ASCII 或位域放入多个寄存器时,寄存器之间的字顺序通常是厂商约定,不能仅凭 Modbus 标准推断。

同一个浮点数可能采用:

1
2
3
4
AB CD  // 高字在前
CD AB  // 低字在前
BA DC  // 每字节交换
DC BA  // 字与字节都交换

应使用手册给出的已知值或设备模拟器验证,代码中以明确枚举表达顺序。

5. 构造读 Holding Registers 请求

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
using System.Buffers.Binary;

public static byte[] BuildReadHoldingRegisters(
    byte serverAddress,
    ushort startAddress,
    ushort quantity)
{
    if (quantity is < 1 or > 125)
        throw new ArgumentOutOfRangeException(nameof(quantity));

    // 读请求必须发给单播服务器地址;0 是广播地址,不产生响应。
    if (serverAddress is < 1 or > 247)
        throw new ArgumentOutOfRangeException(nameof(serverAddress));

    if (startAddress + quantity > 0x1_0000)
        throw new ArgumentOutOfRangeException(nameof(startAddress));

    var frame = new byte[8];
    frame[0] = serverAddress;
    frame[1] = 0x03;
    BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(2), startAddress);
    BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(4), quantity);

    ushort crc = ModbusCrc16.Compute(frame.AsSpan(0, 6));
    frame[6] = (byte)crc;       // RTU CRC 低字节先发送
    frame[7] = (byte)(crc >> 8);
    return frame;
}

ModbusCrc16 沿用本系列第 4 篇《校验和与 CRC 的能力边界》中的实现。串行链路中的地址 0 用于广播,1—247 是单播服务器地址;读请求需要响应,因此示例拒绝地址 0。读取数量上限来自功能码 03 的规范;其他功能码有各自限制,不应复用一个全局“最大寄存器数”。

6. 正常响应与异常响应

功能码 03 的正常响应包含字节数和寄存器数据。客户端必须同时验证:

  • Server/Unit 地址与事务 ID 是否匹配当前请求;
  • 功能码是否为期望值;
  • 字节数是否等于寄存器数量乘 2;
  • RTU CRC 或 TCP MBAP 长度是否正确;
  • 整帧没有多余或缺失数据。

异常响应把原功能码最高位置 1,例如 0x03 变为 0x83,随后携带异常码。常见异常码:01 非法功能、02 非法数据地址、03 非法数据值、04 服务器设备故障(V1.1b3 第 7 节)。异常码表示服务器明确拒绝或无法处理请求,不应与本地超时、CRC 错误混为一类。

7. RTU 的时间边界

Modbus RTU 使用字符间隔和帧间静默识别边界。串行线路指南对不高于 19200 bit/s 的链路以字符时间描述 t1.5t3.5;更高速率建议使用固定的 750 µs 与 1.750 ms。RS-485 电气层与半双工约束见本系列第 1 篇。

普通桌面操作系统和托管定时器不适合用“每字节启动一个高精度定时器”硬凑边界。更稳健的方案是:

  • 由串口接收循环累积字节;
  • 使用协议允许的完整长度和 CRC 尽快验证;
  • 把时间间隔作为帧结束/失效条件;
  • 若需要严格实时主站,由实时控制器或成熟协议栈承担。

8. 轮询与一致性

Modbus 没有通用订阅机制,主站通常轮询。优化时应:

  • 合并连续地址,但不要跨越设备声明的非法区;
  • 按变化速度设置不同轮询周期;
  • 限制单设备在途请求数,RTU 通常一次一个;
  • 给每个值附带采集时间与质量状态;
  • 多寄存器业务值一次读完,避免跨轮询拼接撕裂数据。

读取多个寄存器不必然意味着设备内部快照一致;需要一致性保证时必须查设备手册。超时与重试设计见本系列第 6 篇。

9. 写入与安全

写单寄存器、写多寄存器和读写多寄存器的原子性边界由设备实现与功能码契约决定。关键动作应在业务层检查设备状态、权限、范围和互锁,不能因为 Modbus 返回成功就绕过安全逻辑。

传统 Modbus 不提供认证和加密。Modbus Organization 另有基于 TLS 与 X.509 的 Modbus Security Protocol;生产网络应结合设备支持、网络分区和访问控制选择方案。

10. 小结

Modbus 的难点不在功能码本身,而在地址映射和业务类型契约。明确 PDU 地址、数据块、功能码、缩放、单位、字顺序和错误分类,再谈轮询优化,才能避免“读到了数但解释错了”的隐蔽故障。

参考资料

Licensed under CC BY-NC-SA 4.0