3、RTU帧格式:起始位、地址码、功能码、数据区、CRC校验

咱们搞Modbus RTU,说白了就是在跟一串字节流打交道。这串字节流怎么组织、怎么解析,就是帧格式要解决的问题。我刚开始接触RTU时,总觉得这东西玄乎,后来亲手抓了几次波形,才发现——嗯,其实就是个“信封”结构。

一句话总结:RTU帧 = 地址码 + 功能码 + 数据区 + CRC校验。没有起始位和停止位标记,靠“静默时间”来切分帧。

3.1 帧结构总览

先看一张我手绘的结构图,帮你建立整体印象:

Modbus RTU 帧结构 地址码 1字节 功能码 1字节 数据区 N字节(0~252) CRC校验 2字节(低字节在前) ← 3.5字符静默 → ← 3.5字符静默 →

你看,整个帧就是这四部分拼起来的。没有额外的起始位和停止位——别误会,物理层上的起始位/停止位是UART的事,跟Modbus协议层无关。协议层的“帧起始”靠的是总线空闲时间来判断。

我的习惯:调试时先用示波器看UART波形,确认物理层没问题,再分析协议层帧格式。两步走,不慌。

3.2 地址码——谁在说话?

地址码占1个字节,范围0~247。0是广播地址,所有从机都得听,但不回复。1~247是单播地址,每个从机一个。

我在项目里遇到过一个问题:有人把地址设成了0,结果从机死活不回复。查了半天,原来是广播地址用错了场景。你想想看,广播地址是主机发命令让所有从机同时执行,比如“全部复位”。但从机收到广播后不能回数据,否则总线就打架了。

地址值 用途 从机行为
0 广播地址 执行命令,不回复
1~247 单播地址 执行命令,回复响应
248~255 保留 不建议使用

注意:地址码是帧的第一个字节。主机发请求时填目标从机地址,从机回复时填自己的地址。千万别搞反了——我曾经见过有人把主机地址填进去,结果从机根本不认。

3.3 功能码——要干什么?

功能码1个字节,告诉从机要执行什么操作。常用的就那么几个:

  • 0x01:读线圈状态(DO)
  • 0x02:读离散输入(DI)
  • 0x03:读保持寄存器(AO)
  • 0x04:读输入寄存器(AI)
  • 0x05:写单个线圈
  • 0x06:写单个寄存器
  • 0x0F:写多个线圈
  • 0x10:写多个寄存器

功能码还有个“异常镜像”机制。如果从机执行出错,会把功能码的最高位置1,比如0x03变成0x83,然后后面跟一个错误码。我调试时经常用这个来快速定位问题——从机回0x83,说明它不支持这个功能或者参数不对。

3.4 数据区——具体参数

数据区长度可变,0~252字节。具体内容取决于功能码:

  • 读操作:数据区包含起始地址和读取数量。比如读寄存器0x0000开始的10个寄存器,数据区就是 00 00 00 0A
  • 写操作:数据区包含地址、数据值。比如写单个寄存器,数据区是 00 01 00 FF(地址0x0001,值0x00FF)。
  • 响应帧:数据区包含字节计数和实际数据。比如读10个寄存器成功,数据区是 14(20个字节)后面跟20个字节的数据。

避坑指南:我曾经在数据区里把地址搞成了“寄存器编号”而不是“偏移地址”。比如Modbus地址40001对应偏移0x0000,有人直接填40001,结果从机根本找不到。记住:数据区里填的是偏移地址,不是PLC里的点号。

3.5 CRC校验——数据对不对?

CRC校验占2个字节,校验范围是从地址码开始到数据区结束的所有字节。算法是CRC-16(Modbus),多项式0x8005,初始值0xFFFF。

这里有个坑:CRC在帧里是低字节在前,高字节在后。比如计算出的CRC是0x1234,那么帧里先发0x34,再发0x12。我刚开始写程序时没注意字节序,结果校验一直不过,折腾了一下午。

给你一个C语言参考实现:

uint16_t crc16_modbus(uint8_t *data, uint16_t len) {
    uint16_t crc = 0xFFFF;
    for (uint16_t i = 0; i < len; i++) {
        crc ^= data[i];
        for (uint8_t j = 0; j < 8; j++) {
            if (crc & 0x0001) {
                crc = (crc >> 1) ^ 0xA001;
            } else {
                crc >>= 1;
            }
        }
    }
    return crc;
}

我的经验:调试时先用PC串口助手抓一帧完整数据,然后用这个算法算一遍CRC,跟抓到的CRC对比。如果一致,说明通信链路没问题;如果不一致,八成是字节序或者计算范围搞错了。

3.6 帧间隔——怎么切分帧?

RTU没有帧起始和结束标记,靠的是“静默时间”。标准规定:

  • 帧间间隔:至少3.5个字符时间(波特率相关,9600bps下约3.5ms)
  • 帧内间隔:不能超过1.5个字符时间

说白了,如果总线空闲超过3.5个字符时间,就认为上一帧结束了,下一帧开始。如果帧内两个字节间隔超过1.5个字符时间,接收方就认为帧出错了,直接丢弃。

注意:有些廉价的USB转485模块,发送完一帧后会产生一个“尾巴”电平抖动,导致接收方误判为下一帧开始。我遇到过好几次,最后在软件里加了个5ms的延时才解决。硬件上建议用带自动换向的485芯片。

3.7 完整帧示例

看一个实际例子:主机读取地址为1的从机,从寄存器0x0000开始读2个寄存器。

请求帧:

01 03 00 00 00 02 C4 0B
字节 含义
1 0x01 从机地址
2 0x03 功能码:读保持寄存器
3~4 0x0000 起始地址
5~6 0x0002 读取数量
7~8 0xC40B CRC校验(低字节0x0B在前)

响应帧:

01 03 04 00 0A 00 14 3A 9F
字节 含义
1 0x01 从机地址
2 0x03 功能码
3 0x04 数据字节数(4个字节)
4~5 0x000A 寄存器0的值(10)
6~7 0x0014 寄存器1的值(20)
8~9 0x3A9F CRC校验

你看,整个结构非常清晰。只要把地址、功能码、数据、CRC这四块拼好,就是一帧完整的RTU报文。调试时我习惯先用十六进制看原始数据,确认帧结构没问题,再解析具体数值。

最后说一句:RTU帧格式看着简单,但细节决定成败。字节序、CRC算法、帧间隔,任何一个地方出问题,通信就废了。我建议你手边常备一个Modbus调试工具,抓几帧数据对照着看,比死记硬背强得多。


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