☰
Modbus RTU串口通讯实战:从串口参数到寄存器读写的完整指南
2026/10/14 1:13:37 网站建设 项目流程

简介:一份基于STM32CubeMX与HAL库实现的MODBUS RTU从机程序源码包,面向嵌入式开发与工业通信学习者,适合希望快速掌握MODBUS协议帧格式、HAL库UART收发及从机响应机制的读者。压缩包共2个文件,包含1个C源文件与1个头文件,整体仅5KB,代码精简,便于直接阅读和移植。已有2067人学习下载,具有较高参考热度。源码覆盖MODBUS从机核心逻辑,包括地址域、功能码、数据域与CRC校验的构建解析,以及寄存器读写回调函数的实现;配套头文件清晰定义函数原型与数据结构,方便其他模块调用。通过阅读可掌握用HAL_UART_Init、HAL_UART_Receive等接口搭建从机通信的完整流程,并理解如何利用中断或主循环处理请求及错误恢复,是一份轻量实用的入门参考。

1. MODBUS(RTU) 不是协议难题,是串口细节难题

拿到一个名为「MODBUS(RTU).zip」的压缩包,大概率是下面两种东西之一:要么是某个设备厂商随产品附带的通讯说明与示例代码,要么是某位工程师把自己沉淀的串口通讯封装打成包传给了你。无论哪种,它在解决同一件事:让你的电脑或单片机通过 RS485 串口,按 Modbus RTU 协议把设备里的寄存器数据读出来、写进去。这个协议本身不难,难的从来不是协议,而是串口的物理层细节——波特率对不上、校验位选错、CRC 算错、帧间隔超时,任何一个细节都会让通讯玄学般地“时好时坏”。这篇笔记适合刚接触工业通讯、手头有一台 Modbus 从站设备但读不出数据的开发者。我会把从接线、串口参数、帧格式、CRC 校验到读写闭环的实现一步步拆开,最后附上几个高频踩坑记录。

2. 串口参数与连接:先把 RTU 的物理层校正

2.1 串口五件套:从设备手册里抓出波特率、校验位、停止位

Modbus RTU 跑在串口上,串口参数错一个,后面全是白费。你需要从设备手册或铭牌上确认五件事:波特率、数据位、停止位、校验位、从站地址。工业现场常用的组合是 9600-8-N-1,即 9600 波特率、8 数据位、无校验、1 停止位;但也有不少设备出厂是 19200-8-E-1 或 9600-8-O-1。不要想当然,必须逐项核对手册。

在实际项目里,我一般会先用串口调试助手或者自己写的扫描脚本,把常见的波特率轮一遍,找到能返回正常报文的那一组参数。这样做不是不相信手册,而是很多设备的拨码开关或配置软件改动过默认为参数,手册反而滞后了。串口五件套里最容易翻车的是校验位——某些国产设备明明标注“无校验”,但实际从站配置里默认开了偶校验,导致主机发的每一帧都被从站丢弃。

从站地址同样关键。同一总线上可以挂 247 个从站设备,地址范围 1~247,0 是广播地址。如果你只接了一个设备,很多设备默认地址是 1,但也有默认 255 或者 247 的。地址不对,报文发出去设备根本不会应答。接线方面,RS485 是差分信号,A 接 A、B 接 B,不要接反,屏蔽层单端接地。如果通讯距离超过 100 米或总线上设备超过 32 台,需要在总线两端加 120 欧姆终端电阻,否则波形反射会让通讯随机出错。

2.2 用最小脚本验证串口连通性

在开始解析 Modbus 报文之前,先确认串口链路本身是通的。下面是一个最小验证脚本,打开串口后直接把串口收到的原始字节打印出来。这一步可以验证驱动、串口号、参数配置是否正确。

