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主站程序,包括自动重连、错误处理、日志记录等实用功能。到时候咱们再细聊。