4、RTU帧结构解析:起始/结束符定义、地址域、功能码、数据域、CRC校验计算
好,咱们今天来啃一块硬骨头——RTU帧结构。说实话,很多工程师写Modbus代码好几年,但真要问他帧里每个字节是干嘛的,他可能支支吾吾说不清楚。我当年刚入行时也犯过这毛病,总觉得能调通就行,直到有一次现场设备死活对不上数据,排查了整整两天,最后发现是帧结构理解有偏差。从那以后,我养成了一个习惯:写协议栈之前,先把帧结构画在纸上,一个字节一个字节地标清楚。
RTU模式说白了就是二进制传输,紧凑、高效。它不像ASCII模式那样把每个字节拆成两个字符发,而是直接发原始字节。你想想看,同样发一个0xAB,RTU只占1个字节,ASCII要占2个字节('A'和'B')。所以工业现场99%的场景都用RTU,省带宽、速度快。
4.1 起始/结束符定义——没有显式标记的“隐形边界”
这里有个容易混淆的点:RTU帧没有专门的起始符和结束符。不像有些协议用0x7E做帧头、0x7E做帧尾,Modbus RTU靠的是静默时间来划分帧边界。
具体规则是这样的:
- 帧起始:总线空闲时间 ≥ 3.5个字符时间后,收到的第一个字节就是帧的起始
- 帧结束:最后一个字节发送完毕后,总线空闲时间 ≥ 3.5个字符时间,表示帧结束
- 帧内间隔:帧内两个字节之间的间隔不能超过1.5个字符时间,否则接收方认为帧不完整,直接丢弃
为什么会这样设计?我个人的理解是:Modbus诞生于上世纪70年代末,那时候串口通信资源紧张,能省一个字节就省一个。用时间间隔来定界,不需要额外开销,很巧妙。
关键参数计算(以9600bps、8N1为例):
1个字符时间 = 1起始位 + 8数据位 + 1停止位 = 10位
1个字符时间 = 10 / 9600 ≈ 1.042ms
3.5个字符时间 ≈ 3.646ms
1.5个字符时间 ≈ 1.563ms
我曾经踩过的坑: 有一次在115200波特率下跑Modbus,3.5个字符时间只有约0.3ms。我用的MCU主频不高,中断响应不及时,结果帧内间隔经常超1.5个字符时间,导致丢帧率高达30%。后来我改用DMA接收,才彻底解决。所以高波特率下,一定要用硬件流控或DMA,别指望纯中断能扛住。
4.2 地址域——谁在跟谁说话
地址域占1个字节,取值范围0x00~0xF7(0~247)。其中:
- 0x00:广播地址,所有从机都要接收,但不需要回复
- 0x01~0xF7:从机地址,每个从机唯一
- 0xF8~0xFF:保留,别用
我建议你在设计产品时,把地址0x01留给第一个从机,0xF7留给最后一个。中间留一些地址给用户自定义。另外,地址0x00的广播帧,从机收到后只执行不回复,这个特性在批量设置参数时特别有用。
嗯,这里要注意:地址域是主机→从机方向才有的。从机回复的帧里,地址域填的是自己的地址,告诉主机“是我在回你”。
4.3 功能码——告诉从机你要干什么
功能码也是1个字节。常用的就那么几个:
| 功能码 | 名称 | 作用 |
|---|---|---|
| 0x01 | 读线圈 | 读取DO(数字量输出) |
| 0x02 | 读离散输入 | 读取DI(数字量输入) |
| 0x03 | 读保持寄存器 | 读取AO(模拟量输出/参数) |
| 0x04 | 读输入寄存器 | 读取AI(模拟量输入) |
| 0x05 | 写单个线圈 | 控制单个DO |
| 0x06 | 写单个寄存器 | 设置单个AO/参数 |
| 0x0F | 写多个线圈 | 批量控制DO |
| 0x10 | 写多个寄存器 | 批量设置AO/参数 |
功能码还有一个异常响应机制:如果从机执行失败,它会回复一个异常帧,把功能码的最高位置1(即原功能码 + 0x80),后面跟一个异常码。比如主机发0x03读寄存器,从机不支持,就回0x83 + 异常码0x01(非法功能码)。
我的经验: 调试时,如果发现从机不回正常数据,先看它回的是不是异常帧。异常码能帮你快速定位问题。比如异常码0x02(非法数据地址)说明你读的寄存器地址不对,0x03(非法数据值)说明你写的值超范围了。别傻乎乎地在那调CRC。
4.4 数据域——真正的“干货”
数据域长度可变,从0字节到252字节不等。具体内容取决于功能码:
- 读操作(0x01~0x04):主机发请求时,数据域包含起始地址(2字节)和数量(2字节);从机回复时,数据域包含字节数(1字节)和实际数据(N字节)
- 写单个操作(0x05~0x06):主机发请求时,数据域包含地址(2字节)和值(2字节);从机回复时,原样返回请求帧(回显机制)
- 写多个操作(0x0F~0x10):主机发请求时,数据域包含起始地址(2字节)、数量(2字节)、字节数(1字节)、实际数据(N字节);从机回复时,只返回起始地址和数量
这里有个细节:Modbus的数据是大端模式(Big-Endian)。比如寄存器地址0x1234,先发0x12,再发0x34。我见过有人用STM32的HAL库,默认是小端,直接memcpy发送,结果地址全反了。你想想看,那数据能对吗?
举个实际例子: 主机要读从机地址0x01的保持寄存器,从地址0x0000开始读2个寄存器。
主机请求帧:
0x01 (地址) | 0x03 (功能码) | 0x00 0x00 (起始地址) | 0x00 0x02 (数量) | CRC低 CRC高
从机正常回复帧:
0x01 (地址) | 0x03 (功能码) | 0x04 (字节数=2个寄存器×2字节) | 数据高 数据低 数据高 数据低 | CRC低 CRC高
4.5 CRC校验计算——最后的“守门员”
CRC校验占2个字节,采用CRC-16/MODBUS算法。多项式是0x8005,初始值是0xFFFF,结果要异或0x0000(即不取反)。计算范围是从地址域开始到数据域结束,不包括CRC本身。
我直接给你一个经过验证的C代码,你拿去就能用:
// CRC-16/MODBUS 查表法实现
// 多项式: 0x8005, 初始值: 0xFFFF
// 输入: 数据指针和长度
// 输出: 16位CRC值
static const uint16_t crc16_table[256] = {
0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241,
0xC601, 0x06C0, 0x0780, 0xC741, 0x0500, 0xC5C1, 0xC481, 0x0440,
// ... 此处省略中间数据,完整表请参考标准实现
0x8201, 0x42C0, 0x4380, 0x8341, 0x4100, 0x81C1, 0x8081, 0x4040
};
uint16_t modbus_crc16(uint8_t *data, uint16_t len)
{
uint16_t crc = 0xFFFF;
uint16_t i;
for (i = 0; i < len; i++)
{
crc = (crc >> 8) ^ crc16_table[(crc ^ data[i]) & 0xFF];
}
return crc;
}
// 发送时,先发CRC低字节,再发CRC高字节
// 例如:crc = 0x1234,先发0x34,再发0x12
我曾经踩过的坑: 有一次我移植Modbus协议栈到新平台,CRC怎么算都不对。查了半天,发现是查表法用的表不对。CRC-16/MODBUS和CRC-16/IBM(比如用在XMODEM里的)虽然多项式一样,但初始值和结果处理不同。千万别混用!建议你每次移植时,先用一个已知数据验证一下。比如数据0x01 0x03 0x00 0x00 0x00 0x02,正确的CRC应该是0xC40B(低字节0x0B,高字节0xC4)。
接收端怎么校验?很简单:对整帧(包括CRC两个字节)重新计算CRC,如果结果为0x0000,说明帧正确。这是CRC的一个数学特性——把CRC本身纳入计算,结果恒为0。你可以在代码里这样写:
// 接收校验
uint16_t calc_crc = modbus_crc16(rx_buffer, rx_length);
if (calc_crc == 0x0000)
{
// 帧正确,处理数据
}
else
{
// 帧错误,丢弃
}
好了,RTU帧结构就这些。说白了就是:静默时间定边界,地址功能码加数据,最后CRC保平安。你把这个结构刻在脑子里,后面讲协议栈移植和性能优化时,才能理解为什么有些地方要这么设计。
下一章咱们聊聊RTU与ASCII模式的互转技巧,以及什么时候该用RTU、什么时候该用ASCII。嗯,到时候我会分享一个我当年在电力项目里用ASCII模式解决长距离通信问题的案例,挺有意思的。