第 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 位,不属于规范推荐配置:
以 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 都直接支持。