☰
Linux串口通信实战:Modbus RTU协议解析与Python轮询实现
2026/10/16 3:07:11 网站建设 项目流程

1. 从一个真实场景说起:为什么要在嵌入式设备上跑 Modbus RTU

如果你手头有一块树莓派或者类似的 Linux 开发板,又恰好需要和工业现场的 PLC、电表、温控器、变频器打交道,那你大概率绕不开Modbus RTU这个协议。它诞生于上世纪七十年代末,至今仍然是工业自动化领域最通用的串行通信协议之一。原因很简单:协议本身足够轻量,帧结构清晰,对硬件资源要求极低,而且几乎所有主流工控设备都支持它。

我最初接触这个组合,是因为一个模拟项目:需要让一块运行 Linux 的开发板作为主站,轮询三台从站设备——一台温湿度变送器、一台单相电表、一台简单的继电器模块。需求听起来不复杂,但真正动手之后才发现,从串口参数配置、帧间隔控制到 CRC 校验、异常码处理,每一个环节都有坑。市面上的教程要么只讲协议理论,要么只给一段跑不通的代码,很少有人把"在 Linux 开发板上用 Python 跑 Modbus RTU 主站"这件事从头到尾讲透。

这篇文章就是把我自己踩过的坑、验证过的方案、以及实际跑通之后的经验完整地梳理出来。内容会覆盖:Modbus RTU 的帧结构到底长什么样、Linux 下串口怎么配置才稳、Python 用哪个库最省心、轮询逻辑怎么写才不会丢包、遇到超时和异常码怎么排查。无论你是刚接触工业通信的新手,还是已经用过 Modbus 但想换到 Linux 平台的老手,应该都能从里面找到有用的东西。

提示:本文所有代码和配置均基于通用 Linux 环境,不涉及任何特定厂商的私有协议或工具。示例中的设备地址、寄存器地址均为虚构,实际使用时请查阅你手头设备的通信手册。

2. Modbus RTU 帧结构拆解:别急着写代码,先把这 256 字节搞清楚

2.1 一帧数据里到底装了什么

Modbus RTU 的一帧数据,本质上就是一串字节。它的结构非常紧凑,没有多余的握手和包头,具体格式如下:

字段长度说明
从站地址1 字节范围 1~247,0 为广播地址
功能码1 字节决定操作类型,如 0x03 读保持寄存器
数据域N 字节具体参数,长度由功能码决定
CRC 校验2 字节低字节在前,高字节在后

以最常用的"读保持寄存器"(功能码 0x03)为例,主站发送的请求帧格式是:从站地址 + 0x03 + 起始寄存器地址高字节 + 起始寄存器地址低字节 + 寄存器数量高字节 + 寄存器数量低字节 + CRC 低字节 + CRC 高字节。总共 8 个字节。从站正常响应时,返回:从站地址 + 0x03 + 字节数 + 数据高字节 + 数据低字节 + ... + CRC 低字节 + CRC 高字节。

这里有一个非常容易搞错的地方:Modbus 协议中所有多字节数值都是大端序(高字节在前),但 CRC 校验值却是小端序(低字节在前)。我见过不少人在拼接请求帧时把寄存器地址写成了小端,结果从站要么不响应,要么返回异常码 0x02(非法数据地址)。这个细节在协议文档里往往一笔带过,但实际调试时能卡你半天。

2.2 帧间隔:RTU 模式的隐形时钟

Modbus RTU 和 Modbus ASCII 最大的区别在于,RTU 模式没有明确的起始和结束字符,它依靠时间间隔来划分帧边界。协议规定:帧内字符之间的间隔不能超过 1.5 个字符时间,帧与帧之间的间隔至少为 3.5 个字符时间。所谓"一个字符时间",取决于波特率。以 9600 bps、8 数据位、无校验、1 停止位为例,一个字符共 10 位(1 起始位 + 8 数据位 + 1 停止位),所以一个字符时间 = 10 / 9600 ≈ 1.04 毫秒。3.5 个字符时间就是大约 3.65 毫秒。

