前言
上一章我们建立了 Modbus 的全局认知:主从一问一答、地址识别、功能码操作、寄存器读写、CRC 校验。
但真正写代码时,这些概念还不够。
你必须能看懂每一帧数据长什么样子:
- 主机发出去的请求帧到底是怎么组成的?
- 从机返回的应答帧每一字节代表什么?
- 为什么有时候设备会返回异常码?
- CRC 校验错了,是哪一环节出了问题?
本章我们进入 Modbus RTU 的核心:帧结构。
读完本章,你不需要再靠 “猜协议” 调试,而是能直接根据串口数据判断:这帧是读寄存器、写寄存器、异常响应,还是 CRC 错误。
1. Modbus RTU 帧总览
1.1 一帧数据到底由什么组成?
Modbus RTU 一帧通常由以下几部分组成:
| 字节 | 字段 | 作用 |
|---|---|---|
| 第 1 字节 | 从站地址 | 告诉总线要和哪个设备通信 |
| 第 2 字节 | 功能码 | 告诉设备要执行什么操作 |
| 第 3~4 字节 | 寄存器起始地址 | 从哪个寄存器开始读 / 写 |
| 第 5~6 字节 | 寄存器数量 / 写入数据 | 读多少个寄存器,或写入什么数据 |
| 最后 2 字节 | CRC 校验 | 校验整帧数据是否正确 |
注意: 不同功能码的帧结构会有差异。 其中最常见、最容易踩坑的是:
- 03 功能码:读保持寄存器
- 06 功能码:写单个寄存器
- 16 功能码:写多个寄存器
- 异常响应帧
2. 最经典案例:03 功能码读保持寄存器
2.1 应用场景
03 功能码是工程中使用频率最高的功能码。
作用:
读取从设备的保持寄存器数据。
例如:
- 读温度
- 读电压
- 读电流
- 读转速
- 读设备状态
- 读 PID 参数
2.2 主机请求帧格式
主机发送一帧:
| 字节 | 含义 | 示例 |
|---|---|---|
| 1 | 从站地址 | 0x01 |
| 2 | 功能码 | 0x03 |
| 3 | 起始地址高字节 | 0x00 |
| 4 | 起始地址低字节 | 0x00 |
| 5 | 寄存器数量高字节 | 0x00 |
| 6 | 寄存器数量低字节 | 0x02 |
| 7 | CRC 低字节 | 0x04 |
| 8 | CRC 高字节 | 0x0B |
示例解析:
01 03 00 00 00 02 04 0B含义:
- 从站地址:0x01
- 功能码:0x03,读保持寄存器
- 起始寄存器地址:0x0000
- 读取寄存器数量:0x0002
- CRC 校验:0x0B04
注意: Modbus 中多字节数据通常采用大端模式,也就是高字节在前,低字节在后。
所以:
00 00代表寄存器起始地址:
0x0000而不是 0x0000 反过来。
2.3 从机正常应答帧格式
如果从机收到合法请求,会返回数据。
应答帧格式如下:
| 字节 | 含义 | 示例 |
|---|---|---|
| 1 | 从站地址 | 0x01 |
| 2 | 功能码 | 0x03 |
| 3 | 字节数 | 0x04 |
| 4~5 | 寄存器 1 数据 | 0x00 0x0A |
| 6~7 | 寄存器 2 数据 | 0x00 0x14 |
| 8 | CRC 低字节 | 0xXX |
| 9 | CRC 高字节 | 0xXX |
示例:
01 03 04 00 0A 00 14 XX XX解析:
- 从站地址:0x01
- 功能码:0x03
- 后续数据字节数:0x04
- 第 1 个寄存器:0x000A
- 第 2 个寄存器:0x0014
- 最后两字节为 CRC 校验
这里非常关键:
读 N 个寄存器,返回的数据字节数 = N × 2。
因为一个寄存器是 2 字节。
例如:
- 读 1 个寄存器 → 返回 2 字节数据
- 读 2 个寄存器 → 返回 4 字节数据
- 读 10 个寄存器 → 返回 20 字节数据
这一点写代码时必须用循环处理,不能写死长度。
3. 06 功能码写单个寄存器
3.1 应用场景
06 功能码用于写入单个寄存器。
常见用途:
- 修改设备地址
- 修改波特率
- 设置转速
- 设置 PID 参数
- 控制设备启停
- 修改报警阈值
3.2 主机写请求帧格式
| 字节 | 含义 | 示例 |
|---|---|---|
| 1 | 从站地址 | 0x01 |
| 2 | 功能码 | 0x06 |
| 3 | 寄存器地址高字节 | 0x00 |
| 4 | 寄存器地址低字节 | 0x01 |
| 5 | 写入数据高字节 | 0x00 |
| 6 | 写入数据低字节 | 0x0A |
| 7 | CRC 低字节 | 0xXX |
| 8 | CRC 高字节 | 0xXX |
示例:
01 06 00 01 00 0A XX XX含义:
- 从站地址:0x01
- 功能码:0x06
- 寄存器地址:0x0001
- 写入数据:0x000A
- CRC 校验
3.3 从机正常应答
写成功后,从机通常会回显相同内容:
01 06 00 01 00 0A XX XX这代表:
我已经收到并执行了你的写入命令。
注意: 不是所有设备都会严格回显,但标准 Modbus 设备通常会返回原写帧作为确认。
4. 16 功能码写多个寄存器
4.1 应用场景
06 功能码只能写单个寄存器。
如果需要批量写入多个参数,就要用16 功能码。
典型场景:
- 批量写入 PID 参数
- 批量写入控制指令
- 批量写入配置参数
- 批量校准设备参数
4.2 主机请求帧格式
| 字节 | 含义 |
|---|---|
| 1 | 从站地址 |
| 2 | 功能码:0x10 |
| 3 | 起始地址高字节 |
| 4 | 起始地址低字节 |
| 5 | 寄存器数量高字节 |
| 6 | 寄存器数量低字节 |
| 7 | 写入数据字节数 |
| 8~ | 多组寄存器数据 |
| 最后 2 字节 | CRC 校验 |
示例:
01 10 00 10 00 02 04 00 01 00 02 XX XX解析:
- 从站地址:0x01
- 功能码:0x10
- 起始地址:0x0010
- 写入寄存器数量:0x0002
- 后续数据字节数:0x04
- 第 1 个寄存器写入:0x0001
- 第 2 个寄存器写入:0x0002
- CRC 校验
4.3 从机应答
写成功后,从机通常返回:
01 10 00 10 00 02 XX XX含义:
- 已确认从地址 0x0010 开始,写入 2 个寄存器。
5. 异常响应帧:设备为什么报错?
5.1 异常响应长什么样?
当主机发送非法指令时,从机不会正常返回数据,而是返回异常帧。
异常帧格式:
| 字节 | 含义 |
|---|---|
| 1 | 从站地址 |
| 2 | 功能码最高位置 1 |
| 3 | 异常码 |
| 4 | CRC 低字节 |
| 5 | CRC 高字节 |
例如:
01 83 02 XX XX解析:
- 从站地址:0x01
- 功能码:0x83,说明原来的 0x03 请求发生异常
- 异常码:0x02
- CRC 校验
注意: 异常响应的功能码 = 原功能码 + 0x80。
例如:
- 原功能码 0x03 → 异常码 0x83
- 原功能码 0x06 → 异常码 0x86
- 原功能码 0x10 → 异常码 0x90
这是写代码时判断异常响应的重要依据。
5.2 常见异常码对照表
| 异常码 | 含义 | 常见原因 |
|---|---|---|
| 0x01 | 非法功能码 | 设备不支持该功能码 |
| 0x02 | 非法数据地址 | 寄存器地址超出范围 |
| 0x03 | 非法数据值 | 写入数值不合法 |
| 0x04 | 从设备故障 | 设备内部错误 |
| 0x05 | 确认 | 设备正在处理,需要稍后重试 |
| 0x06 | 从设备忙 | 设备忙,主机稍后再发 |
| 0x08 | 内存奇偶校验错误 | 存储数据异常 |
| 0x0A | 不可用网关路径 | 网关路径不可用 |
| 0x0B | 网关目标设备响应失败 | 目标设备无响应 |
工程中最常见的三个异常码:
0x02:非法数据地址
通常说明:
- 你读的寄存器地址设备没有
- 寄存器地址越界
- 起始地址 + 读取数量超过设备寄存器范围
例如:
设备只支持地址 0x0000~0x0009,你却读了 0x000A,就会返回 0x02。
0x03:非法数据值
通常说明:
- 写入的数据超出设备允许范围
- 参数格式错误
- 设备不接受该数值
例如:
设备转速范围是 0~3000,你写入 6000,可能返回 0x03。
0x06:从设备忙
说明设备正在处理其他任务,主机应该稍后重试。
这也是主机程序必须做超时和重试机制的原因。
6. CRC16 校验:Modbus 的数据安全底线
6.1 CRC 在帧中的位置
Modbus RTU 每一帧最后两字节是 CRC 校验。
例如:
01 03 00 00 00 02 04 0B最后两字节:
04 0B但注意,Modbus RTU 中 CRC 存储方式是:
低字节在前,高字节在后。
所以实际 CRC 值是:
0x0B04而不是 0x040B。
这一点新手极容易写错。
6.2 CRC16-MODBUS 标准算法
Modbus 使用标准 CRC16-MODBUS 算法。
初始值:
0xFFFF多项式:
0xA001计算范围:
从从站地址开始,到 CRC 之前的所有字节。
6.3 直接可用的 C 语言 CRC 代码
#include <stdint.h> uint16_t Modbus_CRC16(uint8_t *data, uint16_t length) { uint16_t crc = 0xFFFF; uint16_t i, j; for(i = 0; i < length; i++) { crc ^= data[i]; for(j = 0; j < 8; j++) { if(crc & 0x0001) { crc >>= 1; crc ^= 0xA001; } else { crc >>= 1; } } } return crc; }6.4 如何使用?
例如你构造了一帧请求:
uint8_t tx_buf[] = {0x01, 0x03, 0x00, 0x00, 0x00, 0x02};计算 CRC:
uint16_t crc = Modbus_CRC16(tx_buf, sizeof(tx_buf));然后把 CRC 低字节和高字节追加到帧尾:
tx_buf[6] = crc & 0xFF; tx_buf[7] = (crc >> 8) & 0xFF;注意顺序:
tx_buf[6] = crc_low tx_buf[7] = crc_high这是 Modbus RTU 标准要求。
7. 主机如何判断一帧数据是否合法?
7.1 主机接收判断流程
主机收到从机应答后,必须按以下顺序判断:
第一步:判断帧长度是否足够
Modbus RTU 最短帧至少也要包含:
- 从站地址
- 功能码
- CRC 低字节
- CRC 高字节
如果长度不足,直接丢弃。
第二步:判断从站地址是否匹配
收到的地址必须等于目标从站地址,或为广播地址。
第三步:判断是否为异常响应
如果功能码最高位为 1,说明是异常响应。
例如:
if(rx_buf[1] & 0x80) { // 异常响应 }第四步:重新计算 CRC 并对比
主机必须重新计算接收帧的 CRC,再和帧尾 CRC 对比。
只有相等,才认为数据有效。
第五步:根据功能码解析数据
如果是 03 功能码,按读寄存器解析。
如果是 06 功能码,按写确认解析。
7.2 主机解析 03 功能码示例代码
uint8_t Modbus_Parse_Read_Holding(uint8_t *rx_buf, uint16_t rx_len, uint16_t *reg_data, uint16_t reg_num) { uint16_t crc_calc; uint16_t crc_rx; uint16_t i; if(rx_len < 5) { return 0; } if(rx_buf[0] != 0x01) { return 0; } if(rx_buf[1] == 0x83) { // 异常响应 return rx_buf[2]; } if(rx_buf[1] != 0x03) { return 0; } if(rx_buf[2] != reg_num * 2) { return 0; } crc_calc = Modbus_CRC16(rx_buf, rx_len - 2); crc_rx = (uint16_t)rx_buf[rx_len - 2] | ((uint16_t)rx_buf[rx_len - 1] << 8); if(crc_calc != crc_rx) { return 0; } for(i = 0; i < reg_num; i++) { reg_data[i] = ((uint16_t)rx_buf[3 + i * 2] << 8) | rx_buf[4 + i * 2]; } return 1; }这段代码已经具备工程雏形。
它判断了:
- 帧长度
- 地址
- 异常响应
- 功能码
- 数据字节数
- CRC 校验
- 寄存器数据解析
8. 从机如何处理主机请求?
8.1 从机处理流程
从机收到一帧后,通常按以下流程处理:
1. 判断地址是否匹配
如果不是本机地址,也不是广播地址,直接忽略。
2. 计算 CRC
如果 CRC 错误,直接丢弃。
3. 判断功能码
根据功能码进入不同处理函数。
4. 判断寄存器地址是否合法
如果地址越界,返回异常码 0x02。
5. 判断写入数据是否合法
如果数据范围错误,返回异常码 0x03。
6. 执行读 / 写操作
读取寄存器或写入寄存器。
7. 构造应答帧
按照 Modbus 格式返回应答。
8.2 从机处理 03 功能码示例代码
uint8_t Modbus_Slave_Process(uint8_t *rx_buf, uint16_t rx_len, uint8_t *tx_buf, uint16_t *tx_len) { uint16_t crc_calc; uint16_t crc_rx; uint16_t start_addr; uint16_t quantity; uint16_t i; if(rx_len < 8) { return 0; } if(rx_buf[0] != 0x01) { return 0; } crc_calc = Modbus_CRC16(rx_buf, rx_len - 2); crc_rx = (uint16_t)rx_buf[rx_len - 2] | ((uint16_t)rx_buf[rx_len - 1] << 8); if(crc_calc != crc_rx) { return 0; } if(rx_buf[1] == 0x03) { start_addr = ((uint16_t)rx_buf[2] << 8) | rx_buf[3]; quantity = ((uint16_t)rx_buf[4] << 8) | rx_buf[5]; if(start_addr + quantity > 10) { tx_buf[0] = 0x01; tx_buf[1] = 0x83; tx_buf[2] = 0x02; *tx_len = 5; Modbus_Append_CRC(tx_buf, tx_len); return 1; } tx_buf[0] = 0x01; tx_buf[1] = 0x03; tx_buf[2] = quantity * 2; for(i = 0; i < quantity; i++) { tx_buf[3 + i * 2] = 0x00; tx_buf[4 + i * 2] = Holding_Reg[start_addr + i]; } *tx_len = 3 + quantity * 2; Modbus_Append_CRC(tx_buf, tx_len); return 1; } return 0; }其中补充一个追加 CRC 的函数:
void Modbus_Append_CRC(uint8_t *buf, uint16_t *len) { uint16_t crc = Modbus_CRC16(buf, *len); buf[*len] = crc & 0xFF; buf[*len + 1] = (crc >> 8) & 0xFF; *len += 2; }9. 新手最容易写错的 5 个点
9.1 CRC 顺序错了
Modbus RTU 中:
CRC低字节在前 CRC高字节在后很多人写成:
tx_buf[6] = (crc >> 8) & 0xFF; tx_buf[7] = crc & 0xFF;这会导致从机校验失败,不响应。
9.2 寄存器地址没有加偏移
很多设备手册里写的寄存器地址是1 起始地址。
例如手册写:
温度寄存器地址:00001实际代码里通常要减 1,变成:
0x0000否则会多读一个地址偏移。
9.3 没有处理异常响应
从机返回异常码后,主机如果继续按正常帧解析,会导致数据错误。
9.4 写死帧长度
读 1 个寄存器和读 10 个寄存器的帧长度不同。
代码必须根据数据字节数动态解析。
9.5 没有超时机制
从机离线后,主机如果一直等待,程序会卡死。
主机必须设置超时:
#define MODBUS_TIMEOUT_MS 100超过 100ms 没有应答,判定失败。
10. 本章总结
本章我们彻底拆解了 Modbus RTU 最核心的帧结构。
你现在应该能读懂:
- 03 功能码读保持寄存器请求帧
- 03 功能码应答帧
- 06 功能码写单个寄存器
- 16 功能码写多个寄存器
- 异常响应帧
- CRC 校验帧
核心记忆点:
- Modbus RTU 帧 = 地址 + 功能码 + 数据 + CRC
- 03 功能码最常用,用于读传感器、电压、转速、参数
- 06 功能码用于写单个寄存器
- 16 功能码用于批量写多个寄存器
- 异常响应功能码 = 原功能码 + 0x80
- CRC 必须低字节在前,高字节在后
- 从机不主动说话,主机必须做超时判断