4、串口协议解析:Modbus RTU协议基础、数据帧结构、CRC校验计算、功能码详解

好,咱们今天来聊聊Modbus RTU。说实话,在工业现场摸爬滚打这么多年,Modbus RTU是我打交道最多的协议之一。你想想看,从PLC到变频器,从温控表到智能电表,几乎每个设备都支持它。为什么?因为它简单、可靠、而且免费。

我个人习惯把Modbus RTU比作工业界的“普通话”。不管你是哪个厂家的设备,只要会说Modbus RTU,就能互相通信。今天我就把这套“普通话”的语法、词汇、发音规则,掰开了揉碎了讲给你听。

4.1 Modbus RTU协议基础

Modbus协议其实分好几种变体,比如ASCII、TCP、还有咱们今天要讲的RTU。RTU是Remote Terminal Unit的缩写,说白了就是远程终端单元。在串口通信里,RTU模式是最常用的,因为它数据密度高,传输效率好。

Modbus RTU采用主从架构。什么意思呢?就是总线上只有一个主设备(通常是你的上位机或PLC),其他都是从设备(比如传感器、执行器)。主设备发出请求,从设备响应。从设备之间不能直接通信,它们只能跟主设备对话。

嗯,这里要注意:总线上最多可以挂247个从设备。为什么是247?因为从设备地址范围是1到247,地址0是广播地址,248到255是保留的。我在项目中遇到过有人把从设备地址设成了0,结果所有设备都响应,总线乱成一锅粥。所以,地址千万别设成0。

核心要点:

  • 主从架构:一问一答
  • 从设备地址:1-247
  • 广播地址:0(所有从设备接收,但不回复)
  • 同一总线上,同一时间只能有一个主设备

4.2 数据帧结构

Modbus RTU的数据帧结构非常规整。我刚开始学的时候,觉得它就像一列火车,每节车厢都有固定的用途。咱们来看看这列火车长什么样:

字段 长度 说明
从站地址 1字节 目标从设备的地址(1-247)
功能码 1字节 告诉从设备要做什么(读、写等)
数据区 N字节 具体的数据内容,长度可变
CRC校验 2字节 循环冗余校验,低字节在前

你看,总共就四个部分。从站地址告诉总线上的设备“这包数据是给谁的”,功能码告诉它“你要干什么”,数据区是具体的参数,CRC校验用来检查数据在传输过程中有没有出错。

这里有个细节:帧与帧之间必须有至少3.5个字符时间的间隔。为什么?因为Modbus RTU没有帧头帧尾标记,它是靠时间间隔来区分不同数据帧的。如果间隔太短,接收方会把两帧数据当成一帧来处理,那就乱套了。

我的经验:波特率9600时,3.5个字符时间大约是3.5ms。我一般会在发送完一帧后,加一个5ms的延时,确保万无一失。别小看这几毫秒,有时候设备不响应,就是帧间隔没给够。

4.3 CRC校验计算

CRC校验,全称是循环冗余校验。说白了,就是发送方根据数据内容算出一个16位的校验值,附在帧尾。接收方收到数据后,用同样的算法再算一遍,如果结果一致,说明数据没被篡改。

Modbus RTU用的是CRC-16,多项式是0x8005。计算过程有点繁琐,但好在咱们不用手算,写代码就行。我给大家准备了一个Python实现的CRC计算函数:

