ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

modbus-tk 实战:Python 实现 Modbus RTU/TCP 主从站通信与避坑指南

modbus-tk 实战:Python 实现 Modbus RTU/TCP 主从站通信与避坑指南

简介:modbus-tk 是一套基于 Python 的 Modbus 全栈实现库,面向需要编写 Modbus TCP 与 RTU 主站、从站的开发者,尤其适合测试验证与工业通信类应用开发。它刻意精简依赖(仅 RTU 模式需 pyserial),与 pymodbus 定位不同,可用于数据库日志、HMI、报表生成等实际场景,并自带从站模拟器与基于 Web 的 HMI 主站示例。资源包共 48 个文件,约 109KB,以 28 个 py 源码为核心,辅以 tpl 模板、txt 说明、md 文档及 css、js、cfg 等配置与前端资源,涵盖 modbus_tk 核心模块、examples 示例、tests 测试与 hmi 演示目录,结构清晰便于按模块查阅。目前已有 4427 人学习下载。读者可借此快速理解 Modbus 协议栈的分层设计,参考 TCP/RTU 主从示例搭建通信链路,并复用模拟器与测试脚本完成功能验证与性能压测,是入门与工程落地兼顾的实用工具包。

1. 从一根 RS485 线说起:modbus-tk 到底解决了谁的痛点

车间里一台老 PLC 只认 Modbus RTU,上位机却要跑在 Linux 工控机上,中间还夹着几台自研的采集板。这种场景下,很多人第一反应是找个组态软件拖控件,但一旦要批量部署、要嵌进自己的调度逻辑、要做自动化测试,组态软件就成了黑匣子。modbus-tk 就是在这个缝隙里活下来的:它是一个纯 Python 实现的 Modbus 协议栈,主站和从站都能写,RTU 和 TCP 都支持,不依赖任何商业库。你可以在树莓派上跑一个从站模拟器,也可以在服务器上跑一个主站轮询几十个设备。它不解决“设备为什么不通”这种玄学问题,但它把协议层的收发、CRC 校验、异常码解析都封装好了,让你把精力放在业务逻辑上。适合谁?适合那些需要把 Modbus 通信嵌进 Python 脚本、测试框架或数据采集服务里的工程师,尤其是手头设备杂、协议文档不全、又不想被某个平台绑死的场景。

2. 把 modbus-tk 跑起来:环境、最小主站与从站

2.1 安装与串口权限的坑

modbus-tk 本身是纯 Python 包,安装没有编译依赖,但串口通信依赖 pyserial。常见做法是直接 pip 安装,然后在 Linux 下把当前用户加入 dialout 组,否则打开串口会报权限错误。

# 安装 modbus-tk 和串口依赖 pip install modbus-tk pyserial # Linux 下查看串口设备 ls /dev/ttyUSB* /dev/ttyS* # 将当前用户加入 dialout 组,避免每次 sudo sudo usermod -aG dialout $USER # 重新登录后生效,或者临时用 newgrp dialout

逻辑说明:modbus-tk 的 RTU 主站底层调用 pyserial 打开串口,如果用户不在 dialout 组,open() 会直接抛 PermissionError。参数上,/dev/ttyUSB0是常见 USB 转 485 设备名,但不同芯片方案可能枚举成 ttyACM0,插拔后编号也可能变,生产环境建议用 udev 规则固定别名。

2.2 写一个最小 RTU 主站轮询

下面这段代码是我在采集项目里最常用的骨架:打开串口,建主站,读保持寄存器,异常时打印错误码而不是直接崩掉。