import serial ser = serial.Serial( port='COM3', # 根据你的实际情况修改,Linux 下通常是 /dev/ttyUSB0 baudrate=9600, bytesize=8, parity='N', # 'N' 无校验,'E' 偶校验,'O' 奇校验 stopbits=1, timeout=1 # 读取超时 1 秒 ) print("串口已打开:", ser.name) # 发送一个读保持寄存器的标准请求帧:01 03 00 00 00 0A C5 CD request = bytes.fromhex('01 03 00 00 00 0A C5 CD') ser.write(request) # 读取响应 response = ser.read(64) print("响应字节:", response.hex(' ')) ser.close()

这段脚本的核心作用是验证串口链路通不通、设备应答不应答。bytes.fromhex('01 03 00 00 00 0A C5 CD')构造了一个完整的 RTU 请求帧,含义是从站地址 0x01、功能码 0x03(读保持寄存器)、起始地址 0x0000、读取数量 0x000A(10 个寄存器)、最后两个字节 C5 CD 是 CRC16 校验。如果设备正常,你应该能看到一个以01 03 14开头的响应帧,0x14即 20 个字节的数据长度(10 个寄存器 × 2 字节)。

如果完全没有响应,第一件事不是查协议,而是确认串口和数据线。把串口助手的发送区循环发送同一帧,用示波器或万用表量 RS485 的 A/B 电平是否在发送时有跳变;没有跳变说明根本没发出数据,多半是串口驱动问题或端口被别的程序占用。有跳变但设备不应答,再逐项检查波特率、校验位和站地址。这个顺序能帮你快速定位到底是哪一层出了问题。

2.3 USB 转串口导致的三个假故障

这几年用笔记本调试现场设备基本都靠 USB 转 RS485 线或转换盒,便宜好用,但也带来了三个非常容易误判成协议问题的假故障。第一个是驱动不稳导致串口设备号漂移:今天插上识别成 COM3,明天插上可能变成 COM5,程序里写死了 COM3 就会提示端口被占用或打不开。解决办法是在程序里加一个自动扫描逻辑,遍历所有可用串口,发送请求帧并等待响应,能收到有效 CRC 的就是目标设备。

第二个假故障是 USB 转串口芯片的缓冲区问题。部分 USB 转串口线在 Windows 下默认的接收缓冲区只有几 KB,但 Modbus RTU 报文很短(通常不超过 256 字节),一般不会触发溢出;真正的问题是某些廉价转接线对时序控制不严格,导致帧间隔抖动,Modbus RTU 的帧间隔要求是 3.5 个字符时间,如果转接线把一帧拆成两段发送,从站会认为是两帧而丢弃。遇到这种情况,优先换一条质量好的转接线,或者把波特率降到 9600,加大字符时间窗口。

第三是地电位差。RS485 是差分信号,理论上抗共模干扰能力强,但如果转换器的地和现场设备的地之间存在较大电位差,通讯会时好时坏,误码率极高。排查方法很简单:用万用表量一下 A 线和 B 线之间的直流电压,正常静止状态下应该在 0~0.2V 之间;如果高于 0.5V,说明共模电压异常,需要在转换器端加隔离模块。

3. RTU 帧格式与 CRC16:报文解析前必须过的两关

3.1 帧的边界:从站地址、功能码、数据域、CRC 的顺序

Modbus RTU 的帧格式非常紧凑,一条完整的请求或响应报文遵循固定结构:从站地址(1 字节)、功能码(1 字节)、数据域(N 字节)、CRC16 校验(2 字节,低字节在前)。帧与帧之间必须有大于等于 3.5 个字符时间的静默间隔;一帧内各字节的间隔不能超过 1.5 个字符时间,否则从站会判定帧接收异常。

理解帧边界对解析响应报文极其重要。以读保持寄存器请求为例:01 03 00 00 00 0A C5 CD,拆开来就是地址 01、功能码 03、起始寄存器地址 00 00、寄存器数量 00 0A、CRC 校验 C5 CD。注意地址和数量都是大端序(高字节在前),这也是新手最容易搞反的地方。很多设备的协议手册会写“起始地址 0000H”,对应的字节就是00 00;你如果按小端序拼成00 00恰好一样,但遇到像01 0A这样的地址就会出错。

功能码你日常用到的就那几个:0x03 读保持寄存器、0x04 读输入寄存器、0x01 读线圈、0x02 读离散输入、0x05 写单线圈、0x06 写单寄存器、0x10 写多寄存器。解析响应时,如果功能码的最高位(bit 7)被置 1,说明从站返回的是异常响应,后一个字节是异常码。比如请求读一个不存在的寄存器地址,从站可能回83 02,0x83 是 0x03 加 0x80 的结果,0x02 表示非法数据地址。这个判断一定要做,否则会把异常码当成正常数据去解析。

3.2 CRC16 模块:查表法实现与验证向量

CRC16 是 Modbus RTU 帧里唯一的差错检测手段,算法固定:多项式为 0xA001,初始值为 0xFFFF,处理每个字节时低位在前。虽然协议里明确规定了算法,但不少设备的实现和你的实现如果字节序不一致,通讯也会失败。验证 CRC 计算是否正确有一种简单方法:把一个完整帧(地址、功能码、数据带 CRC)送入计算函数,如果函数返回 0,说明你收到的帧校验通过。

手算 CRC 太容易出错,实战中都是用查表法。下面是一个可以直接用的 Python 查表法实现:

# 生成 CRC16 查表,多项式 0xA001 crc_table = [] def generate_crc_table(): for i in range(256): crc = i for _ in range(8): if crc & 0x0001: crc = (crc >> 1) ^ 0xA001 else: crc >>= 1 crc_table.append(crc & 0xFFFF) generate_crc_table() def modbus_crc(data: bytes) -> int: crc = 0xFFFF for byte in data: crc = (crc >> 8) ^ crc_table[(crc ^ byte) & 0xFF] return crc # 验证向量:标准 Modbus 文档中的经典例子 test_frame = bytes.fromhex('01 03 00 00 00 0A') crc = modbus_crc(test_frame) print(f"CRC 计算结果: {crc:04X}") # 期望输出 C5CD

代码逻辑分两部分:generate_crc_table()预先生成了一个 256 项的查表,避免逐字节位运算拖慢速度;modbus_crc()对每一字节做查表更新,最终返回 16 位 CRC 值。验证向量用的是 Modbus 协议文档里的经典例子:对01 03 00 00 00 0A计算 CRC,期望结果是0xC5CD。如果输出不是这个值,说明表生成或更新逻辑有误。

完整发送帧时,需要把 CRC 的低字节放在前面、高字节放在后面。以刚才的请求帧为例,CRC 是C5CD,发送时拼CD C5。很多新手在这里翻车,把高字节放前面发出去,从站验帧失败直接丢弃。写代码时建议封装一个函数:

def build_request(slave_id: int, function_code: int, data: bytes) -> bytes: frame = bytes([slave_id, function_code]) + data crc = modbus_crc(frame) full = frame + bytes([crc & 0xFF, crc >> 8]) return full

3.3 站地址与功能码的边界判断

拿到一串原始字节,第一步不是想当然地按帧解析,而是先做三层边界判断:长度是否合理、CRC 是否通过、地址是否匹配。长度这一点容易被忽视——Modbus RTU 是面向字节的协议,没有消息长度字段,只能靠帧间隔和功能码推断数据长度。比如读保持寄存器响应,功能码 0x03 之后紧跟的字节就是数据字节数 N,总长度是 从站地址1 + 功能码1 + 长度1 + N + CRC2。用这个关系可以验证收到的帧是否完整。

站地址匹配也容易踩坑。如果总线上只接了从站 1,但你发的请求地址是 0x01,响应地址必然也是 0x01;当从站响应功能码带了 0x80 高位时,依然要校验从站地址是否匹配。有些实现偷懒,收到任何一帧都解析,不校验地址,结果总线上多接几个设备后就互相串数据了。正确姿势是收到字节流后先找帧边界,再算 CRC,最后比对从站地址,三层都过才进入业务解析。

还有一个细节:广播地址 0x00 用于主机向所有从站发送写命令,从站收到后执行但不回复。如果你的设备在收到 0x00 地址的帧时也回包,说明这个设备的实现不规范,后期维护时要注意区分。我自己就遇到过一台仪表对广播帧回了异常响应,导致总线上其他正常设备被干扰。

4. 一次完整的 MODBUS(RTU) 读写闭环

4.1 构造读保持寄存器请求并解析响应

打通串口链路后,正式进入协议交互。最常见的需求是读取设备寄存器——温度、压力、转速、累计量都映射在保持寄存器或输入寄存器里。下面这段代码演示了从站地址为 1 的设备,读取从 0x0000 开始的 10 个保持寄存器的完整流程,包括请求构造、响应解析和结果打印。

import serial def read_holding_registers(ser, slave_id: int, start_addr: int, quantity: int, timeout: float = 1.0): # 数据域:起始地址(2字节) + 寄存器数量(2字节) data = start_addr.to_bytes(2, byteorder='big') + quantity.to_bytes(2, byteorder='big') request = build_request(slave_id, 0x03, data) ser.timeout = timeout ser.reset_input_buffer() # 清空残留数据,避免读到上一帧 ser.write(request) response = ser.read(256) if len(response) < 5: raise RuntimeError(f"响应太短或超时: {len(response)} 字节,原文 {response.hex(' ')}") # 校验从站地址和功能码 if response[0] != slave_id: raise RuntimeError(f"从站地址不匹配: 期望 {slave_id:#04x}, 实际 {response[0]:#04x}") # 异常响应处理 if response[1] & 0x80: raise RuntimeError(f"从站返回异常: 功能码 {response[1]:#04x}, 异常码 {response[2]:#04x}") # 校验 CRC:对整帧计算后结果应为 0 crc = modbus_crc(response) if crc != 0: raise RuntimeError(f"CRC 校验失败,计算值 {crc:04X}") byte_count = response[2] values = [] for i in range(byte_count // 2): reg_val = int.from_bytes(response[3 + i*2 : 5 + i*2], byteorder='big') values.append(reg_val) return values # 实际调用 ser = serial.Serial('COM3', 9600, timeout=1) vals = read_holding_registers(ser, slave_id=1, start_addr=0x0000, quantity=10) print("读取到的寄存器值:", vals)

这段代码包含了四个关键校验步骤:长度判断、从站地址匹配、异常响应判断、CRC 验算。CRC 校验采用整帧验算方式——把收到的完整字节(含 CRC)重新送入modbus_crc(),返回 0 就代表校验通过,省去了拆出前段再单独比对 CRC 的麻烦。reset_input_buffer()这行很重要,不清空残留数据的话,慢速设备前一帧的尾巴可能被当成下一帧头部,导致解析错乱。

寄存器值的字节序默认按大端解析,即uint16的高字节在前。绝大多数 Modbus 设备都遵循大端序,但总有厂商用 CDAB 字节序(低字节在前),后面会专门讲这个坑。如果你读到的数值和实际不符,先试一下把byteorder改成'little'再解析。

4.2 写单个寄存器:请求构造与响应回读

写操作在调试阶段更常用——把设备的控制字、目标温度、启停标志写进去,观察设备动作。写单个寄存器用功能码 0x06,请求格式是:从站地址、0x06、寄存器地址、要写入的值、CRC。从站成功后会回显同样的帧作为确认。如果写入失败,从站会回异常响应或干脆不响应。

def write_single_register(ser, slave_id: int, reg_addr: int, value: int) -> None: data = reg_addr.to_bytes(2, byteorder='big') + value.to_bytes(2, byteorder='big') request = build_request(slave_id, 0x06, data) ser.reset_input_buffer() ser.write(request) response = ser.read(8) # 写单个寄存器成功响应固定 8 字节 if len(response) != 8: raise RuntimeError(f"写寄存器响应长度异常: {len(response)} 字节, 原文 {response.hex(' ')}") # 回显帧要逐字节比对 if response != request: raise RuntimeError(f"写寄存器回显不一致: 发送 {request.hex(' ')} 接收 {response.hex(' ')}") print(f"寄存器 {reg_addr:#06x} 写值 {value} 成功")

这里用「响应等于请求」这个特性做校验,比重新解析响应内容更直接可靠。正常情况下从站会把请求帧原样返回,因此只要收到了 8 个字节且与发送内容完全一致,就可以确认写入成功。要注意的是,有的设备写入成功后会在 100ms 内响应,有的则要等几百毫秒,串口超时时间建议设置得宽裕一些,不要一超时就立刻重发,否则设备可能正在忙而你的重发请求堆积在缓冲区里,造成后续帧错乱。

重试策略我一般这样处理:第一遍超时后等 200ms 再重发,最多重试 3 次;三次都失败就放弃并报错。绝对不要在循环里以 10ms 间隔疯狂重发——不少仪表对连续快速请求会直接进入 Busy 状态,反而需要断电重启才恢复。

4.3 没有收到响应时先看这三个地方

读写闭环里最让人挠头的问题就是:代码没问题、CRC 正确、串口参数也对,但设备就是不回包。按经验,90% 的情况出在下面三个地方。

第一是 RS485 方向控制问题。如果你用的是串口转 RS485 方案,有些转换器靠硬件自动切换收发方向,有些则要靠 RTS 引脚手动控制。如果 RTS 没拉高或拉低时机不对,发送完请求后总线还处于发送态,设备以为总线忙就不会应答。用示波器能一眼看出问题:正常通讯的 A/B 波形在主机发完最后一个字节后应迅速回到空闲电平。

第二是收发包时序太急。从站设备需要时间处理请求、读取寄存器、组包响应,这可能需要几十毫秒。如果你的串口读超时设成 50ms,设备响应稍慢就超时了。常见做法是把读超时设到 500ms 甚至 1 秒,宁可等待长一点,也不要误判超时。计算帧传输时间有个公式:单字节时间 = 1000 / 波特率 × 10(含起始位和停止位),9600 波特率下单字节约 1.04ms,一次 10 个寄存器的读响应约 26 字节,传输也就 27ms 左右,但设备的处理时间无法预知,所以超时绝不能按传输时间算。

第三是发送的起始地址超出了设备的寄存器范围。某项目X中我把一个温控器的起始地址写成了 0x0064,但设备寄存器区域只到 0x005F,设备直接丢弃了报文。解决办法是先用广播测试或逐个地址扫描的方式找出设备实际可访问的寄存器区间,再据此调整请求帧。

5. 避坑排查:MODBUS(RTU) 实战中的 4 个高频坑

5.1 现象:波特率对不上时出现乱码或半包

调试一个项目时遇到的现象是:设备偶尔返回完整数据,大部分时间收到的是乱码,用十六进制看是半截字节加校验不通。一开始以为是线缆问题,换了屏蔽线依旧。后来逐个排查才发现,设备的实际波特率是 19200,而我程序里配的是 9600——之前那几次“成功”其实是设备处在自动波特率检测阶段,碰巧对齐了一次。

原因在于串口波特率不一致时,接收端的采样点和发送端错位,导致字节流撕裂。解决方法很简单:先用扫描脚本把常见波特率(2400、4800、9600、19200、38400、57600、115200)全部发一遍读取请求,哪个波特率能收到 CRC 校验通过的响应就用哪个。扫描时注意发送间隔要大于 500ms,给设备充分响应时间。这个习惯我一直保留到现在,比查手册快得多。

5.2 现象:从站地址不对时通讯偶尔能通

有台设备设置成了地址 0x0A,程序里写的还是 0x01,按理说应该完全不通。但实际测试中每发几十次请求,设备会偶尔回一帧。原因是总线上还挂了另一台设备,它的地址恰好是 0x01,偶尔响应后我那台地址 0x0A 的设备没有机会发数据。

应对办法是给程序加上地址核对步骤:初始连接时连续发 5 次读请求,统计响应帧里的地址字段。如果多次响应中出现了不同地址,基本可以断定程序地址和设备地址不一致。另外,设备地址比较特别时(比如 0xFF),要小心是否和广播地址 0x00 的判断逻辑冲突。

5.3 现象:串口被系统占用或读到上一帧残留数据

Windows 下串口设备号被占用是最常见的。在程序启动时加了reset_input_buffer(),但在发送第二个请求时,读取到的内容里包含了上一个设备的迟到响应帧,这会造成解析错乱。原因是没有养成发送前清空缓冲区的习惯。

解决方法是每次发送前做两件事:调用ser.reset_input_buffer()清空读缓冲,之后等待一个和帧间隔相当的时间(比如 20ms)再发请求。这是久调串口的老工程师总结出的血泪经验:串口收发的间隙处理比协议本身更影响稳定性。另外,如果程序异常退出导致串口未释放,Windows 下会提示“端口被占用”,重启程序前先确认没有残留的 Python 进程持有该串口。

5.4 现象:读出的浮点数是天文数字

读取某变频器的运行频率时,寄存器值显示成0x42F00000,按十六进制换算就是 120.0 的 IEEE754 单精度浮点数二进制表示,但我按 uint16 解析出来是两段整数,打印出来完全对不上。变频器手册只说“频率值按 IEEE754 格式存储”,却没写两个字在寄存器里的排列顺序。

Modbus 寄存器是 16 位宽,32 位浮点数要占两个寄存器。常见的排列有两种:AABB(高字在前,低字在后)和 BBAA(低字在前,高字在后),两种写法在市场上同时存在。解决办法是先把两个寄存器拼成 32 位,再按两种顺序分别转换,看哪个结果落在合理物理范围内。下面这段转换函数是我的常用工具:

import struct def parse_float32(reg1: int, reg2: int, swapped: bool = False) -> float: if swapped: # BBAA 顺序:低字节在前 packed = struct.pack('>HH', reg2, reg1) else: # AABB 顺序:高字节在前 packed = struct.pack('>HH', reg1, reg2) return struct.unpack('>f', packed)[0] # 示例:读取两个相连寄存器拼出浮点 val = parse_float32(0x42F0, 0x0000, swapped=False) print(val) # 期望接近 120.0

struct.pack('>HH', reg1, reg2)是把两个 16 位整数按大端序打包成 4 字节,再按'>f'解包成浮点数。如果解析出的数值异常,比如 1e-38 这种极小值或 3.4e38 这种极大值,就试一下把swapped参数置为 True。遇到 32 位整数也有同样问题,处理思路完全相同,只是解包用'>i'或'>I'。

6. 进阶:用从站模拟器反向验证整个链路,再上真机

没有真机在手时,可以用 Modbus 从站模拟器在电脑上虚拟一台设备,先把主站代码调试通。常见做法是装一个支持 TCP 转串口的模拟软件,把虚拟从站映射到一个虚拟串口上,主站程序通过另一个虚拟串口对连,就能完成全链路验证。配置时只需设置好波特率、校验位、从站地址,再预设几个寄存器区域的初始值。

真机调试前,建议先做两件事:第一,用模拟器跑一遍你的读写程序,验证 CRC 算法、超时处理、异常码逻辑是否完备;第二,把模拟器设置成不回包或延迟回包模式,测试主站的重试机制是否可靠。这套流程能提前过滤掉大部分低级错误,避免带着满屏解析报错去现场。

批量轮询多台从站设备时,轮询周期设计是有讲究的。常规公式是:轮询周期 = 单次请求超时 × 从站数量 × 2。如果有 10 台设备、每台读一次需要 500ms,那完整轮询一遍至少 10 秒。轮询策略建议按功能分开——急停信号和启停状态用短周期(200ms~500ms)优先轮询,温度、流量这类慢变量放到 2~5 秒一个循环。某些设备在收到频繁请求时会进入保护状态,这个要靠现场实测找出安全的请求间隔,没有通用参数可抄。如果你在调试中反复遇到同一类怪问题,把现象、请求帧、响应帧三者对齐记录,通常能快速定位是协议实现还是物理链路的问题。希望这些踩坑记录能帮你省下几个晚上的排查时间,让你的 MODBUS(RTU) 通讯链路一次跑通。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询