def modbus_crc(data: bytes) -> int:
    """
    计算Modbus RTU的CRC-16校验值
    :param data: 待校验的数据(不包含CRC本身)
    :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
    # 注意:Modbus RTU的CRC是低字节在前
    return crc

这个函数接收一个字节串,返回16位的CRC值。注意看,我用了0xA001而不是0x8005。为什么?因为0xA001是0x8005的反转形式。Modbus协议规定CRC计算时多项式要反转,所以代码里直接用反转后的值。

我曾经在调试一个温控表时,发现数据总是校验失败。查了半天,原来是CRC字节顺序搞反了。Modbus RTU要求低字节在前,高字节在后。比如CRC值是0x1234,发送时先发0x34,再发0x12。这个坑,我替你们踩过了。

避坑指南:

  • CRC计算范围:从站地址到数据区结束,不包括CRC本身
  • 字节顺序:低字节在前,高字节在后
  • 初始值:0xFFFF,不是0x0000
  • 最终结果:需要与0x0000进行异或(但Modbus RTU不需要,直接输出即可)

4.4 功能码详解

功能码是Modbus协议的核心。它告诉从设备你要干什么。常用的功能码就那么几个,我给大家整理一下:

功能码 名称 作用 数据区格式
0x01 读线圈 读取DO(数字量输出)状态 起始地址(2B) + 数量(2B)
0x02 读离散输入 读取DI(数字量输入)状态 起始地址(2B) + 数量(2B)
0x03 读保持寄存器 读取AO(模拟量输出)或参数 起始地址(2B) + 数量(2B)
0x04 读输入寄存器 读取AI(模拟量输入) 起始地址(2B) + 数量(2B)
0x05 写单个线圈 控制单个DO 输出地址(2B) + 输出值(2B)
0x06 写单个寄存器 设置单个AO或参数 寄存器地址(2B) + 寄存器值(2B)
0x0F 写多个线圈 批量控制DO 起始地址(2B) + 数量(2B) + 字节数(1B) + 数据(NB)
0x10 写多个寄存器 批量设置AO或参数 起始地址(2B) + 数量(2B) + 字节数(1B) + 数据(NB)

你看,功能码分三类:读(01-04)、写单个(05-06)、写多个(0F-10)。实际项目中,0x03和0x06用得最多。为什么?因为大多数设备的核心参数都放在保持寄存器里,比如温度设定值、PID参数、运行状态等。

举个例子,你要读取一个温控表的当前温度。假设温控表地址是0x01,温度值存放在寄存器地址0x0000。那么你发送的请求帧就是:

01 03 00 00 00 01 84 0A

拆开来看:

  • 01:从站地址
  • 03:功能码(读保持寄存器)
  • 00 00:起始寄存器地址(高字节在前)
  • 00 01:读取1个寄存器
  • 84 0A:CRC校验(低字节在前)

如果温控表正常响应,它会回复:

01 03 02 01 2C B9 8E
  • 01:从站地址
  • 03:功能码(回显)
  • 02:数据字节数(2个字节)
  • 01 2C:寄存器值,即0x012C = 300,表示30.0°C(假设精度是0.1°C)
  • B9 8E:CRC校验

嗯,这里要注意:寄存器地址和实际值之间的映射关系,每个厂家可能不一样。有的设备用0.1°C精度,有的用0.01°C。我遇到过一台进口仪表,它的温度值要除以10才是实际温度,而另一台国产表却要乘以2。所以,拿到新设备的第一件事,就是看它的数据手册。

异常响应:

如果从设备无法执行请求,它会返回异常响应。异常响应的格式是:

从站地址 + (功能码 | 0x80) + 异常码 + CRC

常见的异常码:

  • 01:非法功能码(不支持该功能)
  • 02:非法数据地址(寄存器地址不存在)
  • 03:非法数据值(参数超出范围)
  • 04:从设备故障(硬件问题)

举个例子,如果你向一个不存在的地址发请求,可能会收到:

01 83 02 C0 F1

01是从站地址,83是功能码0x03加上0x80,02表示非法数据地址。看到这个回复,你就知道是地址写错了。

4.5 实战中的那些坑

讲了这么多理论,最后分享几个我在项目中踩过的坑:

第一个坑:字节序。 Modbus RTU的数据是高位在前(Big-Endian),但有些国产设备偏偏用低位在前(Little-Endian)。我建议你在代码里加一个字节序配置项,遇到不按套路出牌的设备,直接切换。

第二个坑:寄存器地址偏移。 很多设备手册上写的地址是“40001”这种PLC地址,对应Modbus协议地址是0x0000。也就是说,手册上的地址减去40001才是真正的协议地址。这个换算关系,新手特别容易搞错。

第三个坑:超时处理。 从设备可能因为各种原因不响应(比如正在忙、掉线了)。你的程序一定要设置超时机制,一般建议500ms到1000ms。超时后重试3次,如果还不行,就报错。别傻等,否则程序会卡死。

我的建议:刚开始做Modbus RTU开发时,先用串口助手手动发几帧数据,看看设备怎么回复。等把协议摸透了,再写代码。这样能省下不少调试时间。

好了,Modbus RTU的基础知识就讲到这里。下一章咱们会讲怎么用Python实现一个完整的Modbus RTU主站程序,包括自动重连、错误处理、日志记录等实用功能。到时候咱们再细聊。