import serial import modbus_tk import modbus_tk.defines as cst from modbus_tk import modbus_rtu # 打开串口:端口、波特率、数据位、校验、停止位 ser = serial.Serial( port='/dev/ttyUSB0', baudrate=9600, bytesize=8, parity='N', stopbits=1, timeout=1.0 # 读超时,单位秒 ) # 创建 RTU 主站,slave=1 是默认从站地址,实际轮询时可动态改 master = modbus_rtu.RtuMaster(ser) master.set_timeout(1.0) master.set_verbose(True) # 打开后能看到收发原始帧,调试期很有用 try: # 读从站地址 1 的保持寄存器,起始地址 0,读 4 个 data = master.execute(1, cst.READ_HOLDING_REGISTERS, 0, 4) print("寄存器值:", data) except modbus_tk.modbus.ModbusError as e: # 异常码:2 非法地址,3 非法数据值,4 从站故障 print("Modbus 异常,错误码:", e.get_exception_code()) except Exception as e: print("通信失败:", e) finally: master.close() ser.close()

逻辑说明:execute的第一个参数是从站地址,第二个是功能码,后面是起始地址和数量。set_verbose(True)会把发送和接收的字节以十六进制打印出来,排查接线和波特率问题时比猜快得多。超时设 1 秒是折中:太短容易误判,太长会拖慢轮询周期。注意读寄存器数量不要超过从站实际范围,否则会返回异常码 2。

2.3 用从站模拟器验证主站逻辑

没有真实设备时,可以在同一台机器上开两个进程,一个跑从站模拟,一个跑主站轮询。从站模拟器把寄存器映射到内存,主站读到的值就是预设值。

import serial import modbus_tk.defines as cst from modbus_tk import modbus_rtu # 从站模拟:监听 /dev/ttyUSB1,地址 1 ser = serial.Serial(port='/dev/ttyUSB1', baudrate=9600, bytesize=8, parity='N', stopbits=1, timeout=1.0) slave = modbus_rtu.RtuSlave(ser, slave=1) # 添加一个保持寄存器块,起始地址 0,长度 16 slave.add_block('holdings', cst.HOLDING_REGISTERS, 0, 16) # 预设几个值,模拟传感器数据 slave.set_values('holdings', 0, [100, 200, 300, 400]) slave.start() # 阻塞式运行,实际项目里可放到线程中

逻辑说明:add_block定义了从站暴露的寄存器区域,set_values写入初始值。主站读到的就是[100, 200, 300, 400]。如果主站读不到,先确认两个串口设备是否真实存在,或者用虚拟串口对(如 socat)在本地模拟一对 tty。参数上,从站地址必须和主站execute里的地址一致,波特率、校验位、停止位也必须完全一致,否则收到的就是乱码。

3. 从 RTU 到 TCP:协议切换与多从站轮询

3.1 TCP 主站的写法差异

Modbus TCP 去掉了 CRC 校验,多了 MBAP 头,modbus-tk 里用TcpMaster替代RtuMaster,连接对象从串口变成 socket。下面是最小 TCP 主站示例。

import modbus_tk.defines as cst from modbus_tk import modbus_tcp # 连接 TCP 从站,host 和 port 按实际填 master = modbus_tcp.TcpMaster(host='192.168.1.10', port=502) master.set_timeout(2.0) try: # 读输入寄存器,起始 0,读 8 个 data = master.execute(1, cst.READ_INPUT_REGISTERS, 0, 8) print("输入寄存器:", data) except Exception as e: print("TCP 通信失败:", e) finally: master.close()

逻辑说明:TCP 模式下从站地址仍然要填,虽然很多网关忽略它,但协议帧里保留了这个字段。端口 502 是 Modbus TCP 默认端口,如果设备改过端口,这里要同步改。超时设 2 秒是因为 TCP 重传机制下,太短容易在丢包时误断。

3.2 多从站轮询的节奏控制

一条 485 总线上挂多个从站时,不能并发发请求,必须串行轮询,且每个请求之间留间隔。下面是一个轮询多个从站的片段,用字典维护地址和寄存器映射。

