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位,有些老设备反而会不认。
你想想看,起始位和停止位就像快递包裹的封条。封条不对,里面的东西再完整也白搭。
3.2 设备地址:谁在跟谁说话?
设备地址占1个字节,范围是0~247。其中:
- 0:广播地址,所有从站都要接收,但不回复
- 1~247:从站地址,每个从站唯一
- 248~255:保留,别用
我见过有人把地址设成0,然后问为什么从站不回复。兄弟,广播地址本来就不回复啊!
设备地址的发送顺序是:高字节在前,低字节在后。但地址只有1个字节,所以无所谓高低。真正要注意的是后面CRC校验的字节序。
3.3 功能码:你想让设备干什么?
功能码也是1个字节。常用的就这几个:
| 功能码 | 含义 | 我常用的场景 |
|---|---|---|
| 0x01 | 读线圈 | 读取继电器状态 |
| 0x02 | 读离散输入 | 读取按钮、限位开关 |
| 0x03 | 读保持寄存器 | 读取传感器数值、参数 |
| 0x04 | 读输入寄存器 | 读取模拟量输入 |
| 0x05 | 写单个线圈 | 控制单个继电器 |
| 0x06 | 写单个寄存器 | 设置单个参数 |
| 0x0F | 写多个线圈 | 批量控制继电器 |
| 0x10 | 写多个寄存器 | 批量设置参数 |
功能码后面跟着的,就是数据区。数据区的内容取决于功能码。比如功能码0x03,数据区就是起始地址+读取数量。
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的计算方法:
- 初始化CRC寄存器为0xFFFF
- 对每个字节(地址、功能码、数据),与CRC寄存器低8位异或
- 右移1位,如果移出的位是1,则与0xA001异或
- 重复步骤3,直到8位全部处理完
- 对下一个字节重复步骤2~4
- 最终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);
}
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程序里加了延时,问题就解决了。
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:校验值
好了,这一章就到这儿。下一章咱们聊聊功能码的详细用法,特别是0x03和0x06,这两个我用的最多。