Python Modbus 主站从站实战:modbus-tk 与 pymodbus 选型及避坑指南
简介modbus-tk 是一套面向 Python 开发者的 Modbus 协议全栈实现库适合需要编写 Modbus TCP 与 RTU 主站、从站的工程师也可用于测试与教学场景。它刻意限制外部依赖除 RTU 模式需 pyserial 外几乎零依赖与 pymodbus 定位不同更强调贴近实际应用。资源包共 48 个文件约 109KB以 28 个 py 源码为核心辅以 tpl 模板、txt 说明、md 文档及 css、js、cfg 等配置与前端资源涵盖 modbus_tk 核心模块、examples 示例、tests 测试用例以及带 Web HMI 的从站模拟器目录结构清晰便于按模块查阅。目前已有 4427 人学习下载。借助其中的 TCP/RTU 主从示例、模拟器与单元测试读者可快速理解协议交互流程并将其扩展至数据库日志、HMI、报表生成等实际需求是入门与二次开发 Modbus 应用的实用参考。1. 用 Python 写 Modbus 主站从站为什么我最后选了 modbus-tk如果你手头有 PLC、电表、温控器、变频器这类设备想用 Python 快速搭一个 Modbus 主站去轮询数据或者写一个从站来模拟设备做联调大概率绕不开几个库pymodbus、minimalmodbus还有今天要拆的 modbus-tk。前两个用的人多但真到要自己拼帧、控超时、做多从站并发轮询的时候很多人会翻车在「库把细节藏太深」这件事上。modbus-tk 的定位不太一样它把 RTU 和 TCP 的帧结构、CRC 校验、功能码都摊开给你主站从站都能自己搭适合那种「我要清楚每一帧长什么样」的场景。这篇不是科普是我把这份资源拆开跑通之后把能抄的代码、参数怎么设、哪里容易踩坑一条条写给你。适合已经会点 Python、要跟真实设备或仿真环境打交道的人新手照着步骤也能跑起来。2. modbus-tk 的定位它和 pymodbus 到底差在哪2.1 先搞清楚这个库解决的是什么问题Modbus 本身是个很朴素的协议主站发一帧请求从站回一帧响应帧里就是从站地址、功能码、寄存器地址、数据、校验。问题在于不同库对这层的封装程度差别很大。pymodbus 走的是「高层客户端」路线你调read_holding_registers就行底层帧它帮你拼minimalmodbus 更偏串口场景API 简单但扩展性一般。modbus-tk 的路线是「给你积木」它提供modbus_tk.modbus_rtu、modbus_tk.modbus_tcp两个传输层加上modbus_tk.modbus里的Master、Slave抽象功能码对应的请求构造也暴露出来。这意味着两件事。第一你能精确控制超时、重试、帧间隔这对串口 RTU 特别重要因为很多现场设备对帧间静默时间敏感封装太深的库你根本插不进手。第二你能自己写从站逻辑比如模拟一个寄存器地址映射用来在没有真实设备时先把主站程序调通。常见做法是主站用 modbus-tk 写轮询从站也用 modbus-tk 起一个本地仿真两边对着调等逻辑稳了再换真设备。选型上我的判断是如果你只是偶尔读几个寄存器pymodbus 够用如果你要做多从站轮询、要控时序、要自己搭从站仿真modbus-tk 更顺手。它不是最省事的但它是最不藏事的。2.2 安装和最小可运行环境安装没什么玄学pip 直接装。但要注意它依赖 pyserial 做串口如果你只跑 TCP串口依赖也在不影响。# 安装 modbus-tk会自动带上 pyserial pip install modbus-tk # 确认版本不同版本 API 略有差异 pip show modbus-tk装完之后先别急着连设备用 TCP 起一个本地从站再用主站读一次这是验证环境最快的方式。下面这段是从站的最小骨架跑起来之后它会监听 5020 端口暴露 100 个保持寄存器。import modbus_tk.modbus_tcp as modbus_tcp import modbus_tk.defines as cst # 创建 TCP 从站监听本机 5020 端口 slave modbus_tcp.TcpServer(port5020) slave.start() # 添加一个从站单元unit_id1 slave_1 slave.add_slave(1) # 添加一个保持寄存器块起始地址 0长度 100 slave_1.add_block(hold, cst.HOLDING_REGISTERS, 0, 100) # 给前几个寄存器写点初始值方便主站读出来验证 slave_1.set_values(hold, 0, [11, 22, 33, 44]) print(从站已启动监听 5020)逻辑说明TcpServer是传输层add_slave(1)里的 1 是 unit_id也就是从站地址主站请求里必须对得上。add_block定义了一块寄存器区域第一个参数是块名后面主站读写都要用这个名字或对应地址。set_values用来预置数据方便你确认读到的不是空值。参数上port按你环境改unit_id和真实设备保持一致寄存器长度按实际点表来别一上来就开几千个没必要。主站这边读一次import modbus_tk.modbus_tcp as modbus_tcp import modbus_tk.defines as cst # 连接从站 master modbus_tcp.TcpMaster(host127.0.0.1, port5020) master.set_timeout(2.0) # 超时 2 秒 # 读保持寄存器从地址 0 开始读 4 个 values master.execute(1, cst.READ_HOLDING_REGISTERS, 0, 4) print(读到:, values)execute是核心方法第一个参数是从站地址第二个是功能码后面跟起始地址和数量。set_timeout一定要设默认超时在某些环境下偏长轮询时会拖慢整体节奏。读到(11, 22, 33, 44)就说明链路通了。3. 主站轮询实战多从站、超时和异常处理怎么写3.1 多从站轮询的循环结构真实场景很少只连一个从站通常是一条 485 总线上挂好几个设备或者 TCP 网关后面接多个 unit_id。modbus-tk 的execute每次都要传从站地址所以多从站就是循环里换地址。但这里有个坑串口 RTU 下不同从站的响应时间不一样如果你用一个固定超时慢的设备会把整个循环拖垮。我一般会这样组织把从站地址和要读的寄存器配置写成列表循环里逐个读每个从站单独 try 包起来一个失败不影响后面的。import modbus_tk.modbus_tcp as modbus_tcp import modbus_tk.defines as cst import time # 从站配置地址、起始寄存器、数量 slaves [ {unit: 1, start: 0, count: 4}, {unit: 2, start: 0, count: 6}, {unit: 3, start: 10, count: 2}, ] master modbus_tcp.TcpMaster(host127.0.0.1, port5020) master.set_timeout(1.5) while True: for cfg in slaves: try: values master.execute( cfg[unit], cst.READ_HOLDING_REGISTERS, cfg[start], cfg[count] ) print(f从站{cfg[unit]} 读到: {values}) except Exception as e: # 单个从站失败只记录不中断整个轮询 print(f从站{cfg[unit]} 读取失败: {e}) time.sleep(1) # 轮询间隔逻辑说明try/except放在循环内部而不是外部这是关键。如果放外面一个从站超时整个程序就退出了。set_timeout(1.5)是折中值串口场景可以按波特率算TCP 场景 1 到 2 秒通常够。time.sleep(1)是轮询间隔别设太小否则会把总线或网关打满反而丢包。参数上要提醒的是count读寄存器数量别超过设备允许的单次上限很多设备一次最多读 125 个超了会直接返回异常码。起始地址也要注意Modbus 有「协议地址」和「PLC 地址」的偏移问题有的设备文档写 40001实际协议地址是 0这个后面避坑章节细说。3.2 异常码和重试策略modbus-tk 在从站返回异常帧时会抛异常异常信息里带功能码和异常码。常见的异常码有01 非法功能、02 非法数据地址、03 非法数据值、04 从站故障。你要做的是区分「这个从站暂时不通」和「这个请求本身有问题」。前者可以重试后者重试一百次也没用。import modbus_tk.modbus_tcp as modbus_tcp import modbus_tk.defines as cst master modbus_tcp.TcpMaster(host127.0.0.1, port5020) master.set_timeout(1.5) def read_with_retry(unit, start, count, retries3): for i in range(retries): try: return master.execute(unit, cst.READ_HOLDING_REGISTERS, start, count) except Exception as e: # 打印异常内容判断是超时还是异常码 print(f第{i1}次失败: {e}) return None result read_with_retry(1, 0, 4) print(最终结果:, result)逻辑说明重试次数别设太多3 次足够再多会拖慢轮询周期。真正要做的其实是把异常内容打出来看modbus-tk 抛出的异常里通常有Exception code字样看到 02 就去查地址看到 04 就去查设备状态。我见过有人无脑重试结果地址填错了日志刷了几百行还在重试纯属浪费时间。4. 从站仿真没有真设备时怎么把主站调通4.1 用 modbus-tk 搭一个可写的从站从站不只是用来读的你还可以让主站写从站这边接收并更新寄存器值这样就能模拟一个完整的读写闭环。下面这个从站加了写单个寄存器和写多个寄存器的处理modbus-tk 的add_block之后写操作会自动落到块里你只要在循环里读出来看就行。import modbus_tk.modbus_tcp as modbus_tcp import modbus_tk.defines as cst import time slave modbus_tcp.TcpServer(port5020) slave.start() slave_1 slave.add_slave(1) slave_1.add_block(hold, cst.HOLDING_REGISTERS, 0, 100) # 模拟设备每秒把寄存器 0 的值加 1 counter 0 while True: counter (counter 1) % 1000 slave_1.set_values(hold, 0, [counter]) time.sleep(1)逻辑说明set_values在从站侧可以直接改寄存器值主站读到的就是最新值。这个结构适合模拟电表读数、温度值这类会变化的量。参数上add_block的起始地址和长度决定了主站能访问的范围主站请求超出这个范围会返回非法数据地址异常这正好可以用来测试主站的异常处理。4.2 串口 RTU 从站的差异点TCP 和 RTU 在 modbus-tk 里 API 很像但 RTU 多几个参数串口号、波特率、数据位、停止位、校验位。这些必须和真实设备完全一致错一个就通不了。import modbus_tk.modbus_rtu as modbus_rtu import modbus_tk.defines as cst import serial # 打开串口参数必须和设备一致 slave modbus_rtu.RtuServer( serial.Serial(portCOM3, baudrate9600, bytesize8, parityN, stopbits1) ) slave.start() slave_1 slave.add_slave(1) slave_1.add_block(hold, cst.HOLDING_REGISTERS, 0, 100) print(RTU 从站已启动)逻辑说明serial.Serial的参数里parityN是无校验常见还有E偶校验、O奇校验。stopbits1最常见但有些设备是 2。这些参数在设备手册里都有抄错一个就是连不上。RTU 下还要注意帧间静默modbus-tk 内部会处理但如果你自己拼帧就要小心。5. 避坑与排查这几个问题我踩过不止一次5.1 现象读到的值全是 0 或者明显不对原因地址偏移搞错了。Modbus 文档里常写 40001、30001 这种「PLC 地址」但协议里的实际地址是从 0 开始的。40001 对应保持寄存器地址 040002 对应 1以此类推。如果你直接把 40001 填进execute的起始地址读的就是地址 40001当然读不到。解决先确认设备文档用的是哪种地址表示法。如果是 4xxxx减 1 再用如果是 3xxxx那是输入寄存器功能码要用READ_INPUT_REGISTERS。拿不准就先读地址 0 附近几个寄存器看值对不对得上。5.2 现象TCP 能通RTU 死活连不上原因串口参数不匹配或者串口号写错。Windows 下是 COM3 这种Linux 下是 /dev/ttyUSB0。另外有些 USB 转 485 的线驱动装完串口号会变插拔一次就换一个。解决先用串口调试工具确认能收到数据再上 modbus-tk。参数逐个核对波特率、数据位、停止位、校验位。如果设备是 485还要确认 A/B 线没接反这个接线问题能让你查半天软件。5.3 现象轮询一段时间后开始大量超时原因轮询间隔太短或者超时设得太长导致请求堆积。TCP 网关和串口总线都有处理上限你发得太快设备或网关来不及回就会丢帧。解决把time.sleep调大或者用「请求-响应」严格串行的方式不要并发发多个请求。超时值也别设太大1 到 2 秒够用设 10 秒只会让失败请求卡更久。5.4 现象写寄存器返回非法数据值异常原因写的数据超出了寄存器范围或者设备对某些寄存器只读。保持寄存器是 16 位的你写个 70000 进去肯定报错。解决写之前先确认寄存器位宽和取值范围16 位就是 0 到 65535。如果设备文档说某个寄存器只读那就别写写了必报异常。5.5 现象从站仿真跑起来主站连不上原因端口被占用或者防火墙拦了。5020 端口如果之前有程序没退干净再起就会报地址已占用。解决换个端口或者先确认端口没被占。Linux 下可以用lsof -i:5020看Windows 下用netstat -ano | findstr 5020。防火墙的话本地回环一般不受影响跨机访问才需要放行。6. 进阶技巧把寄存器解析成有意义的工程值6.1 16 位寄存器的符号和缩放处理真实设备读回来的寄存器值往往不是最终工程值。比如温度寄存器读回来是 235实际是 23.5 度缩放系数 0.1又比如某些值是有符号的读回来 65535 其实是 -1。modbus-tk 返回的是原始整数这些转换要你自己做。def to_signed_16(value): # 16 位无符号转有符号 if value 0x8000: return value - 0x10000 return value def scale(value, factor0.1, offset0.0): # 缩放加偏移常见于温度、压力 return value * factor offset # 假设读回来 raw 65535 raw 65535 signed to_signed_16(raw) print(有符号值:, signed) # -1 print(工程值:, scale(signed, 0.1)) # -0.1逻辑说明to_signed_16处理的是补码0x8000 以上视为负数。scale里的factor和offset来自设备手册不同设备不一样。这两个函数建议单独放一个模块别混在轮询逻辑里不然改起来到处找。6.2 32 位数据的拼接有些设备一个物理量占两个寄存器比如 32 位整数或浮点数。modbus-tk 一次读回来是个元组你要自己拼。这里有个字节序的坑不同设备对高低字的排列不一样有的高字在前有的低字在前。import struct def combine_32(high, low, byte_orderbig): # 把两个 16 位寄存器拼成 32 位整数 if byte_order big: raw (high 16) | low else: raw (low 16) | high return raw def combine_float(high, low, byte_orderbig): # 拼成 32 位浮点数 if byte_order big: packed struct.pack(HH, high, low) else: packed struct.pack(HH, low, high) return struct.unpack(f, packed)[0] # 假设读到 (high16968, low0) print(32位整数:, combine_32(16968, 0)) print(浮点数:, combine_float(16968, 0))逻辑说明combine_32用移位拼接byte_order决定高低字顺序。combine_float用struct打包再解包成浮点。这两个函数的关键是确认设备的字节序手册里一般会写「高字在前」或「低字在前」拿不准就两种都试看哪个值合理。6.3 一个可复用的轮询框架结构最后给一个我常用的组织方式配置、传输、解析三层分开。配置层放从站列表和寄存器映射传输层封装 modbus-tk 的读写和重试解析层做符号和缩放转换。这样换设备时只改配置和解析传输层不动。层级职责改动频率配置层从站地址、寄存器地址、数量、缩放系数换设备时改传输层连接、超时、重试、异常捕获基本不动解析层符号转换、32 位拼接、工程值缩放按设备手册改这个结构的好处是调试的时候你能快速定位问题出在哪一层。读不到是传输层读到了但值不对是解析层地址对不上是配置层。从那以后我每次接新设备都先把这三层分开写再也没出现过改一处崩一片的情况。希望帮到你。本文还有配套的精品资源点击获取