4. Modbus RTU帧结构:深入解析与实战经验

各位同学,今天我们聊聊Modbus RTU的帧结构。说实话,这是整个通信接口开发中最基础、也最容易踩坑的部分。我当年刚入行时,就因为搞错了一个字节的传输顺序,让整条产线停了两个小时。嗯,从那以后,我对帧结构的每个细节都格外较真。

4.1 RTU帧格式概览

一个完整的RTU报文,说白了就是四段数据拼在一起:地址码、功能码、数据区、CRC校验。我习惯把它想象成一个快递包裹——地址码是收件人,功能码是你要干什么,数据区是具体内容,CRC就是那个防拆封条。

RTU帧结构(无起始位和停止位,只有数据)

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

这里有个关键点:RTU帧没有起始位和停止位。它靠的是帧间空闲时间来区分报文。我见过不少新手在调试时,把串口参数设成带起始位,结果死活对不上。记住,RTU模式下,总线空闲时间超过3.5个字符周期,就表示上一帧结束了。

4.2 地址码(1字节)

地址码范围是0x00到0xF7(0~247)。0x00是广播地址,所有从站都要响应,但从站不能回复。1~247是单播地址。我建议你给设备分配地址时,尽量避开0x00和0xFF,虽然0xFF理论上可以用,但很多老设备会把它当成广播来处理。

我在项目中遇到过一件事:一个客户把32台变频器的地址全设成了0x01。你想想看,主站发一条指令,32个从站同时回复,总线直接炸了。后来我帮他们重新规划了地址表,从0x01到0x20依次分配,问题才解决。

4.3 功能码(1字节)

功能码告诉从站你要干什么。常用的就那么几个,我列个表给你看:

功能码 名称 作用 我常用的场景
0x01 读线圈 读取DO状态 控制继电器、指示灯
0x02 读离散输入 读取DI状态 读取按钮、限位开关
0x03 读保持寄存器 读取模拟量/参数 读取电机转速、温度值
0x04 读输入寄存器 读取只读模拟量 读取传感器原始值
0x05 写单个线圈 控制单个DO 启动/停止电机
0x06 写单个寄存器 设置单个参数 设定目标速度
0x0F 写多个线圈 批量控制DO 同时控制多个阀门
0x10 写多个寄存器 批量设置参数 下载PID参数组

为什么功能码这么重要?因为从站收到报文后,第一件事就是检查功能码是否支持。如果不支持,它会返回异常码。我建议你在开发时,先只实现0x03和0x06这两个最常用的,跑通了再扩展其他功能码。

4.4 数据区(0~252字节)

数据区的内容取决于功能码。比如读保持寄存器(0x03),数据区包含起始地址和读取数量,各占2字节。写单个寄存器(0x06),数据区包含寄存器地址和要写入的值,也是各2字节。

举个例子,我要读取地址为0x0001的寄存器,读2个:

请求报文:01 03 00 01 00 02 CRC_LO CRC_HI
          |  |  |     |     |
          |  |  |     |     +-- 读取数量(2个寄存器)
          |  |  |     +-------- 起始地址高字节
          |  |  +-------------- 起始地址低字节
          |  +----------------- 功能码 0x03
          +-------------------- 从站地址 0x01

从站回复:

响应报文:01 03 04 12 34 56 78 CRC_LO CRC_HI
          |  |  |  |     |
          |  |  |  |     +-- 寄存器2的值(0x5678)
          |  |  |  +-------- 寄存器1的值(0x1234)
          |  |  +----------- 数据字节数(4字节)
          |  +-------------- 功能码 0x03
          +----------------- 从站地址 0x01

注意看,数据区第一个字节是字节数(04),表示后面跟了4个有效数据字节。这个设计很巧妙,接收方不用提前知道要读多少数据,直接读这个字节就能知道后续长度。

避坑指南:我曾经在写多个寄存器(0x10)时,把数据区里的字节数算错了。比如要写3个寄存器,每个寄存器2字节,总共6字节。但我写成了3字节,结果从站只解析了前1.5个寄存器,后面的数据全乱了。记住:字节数 = 寄存器数量 × 2。

4.5 CRC校验(2字节)

CRC校验是RTU帧的守护神。它用CRC-16算法,多项式是0x8005,计算范围从地址码开始到数据区结束。我习惯用查表法来计算,速度快,适合嵌入式环境。

下面是我常用的CRC计算函数,Python版:

def crc16_modbus(data: bytes) -> int:
    """
    计算Modbus RTU CRC-16校验值
    :param data: 待校验的字节序列(不含CRC本身)
    :return: 16位CRC值
    """
    crc = 0xFFFF
    for byte in data:
        crc ^= byte
        for _ in range(8):
            if crc & 0x0001:
                crc = (crc >> 1) ^ 0xA001
            else:
                crc >>= 1
    # 交换高低字节(Modbus要求低字节在前)
    return ((crc & 0xFF) << 8) | (crc >> 8)

