3、Modbus RTU协议:帧结构、功能码详解、CRC校验计算、主从站实现

好,咱们今天来啃一块硬骨头——Modbus RTU。说实话,这协议在工控圈子里,就跟英语里的ABC一样基础。但越是基础的东西,越容易出幺蛾子。我见过太多项目,最后排查下来,问题就出在RTU的帧结构或者CRC算错了。

咱们今天不讲虚的,直接上干货。从帧结构到功能码,再到CRC校验和主从站实现,一条龙捋清楚。

3.1 帧结构:别小看这串字节

Modbus RTU的帧结构,说白了就是一组字节的排列规则。你想想看,两个设备要聊天,总得有个规矩吧?谁先说话,说多长,怎么结尾,都得定好。

RTU的帧结构长这样:

| 地址码 | 功能码 | 数据区 | CRC校验 |
| 1字节  | 1字节  | N字节  | 2字节   |

嗯,就这么简单。但简单不代表容易。我当年刚入行时,就犯过一个低级错误——把地址码和功能码的顺序搞反了。结果设备死活不响应,查了三天才发现。

地址码:从站地址,范围1-247。0是广播地址,所有从站都得听,但不回复。

功能码:告诉从站要干啥。读还是写,读线圈还是读寄存器。

数据区:具体参数。比如从哪个地址开始读,读多少个。

CRC校验:两个字节,用来检查数据在传输过程中有没有被干扰。

重要提醒:RTU帧没有起始位和停止位标识。它是靠总线空闲时间来判断帧边界的。一般要求3.5个字符时间的静默,才算一帧结束。这个时间跟波特率有关,我建议你算好再配置。

3.2 功能码详解:常用的就这几个

Modbus功能码很多,但实际项目中常用的,一只手数得过来。我个人习惯,先把这五个吃透:

功能码名称作用我常用的场景
01读线圈读取DO状态读取继电器输出
02读离散输入读取DI状态读取按钮、限位开关
03读保持寄存器读取模拟量输出值读取变频器频率设定值
04读输入寄存器读取模拟量输入值读取温度、压力传感器
06写单个寄存器写入一个保持寄存器设定PID目标值

举个例子,功能码03的请求帧:

从站地址: 01
功能码:    03
起始地址:  00 01 (高字节在前)
寄存器数量: 00 02 (读2个寄存器)
CRC校验:   95 CB

响应帧呢?从站会回复:

从站地址: 01
功能码:    03
字节数:    04 (2个寄存器×2字节)
数据:      00 0A 00 14 (两个寄存器的值)
CRC校验:   F8 7D

这里有个坑——地址是从0开始的。比如PLC里地址是40001,那Modbus地址就是0000。我见过有人直接拿40001去发,结果读回来的全是错的。

我的小技巧:写代码时,把地址映射关系单独做个表。40001对应0x0000,40002对应0x0001,这样不容易乱。

3.3 CRC校验计算:别偷懒,自己算一遍

CRC校验,很多人直接调库。但我建议你至少手算一次,理解原理。为什么?因为调试时,CRC算不对是最常见的故障原因之一。

Modbus RTU用的是CRC-16,多项式是0x8005。计算步骤:

  1. 初始化CRC寄存器为0xFFFF
  2. 取第一个字节与CRC低8位异或
  3. 右移一位,如果移出的位是1,则与0xA001异或
  4. 重复8次
  5. 处理下一个字节
  6. 最后得到的CRC,低字节在前,高字节在后

来个C语言实现,我当年在STM32上就是这么写的:

uint16_t crc16_modbus(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;
            } else {
                crc >>= 1;
            }
        }
    }
    // 注意:低字节在前
    return crc;
}

我曾经在一个项目里,CRC算出来总是差一个字节。排查了半天,发现是大小端搞反了。Modbus RTU的CRC是低字节在前,高字节在后,跟很多人的习惯不一样。

避坑指南:我曾经遇到过一台国产仪表,它的CRC顺序跟标准是反的。后来我加了个配置项,让用户自己选CRC顺序。嗯,有时候兼容性比标准更重要。

3.4 主从站实现:谁先说话?

Modbus RTU是典型的主从架构。主机先发请求,从机收到后回复。从机不能主动发数据,只能等主机问。

主站实现要点

  • 轮询所有从站,每个从站发完请求后,等待响应
  • 设置超时时间,我一般设500ms,够用了
  • 收到响应后,检查地址码、功能码、CRC
  • 如果超时或CRC错误,重试3次,再不行就报错

从站实现要点

  • 监听总线,收到完整一帧后,检查地址是否匹配
  • 解析功能码,执行对应操作
  • 组装响应帧,计算CRC,发回总线
  • 如果功能码不支持,返回异常码

异常码长这样:

从站地址: 01
功能码:    83 (03 + 0x80,表示异常)
异常码:    02 (非法数据地址)
CRC校验:   C0 F1

常见的异常码:

  • 01:非法功能码
  • 02:非法数据地址
  • 03:非法数据值
  • 04:从站设备故障

你想想看,如果从站回复了异常码,主站该怎么处理?我建议是记录日志,然后跳过这个从站,继续轮询下一个。别卡死在那里。

实战经验:我在一个污水处理项目中,用了32个从站。一开始轮询一圈要3秒,后来优化成只读变化的数据,轮询时间降到了0.5秒。说白了,Modbus RTU的效率瓶颈往往不在协议本身,而在你的轮询策略。

嗯,今天就先聊到这儿。Modbus RTU看着简单,但真正用好,还是得靠实践。下一章咱们聊聊Modbus TCP,看看它跟RTU有什么不同。