ModBus RTU 从站知识手册
ModBus RTU 从站知识手册
面向嵌入式开发者,系统化整理协议原理、寄存器模型、DMA+空闲中断接收、RTU 帧解析、异常码、FreeRTOS 任务集成与常见踩坑。
- 对应架构:串口 + DMA + 空闲中断
- CH32 / STM32 / ESP32 通用
- 配套 FreeRTOS
目录
- ModBus 协议族概览
- RTU 帧结构与时序
- 数据模型与功能码
- 从站寄存器规划
- CRC-16 校验
- 从站状态机
- DMA + 空闲中断接收
- RS-485 收发切换
- FreeRTOS 任务集成
- 异常码与错误处理
- 常见踩坑与调试
- 测试方法与工具
1. ModBus 协议族概览
ModBus 是施耐德 1979 年发布的工业串行通信协议,因开放、简单、互操作性强被广泛用于 PLC、变频器、传感器、电表等设备。
| 变体 | 物理层 | 数据格式 | 典型应用 |
|---|---|---|---|
| ModBus-RTU | RS-485 / RS-232 | 二进制(紧凑) | 工业现场总线(最常见) |
| ModBus-ASCII | RS-485 / RS-232 | ASCII(可读) | 老旧设备、调试 |
| ModBus-TCP | Ethernet | MBAP 头 + PDU | 工业以太网、网关 |
| ModBus-RTU over TCP | TCP | 裸 RTU 帧(无 MBAP) | 串口服务器透传 |
你做的是 ModBus-RTU 从站(Slave/Server):响应上位机(主站 Master/Client)的请求,被动执行读写。
2. RTU 帧结构与时序
2.1 帧格式
| 地址(1B) | 功能码(1B) | 数据(N 字节) | CRC-16(2B, 低位在前) |
每帧 4~256 字节(典型),地址 0x00 为广播(从站不应答,仅写),0x01~0xF7 为从站地址。
2.2 帧间隔(关键!)
ModBus-RTU 用静默时间区分帧边界:
- 3.5 字符时间的静默 → 视为帧结束。
- 帧内字节间隔 ≤ 1.5 字符时间(否则主站/从站丢弃)。
以 9600 bps, 11 位/字符(1 起始 + 8 数据 + 1 校验 + 1 停止)为例:1 字符 ≈ 1.146 ms,
3.5 字符 ≈ 4 ms(≥ 1.75 ms 时按 4 ms 算),1.5 字符 ≈ 1.75 ms。这就是为什么 9600bps 以上通常取 4ms 作为帧超时阈值。
2.3 字符格式
- 8N1(最常见,无校验)、8E1(偶校验)、8O1(奇校验) — 主从必须一致。
- 校验位通常用于检测物理层错误,但 RTU 帧仍必须有 CRC-16 校验。
3. 数据模型与功能码
ModBus 把数据抽象成 4 类存储区(寄存器):
| 存储区 | 类型 | 访问 | 地址空间 | 常用功能码 |
|---|---|---|---|---|
| Coil(线圈) | 1 bit(只读/读写) | R / RW | 00001~09999 | 01 读 / 05 写单 / 0F 写多 |
| Discrete Input(离散输入) | 1 bit(只读) | R | 10001~19999 | 02 读 |
| Holding Register(保持寄存器) | 16 bit(读写) | RW | 40001~49999 | 03 读 / 06 写单 / 10 写多 |
| Input Register(输入寄存器) | 16 bit(只读) | R | 30001~39999 | 04 读 |
注意:PDU 协议层用 0x0000 基址,如"40001"在请求中是 0x0000。常用换算:协议地址 = 人读地址 - 偏移(40001→-40001, 30001→-30001)。
3.1 8 个常用功能码
| 码 | 名称 | PDU 请求 | PDU 响应 |
|---|---|---|---|
0x01 |
读线圈 | 起始地址(2B) + 数量(2B) | 字节数(1B) + 线圈值(按位打包) |
0x02 |
读离散输入 | 同 01 | 同 01 |
0x03 |
读保持寄存器 | 起始地址(2B) + 数量(2B) | 字节数(1B) + 寄存器值(大端) |
0x04 |
读输入寄存器 | 同 03 | 同 03 |
0x05 |
写单线圈 | 地址(2B) + 值(0xFF00=ON, 0x0000=OFF) | 回显请求(8B PDU) |
0x06 |
写单寄存器 | 地址(2B) + 值(2B) | 回显请求(8B PDU) |
0x0F |
写多线圈 | 地址(2B) + 数量(2B) + 字节数(1B) + 数据 | 地址(2B) + 数量(2B) |
0x10 |
写多寄存器 | 地址(2B) + 数量(2B) + 字节数(1B) + 数据 | 地址(2B) + 数量(2B) |
4. 从站寄存器规划
建议在 C 端用结构体/数组统一管理,避免在协议层散落数据。
// 保持寄存器(40001 起)
typedef struct {
uint16_t mv_out; // 测量值
uint16_t setpoint; // 设定值
uint16_t kp, ki, kd; // PID 参数
uint16_t ctrl_word; // 控制字
uint16_t baud_idx; // 波特率索引
} modbus_holding_t;
// 输入寄存器(30001 起,只读)
typedef struct {
int16_t temperature;
uint16_t pressure;
uint16_t status_word;
} modbus_input_t;
// 线圈(00001 起,位操作)
typedef struct {
uint8_t relay1 : 1;
uint8_t relay2 : 1;
uint8_t enable : 1;
uint8_t alarm : 1;
} modbus_coil_t;
extern modbus_holding_t g_holding;
extern modbus_input_t g_input;
extern modbus_coil_t g_coil;
大端 vs 小端: ModBus 协议层是大端(网络字节序),但 MCU 多为小端。推荐在发送/接收时统一用
__REV16()或位操作做转换,不要假设编译器自动转换。
5. CRC-16 校验
ModBus RTU 使用多项式 0xA001(即 x^16 + x^15 + x^2 + 1 的反序)的 16 位 CRC,低位先发。
5.1 查表法(推荐,O(1) 每字节)
static const uint16_t crc16_table[256] = { /* 256 项,生成工具可一次输出 */ };
uint16_t modbus_crc16(const uint8_t *buf, uint16_t len)
{
uint16_t crc = 0xFFFF;
while (len--) {
crc = (crc >> 8) ^ crc16_table[(crc ^ *buf++) & 0xFF];
}
return crc; // 返回时低字节是 crc_lo
}
// 发送时:
buf[len] = crc & 0xFF; // CRC-Lo 先发
buf[len+1] = (crc >> 8) & 0xFF; // CRC-Hi 后发
// 接收校验:
uint16_t crc_calc = modbus_crc16(rx_buf, rx_len - 2);
uint16_t crc_recv = rx_buf[rx_len-1] << 8 | rx_buf[rx_len-2];
if (crc_calc != crc_recv) return; // 校验失败直接丢弃
256 项表约 512 字节 Flash,极小。如需零占用,可用 0xA001 多项式的位移计算版(约 20 行代码,执行稍慢)。
6. 从站状态机
收到一帧的完整流程:
- 校验帧长度 ≥ 4(地址+功能码+CRC)。
- 校验 CRC-16。
- 校验地址 == 本机或 == 广播(
0x00且为写命令)。 - 解析功能码,执行对应读写,组织响应 PDU。
- 计算响应 CRC,启动 DMA 发送。
7. DMA + 空闲中断接收
这是你架构图里选的方案,最省 CPU 也最稳。原理:用 DMA 循环接收,当 UART 总线出现 ≥ 3.5 字符的静默时触发 IDLE 中断,此时 DMA 计数器值差就是本帧长度。
7.1 初始化
// 注意:必须 先开 DMA,后开 UART,最后使能 IDLE 中断
#define MODBUS_RX_BUF_SIZE 256
static uint8_t rx_buf[MODBUS_RX_BUF_SIZE];
void modbus_uart_init(void)
{
// 1. UART 115200 8N1(示例)
uart_init(MODBUS_UART, 115200);
// 2. DMA 循环模式: UART RX → rx_buf
dma_circular_rx(MODBUS_UART_DMA, MODBUS_UART, rx_buf, MODBUS_RX_BUF_SIZE);
// 3. 使能 UART 空闲中断
USART_ITConfig(MODBUS_UART, USART_IT_IDLE, ENABLE);
USART_ITConfig(MODBUS_UART, USART_IT_TC, ENABLE); // 发送完成(切回接收)
NVIC_EnableIRQ(MODBUS_UART_IRQn);
}
7.2 空闲中断服务程序
void MODBUS_UART_IRQHandler(void)
{
// --- 接收: 空闲中断 = 一帧结束 ---
if (USART_GetITStatus(MODBUS_UART, USART_IT_IDLE)) {
// 必须:读 SR + 读 DR 才能清 IDLE 标志(写入 0 也不行)
volatile uint8_t sr = MODBUS_UART->STATR;
volatile uint8_t dr = MODBUS_UART->DATAR;
(void)sr; (void)dr;
// 计算本次接收字节数
uint16_t recv_len = MODBUS_RX_BUF_SIZE
- DMA_GetCurrDataCounter(MODBUS_UART_DMA);
// 复制出整帧,防止下一帧覆盖(双缓冲)
memcpy(frame_buf, rx_buf, recv_len);
frame_len = recv_len;
xSemaphoreGiveFromISR(s_frame_ready, NULL); // 通知协议任务
// 重置 DMA 指针(对部分 MCU 必须显式重新设置)
DMA_Cmd(MODBUS_UART_DMA, DISABLE);
DMA_SetCurrDataCounter(MODBUS_UART_DMA, MODBUS_RX_BUF_SIZE);
DMA_Cmd(MODBUS_UART_DMA, ENABLE);
}
// --- 发送完成: 切回接收 ---
if (USART_GetITStatus(MODBUS_UART, USART_IT_TC)) {
USART_ClearITPendingBit(MODBUS_UART, USART_IT_TC);
rs485_set_tx_mode(false); // RS-485 收发切换
}
}
易错: CH32/STM32 的 IDLE 中断清除必须
SR 读 + DR 读,写 0 清不掉!
另外,DMA 计数器重置是"安全网",循环模式本身会自动回卷,但显式重置可避免历史数据干扰。
8. RS-485 收发切换
RS-485 是半双工,主/从站都需要控制 DE/RE 引脚:
- 接收态:DE=0,RE=0(或者 RE=1,看芯片)。
- 发送态:DE=1,RE=1。
// 切换要在字节发送开始前,且发送完成时切回
void rs485_set_tx_mode(bool en) {
GPIO_WriteBit(RS485_DE_PORT, RS485_DE_PIN, en ? Bit_SET : Bit_RESET);
// 留几 us 切换时间,典型电路需 > 1 字符时间
delay_us(20);
}
// 发送响应时:
void modbus_send(const uint8_t *buf, uint16_t len)
{
rs485_set_tx_mode(true);
DMA_TxStart(MODBUS_UART_DMA, buf, len); // 发送完成后由 TC 中断切回
}
隐患: 部分廉价 RS-485 收发器在切换方向时会产生短暂毛刺,可能让对端误识别为"空闲"而结束一帧。建议在 DE 下降沿后再加 1~2 字符时间的等待,再开始下一帧接收。
9. FreeRTOS 任务集成
推荐的任务划分:
| 任务 | 优先级 | 职责 |
|---|---|---|
| ModBus 任务 | 中(响应延迟 ≤ 1ms) | 解析请求、查/改寄存器、组响应 |
| 采集任务 | 高(实时性) | ADC/DMA、算法处理 |
| 存储任务 | 低 | SDIO/FATFS 写入 |
- 共享数据保护:应用任务读取寄存器时,使用
xSemaphoreCreateMutex()或 Critical Section。 - 零拷贝:把寄存器定义为全局
volatile,RX 任务只做地址/数量边界检查,直接读写。 - 原子写:写多寄存器(0x10)时要分两步:先写入影子区 + 标志位,然后由应用任务一次性 commit,避免读到半新半旧数据。
10. 异常码与错误处理
当请求合法但从站无法执行时,返回 0x80 | 功能码 + 异常码:
| 异常码 | 名称 | 典型场景 |
|---|---|---|
| 0x01 | 非法功能码 | 不支持的功能码 |
| 0x02 | 非法数据地址 | 起始地址或结束地址越界 |
| 0x03 | 非法数据值 | 写入值非法(超出范围) |
| 0x04 | 从站设备故障 | 底层传感器/执行器故障 |
| 0x05 | 确认(长处理) | 已接受,但尚未完成 |
| 0x06 | 从站设备忙 | 正在处理上一个请求 |
| 0x08 | 存储奇偶校验差错 | 内部存储异常 |
| 0x0A | 网关路径不可用 | 仅网关用 |
| 0x0B | 网关目标无响应 | 仅网关用 |
// 异常响应 PDU: [0x80|func] [exception_code]
void modbus_send_exception(uint8_t func, uint8_t exc)
{
tx_buf[0] = slave_addr;
tx_buf[1] = func | 0x80;
tx_buf[2] = exc;
uint16_t crc = modbus_crc16(tx_buf, 3);
tx_buf[3] = crc & 0xFF;
tx_buf[4] = crc >> 8;
modbus_send(tx_buf, 5);
}
11. 常见踩坑与调试
| 症状 | 根因 | 解法 |
|---|---|---|
| 主站读不到任何响应 | DE/RE 方向未切换 / 切换太快 | DE 拉高后等 ≥ 1 字符再发;TC 中断切回 |
| CRC 校验始终失败 | 发送时高低字节反了 | CRC-Lo 先发;接收按 lo|hi<<8 拼 |
| 偶发丢帧 | 3.5 字符判定被干扰 | 提高总线质量、加终端电阻 120Ω |
| 同一帧被当成两帧处理 | 两帧间隔不足 3.5 字符 | 主站加帧间隔;提高 MCU 晶振精度 |
| 地址匹配却无响应 | 广播(0x00)用了读指令 |
广播只对 05/06/0F/10 写指令有效,读不响应 |
| 读多寄存器值全 0 或全 1 | 未做字节序转换 | 协议是大端,MCU 小端要 __REV16 |
| 长时间运行后死机 | DMA 缓冲区溢出 / 中断未清 | 增大缓冲;确保 IDLE 标志清理顺序正确 |
| 多个主站冲突 | 总线有多个主站 | ModBus 不支持多主,需应用层协调 |
调试黄金组合: USB 转 RS-485 工具 + ModBus Poll/ModbusMaster(主站模拟器) + 串口助手(logic 抓帧)。三者配合能定位 99% 的问题。
12. 测试方法与工具
12.1 上位机工具
- ModBus Poll(Win):主站仿真,功能齐全。
- ModbusMasterTool / libmodbus:开源,适合集成自测。
- 维博智控 / 各类 HMI 配置软件:国产主站,直接验证互通性。
- VS Code 插件 + Wireshark + pymodbus:Python 端快速验证。
12.2 自测关键点
- 所有支持的 8 个功能码各跑一遍正常/越界/超长场景。
- 广播写(
0x00)测试。 - CRC 故意造 1 位错 → 应不响应。
- 连续 1000 帧压测,统计丢包率、响应时间分布。
- 长时间(24h+) 跑,看是否有内存泄漏、计数器溢出。
- 波特率阶梯测试:1200 / 2400 / 9600 / 19200 / 38400 / 115200。
12.3 一致性认证(可选)
ModBus-IDA(现 ModBus Organization)提供官方 Conformance Test,费用 500~1500 美元。如果产品要销售海外或对接客户系统,通过认证可大幅减少兼容性投诉。
参考资料
- ModBus Organization 官方协议规范(英文,ModBus Application Protocol V1.1b3 / ModBus Serial Line V1.02)
- ModBus Tools - 官方维护的工具与厂商资源目录
- libmodbus - 开源 C 库,支持 RTU/TCP,工业级实现
- FreeMODBUS - 嵌入式 ModBus RTU/ASCII 从站协议栈(经典参考)
- pymodbus - Python 实现的 ModBus 协议栈,适合自测脚本
- STM32 / CH32 HAL 库串口 DMA+空闲中断接收模板(ST 官方应用笔记)
- 100ask ModBus 中文教程与社区资源
浙公网安备 33010602011771号