跳转至

第 6 章 Modbus RTU

Modbus 由 Modicon(施耐德前身)于 1979 年提出,是最"长寿且通用"的工业串行协议之一。协议本体简单到一句话说得完:主站发一条 PDU 请求、从站回一条 PDU 响应;从站不主动说话、也不彼此对话。传输层可选 RTU(串口 + CRC16)、ASCII(串口 + LRC)或 TCP(以太网 + MBAP 头);本章覆盖 RTU,Modbus TCP 在第 7 章。机器人上的 Modbus 通常承担非关节慢变量——BMS、电动夹爪、气动比例阀、温湿度、老旧设备数据采集。

主从一问一答与寄存器四区的映射:

flowchart LR
    req["主站请求<br/>地址 + 功能码 + 数据 + CRC"] --> rsp["从站响应<br/>同帧结构返回"]
    rsp --> map["寄存器四区:线圈 / 离散输入 /<br/>输入寄存器 / 保持寄存器"]

1. Modbus 谱系与规范版本

Modbus 规范由 Modbus Organization(modbus.org)维护,现行文档:

  • Modbus Application Protocol Specification V1.1b3(2012):定义 PDU、功能码、四类数据模型。
  • Modbus over Serial Line Specification and Implementation Guide V1.02(2006):定义 RTU 与 ASCII 的 ADU 结构、CRC / LRC、静默间隔。
  • MODBUS Messaging on TCP/IP Implementation Guide V1.0b(2006):定义 TCP 上的 MBAP 头与 RTU-over-TCP 变体。

规范文本简短(每份几十页),无授权费、实现自由。跨厂商互通性由"只用标准功能码 + 只读公开寄存器映射"保证;用户自定义功能码(65–72 与 100–110)的互通性自担。

2. 主从模型与帧结构

Modbus 的报文分两层:PDU(Protocol Data Unit,与传输层无关)+ ADU(Application Data Unit,含传输层头尾)。RTU 的 ADU 是在 PDU 前后各加"从站地址"和"CRC16"。

2.1 PDU 与 ADU 的分层

┌──────────────────── ADU(RTU 完整帧,≤ 256 字节)─────────────────────┐
│  Slave Addr (1B)  │        PDU (≤ 253 B)         │  CRC-16 (2B)  │
│                   │  Func Code (1B) + Data (N B) │               │
└──────────────────────────────────────────────────────────────────────┘

PDU 只由功能码 + 数据两部分构成,最长 253 字节;ADU 再加 1 字节从站地址与 2 字节 CRC,最长 256 字节。功能码占 1 字节、决定请求与响应的数据段结构。

2.2 RTU 帧与 3.5 字符静默间隔

RTU 帧的边界不靠 SOF / EOF 特殊字符,而靠帧间静默时间(silent interval):

  • 3.5 字符时间(t3.5):帧开始与帧结束的标志;主站发新帧前必须等总线至少静默 t3.5。
  • 1.5 字符时间(t1.5):一帧内两个字节之间允许的最大间隔,超过则视为帧被打断、从站丢弃半帧。

字符时间按波特率算。Modbus 串行规范要求字符帧为 11 位:默认 8E1(1 起始 + 8 数据 + 1 偶校验 + 1 停止),无校验时用 8N2(补 2 位停止);8N1 只有 10 位,不属于规范推荐配置:

\[t_{char} = \frac{11}{f_{baud}}\]

以 9600 bps 为例:\(t_{char} = 11 / 9600 \approx 1.146\ \text{ms}\);t1.5 ≈ 1.72 ms、t3.5 ≈ 4.01 ms。波特率越高、静默间隔越短,115200 bps 下 t3.5 只剩约 0.34 ms——串口缓冲区抖动大时容易触发假边界。

工程惯例:调试时把帧间隔留 10 ms 以上(远大于 t3.5),排除时序误判;生产环境严格按波特率算 t3.5。

