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帧结构的知识点串起来了。你保存下来,调试时对照着看,会清晰很多。
4.9 避坑总结
最后,我把自己这些年踩过的坑总结一下,你记好了:
- CRC字节顺序:低字节在前,高字节在后。别搞反了。
- 数据区字节数:写多个寄存器时,字节数 = 寄存器数量 × 2,不是寄存器数量。
- 帧间间隔:发送完一帧后,必须等待至少3.5个字符时间再发下一帧。我见过有人连续发帧,结果从站把两帧当成一帧处理了。
- 地址冲突:同一总线上不能有两个相同地址的从站。这个看似简单,但现场调试时经常有人忽略。
- 异常处理:从站返回异常码时(功能码最高位为1),数据区第一个字节是异常码。比如功能码0x83表示读寄存器异常,数据区0x02表示非法数据地址。
好了,RTU帧结构就讲到这里。记住,纸上得来终觉浅,绝知此事要躬行。找个串口调试工具,抓几包实际数据看看,比什么都强。
公众号:蓝海资料掘金营,微信deep3321