3、Modbus RTU协议详解:报文帧结构、地址域、功能码、数据域、CRC校验
好,咱们接着聊。上一章我们把Modbus TCP扒了个底朝天,这一章轮到RTU了。说实话,在我十几年的工控生涯里,RTU才是真正让我又爱又恨的协议。爱它,是因为它简单可靠,一根双绞线就能跑几百米;恨它,是因为调试时一旦碰上CRC校验不对,那真是让人抓狂。
RTU和TCP最大的不同是什么?说白了,RTU是串行通信,没有TCP那个“马甲”——MBAP头。它靠的是时间间隔来区分报文,靠的是CRC来保证数据完整。嗯,咱们一个一个来看。
3.1 报文帧结构:RTU的“裸奔”格式
RTU的报文帧,结构非常简洁。我习惯把它想象成一封没有信封的信——内容直接写在纸上,末尾加个校验码。具体格式如下:
| 地址域 | 功能码 | 数据域 | CRC校验 |
| 1字节 | 1字节 | N字节 | 2字节 |
你看,总共就四个部分。没有TCP里那些花里胡哨的头部信息。每个部分各司其职,咱们逐个拆解。
关键点:RTU报文没有明确的起始和结束标志。它靠的是“静默时间”——3.5个字符时间没有数据传输,就认为上一帧结束了,下一帧要开始了。这个机制,我在项目里吃过亏,后面会讲。
3.2 地址域:谁在说话?
地址域只有1个字节,取值范围是0到247。其中:
- 0:广播地址,所有从站都要接收,但不回复。
- 1-247:从站地址,每个从站必须唯一。
我记得有一次在现场,一个柜子里装了8个温控器,地址设成了1、2、3、4、5、6、7、8。本来跑得好好的,后来加了一个新设备,默认地址也是1。好家伙,一上电,主站发个读温度的命令,两个地址为1的设备同时回复,总线直接乱套了。所以,我建议你在项目初期就做好地址规划表,避免这种低级错误。
个人习惯:我一般把地址1留给主站或者网关,从站从10开始编号。这样即使后面加设备,也不容易冲突。
3.3 功能码:告诉从站干什么
功能码也是1个字节。常用的就那么几个,我列个表,你一看就明白:
| 功能码 | 含义 | 我常用的场景 |
|---|---|---|
| 01 (0x01) | 读取线圈状态 | 读继电器、开关量输入 |
| 02 (0x02) | 读取离散输入状态 | 读按钮、限位开关 |
| 03 (0x03) | 读取保持寄存器 | 读模拟量、设定值、状态字 |
| 04 (0x04) | 读取输入寄存器 | 读传感器采集的原始值 |
| 05 (0x05) | 写单个线圈 | 控制单个继电器通断 |
| 06 (0x06) | 写单个寄存器 | 修改单个参数,比如PID设定值 |
| 15 (0x0F) | 写多个线圈 | 批量控制输出 |
| 16 (0x10) | 写多个寄存器 | 批量修改参数,比如下载配方 |
你可能会问,为什么功能码这么少?其实够用了。工业现场讲究的是稳定,不是花哨。功能码越少,出错的概率越低。我曾经见过一个项目,工程师非要用自定义功能码,结果换了PLC品牌后不兼容,折腾了两周。所以,能用标准功能码,就别自己造轮子。
3.4 数据域:真正要传的东西
数据域是变长的,从0字节到252字节不等。具体内容取决于功能码。比如:
- 读保持寄存器(功能码03):数据域包含起始地址(2字节)和寄存器数量(2字节)。
- 写单个寄存器(功能码06):数据域包含寄存器地址(2字节)和要写入的值(2字节)。
举个例子,我要读取从站地址为1的设备,从寄存器地址0开始,读2个寄存器。报文是这样的:
请求:01 03 00 00 00 02 C4 0B
回复:01 03 04 00 0A 00 14 3A 86
拆开来看:
- 01:从站地址
- 03:功能码
- 00 00:起始寄存器地址(高字节在前)
- 00 02:读取2个寄存器
- C4 0B:CRC校验
回复报文:
- 01:从站地址
- 03:功能码
- 04:数据字节数(2个寄存器 × 2字节 = 4字节)
- 00 0A:第一个寄存器的值(10)
- 00 14:第二个寄存器的值(20)
- 3A 86:CRC校验
注意:数据域中的多字节数据,都是高字节在前(Big-Endian)。这是Modbus的规矩,别搞反了。我曾经见过一个新手,把高低字节写反了,结果读出来的温度值变成了65535,他愣是查了一下午的硬件。
3.5 CRC校验:数据的“指纹”
CRC校验是RTU协议里最核心、也最容易出问题的地方。它占2个字节,校验范围是从地址域开始到数据域结束的所有字节。
CRC的计算方法,说白了就是一个多项式除法。具体算法如下:
1. 预置一个16位寄存器为0xFFFF
2. 取第一个字节与寄存器低8位异或
3. 右移1位,高位补0
4. 如果移出的最低位为1,则与0xA001异或
5. 重复步骤3-4,直到8位全部移完
6. 取下一个字节,重复步骤2-5
7. 最终得到的寄存器值取反,就是CRC
听着有点绕?我给你一个C语言的实现,你一看就明白了:
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;
}
这个函数,我用了十年,从来没出过问题。你写程序的时候,直接复制粘贴就行。
避坑指南:我曾经在一个项目里,用PLC自带的CRC指令计算,结果怎么都对不上。后来发现,PLC的CRC指令用的是多项式0x8005,而Modbus用的是0xA001。这两个多项式是“反转”关系,千万别搞混了。如果你用现成的库,一定要确认是“Modbus CRC-16”,不是“CRC-16/IBM”或者“CRC-16/ARC”。
3.6 实际报文示例:一个完整的读写过程
咱们来个完整的例子。假设我要把从站地址1的寄存器地址100(0x0064)的值,改成1234(0x04D2)。
写单个寄存器(功能码06)请求:
01 06 00 64 04 D2 CRC_H CRC_L
计算CRC:
- 输入数据:01 06 00 64 04 D2
- 计算结果:CRC = 0x1A0B
- 发送顺序:低字节在前,高字节在后 → 0B 1A
所以完整的请求报文是:
01 06 00 64 04 D2 0B 1A
正常回复:
01 06 00 64 04 D2 0B 1A
回复和请求一模一样,表示写入成功。
异常回复:
01 86 02 C0 F1
拆解:
- 01:从站地址
- 86:功能码 + 0x80(表示异常)
- 02:异常码,02表示“非法数据地址”
- C0 F1:CRC校验
你看,如果从站回复了异常码,你就知道是地址写错了,而不是设备坏了。这个调试技巧,能帮你省不少时间。
总结一下:RTU协议的精髓,就是“简单、可靠、低成本”。它没有TCP那么复杂,但正因为简单,所以对时序和校验的要求更高。下一章,我会把TCP和RTU放在一起对比,看看它们到底差在哪,以及怎么在它们之间做转换。