import time import serial import modbus_tk.defines as cst from modbus_tk import modbus_rtu ser = serial.Serial(port='/dev/ttyUSB0', baudrate=19200, bytesize=8, parity='E', stopbits=1, timeout=0.5) master = modbus_rtu.RtuMaster(ser) master.set_timeout(0.5) # 从站地址 -> 起始地址, 数量 slaves = { 1: (0, 4), 2: (0, 4), 3: (10, 2), } while True: for addr, (start, count) in slaves.items(): try: data = master.execute(addr, cst.READ_HOLDING_REGISTERS, start, count) print(f"从站 {addr}: {data}") except Exception as e: print(f"从站 {addr} 异常: {e}") time.sleep(0.05) # 帧间间隔,给从站切换时间 time.sleep(1.0) # 一轮结束后等待

逻辑说明:time.sleep(0.05)是帧间最小间隔,485 收发切换需要时间,太快会导致从站还没准备好就收到下一帧。波特率 19200 且校验位为偶校验(E)时,一帧 8 字节大约 5 毫秒,50 毫秒间隔足够。如果从站响应慢,可以适当加大。异常时不要直接退出循环,记录后继续轮询其他从站,否则一个设备掉线会拖垮整个采集。

3.3 寄存器地址映射的常见误区

Modbus 文档里的地址有 0 基和 1 基两种写法,PLC 手册常写 40001 表示保持寄存器第一个,而 modbus-tk 的execute里起始地址填 0。这个偏移差一,是新手最容易翻车的地方。

文档写法功能码modbus-tk 起始地址说明
40001030保持寄存器,1 基文档减 1
30001040输入寄存器,1 基文档减 1
10001020离散输入,1 基文档减 1
00001010线圈,1 基文档减 1

注意:如果读到的值明显不对但通信正常,先检查地址偏移,再检查数据类型(16 位有符号/无符号、32 位高低字顺序)。

4. 避坑与排查:那些让通信时通时断的细节

4.1 现象:偶尔超时,重试又正常

原因:485 总线终端电阻缺失或线缆过长,信号反射导致部分帧校验失败。解决:在总线两端各加 120 欧姆终端电阻,缩短线缆或降低波特率。软件层面可以加重试机制,但重试次数不要超过 3 次,否则会掩盖硬件问题。

4.2 现象:读到的寄存器值跳变或全为 0

原因:从站地址冲突,或者主站轮询间隔太短,从站还没处理完上一帧。解决:确认总线上没有两个相同地址的从站;加大帧间间隔;用set_verbose(True)看发出的帧和收到的帧是否对应。

4.3 现象:TCP 连接正常但读不到数据

原因:单元标识符(从站地址)填错,或者网关做了地址映射。解决:很多 Modbus TCP 网关把单元标识符当作路由地址,填 0 或 255 可能被忽略。先试 1,再试 0,同时确认网关的映射表。

4.4 现象:程序跑一段时间后串口报错

原因:串口被其他进程占用,或者 USB 转 485 设备热插拔后设备名变了。解决:用lsof /dev/ttyUSB0检查占用;生产环境用 udev 规则固定设备名;捕获异常后重新打开串口,而不是让进程退出。

4.5 现象:浮点数读出来是乱码

原因:32 位浮点数在 Modbus 里占两个寄存器,高低字顺序因设备而异。解决:先读两个寄存器,按设备手册确定字序,再用 struct 拼接。

import struct # 假设读到两个寄存器 regs = [0x4248, 0x0000] regs = [0x4248, 0x0000] # 高字在前 raw = struct.pack('>HH', regs[0], regs[1]) value = struct.unpack('>f', raw)[0] print(value) # 50.0 # 如果设备是低字在前,交换 regs[0] 和 regs[1] 再解

逻辑说明:>HH表示大端两个无符号短整型,>f表示大端浮点。字序不对时结果会完全离谱,先用已知值(如 50.0)验证字序,再批量解析。

5. 进阶:把 modbus-tk 嵌进异步采集与自动化测试