一问一答的时序与两处时间判据:

sequenceDiagram
    participant M as 主站
    participant S as 从站
    M->>S: 从站地址 + 功能码 + 数据 + CRC
    S-->>M: 同结构响应(地址 + 功能码 + 数据 + CRC)
    Note over M,S: 帧间至少 3.5 字符静默;帧内字符间隔超过 1.5 字符即判帧结束
    M->>S: 请求(从站无响应)
    Note over M: 响应超时 → 重试或判从站离线

2.3 CRC-16 计算

RTU 用 CRC-16 校验整帧(从站地址 + PDU 全部):

  • 多项式:0xA001(0x8005 反射);初值 0xFFFF;无最终异或;结果低字节先发(与 Modbus 大端约定不同的唯一例外)。

Modbus Organization 给出的标准算法:

## 运行环境:Python 3.10+;纯计算,无外部依赖
def modbus_crc16(data: bytes) -> bytes:
    """输入待校验字节序列,返回 2 字节 CRC(低字节在前)。"""
    crc = 0xFFFF
    for b in data:
        crc ^= b
        for _ in range(8):
            if crc & 0x0001:
                crc = (crc >> 1) ^ 0xA001
            else:
                crc >>= 1
    return bytes([crc & 0xFF, (crc >> 8) & 0xFF])   # Lo, Hi

## 示例:读 40001 起 2 个保持寄存器,从站地址 0x01
req = bytes([0x01, 0x03, 0x00, 0x00, 0x00, 0x02])   # 无 CRC 的请求主体
crc = modbus_crc16(req)                              # CRC=0x0BC4,线上低字节先发 C4 0B
## 完整 RTU 帧:01 03 00 00 00 02 C4 0B

对同一帧重复算 CRC、结果稳定;pymodbus 与 libmodbus 都用这份实现,跨库互通性有保障。

3. 四类数据模型与地址映射

Modbus 把从站的数据分成四类地址空间,每类由功能码读写;空间之间独立编号,从 0 或 1 起(历史遗留,规范本身写 0、PLC 手册常写 1)容易混。

空间 记法 位/字节 访问 典型功能码 语义
Coils(线圈) 0x(000001–065536) 位 读 / 写 0x01 读、0x05 写单个、0x0F 写多个 输出继电器 / 开关量控制
Discrete Inputs(离散输入) 1x(100001–165536) 位 只读 0x02 输入开关量、按钮、限位
Input Registers(输入寄存器) 3x(300001–365536) 16 位字 只读 0x04 传感器模拟量输入
Holding Registers(保持寄存器) 4x(400001–465536) 16 位字 读 / 写 0x03 读、0x06 写单个、0x10 写多个 参数、设定值、状态字

跨设备"读某个温度"或"设一个阈值",落到某个空间某个地址。地址编号约定:

  • 规范编号:从 0 起,例如 Coils 0–65535、Holding Registers 0–65535;帧里 2 字节地址字段直接放这个编号。
  • PLC 手册编号(传统 Modicon):从 1 起且带空间前缀,如 "40001" 表示 Holding Register 编号 0;写代码时容易差 1。工程上以功能码文档为准,看到 40001 心里减 1 变 0x0000。

Modbus 不支持多主。Modbus 是严格主从模型——主站发起请求、从站被动响应;从站之间不允许直接对话、也不能主动上报。Modbus TCP 允许多个客户端并发访问一台服务器,但从语义上仍是"多客户端对一服务器",不构成总线级多主仲裁;RTU 半双工总线在电气上支持多个收发器,但协议规定"任一时刻只有一个主站在说话",若两个主站同时发(异常场景)从站会读到乱帧。

设备、寄存器区与解释方式之间的对应关系:

