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模式解决长距离通信问题的案例,挺有意思的。