注意最后一步:我交换了高低字节。因为Modbus RTU规定,CRC在帧中先发低字节,再发高字节。很多新手在这里栽跟头,包括我。记得有一次,我写了一个PLC的通信驱动,CRC算出来总是对不上。折腾了半天,才发现是字节顺序反了。

重要提醒:CRC校验失败时,从站必须丢弃整帧数据,不能回复任何内容。主站侧如果连续3次收不到正确响应,我建议你主动复位通信链路,而不是一直重发。这是我从一次现场事故中学到的教训——重发只会让总线更拥堵。

4.6 字节传输规则

RTU模式下,每个字节的传输规则是这样的:

  • 1个起始位(逻辑0)
  • 8个数据位(先发LSB,后发MSB)
  • 1个校验位(可选,常用无校验或偶校验)
  • 1个停止位(逻辑1)

举个例子,发送字节0x41(二进制01000001),在串口线上实际发送的顺序是:

起始位  0  1  0  0  0  0  1  0  停止位
        |  |  |  |  |  |  |  |
        |  |  |  |  |  |  |  +-- 第7位 (MSB)
        |  |  |  |  |  |  +----- 第6位
        |  |  |  |  |  +-------- 第5位
        |  |  |  |  +----------- 第4位
        |  |  |  +-------------- 第3位
        |  |  +----------------- 第2位
        |  +-------------------- 第1位
        +----------------------- 第0位 (LSB) 先发

看到没?LSB先发。这个细节在调试时特别容易忽略。我建议你用示波器抓一下波形,亲眼看看数据是怎么在线上跑的,比看一百遍文档都管用。

4.7 典型报文解析

我们来解析一个完整的例子。假设我要读取从站地址0x01的保持寄存器,起始地址0x0000,读2个寄存器:

主站发送:

01 03 00 00 00 02 C4 0B

逐字节解析:

  • 01:从站地址
  • 03:功能码(读保持寄存器)
  • 00 00:起始地址(高字节在前)
  • 00 02:读取数量(2个寄存器)
  • C4 0B:CRC校验(低字节C4在前,高字节0B在后)

从站回复:

01 03 04 00 64 00 C8 7A 3F

解析:

  • 01:从站地址(确认是自己)
  • 03:功能码(确认是读操作)
  • 04:数据字节数(2个寄存器 × 2字节 = 4字节)
  • 00 64:寄存器0的值(0x0064 = 100)
  • 00 C8:寄存器1的值(0x00C8 = 200)
  • 7A 3F:CRC校验

你看,从站回复的数据区里,第一个字节就是字节数。这个设计让我在写接收解析时省了不少事——先读1字节知道长度,再读对应字节数的数据,最后校验CRC。

我的经验:在实际项目中,我习惯在接收缓冲区里先收满至少8个字节(最小帧长度),再开始解析。因为RTU帧没有固定长度,如果边收边解析,很容易被不完整的数据误导。等收完一帧再统一处理,逻辑清晰,也不容易出错。

4.8 知识体系结构图

下面这张图是我自己画的,把RTU帧结构的知识点串起来了。你保存下来,调试时对照着看,会清晰很多。

Modbus RTU帧结构知识体系 RTU帧结构 地址码 (1字节) 功能码 (1字节) 数据区 (0~252字节) CRC校验 (2字节) 范围: 0x00~0xF7 0x00=广播 1~247=单播 0x01~0x10常用 读/写线圈 读/写寄存器 内容随功能码变 地址+数量/值 字节数在前 CRC-16算法 多项式0x8005 低字节在前 字节传输规则: 1起始位 + 8数据位(LSB先发) + 校验位(可选) + 1停止位 帧间间隔 ≥ 3.5字符时间,用于区分不同报文

4.9 避坑总结

最后,我把自己这些年踩过的坑总结一下,你记好了:

  1. CRC字节顺序:低字节在前,高字节在后。别搞反了。
  2. 数据区字节数:写多个寄存器时,字节数 = 寄存器数量 × 2,不是寄存器数量。
  3. 帧间间隔:发送完一帧后,必须等待至少3.5个字符时间再发下一帧。我见过有人连续发帧,结果从站把两帧当成一帧处理了。
  4. 地址冲突:同一总线上不能有两个相同地址的从站。这个看似简单,但现场调试时经常有人忽略。
  5. 异常处理:从站返回异常码时(功能码最高位为1),数据区第一个字节是异常码。比如功能码0x83表示读寄存器异常,数据区0x02表示非法数据地址。

好了,RTU帧结构就讲到这里。记住,纸上得来终觉浅,绝知此事要躬行。找个串口调试工具,抓几包实际数据看看,比什么都强。


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