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放在一起对比,看看它们到底差在哪,以及怎么在它们之间做转换。