3. Modbus RTU帧结构:从报文到校验,一次讲透

各位同学,咱们今天聊点实在的。Modbus RTU的帧结构,说白了就是设备之间通信的「语言格式」。你发出去的数据长什么样,对方怎么知道你没发错——这些都在帧结构里规定死了。

我记得刚入行那会儿,拿着示波器抓总线波形,对着文档一行行对报文。那时候就想,要是有人能把这些零碎的知识点串起来讲一遍该多好。今天咱们就来干这件事。

3.1 RTU报文格式:一个完整的帧长什么样

先看整体。一个完整的Modbus RTU帧,由四部分组成:

字段 长度 说明
地址域 1字节 从站地址,范围1~247
功能码域 1字节 告诉从站要干什么
数据域 N字节 具体参数或数据
CRC校验 2字节 低字节在前,高字节在后

嗯,这里要注意:CRC校验是低字节在前。很多新手在这里栽跟头,我当年也犯过这个错——算出来的CRC明明是0x8001,结果发出去怎么都对不上。后来才发现,要先发0x01,再发0x80。

核心要点:RTU帧没有起始位和停止位标记。它靠「总线空闲时间」来区分帧边界。这就是为什么报文间隔时间那么重要。

3.2 地址域:谁在说话?

地址域只有1个字节。主站发请求时,地址域指明要跟哪个从站对话。从站回复时,地址域告诉主站「是我在回话」。

  • 有效地址范围:1 ~ 247
  • 广播地址:0(所有从站都要接收,但不回复)
  • 保留地址:248 ~ 255(别用)

我个人习惯把从站地址写在设备标签上,方便现场排查。有一次在工厂调试,二十多个从站,地址全乱了。最后只能一个个断电、上电、看报文,折腾了一下午。从那以后,我每个设备都贴标签,地址、波特率、奇偶校验,一目了然。

3.3 功能码域:你想干什么?

功能码告诉从站要执行什么操作。常用的就那么几个:

功能码 含义 我常用的场景
0x01 读取线圈状态 读继电器输出
0x02 读取离散输入 读按钮、传感器
0x03 读取保持寄存器 读模拟量、参数
0x04 读取输入寄存器 读AD转换值
0x05 写单个线圈 控制一个继电器
0x06 写单个寄存器 设置一个参数
0x0F 写多个线圈 批量控制
0x10 写多个寄存器 批量设置参数

你想想看,功能码其实就两种:读和写。读用0x01~0x04,写用0x05、0x06、0x0F、0x10。记住这个规律,用起来就顺手多了。

小技巧:如果从站返回的功能码最高位是1(比如0x81),说明出错了。这叫异常响应。具体是什么错误,看数据域里的异常码。

3.4 数据域:具体参数放这里

数据域是帧里最灵活的部分。长度不固定,取决于功能码和具体操作。

举个例子,读取保持寄存器(功能码0x03):

主站发送:01 03 00 00 00 0A XX XX
从站回复:01 03 14 [20字节数据] XX XX

这里:

  • 00 00:起始寄存器地址(从0开始)
  • 00 0A:要读10个寄存器
  • 14:从站回复的数据字节数(20字节 = 10个寄存器 × 2字节)

我曾经遇到过一个坑:某个国产温控器,它的寄存器地址从1开始编号,但Modbus协议里地址是从0开始的。结果我读地址1,它给我返回地址2的数据。折腾了半天才发现是地址偏移的问题。所以,拿到设备手册,先看地址定义是0-based还是1-based

3.5 CRC校验原理与计算:别让数据出错

CRC校验,说白了就是给报文算一个「指纹」。接收方收到后重新算一遍,如果指纹对不上,说明数据在传输过程中被污染了。

Modbus RTU用的是CRC-16,多项式是0x8005。计算过程不复杂,但手算太麻烦。我一般直接用查表法:

// CRC-16查表法计算(C语言)
uint16_t crc16(uint8_t *data, uint16_t len) {
    uint16_t crc = 0xFFFF;
    uint16_t i, j;
    
    for (i = 0; i < len; i++) {
        crc ^= data[i];
        for (j = 0; j < 8; j++) {
            if (crc & 0x0001) {
                crc = (crc >> 1) ^ 0xA001;
            } else {
                crc = crc >> 1;
            }
        }
    }
    return crc;
}

这段代码我用了十年,从8位单片机到ARM Cortex-M,从没出过问题。你直接拿去用就行。

注意:计算完的CRC要低字节在前发送。比如算出来是0x1234,先发0x34,再发0x12。很多通信异常都是因为这个顺序搞反了。

3.6 报文间隔时间:帧与帧之间的「呼吸」

这是最容易忽略,也最容易出问题的地方。Modbus RTU没有帧起始和结束标记,它靠时间间隔来区分帧。

标准规定:

  • 帧内间隔:两个字节之间的时间不能超过1.5个字符时间
  • 帧间间隔:两个帧之间至少要有3.5个字符时间的空闲

字符时间怎么算?很简单:

字符时间 = 1 / 波特率 × (数据位 + 起始位 + 停止位 + 校验位)

举例:9600波特率,8数据位,1停止位,无校验
字符时间 = 1/9600 × (8 + 1 + 1 + 0) = 1.04ms

3.5个字符时间 ≈ 3.64ms
1.5个字符时间 ≈ 1.56ms

我在项目里一般这样处理:

  • 接收时,用定时器监控字节间隔。超过1.5字符时间,认为帧结束
  • 发送时,确保帧间空闲至少3.5字符时间

有一次在PLC项目里,通信老是丢包。查了半天,发现是上位机发送间隔太短,只有2ms。而我们的波特率是19200,3.5字符时间需要1.8ms。理论上够,但实际因为系统调度延迟,偶尔会超。后来把间隔改成5ms,问题就解决了。

实战建议:帧间间隔不要卡着3.5字符时间算。留点余量,5~10个字符时间更稳妥。尤其是用Windows上位机时,系统调度不确定性很大。

知识体系总览

下面这张图,把RTU帧结构的核心逻辑串起来了。你一看就明白:

Modbus RTU帧结构知识体系 RTU报文格式 地址域 (1字节) 功能码域 (1字节) 数据域 (N字节) CRC校验 (2字节) 关键知识点 • 地址范围:1~247,0为广播地址 • 功能码:读(0x01~04) / 写(0x05,06,0F,10) • 数据域:长度可变,取决于功能码 • CRC:低字节在前,多项式0x8005 报文间隔时间 • 帧内间隔:< 1.5字符时间 • 帧间间隔:≥ 3.5字符时间 • 字符时间 = 1/波特率 × 总位数 • 实战建议:留余量,5~10字符时间

这张图把今天讲的内容都串起来了。你保存下来,写代码或者调试的时候对照着看,思路会清晰很多。

好了,RTU帧结构就讲到这里。记住:地址域找对人,功能码说对事,数据域带对参数,CRC保正确,间隔时间别乱来。这五条做到了,Modbus通信就稳了八成。


公众号:蓝海资料掘金营,微信deep3321