5.1 用线程池隔离慢设备

轮询几十个从站时,某个设备响应慢会拖累整轮。常见做法是把每个从站放到独立线程,但 485 总线本身是串行的,多线程反而增加冲突。更稳的方式是单线程轮询,但给每个从站单独设超时,超时后跳过,下一轮再试。下面是一个带超时隔离的轮询骨架。

import time import serial import modbus_tk.defines as cst from modbus_tk import modbus_rtu ser = serial.Serial(port='/dev/ttyUSB0', baudrate=9600, bytesize=8, parity='N', stopbits=1, timeout=0.3) master = modbus_rtu.RtuMaster(ser) master.set_timeout(0.3) slaves = [1, 2, 3, 4, 5] fail_count = {addr: 0 for addr in slaves} while True: for addr in slaves: try: data = master.execute(addr, cst.READ_HOLDING_REGISTERS, 0, 2) fail_count[addr] = 0 print(f"从站 {addr}: {data}") except Exception: fail_count[addr] += 1 if fail_count[addr] >= 3: print(f"从站 {addr} 连续失败,标记离线") time.sleep(0.03) time.sleep(0.5)

逻辑说明:fail_count记录连续失败次数,达到阈值后标记离线,避免每轮都等超时。超时设 0.3 秒是因为 9600 波特率下一帧很短,正常从站应在几十毫秒内响应。如果某个从站经常超时,先查接线和地址,再考虑降低波特率。

5.2 用从站模拟器做自动化回归

在 CI 里跑 Modbus 相关代码时,不可能接真实设备。用 modbus-tk 的从站模拟器加虚拟串口,可以在容器里跑完整的读写回归。下面是一个用 pytest 验证读写一致性的例子。

import threading import time import serial import modbus_tk.defines as cst from modbus_tk import modbus_rtu def start_slave(port): ser = serial.Serial(port=port, baudrate=9600, bytesize=8, parity='N', stopbits=1, timeout=1.0) slave = modbus_rtu.RtuSlave(ser, slave=1) slave.add_block('holdings', cst.HOLDING_REGISTERS, 0, 16) slave.set_values('holdings', 0, [11, 22, 33, 44]) slave.start() # 在测试夹具里启动从站线程,主站连接另一个虚拟串口 # 然后断言 master.execute(1, cst.READ_HOLDING_REGISTERS, 0, 4) == (11, 22, 33, 44)

逻辑说明:虚拟串口对可以用 socat 创建,例如socat -d -d pty,raw,echo=0 pty,raw,echo=0,得到两个设备路径,一个给从站,一个给主站。这样测试不依赖硬件,跑在 CI 里也稳定。注意从站线程要设成守护线程,测试结束能自动退出。

5.3 一个容易被忽略的参数:帧间延迟

modbus-tk 的 RTU 主站没有内置帧间延迟,连续调用execute时,帧与帧之间只隔了 Python 解释器执行时间,可能只有几毫秒。对于老式从站,这个间隔不够。我一般会在每次execute后手动time.sleep(0.02)到0.05,具体值用示波器看总线空闲时间来确定。TCP 模式下不需要这个延迟,因为网络栈本身有间隔。

提示:如果从站手册里写了“帧间最小间隔 3.5 字符时间”,按波特率算一下。9600 波特率下 3.5 字符约 4 毫秒,但实际留 20 毫秒更保险。

5.4 验证方法:用已知值反推字序和地址

调试新设备时,我习惯先写一个已知值到从站(如果支持写),再读回来,确认地址和字序。对于只读设备,用两个相邻寄存器拼一个已知浮点数,看解析结果是否合理。如果读到的值在合理范围内但小数点不对,多半是字序问题;如果值完全离谱,先查地址偏移。这套方法比对着手册猜快得多,也是我这些年少有的后悔药——早些年因为没做这一步,在客户现场耗了一整天。

希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进