4. Modbus RTU通信协议:物理层与数据帧结构、功能码详解、CRC校验计算

各位同学,今天我们来聊聊Modbus RTU。说实话,这个协议在工业现场太常见了。我做了十几年自动化,几乎每个项目都离不开它。你想想看,从PLC到变频器,从温控表到智能仪表,Modbus RTU就像工业界的“普通话”。

嗯,咱们先搞清楚一件事:Modbus RTU不是最先进的协议,但它绝对是最实用的。为什么?因为它简单、可靠、成本低。我见过太多复杂的协议最后搞出一堆问题,反而是Modbus RTU稳如老狗。

4.1 物理层:RS-485与RS-232

Modbus RTU的物理层,说白了就是决定信号怎么在线上跑。最常见的是RS-485,也有用RS-232的,但现场我几乎只用RS-485。

核心区别:

  • RS-485:差分信号传输,抗干扰能力强,最远1200米,支持多站(最多32个节点)
  • RS-232:单端信号传输,抗干扰弱,最远15米,只能点对点

我个人习惯,只要距离超过10米,或者现场有变频器、电机这些干扰源,一律上RS-485。记得有一次在钢厂做项目,现场电磁环境恶劣得不行,用RS-232根本没法通信,换成RS-485加双绞屏蔽线,问题立马解决。

接线要点:

  • RS-485用A、B两根线(也叫D+、D-),千万别接反
  • 屏蔽层单端接地,别两端都接,容易形成地环路
  • 终端电阻:长距离(>300米)或高速(>9600bps)时,在总线两端各加一个120Ω电阻

注意:我曾经遇到过一个问题,现场通信时好时坏,查了半天发现是终端电阻没加。加了之后,波形干净多了,再也没有丢包过。

4.2 数据帧结构:从字节到报文

Modbus RTU的数据帧,结构非常清晰。每个报文由四部分组成:

字段 长度 说明
地址码 1字节 从站地址,范围1-247,0为广播地址
功能码 1字节 指示操作类型(读/写线圈、寄存器等)
数据区 N字节 具体的数据内容,长度可变
CRC校验 2字节 循环冗余校验,低字节在前

举个例子,读取从站地址为1的设备的保持寄存器,起始地址0,读取2个寄存器:

请求报文:01 03 00 00 00 02 C4 0B
  - 01:从站地址
  - 03:功能码(读保持寄存器)
  - 00 00:起始地址(高字节在前)
  - 00 02:寄存器数量
  - C4 0B:CRC校验

响应报文:

响应报文:01 03 04 00 0A 00 14 3A 0F
  - 01:从站地址
  - 03:功能码
  - 04:数据字节数(2个寄存器×2字节)
  - 00 0A:第一个寄存器值(10)
  - 00 14:第二个寄存器值(20)
  - 3A 0F:CRC校验

这里有个细节:Modbus RTU是大端模式,也就是高字节在前。很多新手在这里栽跟头,我刚开始也搞错过。你想想看,如果大小端搞反了,读出来的数据完全不对。

4.3 功能码详解:常用的几个

Modbus RTU的功能码很多,但实际项目中常用的就那么几个。我给大家列个表:

功能码 名称 操作对象 说明
01 读线圈 DO(数字量输出) 读取开关量输出状态
02 读离散输入 DI(数字量输入) 读取开关量输入状态
03 读保持寄存器 AO(模拟量输出) 读取模拟量输出值或参数
04 读输入寄存器 AI(模拟量输入) 读取模拟量输入值
05 写单个线圈 DO 控制单个开关量输出
06 写单个寄存器 AO 设置单个模拟量输出值
15 写多个线圈 DO 批量控制开关量输出
16 写多个寄存器 AO 批量设置模拟量输出值

我个人最常用的是03和06,一个读一个写,搞定大部分场景。功能码01和05用在数字量模块上,比如控制继电器、读取按钮状态。

小技巧:如果你不确定设备支持哪些功能码,可以发一个03功能码试试。大部分设备都支持读保持寄存器。如果返回异常码01(非法功能码),那就换个功能码试试。

4.4 CRC校验计算:手算与代码实现

CRC校验是Modbus RTU的“安全锁”。没有它,数据在传输过程中被干扰了都不知道。我曾经遇到过现场通信偶尔出错,就是因为CRC校验没做好,设备收到了错误的数据还在傻傻地执行。

CRC-16/MODBUS算法特点:

  • 多项式:0x8005(实际计算用0xA001,因为反转了)
  • 初始值:0xFFFF
  • 结果异或:0x0000
  • 低字节在前,高字节在后

手算太麻烦,咱们直接上代码。这是我在项目中反复使用的Python实现:

def crc16_modbus(data: bytes) -> int:
    """
    计算Modbus RTU CRC-16校验
    :param data: 待校验的字节数据
    :return: 16位CRC值
    """
    crc = 0xFFFF
    for byte in data:
        crc ^= byte
        for _ in range(8):
            if crc & 0x0001:
                crc = (crc >> 1) ^ 0xA001
            else:
                crc >>= 1
    return crc

# 示例:计算请求报文的CRC
data = bytes([0x01, 0x03, 0x00, 0x00, 0x00, 0x02])
crc_value = crc16_modbus(data)
print(f"CRC值: 0x{crc_value:04X}")
# 输出:CRC值: 0xC40B
# 注意:发送时低字节在前,即0x0B 0xC4

代码很简单,但有几个坑要注意:

  • CRC计算的是地址码+功能码+数据区,不包括CRC本身
  • 发送时先发低字节,再发高字节。比如CRC=0xC40B,先发0x0B,再发0xC4
  • 接收时也要验证CRC,如果不对,直接丢弃报文

避坑指南:我曾经在项目中遇到一个设备,它返回的CRC总是和我算的不一样。查了半天,发现那个设备用的是CRC-16/IBM,不是Modbus的CRC-16/MODBUS。虽然多项式一样,但初始值和结果异或不同。所以,一定要确认设备手册里写的是“Modbus RTU CRC-16”。

4.5 实战经验总结

说了这么多,最后给大家几点实战建议:

  1. 波特率统一:主站和从站的波特率、数据位、停止位、校验位必须完全一致。我一般用9600、8、N、1,兼容性最好。
  2. 地址不能重复:总线上每个从站地址必须唯一。我曾经犯过这个错,两个设备都设成地址1,结果通信乱套了。
  3. 超时处理:主站发送请求后,要设置超时时间。一般100ms-500ms,根据现场情况调整。如果超时了,重试3次,还不行就报错。
  4. 帧间隔:Modbus RTU要求帧与帧之间至少有3.5个字符时间的间隔。这个时间很短,但很重要。如果间隔太短,从站可能把两帧当成一帧处理。

嗯,今天就讲到这里。Modbus RTU看起来简单,但真正用好它,还是需要一些实战经验的。下一章我们讲Modbus TCP,那个和RTU有些不同,但核心思想是一样的。

记住一句话:协议是死的,人是活的。理解原理,灵活运用,才是王道。