在裸机单片机时代,这个间隔通常由定时器硬件来保证。但在 Linux 这种非实时操作系统上,串口读写是通过内核驱动和用户态程序协作完成的,调度延迟可能达到几毫秒甚至几十毫秒。如果你在用户态手动控制帧间隔,很容易因为系统调度抖动导致间隔不稳定,进而出现从站不响应或响应被截断的问题。

我的做法是:尽量依赖串口驱动本身的帧间隔处理,而不是在应用层用 sleep 去凑时间。Linux 的串口子系统(特别是termios框架)在设置好波特率后,驱动会按照波特率自动处理字符间的时序。对于帧间间隔,可以在发送完一帧后,等待从站响应之前,留出足够的超时时间(通常 100~500 毫秒),而不是精确地去卡 3.5 个字符时间。因为从站设备本身的响应时间往往远大于 3.5 个字符时间,精确控制帧间隔在 Linux 上既不现实,也没必要。

2.3 CRC 校验:手算一遍就再也不会忘

CRC-16/Modbus 的算法并不复杂,但第一次接触的人容易被各种参数搞晕。它的关键参数是:多项式 0xA001(这是 0x8005 的反转表示),初始值 0xFFFF,输入和输出都不反转,结果异或 0x0000。下面是一段可以直接用的 Python 实现:

def crc16_modbus(data: bytes) -> bytes: crc = 0xFFFF for byte in data: crc ^= byte for _ in range(8): if crc & 0x0001: crc = (crc >> 1) ^ 0xA001 else: crc >>= 1 # 返回低字节在前 return bytes([crc & 0xFF, (crc >> 8) & 0xFF])

这段代码我用了很多年,在各种设备上验证过,没有出过错。需要注意的是,计算 CRC 时只对"从站地址 + 功能码 + 数据域"进行计算,不包含 CRC 本身。收到响应后,也要先校验 CRC,再解析数据。如果 CRC 不匹配,直接丢弃这一帧,不要尝试去"修复"它。

注意:有些设备手册会写"CRC 高字节在前",这通常是指计算结果的数值表示,而不是传输顺序。实际传输时,Modbus RTU 规定 CRC 低字节先发。如果你发现从站不响应,可以试着把 CRC 两个字节对调一下再发,但更可能的问题是寄存器地址或功能码不对。

3. Linux 串口配置:为什么你的 /dev/ttyUSB0 总是读不到数据

3.1 串口设备节点的识别与权限

在 Linux 上,USB 转串口设备通常会被识别为/dev/ttyUSB0、/dev/ttyUSB1这样的节点,而板载串口可能是/dev/ttyS0、/dev/ttyAMA0等。插上设备后,先用dmesg | grep tty看一下内核有没有正确识别。如果看到类似ch341-uart converter now attached to ttyUSB0的输出,说明驱动加载正常。

接下来是权限问题。默认情况下,/dev/ttyUSB0的属主是root,普通用户没有读写权限。你可以用sudo运行程序,但更好的做法是把当前用户加入dialout组:

sudo usermod -aG dialout $USER

然后重新登录使组权限生效。这样以后就不需要每次都sudo了。我见过不少人因为权限问题折腾半天,以为是代码写错了,其实只是串口打不开。

3.2 termios 参数设置:八个参数一个都不能少

Linux 下配置串口,核心就是填充termios结构体。虽然 Python 的pyserial库已经帮你封装好了,但理解底层参数仍然很重要,因为一旦通信异常,你需要知道去检查什么。关键参数包括:

  • 波特率:必须和从站设备完全一致。常见的 9600、19200、38400、115200 都要支持。
  • 数据位:Modbus RTU 通常用 8 位。
  • 校验位:可以是无校验(N)、偶校验(E)、奇校验(O)。很多设备默认无校验,但工业现场为了抗干扰,有时会用偶校验。
  • 停止位:通常 1 位,少数设备用 2 位。
  • 流控:Modbus RTU 一般不用硬件流控,设为无流控。