erDiagram
    DEVICE ||--o{ REGISTER_AREA : exposes
    REGISTER_AREA ||--o{ REGISTER : contains
    REGISTER ||--|| DATATYPE : interpreted_as
    DEVICE {
        int slave_id
    }
    REGISTER_AREA {
        string area
        int base_address
    }
    REGISTER {
        int offset
        int words
        int scale
    }
    DATATYPE {
        string endian
    }

4. 功能码分组

功能码 1 字节,规范划分三段:公共功能码 1–64、73–99 与 111–127;用户自定义 65–72 与 100–110;128–255 保留。常用功能码按操作对象分组如下。

4.1 读写位(Coils / Discrete Inputs)

  • 0x01 Read Coils:起始地址 + 数量,响应字节数 + 位图(1–2000 位)。
  • 0x02 Read Discrete Inputs:同上,只读空间。
  • 0x05 Write Single Coil:地址 + 值(0xFF00 = ON、0x0000 = OFF,其他值从站拒绝);响应回显请求。
  • 0x0F Write Multiple Coils:起始地址 + 数量 + 字节数 + 位图;响应起始地址 + 数量。

4.2 读写寄存器(Input / Holding Registers)

  • 0x03 Read Holding Registers:起始地址 + 数量(1–125 字);响应字节数 + 数据。
  • 0x04 Read Input Registers:同上,只读空间。
  • 0x06 Write Single Register:地址 + 16 位值;响应回显。
  • 0x16 Mask Write Register:地址 + AND 掩码 + OR 掩码,用于按位修改。
  • 0x10 Write Multiple Registers:起始地址 + 数量 + 字节数 + 数据;响应起始 + 数量。
  • 0x17 Read/Write Multiple Registers:一次事务同时读一段和写另一段,用于"读回再改"的紧凑操作。
  • 0x2B MEI(Modbus Encapsulated Interface):子功能码 0x0E 读设备识别信息(Vendor Name、Product Name、Major/Minor Revision 等)。

4.3 封装与文件

  • 0x08 Diagnostics:子功能码 0x0000 Return Query Data(回环)、0x0001 Restart Communications Option(重启通信)、0x000A Clear Counters、0x000B–0x0012 各类总线计数与诊断寄存器读回。
  • 0x07 / 0x0B / 0x0C / 0x11:分别读异常状态、通信事件计数、通信事件日志与从站标识,多用于网关与诊断工具。
  • 0x14 Read File Record / 0x15 Write File Record:读设备的历史记录文件或配置块,机器人上不常见。

5. 异常响应与常见异常码

从站无法完成请求时回异常响应:功能码 + 0x80(bit7 置 1)+ 1 字节异常码。常见异常码:

码 名称 场景
0x01 ILLEGAL FUNCTION 从站不支持该功能码
0x02 ILLEGAL DATA ADDRESS 起始地址或数量超出该空间范围
0x03 ILLEGAL DATA VALUE 值不合法(如 0x05 写了 0x1234)
0x04 SERVER DEVICE FAILURE 从站内部错误,需查厂商手册
0x05 ACKNOWLEDGE 长操作已受理但尚未完成(如 flash 写入)
0x06 SERVER DEVICE BUSY 从站忙、主站应重发
0x08 MEMORY PARITY ERROR 从站内存奇偶校验错,一般硬件故障
0x0A GATEWAY TARGET UNRESPONSIVE 网关下无响应(TCP 网关到 RTU 场景)
0x0B GATEWAY PATH UNAVAILABLE 网关路径不可达

主站侧超时(RTU 通常 500 ms–1 s 无响应即超时)与"收到异常响应"是两回事,处理策略不同:前者重试或标节点丢失,后者按异常码分类响应。

6. 字节序与字序反转

Modbus 数据模型的基本单位是 16 位寄存器,一个寄存器按大端(Most Significant Byte first,网络字节序)发送。跨寄存器拼 32 位及以上数据时出现"字序"问题:先送哪个寄存器不是规范强制的。

6.1 大端约定与 32 位量的四种排列

单个寄存器(16 位)内的字节序固定:地址 N 的寄存器 0x1234 在线上是 12 34。跨两个寄存器(32 位浮点或整型)时规范不规定寄存器顺序,形成两种约定:

  • Big-endian word order("高字在前",AB CD):地址 N 放高 16 位、N+1 放低 16 位;符合 Modbus "大端"直觉。
  • Little-endian word order("低字在前",CD AB):地址 N 放低 16 位;受西门子、施耐德部分设备影响流行。

叠加字节内部序,32 位量 0x12345678 在地址 N 与 N+1 上有四种可能的字节排列:

名称 地址 N 内容 地址 N+1 内容 上线字节序列
ABCD(big word + big byte) 0x1234 0x5678 12 34 56 78
CDAB(little word + big byte,字 swap) 0x5678 0x1234 56 78 12 34
BADC(big word + little byte,字节 swap) 0x3412 0x7856 34 12 78 56
DCBA(little word + little byte,全反) 0x7856 0x3412 78 56 34 12

6.2 word swap 与厂商差异

Word swap 特指 AB↔CD 的对调(第一种 ↔ 第二种)。工程踩坑主要来自这里——同一款设备不同固件版本可能默认不同的字序,或提供"swap" 开关;浮点数(IEEE 754 float32)尤其敏感,字序不对时读出的数值可能大得离谱或接近 0。

排查手法:读一个"应当等于 1.0"的浮点常量寄存器,看回来的 4 字节是什么排列,反推该设备使用哪种约定;pymodbus 提供 convert_from_registers(..., word_order="little") 之类的解包辅助(不同版本 API 略有差异),libmodbus 只到 16 位寄存器,多寄存器拼接与字序需要自行处理。

建议规则:项目起步就把"这条总线上所有 32 位量的字序约定"写进寄存器映射表;跨设备接入前先跑一次单值对照。不假设厂商默认——查手册或直接读常量。

7. RS-485 复用与多从站地址

Modbus RTU 物理层复用第 2 章讲的 RS-485 电气:两线半双工(A / B)或四线全双工。多从站挂在同一对线上,靠 ADU 首字节的 Slave Address(1 字节,1–247,0 是广播)区分:

  • 从站读到"不是我"的地址就丢弃整帧、不响应(也不报错,避免总线噪声被误当响应)。
  • 广播(地址 0)只允许写操作(0x05 / 0x06 / 0x0F / 0x10),从站不响应——避免多从同时回帧撞车。
  • 主站用轮询表(poll list)逐个问,一轮问完所有从站再从头开始;轮询周期与从站数量、单帧长度、串口波特率挂钩,通常在 50 ms–1 s。

RS-485 半双工换向坑:主站发完最后一位到允许从站回帧之间要有换向延迟;RTS(Request To Send)控制的 USB-485 转换器需要在最后字节发送完成后再拉低 RTS,早了会截断自己、晚了会挤掉响应窗口。pyserial 与 pymodbus 内置 rs485_mode(Linux 内核 SER_RS485_ENABLED)可自动处理,配置 delay_rts_before_send / delay_rts_after_send。

8. pymodbus 最小主从站示例

以下片段搭一个本地 TCP 主从站做协议层演示(RTU 场景把 ModbusSerialServer 换成对应 backend 即可):从站暴露 4x 空间 10 个保持寄存器初值 0,主站走 0x06 / 0x10 写与 0x03 / 0x01 读四条请求。运行环境:Ubuntu 22.04、Python 3.10、pymodbus 3.6;3.x 各 minor 版本的 StartSyncTcpServer 与 datastore 模块位置有迁移,片段按 3.6 的布局书写。

## 运行环境:Ubuntu 22.04(Python 3.10),pymodbus 3.6
## 服务端(终端 A)——暴露 4x 空间、响应 0x03/0x06/0x10/0x01
from pymodbus.server import StartSyncTcpServer
from pymodbus.datastore import (
    ModbusSequentialDataBlock, ModbusSlaveContext, ModbusServerContext)

## 一个从站,4x 空间起始地址 0 处 10 个寄存器初值 0;其余空间同理
store = ModbusSlaveContext(
    di=ModbusSequentialDataBlock(0, [0] * 10),   # 1x 输入离散
    co=ModbusSequentialDataBlock(0, [0] * 10),   # 0x 线圈
    hr=ModbusSequentialDataBlock(0, [0] * 10),   # 4x 保持寄存器
    ir=ModbusSequentialDataBlock(0, [0] * 10),   # 3x 输入寄存器
)
ctx = ModbusServerContext(slaves=store, single=True)

## StartSyncTcpServer 封装内部事件循环,直接调用即阻塞服务;开发时 Ctrl+C 退出
StartSyncTcpServer(ctx, address=("127.0.0.1", 5020))
## 运行环境:同上
## 客户端(终端 B)——依次执行 0x03 读 / 0x06 写单个 / 0x10 写多个 / 0x01 读线圈
import asyncio
from pymodbus.client import AsyncModbusTcpClient

async def main():
    cli = AsyncModbusTcpClient("127.0.0.1", port=5020)
    await cli.connect()

    # 0x06 写单个保持寄存器:地址 2 处写入 0x1234
    await cli.write_register(address=2, value=0x1234, slave=1)

    # 0x10 写多个保持寄存器:地址 5 起写入 [10, 20, 30]
    await cli.write_registers(address=5, values=[10, 20, 30], slave=1)

    # 0x03 读 8 个保持寄存器
    rr = await cli.read_holding_registers(address=0, count=8, slave=1)
    # rr.registers 应为 [0, 0, 4660, 0, 0, 10, 20, 30]

    # 0x01 读线圈(未写入,全 0)
    rc = await cli.read_coils(address=0, count=8, slave=1)
    # rc.bits[:8] 全为 False

    cli.close()

asyncio.run(main())

用 tshark 或 Wireshark 抓一次同一端口流量("tcp.port == 5020 && modbus"),MBAP 头与 PDU 逐字段可对齐——RTU-over-TCP 对照留待第 7 章 TCP 帧结构展开。

9. 要点

  • Modbus 只有主从模型:请求-应答、从站不主动说话、不彼此对话;TCP 多客户端是并发而非多主。
  • RTU 帧 = Slave Addr + PDU + CRC-16:0xA001 多项式、初值 0xFFFF、Lo 字节先发;帧边界靠 t3.5 静默间隔,与波特率成反比。
  • 四类地址空间独立编号:Coils 0x / Discrete Inputs 1x / Input Registers 3x / Holding Registers 4x;规范从 0 起、PLC 手册常从 1 起,读寄存器映射表要认清楚。
  • 功能码分组:位读写 0x01 / 0x02 / 0x05 / 0x0F,寄存器读写 0x03 / 0x04 / 0x06 / 0x10 / 0x16 / 0x17,诊断与封装 0x08 / 0x0B / 0x14 / 0x2B。
  • 异常响应 = 功能码 + 0x80 + 异常码:0x01–0x0B 覆盖非法功能 / 地址 / 值、从站忙 / 故障、网关不可达;与"超时"是两回事,处理策略分开。
  • 字节序坑主要来自字序(word swap):单寄存器大端固定,跨寄存器 32 位量有 ABCD / CDAB / BADC / DCBA 四种;接入新设备前读一个已知浮点常量反推,寄存器映射表里写清"这条总线约定"。
  • RS-485 半双工换向:SER_RS485_ENABLED 与 RTS 延迟配置是关键,pyserial 与 pymodbus 都直接支持。

参考文献与延伸阅读

  • Modbus Organization. Modbus 规范入口. 规范
  • pymodbus. 文档. 文档
  • Linux Kernel. SocketCAN 文档(串口与总线的对照). 文档