3. Modbus RTU协议详解:报文帧结构
好,咱们今天来聊聊Modbus RTU的报文帧。说实话,这是整个协议最核心的部分。你搞懂了报文怎么组、怎么拆,基本上就掌握了Modbus RTU的八成。
我刚开始接触这个协议的时候,总觉得它挺简单的——不就是发几个字节嘛。但后来在现场调试时,因为一个字节的顺序搞反了,折腾了整整一个下午。嗯,从那以后,我对报文结构就格外上心了。
3.1 报文帧的总体结构
Modbus RTU的报文帧,说白了就是一条指令。它由四个部分组成:
- 地址码:1个字节,告诉总线上的设备“这条消息是发给谁的”
- 功能码:1个字节,告诉设备“你要干什么”
- 数据区:N个字节,具体要操作的数据
- CRC校验:2个字节,检查报文在传输过程中有没有出错
你看,结构就这么简单。但简单归简单,每个部分都有讲究。
报文帧格式(十六进制表示)
+--------+--------+------------------+--------+--------+
| 地址码 | 功能码 | 数据区 | CRC低字节 | CRC高字节 |
| (1字节) | (1字节) | (N字节) | (1字节) | (1字节) |
+--------+--------+------------------+--------+--------+
这里有个细节我要提醒你:CRC校验字节的顺序是低字节在前,高字节在后。我见过不少新手,包括我自己早期也犯过这个错,把高低字节搞反了,结果设备死活不响应。
3.2 地址码——谁在说话?
地址码的取值范围是0x01到0xF7(1到247)。0x00是广播地址,所有设备都会接收,但不会回复。
我个人习惯把地址码分配做成一张表格,贴在机柜门上。这样现场维护的时候,一眼就能看出哪个设备是哪个地址。
| 地址范围 | 用途 | 说明 |
|---|---|---|
| 0x00 | 广播地址 | 所有设备接收,不回复 |
| 0x01 - 0xF7 | 单播地址 | 每个设备唯一,主站轮询 |
| 0xF8 - 0xFF | 保留 | 协议保留,不要使用 |
你想想看,如果两个设备设成了同一个地址,会发生什么?两个设备都会回复,数据就乱套了。我在一个项目中就遇到过这种情况,排查了半天,最后发现是施工队把两个仪表的拨码开关拨成了一样的。
3.3 功能码——要干什么?
功能码决定了这次通信要执行什么操作。常用的功能码就那么几个:
- 0x01:读取线圈状态(读DO)
- 0x02:读取离散输入状态(读DI)
- 0x03:读取保持寄存器(读AO/AI)
- 0x04:读取输入寄存器(读AI)
- 0x05:写单个线圈(写DO)
- 0x06:写单个寄存器(写AO)
- 0x0F:写多个线圈
- 0x10:写多个寄存器
这里有个坑:功能码0x03和0x04的区别。0x03读的是保持寄存器,可读可写;0x04读的是输入寄存器,只能读不能写。我在做DCS系统集成时,有个工程师把0x04当成了0x03用,结果数据一直读不到,因为那个设备根本没有输入寄存器。
我的经验:在写上位机程序时,建议把功能码定义成枚举类型。这样代码可读性高,也不容易写错。
// C语言示例
typedef enum {
READ_COILS = 0x01,
READ_DISCRETE_INPUTS = 0x02,
READ_HOLDING_REGS = 0x03,
READ_INPUT_REGS = 0x04,
WRITE_SINGLE_COIL = 0x05,
WRITE_SINGLE_REG = 0x06,
WRITE_MULTI_COILS = 0x0F,
WRITE_MULTI_REGS = 0x10
} ModbusFunctionCode;
3.4 数据区——具体内容
数据区的格式取决于功能码。以最常用的0x03(读取保持寄存器)为例:
- 起始地址:2个字节,高字节在前
- 寄存器数量:2个字节,高字节在前
举个例子,我要读取地址为0x0100的寄存器,连续读10个:
请求报文:01 03 01 00 00 0A [CRC]
| | | | |
| | | | +-- 寄存器数量(10个)
| | | +-------- 起始地址高字节
| | +-------------- 起始地址低字节
| +----------------- 功能码0x03
+-------------------- 地址码0x01
设备回复的报文格式是这样的:
响应报文:01 03 14 [20个字节数据] [CRC]
| | | |
| | | +-- 数据内容(每个寄存器2字节)
| | +---------- 数据字节数(20 = 10个寄存器 × 2字节)
| +------------- 功能码0x03
+---------------- 地址码0x01
注意看,响应报文里多了一个字节计数字段。这是告诉主站“我回了多少数据”。
3.5 CRC校验——保证数据不出错
CRC校验是Modbus RTU的“安全锁”。它用的是CRC-16算法,多项式是0x8005。
计算过程是这样的:
- 初始化CRC寄存器为0xFFFF
- 对每个字节,与CRC寄存器低字节异或
- 右移一位,如果移出的位是1,则与多项式异或
- 重复8次,处理完所有字节
- 最终结果取反,得到CRC值
CRC计算示例(C语言)
uint16_t crc16(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; // 0xA001是0x8005的反转
} else {
crc >>= 1;
}
}
}
return crc;
}
我曾经在一个项目中,因为CRC计算错误,导致主站和从站之间通信时断时续。排查了三天,最后发现是单片机的中断影响了CRC计算的时序。从那以后,我写CRC计算函数时都会加上临界区保护。
3.6 字节格式与时序要求
Modbus RTU的字节格式是:1个起始位 + 8个数据位 + 1个校验位(可选) + 1个停止位。最常用的是无校验、1个停止位。
时序要求是Modbus RTU的“软肋”。它要求:
- 帧间隔:至少3.5个字符时间
- 字节间隔:不能超过1.5个字符时间
为什么要有这个要求?因为Modbus RTU没有帧头帧尾标记,它是靠“静默时间”来判断一帧数据结束的。如果字节间隔太长,接收方会认为数据已经发完了,然后开始解析——结果数据还没传完,解析出来的就是错的。
重要提醒:在9600波特率下,3.5个字符时间大约是3.65毫秒。如果你的程序在处理数据时耗时过长,或者串口缓冲区设置太小,很容易出现帧间隔超时的问题。
我建议你在做SCADA系统集成时,串口接收缓冲区至少设置256字节,并且用定时器来检测帧间隔。不要用简单的“收到数据就处理”的方式,那样很容易出问题。
3.7 知识体系结构图
下面这张图,是我自己总结的Modbus RTU报文帧的知识体系。你看一眼,就能把整个结构串起来。
这张图把报文帧的四个部分、字节格式、时序要求都串起来了。你保存下来,以后做项目时对照着看,不容易出错。
3.8 避坑指南
最后,我把自己这些年踩过的坑总结一下:
- CRC高低字节顺序:发送时先发低字节,再发高字节。这个顺序搞反了,设备就不认。
- 寄存器地址偏移:很多PLC的寄存器地址是从1开始的,但Modbus协议里是从0开始的。你写程序时要注意这个偏移量。
- 帧间隔时间:在高速率(如115200)下,3.5个字符时间很短,大约0.3毫秒。如果你的程序处理不及时,很容易丢帧。
- 数据区字节序:Modbus RTU默认是大端模式(高字节在前)。但有些设备厂商会用自己的字节序,你得看设备手册确认。
我的建议:在调试阶段,先用Modbus调试工具(比如ModScan、ModSim)验证报文格式。等报文格式确认无误后,再写正式的通信程序。这样可以省去很多排查时间。
好了,关于Modbus RTU的报文帧结构,我就讲到这里。你把这些内容消化掉,后面讲功能码详解和异常处理时,就会轻松很多。
公众号:蓝海资料掘金营,微信deep3321