用pyserial打开串口的典型代码如下:

import serial ser = serial.Serial( port='/dev/ttyUSB0', baudrate=9600, bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, timeout=0.5, # 读超时 0.5 秒 write_timeout=0.5 # 写超时 0.5 秒 )

这里timeout参数非常关键。它决定了read()调用在没收到数据时最多阻塞多久。如果设得太短,可能从站还没响应就超时了;设得太长,轮询周期会被拖慢。我的经验是:对于 9600 波特率、响应较慢的设备,设 0.5 秒比较稳妥;对于 115200 波特率、响应快的设备,0.1 秒就够了。

3.3 一个容易被忽略的坑:串口被占用

有时候你明明代码没问题,但就是打不开串口,报Permission denied或Device or resource busy。这通常是因为有另一个进程占用了串口。可能是之前调试时开的minicom没关,也可能是某个后台服务在轮询。用lsof /dev/ttyUSB0可以查看哪个进程占用了它。另外,某些开发板上的蓝牙服务会占用板载串口,如果你用的是/dev/ttyAMA0,可能需要先禁用蓝牙对它的占用。

还有一个隐蔽的问题:USB 转串口线拔插后,设备节点号可能会变。比如原来是ttyUSB0,重新插拔后变成了ttyUSB1。如果你的程序硬编码了ttyUSB0,就会找不到设备。解决办法是使用 udev 规则创建固定名称的符号链接,或者通过设备的序列号来识别。对于快速验证,可以先ls /dev/ttyUSB*确认当前节点。

4. Python 实现 Modbus RTU 主站:从零封装一个够用的轮询器

4.1 库的选择:pymodbus 还是自己撸

Python 生态里做 Modbus 通信,最常用的是pymodbus库。它支持 RTU 和 TCP,功能码覆盖全面,文档也算清楚。但我在实际项目中发现,pymodbus的版本迭代比较快,不同版本之间的 API 有差异,有时候升级后老代码就跑不通了。而且对于简单的轮询场景,它的抽象层反而增加了调试难度。

所以我的选择是:对于功能简单的项目,直接用pyserial自己封装请求和响应解析。这样每一行代码都在自己掌控之中,出了问题也容易定位。下面我就以自己封装的ModbusRTUClient类为例,讲一下核心逻辑。

4.2 请求帧的构造与发送

构造请求帧的核心就是按照前面讲的帧结构拼接字节,然后计算 CRC。以读保持寄存器为例:

def build_read_holding_registers(self, slave_addr, start_addr, count): frame = bytearray() frame.append(slave_addr) frame.append(0x03) frame.append((start_addr >> 8) & 0xFF) frame.append(start_addr & 0xFF) frame.append((count >> 8) & 0xFF) frame.append(count & 0xFF) crc = crc16_modbus(frame) frame.extend(crc) return bytes(frame)

发送之前,最好先清空接收缓冲区,避免上一轮的残留数据干扰。pyserial提供了reset_input_buffer()和reset_output_buffer()方法。发送时用ser.write(frame),然后等待响应。

4.3 响应解析与异常码处理

从站的响应有两种:正常响应和异常响应。正常响应的功能码与请求相同,异常响应的功能码是请求功能码加上 0x80。比如请求 0x03,异常响应就是 0x83,后面跟一个字节的异常码。常见的异常码有:

异常码含义可能原因
0x01非法功能码从站不支持该功能
0x02非法数据地址寄存器地址不存在
0x03非法数据值数据域中的值超出范围
0x04从站设备故障设备内部错误
0x05确认从站已接受请求,正在处理
0x06从站设备忙设备暂时无法处理

解析响应时,先检查长度是否足够,再校验 CRC,然后判断功能码。如果功能码的最高位是 1,说明是异常响应,读取异常码并抛出对应的异常信息。我在代码里会把这些异常码映射成可读的文字,方便日志排查。

4.4 轮询逻辑:顺序、间隔与重试

轮询多个从站时,最忌讳的是"发完一个请求立刻发下一个"。因为从站处理请求需要时间,而且总线上只能有一个主站发送数据。正确的做法是:发送请求后等待响应,收到响应或超时后,再等待一小段时间(比如 50 毫秒),然后发送下一个请求。

对于超时的请求,可以重试 1~2 次。如果连续重试都失败,记录错误并跳过该从站,继续轮询下一个。不要让一个故障从站阻塞整个轮询循环。下面是一个简化的轮询框架:

import time def poll_devices(client, devices): results = {} for dev in devices: for attempt in range(3): try: data = client.read_holding_registers( dev['addr'], dev['start'], dev['count'] ) results[dev['name']] = data break except ModbusTimeoutError: if attempt == 2: results[dev['name']] = None print(f"设备 {dev['name']} 超时,跳过") time.sleep(0.05) except ModbusException as e: results[dev['name']] = None print(f"设备 {dev['name']} 异常: {e}") break time.sleep(0.05) # 帧间间隔 return results

这个框架虽然简单,但已经能覆盖大部分中小型项目的需求。关键点在于:每个设备独立处理异常,重试次数有限,帧间有固定间隔。

5. 调试实战:从"完全没反应"到"稳定轮询"的排查链路

5.1 第一步:确认物理层和串口参数

当通信完全没反应时,不要急着改代码。先确认三件事:接线对不对、串口参数对不对、设备地址对不对。Modbus RTU 通常用两线制(A 接 A,B 接 B),有些设备标注为 D+ 和 D-,对应关系要查手册。如果接反了,数据收发会完全乱掉。串口参数方面,波特率、数据位、校验位、停止位必须和从站完全一致,差一个都不行。

我习惯用minicom或picocom先手动发一帧数据看看。比如用minicom打开串口,按Ctrl+A然后O进入配置,设置好波特率等参数,然后直接输入十六进制字节。如果从站有响应,说明物理层和参数没问题,问题在代码;如果没响应,就要继续查硬件和参数。

5.2 第二步:用示波器或逻辑分析仪看波形

如果手头有逻辑分析仪,抓一下 A/B 线上的差分信号,能非常直观地看到主站有没有发出数据、从站有没有响应。我遇到过一种情况:主站发送的数据完全正确,但从站就是不响应,后来用逻辑分析仪一看,发现从站的响应信号幅度非常弱,几乎被噪声淹没。原因是总线末端没有加终端电阻,信号反射导致通信失败。加上 120 欧姆终端电阻后,通信立刻稳定了。

5.3 第三步:检查代码中的字节序和寄存器地址

如果物理层没问题,但代码总是收到异常码 0x02,那多半是寄存器地址不对。Modbus 协议中的寄存器地址有两种表示方式:协议地址和设备地址。协议地址从 0 开始,设备地址通常从 1 开始,而且不同设备手册的写法不一样。有的手册写"保持寄存器 40001",这里的 40001 是设备地址,对应的协议地址是 0。有的手册直接写"寄存器地址 0x0000",那就是协议地址。搞混了就会访问到错误的寄存器。

另外,有些设备的数据是 32 位浮点数,占用两个连续的保持寄存器。读取时数量要设为 2,解析时要把两个 16 位寄存器拼成一个 32 位值,再按 IEEE 754 标准转成浮点数。字节序可能是高字在前,也可能是低字在前,需要根据设备手册确认。

5.4 第四步:处理电磁干扰和接地问题

工业现场的电磁环境往往比较恶劣,变频器、接触器、电机启停都会产生干扰。如果通信时好时坏,偶尔出现 CRC 错误或超时,可以考虑以下措施:使用屏蔽双绞线,屏蔽层单端接地;缩短通信线长度,降低波特率;在总线两端加终端电阻;把通信线和动力线分开走线,避免平行敷设。这些措施看起来是"硬件问题",但实际项目中,通信不稳定的根源有一大半都在这里。

6. 性能优化与长期运行稳定性:让轮询器连续跑几个月不出问题

6.1 轮询周期的合理设定

轮询周期不是越短越好。周期太短,从站可能来不及响应,导致超时和重试,反而降低效率。周期太长,数据刷新不及时,影响监控效果。我的经验是:先测量单个设备的平均响应时间,然后乘以设备数量,再加上足够的余量。比如三个设备,每个响应时间约 50 毫秒,那么轮询周期至少设为 300 毫秒比较稳妥。如果对实时性要求高,可以提高波特率到 115200,这样单帧传输时间会大幅缩短。

6.2 日志与异常记录

长期运行的程序,必须有日志。但日志不能太多,否则磁盘很快被写满;也不能太少,否则出了问题无从查起。我的做法是:正常轮询不记录,只在超时、CRC 错误、异常码出现时记录一条日志,包含时间戳、设备地址、错误类型。另外,每隔一段时间(比如一小时)记录一次统计信息,包括总请求数、成功数、失败数、平均响应时间。这样既能掌握运行状况,又不会产生大量日志。

6.3 串口异常恢复

有时候串口会因为硬件干扰或驱动问题进入异常状态,表现为read()一直返回空或抛出SerialException。这时候需要关闭串口再重新打开。我通常会在捕获到SerialException后,先ser.close(),等待 1 秒,再重新serial.Serial(...)打开。如果连续多次打开失败,就记录错误并等待更长时间再试。这个恢复机制能让程序在无人值守的情况下自动从偶发故障中恢复。

6.4 资源占用与看门狗

在资源有限的开发板上,Python 程序的内存占用和 CPU 占用也需要关注。轮询间隔中适当sleep,避免忙等待。如果开发板支持硬件看门狗,可以启用它,防止程序卡死。另外,定期检查串口文件描述符是否泄漏,确保每次异常后都正确关闭了串口。

提示:如果你的项目需要 7x24 小时运行,建议用systemd把程序注册成服务,设置自动重启。这样即使程序崩溃,也能在几秒内恢复。

7. 几个实际项目中总结出来的小技巧

第一个技巧是关于超时时间的动态调整。不同从站的响应速度差异很大,统一设一个超时时间往往不合适。我的做法是:第一次轮询时给每个设备设一个较长的超时(比如 1 秒),记录下实际响应时间,然后后续轮询把超时设为实际响应时间的 2~3 倍。这样既能保证不误判超时,又能让轮询更紧凑。

第二个技巧是关于批量读取。如果多个连续的寄存器都需要读取,尽量用一次请求读完,而不是分多次读。比如要读地址 0 到地址 9 的 10 个寄存器,一次请求读 10 个,比读 10 次每次读 1 个效率高得多。但要注意,有些设备对单次读取的寄存器数量有限制,比如最多 125 个,超过会返回异常。

第三个技巧是关于数据缓存。如果某个设备偶尔超时,不要立刻把它的数据标记为无效。可以保留上一次的有效数据,并标注"数据可能过期"。这样上层监控界面不会因为一次偶发超时就出现数据跳变。

第四个技巧是关于串口参数的运行时修改。有些项目需要在运行过程中切换波特率,比如先以 9600 连接,再切换到 115200。这时候不能直接改ser.baudrate,正确做法是关闭串口,重新以新参数打开。因为termios的某些参数在串口打开后修改可能不生效。

这些技巧看起来都是小细节,但在实际项目中,正是这些细节决定了程序是"能跑"还是"跑得稳"。我在多个模拟项目中反复验证过这些做法,最长的一个连续运行了三个多月,没有出现需要人工干预的故障。希望这些经验能帮你少走一些弯路。

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

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

立即咨询