☰
pymodbus 自定义协议实战:从 framer 到 payload 的完整指南
2026/10/14 12:21:10 网站建设 项目流程

简介:pymodbus 是 Python 中实现 Modbus 通信协议的完整协议栈,支持 TCP、UDP、RTU 与 ASCII 四种传输模式,并允许自定义数据格式,适合从事工业自动化、PLC 数据采集与设备通信的开发者及运维人员使用。资源包共 306 个文件,以 167 个 py 源码与 61 个 rst 文档为主,辅以 txt、js、c、makefile、java、md 等类型,涵盖协议实现、示例脚本、测试用例与说明文档,压缩包约 1.17MB,目录结构清晰便于按模块查阅。目前已有 1119 人学习下载。内容围绕 Modbus 协议介绍、库的安装与基本用法、客户端与服务器搭建、自定义数据存储类、功能代码与数据类型、错误处理与日志调试等展开,读者可据此掌握寄存器读写、浮点数据转换与通信排错思路,快速构建自动化控制与数据采集系统。

1. 从一次现场调试说起:为什么我最后选了 pymodbus

去年冬天,某公司的一条产线改造项目卡在最后联调阶段。PLC 侧数据死活读不上来,现场工程师换了三根串口线、两轮终端电阻,示波器都架上了,波形干净得很,可上位机就是拿不到寄存器值。后来把抓包数据拉出来一看,问题出在报文格式上——对方设备用的是非标准的寄存器映射和自定义功能码,而当时用的调试工具只认标准 Modbus 帧,压根没法改。那天晚上我回去翻了半天,最后用 pymodbus 写了个几十行的脚本,把自定义格式拼进去,十分钟跑通。

这就是 pymodbus 的价值所在:它不只是一个「能连 Modbus」的库,而是一个允许你拆开协议栈、自己拼帧、自己定义解析规则的完整 Python 实现。标准 Modbus TCP/RTU/ASCII 它全支持,但真正让它在现场站住脚的,是那套可扩展的 framer、decoder 和 payload 体系。你遇到非标设备、私有寄存器、混合协议网关,标准工具趴窝的时候,它能让你从协议层往下改。

这篇文章面向的是手里有真实设备、需要落地通信的从业者。不管你是第一次接触 Modbus 的新手,还是被非标协议折磨过的老手,下面会从环境搭建一路讲到自定义格式的实现和排错,把我在现场踩过的坑都摊开说。

2. 环境搭建与最小可运行示例:先把链路跑通再谈自定义

2.1 安装与版本选择

pymodbus 的版本迭代比较快,2.x 和 3.x 在 API 上有明显差异。3.x 重构了 client 和 server 的接口,把同步和异步彻底分开,framer 的注册方式也变了。如果你维护的是老项目,2.x 的ModbusClient和ModbusServer还能用,但新项目建议直接上 3.x,因为自定义格式的扩展点在新版里更清晰。

安装本身没什么玄学:

# 建议在虚拟环境里装,避免和系统 Python 冲突 python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate # 安装 pymodbus,指定 3.x 大版本 pip install "pymodbus>=3.0,<4.0" # 串口通信需要 pyserial,TCP 不需要但建议一起装 pip install pyserial

装完之后验证一下:

import pymodbus print(pymodbus.__version__)

版本号打印出来是 3.x 就对了。这里有个细节:pymodbus 3.x 把pymodbus.client.sync改成了pymodbus.client,很多网上抄来的老代码会在这里报ModuleNotFoundError,遇到别慌,是版本对不上。

2.2 一个能跑通的 TCP 客户端

先别急着上自定义,把标准链路跑通,确认环境、网络、设备三件事都没问题。下面这段代码读保持寄存器,是最常见的场景:

from pymodbus.client import ModbusTcpClient # 连接设备,端口默认 502 client = ModbusTcpClient('192.168.1.100', port=502) client.connect() # 读保持寄存器,从地址 0 开始读 10 个 # slave=1 是从站地址,3.x 里参数名是 slave rr = client.read_holding_registers(address=0, count=10, slave=1) if rr.isError(): print(f"读取失败: {rr}") else: print(f"寄存器值: {rr.registers}") client.close()

