3、Modbus RTU帧结构:起始位、设备地址、功能码、数据区、CRC校验、停止位详解

咱们搞工控的,天天跟Modbus RTU打交道。说白了,RTU就是一串字节在串口线上排队跑。但很多人只知其然,不知其所以然。今天我就把这帧结构掰开揉碎了讲给你听。

一个完整的RTU帧,长这样:

| 起始位(隐式) | 设备地址(1字节) | 功能码(1字节) | 数据区(N字节) | CRC校验(2字节) | 停止位(隐式) |

嗯,这里要注意——起始位和停止位是物理层的东西,咱们在软件里看不到。但理解它们,能帮你解决很多串口通信的玄学问题。

3.1 起始位与停止位:物理层的隐形守护者

先说说起始位和停止位。它们不是Modbus协议定义的,而是串口通信的底层机制。

起始位:一个逻辑0的电平,持续1个位时间。它告诉接收端:「注意,数据要来了!」

停止位:一个逻辑1的电平,持续1、1.5或2个位时间。它告诉接收端:「这字节发完了,歇会儿。」

我个人习惯把停止位设成1位。为什么?因为大多数设备默认就是1位停止位。你设成2位,有些老设备反而会不认。

我的经验: 曾经有个项目,PLC和变频器死活通不上。查了半天,发现变频器设的是1位停止位,PLC那边设成了2位。改过来立马就好了。这种低级错误,我犯过一次就再也没忘。

你想想看,起始位和停止位就像快递包裹的封条。封条不对,里面的东西再完整也白搭。

3.2 设备地址:谁在跟谁说话?

设备地址占1个字节,范围是0~247。其中:

  • 0:广播地址,所有从站都要接收,但不回复
  • 1~247:从站地址,每个从站唯一
  • 248~255:保留,别用

我见过有人把地址设成0,然后问为什么从站不回复。兄弟,广播地址本来就不回复啊!

避坑指南: 我曾经在一个项目里,把32个从站的地址从1排到32。结果有个从站死活连不上。查了两天,发现那个从站默认地址是247,跟保留地址冲突了。后来我学乖了,新设备到手第一件事就是改地址。

设备地址的发送顺序是:高字节在前,低字节在后。但地址只有1个字节,所以无所谓高低。真正要注意的是后面CRC校验的字节序。

3.3 功能码:你想让设备干什么?

功能码也是1个字节。常用的就这几个:

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

功能码后面跟着的,就是数据区。数据区的内容取决于功能码。比如功能码0x03,数据区就是起始地址+读取数量。

重点: 功能码的最高位(bit7)如果被置1,表示异常响应。比如你发0x03读寄存器,从站回0x83,那就说明出错了。错误码在数据区里。

3.4 数据区:真正的干货

数据区长度可变,从0字节到252字节不等。具体格式由功能码决定。

举个例子,读保持寄存器(功能码0x03)的请求帧:

设备地址: 0x01
功能码:   0x03
起始地址: 0x00 0x6B (高字节在前)
寄存器数: 0x00 0x03 (高字节在前)
CRC校验:  0x?? 0x??

这个请求的意思是:「地址为1的从站,从地址0x6B开始,读3个寄存器。」

对应的响应帧:

设备地址: 0x01
功能码:   0x03
字节数:   0x06 (3个寄存器 × 2字节)
数据:     0x?? 0x?? 0x?? 0x?? 0x?? 0??
CRC校验:  0x?? 0x??

我刚开始做的时候,老搞混字节序。寄存器数据是高字节在前,这一点千万记住。曾经有个同事,把高低字节搞反了,读出来的温度值直接翻了好几倍,差点把加热器烧了。

3.5 CRC校验:数据有没有被篡改?

CRC校验占2个字节,是整个帧的「指纹」。发送方计算好CRC,附在帧尾。接收方重新计算,如果对不上,说明数据在传输过程中被干扰了。

CRC的计算方法:

  1. 初始化CRC寄存器为0xFFFF
  2. 对每个字节(地址、功能码、数据),与CRC寄存器低8位异或
  3. 右移1位,如果移出的位是1,则与0xA001异或
  4. 重复步骤3,直到8位全部处理完
  5. 对下一个字节重复步骤2~4
  6. 最终CRC寄存器的高8位和低8位互换,作为校验值

代码实现:

unsigned int crc16(unsigned char *buf, int len) {
    unsigned int crc = 0xFFFF;
    for (int i = 0; i < len; i++) {
        crc ^= buf[i];
        for (int j = 0; j < 8; j++) {
            if (crc & 0x0001) {
                crc = (crc >> 1) ^ 0xA001;
            } else {
                crc >>= 1;
            }
        }
    }
    // 高低字节互换
    return ((crc & 0xFF) << 8) | ((crc >> 8) & 0xFF);
}
注意: CRC的字节序是低字节在前。比如CRC计算结果是0x1234,发送时先发0x34,再发0x12。这一点跟数据区的字节序正好相反。我见过有人在这里栽跟头,调试了一整天。

3.6 帧间隔:别把两帧当成一帧

RTU帧之间必须有至少3.5个字符时间的间隔。如果间隔小于1.5个字符时间,接收端会认为这是同一帧的数据。

这个时间怎么算?

1个字符时间 = 起始位(1) + 数据位(8) + 校验位(0或1) + 停止位(1)
            = 10~11位

3.5个字符时间 = 35~38.5位时间

位时间 = 1 / 波特率

比如9600波特率:
3.5字符时间 = 35 / 9600 ≈ 3.65ms

我曾经在一个项目里,用PLC跟变频器通信。PLC发帧太快,间隔只有2ms。变频器把两帧当成一帧,CRC校验当然过不了。后来我在PLC程序里加了延时,问题就解决了。

建议: 如果你自己写RTU驱动,帧间隔最好设成4ms以上(9600波特率)。波特率越高,间隔可以越小。但保守一点总没错。

3.7 完整帧示例:从请求到响应

咱们看一个完整的例子。主站读取从站地址1的3个保持寄存器,起始地址0x006B。

请求帧:

01 03 00 6B 00 03  CRC_LO CRC_HI

计算CRC:

  • 对 01 03 00 6B 00 03 计算CRC
  • 得到 0x?? (假设是0x1234)
  • 发送顺序:先发0x34,再发0x12

实际发送的字节:

01 03 00 6B 00 03 34 12

正常响应帧:

01 03 06 00 01 00 02 00 03 CRC_LO CRC_HI

解释:

  • 01:设备地址
  • 03:功能码
  • 06:后面有6个字节数据
  • 00 01:寄存器0x006B的值
  • 00 02:寄存器0x006C的值
  • 00 03:寄存器0x006D的值
  • CRC_LO CRC_HI:校验值

异常响应帧:

01 83 02 CRC_LO CRC_HI

解释:

  • 01:设备地址
  • 83:功能码0x03 + 0x80(异常标志)
  • 02:异常码(02表示地址越界)
  • CRC_LO CRC_HI:校验值
总结一下: 帧结构看着复杂,其实就三部分——头部(地址+功能码)、身体(数据)、尾巴(CRC)。起始位和停止位是物理层的,不用你操心。但帧间隔你得管,不然数据会乱套。

好了,这一章就到这儿。下一章咱们聊聊功能码的详细用法,特别是0x03和0x06,这两个我用的最多。