3. Modbus RTU帧结构:地址码、功能码、数据区、CRC校验的详细拆解

各位工程师朋友,咱们今天来聊聊Modbus RTU的帧结构。说白了,这就是一串字节在线上怎么排兵布阵的问题。我当年刚接触时,觉得这东西不就是几个字节嘛,有啥好研究的?结果第一次调试就栽了跟头——CRC算错了,设备死活不响应。嗯,从那以后,我老老实实把每个字节都掰开揉碎了看。

3.1 帧结构总览:一条消息的完整骨架

一个完整的Modbus RTU帧,就像一列火车。车头是地址码,车厢是功能码和数据区,车尾是CRC校验。少了哪一节,这趟车都开不了。

标准帧结构(共N字节):

| 地址码 (1字节) | 功能码 (1字节) | 数据区 (N-4字节) | CRC校验 (2字节) |

最小帧: 4字节(地址+功能+CRC,无数据)
最大帧: 256字节(RTU协议规定)

我个人习惯把帧结构画成一张图,这样看着更直观。下面这张SVG图,就是我当年做项目时手绘的电子版——它把帧的每个部分都标得清清楚楚。

地址码 1字节 功能码 1字节 数据区 N-4字节 CRC校验 2字节 帧总长度 = 1 + 1 + (N-4) + 2 = N 字节 最小帧:4字节(无数据区) | 最大帧:256字节 帧间间隔 ≥ 3.5字符时间(关键!否则会粘帧)

3.2 地址码:谁在说话?

地址码占1个字节,取值范围是0~247。但这里有个坑——0是广播地址,所有从机都得听,但不准回复。我见过有人把从机地址设成0,结果主机收不到任何响应,查了半天才发现是地址冲突。

实用建议:

  • 从机地址范围:1~247(0是广播,248~255保留)
  • 同一个总线上,地址必须唯一。我曾经在项目里用DIP开关设置地址,结果两个设备都拨到了5,数据全乱套了。
  • 地址0的广播帧,从机只收不回。你想想看,如果所有从机同时回复,总线不就炸了吗?

3.3 功能码:你想干啥?

功能码也是1个字节。它告诉从机:你要读还是写?读什么?写什么?常用的就那么几个,我列个表给你看。

功能码 名称 作用 我常用的场景
0x01 读线圈 读取DO状态 控制继电器反馈
0x02 读离散输入 读取DI状态 读取按钮、限位开关
0x03 读保持寄存器 读取AI/参数 读取电表电压、电流
0x04 读输入寄存器 读取AI(只读) 读取传感器原始值
0x05 写单个线圈 控制单个DO 启动/停止电机
0x06 写单个寄存器 设置单个参数 修改设定值
0x0F 写多个线圈 批量控制DO 一键复位所有输出
0x10 写多个寄存器 批量设置参数 下载配置表

注意: 功能码0x03和0x04容易搞混。我刚开始做项目时,用0x03去读一个只读的输入寄存器,从机返回了异常码0x01(非法功能码)。后来才明白——0x04才是专门读输入寄存器的。记住了:保持寄存器可读可写,输入寄存器只读。

3.4 数据区:干货都在这里

数据区是帧里最灵活的部分。它可以是0字节(比如只读一个状态),也可以长达252字节。数据区的格式取决于功能码。

举个例子,用功能码0x03读保持寄存器时,数据区是这样的:

请求帧:| 地址 0x01 | 功能 0x03 | 起始地址高 0x00 | 起始地址低 0x6E | 寄存器数高 0x00 | 寄存器数低 0x02 | CRC低 | CRC高 |
响应帧:| 地址 0x01 | 功能 0x03 | 字节数 0x04 | 数据高 0x12 | 数据低 0x34 | 数据高 0x56 | 数据低 0x78 | CRC低 | CRC高 |

你注意看,请求帧里数据区是4个字节(起始地址+寄存器数量),响应帧里数据区是5个字节(字节数+数据)。这就是为什么我说数据区长度是动态的——它取决于你要读多少数据。

避坑指南: 我曾经在读取电表数据时,一次读了120个寄存器。结果从机返回了240字节的数据,加上地址码、功能码、CRC,总帧长达到了244字节。差点就超了256字节的上限。所以,批量读取时一定要算好总长度。

3.5 CRC校验:最后的守护神

CRC校验占2个字节,采用CRC-16算法。它是整个帧的「指纹」——只要有一个bit传错了,CRC就对不上,从机就会丢弃这帧数据。

CRC的计算过程是这样的:

  1. 初始化CRC寄存器为0xFFFF
  2. 对每个字节(地址码、功能码、数据区)进行异或和移位操作
  3. 最后得到的16位值,低字节在前,高字节在后

我给你一个C语言的CRC计算函数,这是我项目里一直在用的,经过无数次验证:

unsigned int crc16_modbus(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 = crc >> 1;
            }
        }
    }
    return crc;
}

重要提醒: CRC在帧里是低字节在前,高字节在后。比如计算出的CRC是0x1234,那么帧里先发0x34,再发0x12。这个顺序搞反了,设备就不认。我当年第一次写驱动时,就栽在这个小细节上,折腾了一整天。

3.6 帧间间隔:别让数据「粘」在一起

RTU协议没有起始位和停止位,它靠时间间隔来区分帧。标准规定:帧间间隔必须大于等于3.5个字符时间。

什么是3.5个字符时间?以9600波特率、8数据位、1停止位、无校验为例:

1个字符时间 = (1起始位 + 8数据位 + 1停止位) / 9600 = 1.04ms
3.5个字符时间 ≈ 3.64ms

也就是说,如果总线上超过3.64ms没有数据,从机就认为上一帧结束了,新来的数据是新的一帧。

我曾经踩过的坑: 有一次用USB转485的廉价转换器,它的发送间隔不稳定,有时候只有2ms。结果从机把两帧数据当成一帧处理,CRC校验当然过不了。后来换了工业级的转换器,问题就解决了。所以,硬件质量真的很重要。

3.7 异常响应帧:出错了怎么办?

如果从机收到一个错误的请求(比如功能码不支持、地址越界),它会返回一个异常帧。异常帧的格式是:

| 地址码 | 功能码 + 0x80 | 异常码 | CRC |

常见的异常码有:

异常码 含义 常见原因
0x01 非法功能码 从机不支持该功能
0x02 非法数据地址 寄存器地址超出范围
0x03 非法数据值 写入的数据超出范围
0x04 从机设备故障 硬件异常

你想想看,如果主机发了0x03读寄存器,从机回复0x83 + 0x02,意思就是:「你让我读的地址不存在,我办不到。」这时候你就得去查查寄存器地址表了。

好了,Modbus RTU帧结构的每个部分,咱们都拆解了一遍。从地址码到CRC,从正常帧到异常帧,该说的我都说了。记住一句话:帧结构是协议的基础,基础不牢,地动山摇。下次调试时,拿个串口抓包工具,对着帧结构一个一个字节看,你会有新的收获。


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