逻辑说明:ModbusTcpClient封装了 socket 连接和 MBAP 头的处理,read_holding_registers对应功能码 0x03。address是起始寄存器地址,count是数量,slave是从站 ID。返回的rr是一个响应对象,正常时.registers是列表,异常时.isError()为 True。

参数上最容易翻车的是地址偏移。Modbus 协议里寄存器地址从 0 开始,但很多设备手册写的是 40001 这种「PLC 地址」。40001 对应的是保持寄存器区,实际协议地址是 0。如果你按手册的 40001 去读,得先减 40001。这个坑我见过太多次,现场对不上地址,八成是这里。

2.3 串口 RTU 的配置要点

RTU 比 TCP 多几个物理层参数,配错了就是收不到数据:

from pymodbus.client import ModbusSerialClient client = ModbusSerialClient( port='/dev/ttyUSB0', # Windows 下是 'COM3' 这种 baudrate=9600, # 波特率必须和设备一致 bytesize=8, # 数据位,通常 8 parity='N', # 校验位,N 无校验 / E 偶 / O 奇 stopbits=1, # 停止位,通常 1 timeout=1 # 超时秒数,现场建议 1~3 ) client.connect() rr = client.read_input_registers(address=0, count=5, slave=2) print(rr.registers if not rr.isError() else rr) client.close()

baudrate、parity、stopbits这三个必须和从站设备完全一致,差一个都通不了。timeout在长距离 RS485 上要适当放大,我一般设 2 秒,太短会误判超时。read_input_registers对应功能码 0x04,和保持寄存器的 0x03 是两个不同的地址空间,别混。

提示:先用标准功能把链路跑通,确认能读到数据,再动自定义格式。否则出了问题你分不清是协议改错了还是线没接好。

3. 拆开协议栈:framer、decoder 与自定义格式的落点

3.1 pymodbus 的分层结构

要改自定义格式,得先知道 pymodbus 把一帧报文拆成了哪几层。从下往上大致是:transport(socket 或串口)→ framer(帧边界识别)→ decoder(字节流解析成 PDU)→ payload(PDU 转成具体请求/响应对象)。

framer 负责从连续的字节流里切出一帧。TCP 靠 MBAP 头里的长度字段,RTU 靠 3.5 字符间隔和 CRC。decoder 负责把切出来的帧解析成功能码加数据。payload 则是把数据映射成ReadHoldingRegistersRequest这类对象。

自定义格式的改动点通常落在两处:一是 framer,当你的设备帧头帧尾不是标准 MBAP 或 RTU 格式时;二是 payload,当功能码或数据字段布局非标时。搞清这个分层,改起来才不会把整个库搅乱。

3.2 自定义 framer 的实现

假设某设备用的是一种私有 TCP 帧:2 字节魔数0xAA 0xBB开头,2 字节长度,然后是 PDU,没有 MBAP 头。标准 framer 认不出这种帧,得自己写一个:

from pymodbus.framer import FramerBase from pymodbus.exceptions import ModbusIOException class CustomFramer(FramerBase): """自定义帧:AA BB + 2字节长度 + PDU""" def __init__(self, client=None): super().__init__(client) self.buffer = b'' def processIncomingPacket(self, data, callback, **kwargs): # 把新数据追加到缓冲区 self.buffer += data while len(self.buffer) >= 4: # 检查魔数 if self.buffer[0:2] != b'\xAA\xBB': # 魔数不对,丢弃一个字节继续找 self.buffer = self.buffer[1:] continue # 读长度字段(大端) length = int.from_bytes(self.buffer[2:4], 'big') frame_len = 4 + length if len(self.buffer) < frame_len: break # 帧还没收全,等下次 frame = self.buffer[:frame_len] self.buffer = self.buffer[frame_len:] # 去掉 4 字节头,把 PDU 交给上层 callback(frame[4:]) def buildPacket(self, message): # 发送时加自定义头 pdu = message.encode() header = b'\xAA\xBB' + len(pdu).to_bytes(2, 'big') return header + pdu

逻辑说明:processIncomingPacket是收包入口,pymodbus 会把 transport 收到的原始字节喂进来。这里维护一个缓冲区,先找魔数,再按长度字段判断帧是否完整,完整了才回调上层。buildPacket是发包时调用,负责加自定义头。

参数上要注意length字段的含义——它指的是 PDU 长度还是整帧长度,必须和设备手册对齐。上面代码里length是 PDU 长度,所以整帧是4 + length。如果手册写的是整帧长度,这里就得改成frame_len = length。这个歧义是自定义格式里最常见的翻车点。

3.3 把自定义 framer 挂到 client 上

写好了 framer,得让 client 用它。3.x 里通过framer参数传入:

from pymodbus.client import ModbusTcpClient client = ModbusTcpClient( '192.168.1.100', port=502, framer=CustomFramer # 传入类,不是实例 ) client.connect() rr = client.read_holding_registers(address=0, count=10, slave=1) print(rr.registers if not rr.isError() else rr) client.close()

注意framer传的是类本身,pymodbus 内部会实例化。如果你传了实例,某些版本会报参数错误。挂上之后,收发都走你的自定义帧,上层read_holding_registers的调用方式不变,这就是分层的好处——改底层不影响业务代码。

3.4 自定义 payload 与功能码

如果设备的功能码也是非标的,比如用 0x41 读数据,那就得自定义 payload。pymodbus 的 payload 体系基于ModbusRequest和ModbusResponse:

from pymodbus.pdu import ModbusRequest, ModbusResponse from pymodbus.register_read_message import ReadRegistersResponseBase class CustomReadRequest(ModbusRequest): function_code = 0x41 # 自定义功能码 def __init__(self, address, count, slave=1): super().__init__(slave) self.address = address self.count = count def encode(self): # 请求 PDU:功能码 + 起始地址(2) + 数量(2) return (self.function_code.to_bytes(1, 'big') + self.address.to_bytes(2, 'big') + self.count.to_bytes(2, 'big')) def decode(self, data): self.address = int.from_bytes(data[0:2], 'big') self.count = int.from_bytes(data[2:4], 'big') class CustomReadResponse(ReadRegistersResponseBase): function_code = 0x41 def decode(self, data): # 响应 PDU:功能码 + 字节数 + 寄存器数据 byte_count = data[0] self.registers = [ int.from_bytes(data[1+i:3+i], 'big') for i in range(0, byte_count, 2) ]

逻辑说明:encode负责把请求对象拼成字节,decode负责把收到的字节还原成对象。function_code是类属性,pymodbus 靠它做路由。响应继承ReadRegistersResponseBase是为了复用寄存器列表的处理逻辑。

参数上,address和count的字节序必须和设备一致,Modbus 标准是大端,但私有协议里小端也不少见。byte_count是响应里数据段的字节数,等于寄存器数乘 2。如果设备返回的字节数不对,registers列表长度就会错,读出来的值全是乱的。

4. 避坑与排查:那些让我加班到凌晨的细节

4.1 现象:连接成功但读不到数据,返回 IllegalFunction

原因:功能码不被从站支持,或者你用的功能码和设备的寄存器区不匹配。比如设备只支持 0x03 读保持寄存器,你用了 0x04 读输入寄存器。

解决:先查设备手册确认支持的功能码,再用标准 client 逐个试。如果标准功能码都不行,说明设备用的是私有功能码,得走自定义 payload 那条路。

4.2 现象:RTU 下偶尔能通,大部分时候超时

原因:多半是串口参数或时序问题。RS485 半双工下,发送和接收切换需要时间,如果timeout太短,或者从站响应慢,就会丢帧。另外终端电阻没接、线太长、波特率太高都会导致误码。

解决:把timeout放大到 2~3 秒,波特率降到 9600 试试,检查 120 欧终端电阻是否接在总线两端。如果用的是 USB 转 485,换一个质量好点的转换器,劣质芯片在 115200 下丢包很严重。

4.3 现象:自定义 framer 挂上后,收到的数据全是乱码

原因:帧长度字段的含义搞错了,或者字节序不对。比如手册里长度是整帧长度,你按 PDU 长度解析,就会多切或少切字节,后续全乱。

解决:抓一段原始字节流,手动按手册算一遍。用print(data.hex())把收到的原始数据打出来,对照手册逐字节核对。这一步没有捷径,只能硬对。

4.4 现象:多从站轮询时,响应串台

原因:TCP 网关下多个从站的响应可能交错到达,如果 client 没有正确匹配请求和响应,就会把 A 从站的数据当成 B 的。

解决:确保每个请求带正确的slave地址,并且用同步方式逐个请求,不要并发。pymodbus 的同步 client 本身是串行的,但如果你自己开了多线程共享一个 client,就会出这个问题。每个线程用独立 client,或者加锁。

4.5 现象:升级到 3.x 后,老代码全报错

原因:3.x 改了 API,pymodbus.client.sync没了,ModbusClient拆成了ModbusTcpClient和ModbusSerialClient,unit参数改成了slave。

解决:按 3.x 的接口重写导入和调用。如果项目大改不动,就锁死在 2.x:pip install "pymodbus<3.0"。但新项目别这么干,2.x 迟早要淘汰。

注意:每次改完 framer 或 payload,先用一个已知正确的标准设备验证库本身没被改坏,再上非标设备。否则你分不清是库的问题还是设备的问题。

5. 进阶技巧:用单元测试锁住自定义格式,别再靠现场试

自定义格式最怕的是「这次通了,下次换个设备又不行」。我现在的习惯是,每写一个自定义 framer 或 payload,先写单元测试把字节级的编解码锁死,现场只做联调,不做调试。

5.1 用 pytest 验证 framer 的切帧逻辑

import pytest from your_module import CustomFramer def test_framer_split(): framer = CustomFramer() received = [] # 模拟分两次到达的半帧 framer.processIncomingPacket(b'\xAA\xBB\x00\x04\x41', received.append) assert received == [] # 帧不完整,不应回调 # 补齐剩余字节 framer.processIncomingPacket(b'\x00\x00\x00\x02', received.append) assert len(received) == 1 assert received[0] == b'\x41\x00\x00\x00\x02' def test_framer_bad_magic(): framer = CustomFramer() received = [] # 前面有垃圾字节,应该能跳过找到魔数 framer.processIncomingPacket(b'\x00\xAA\xBB\x00\x02\x41\x00', received.append) assert len(received) == 1

逻辑说明:第一个测试验证半帧不会提前回调,第二个测试验证垃圾字节能被跳过。这两个场景在现场太常见了——TCP 粘包、串口噪声,都会导致半帧或脏数据。测试里把callback换成一个收集列表,就能断言切出来的帧内容。

参数上,processIncomingPacket的第二个参数是回调函数,测试时传received.append就行。注意断言的是去掉自定义头之后的 PDU,和 framer 的设计保持一致。

5.2 用已知报文做回归

把现场抓到的真实报文存成 fixture,每次改代码跑一遍:

# conftest.py 或测试文件里 KNOWN_FRAMES = [ # (原始字节, 期望的 PDU) (bytes.fromhex('aabb00044100000002'), b'\x41\x00\x00\x00\x02'), (bytes.fromhex('aabb000641000000020001'), b'\x41\x00\x00\x00\x02\x00\x01'), ] @pytest.mark.parametrize("raw,expected", KNOWN_FRAMES) def test_known_frames(raw, expected): framer = CustomFramer() received = [] framer.processIncomingPacket(raw, received.append) assert received[0] == expected

这样每次重构 framer,只要跑一遍测试就知道有没有破坏已有格式。现场联调的时间能省一大半。

5.3 几个我固定会检查的参数

检查项常见值翻车后果
字节序大端 / 小端寄存器值高低字节颠倒
长度字段含义PDU 长度 / 整帧长度切帧错位,全乱
功能码标准 0x03/0x04 / 私有IllegalFunction
地址偏移0 基 / 40001 基读错寄存器
超时1~3 秒误判超时
从站地址1~247无响应或串台

这张表我贴在工位上,每次新设备联调前过一遍,能挡掉八成低级错误。

从那以后我每次写自定义 framer,都先把单元测试和已知报文 fixture 建好,再上设备。现场只做验证,不做调试。希望帮到你。

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

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

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

立即咨询