简介:Modbus通信测试工程压缩包面向STM32-F0/F1/F2系列单片机开发者设计,内含完整源码、编译中间文件与调试辅助文件,适合刚接触MODBUS协议栈移植或需要做工业设备通信联调的嵌入式工程师与学习者。压缩包共含70个文件,以C源文件(.c/.h)、Keil编译中间文件(.o/.crf)、依赖文件(.d)与工程配置文件(.uvproj/.uv2/.uvopt)为主,另附可直接烧录的.hex固件以及.map/.axf调试映像,总大小仅727KB,导入工程即可对照源码阅读。已有163人学习,工程按USART、DMA、TIMER、LED、LCD、MODBUS等模块划分,能从外设初始化、寄存器读写、串口收发、中断处理到通信超时与校验异常检测,理清一条完整的Modbus主从站实现链路。配合hex固件可直接下载观察实际通信效果,后续移植到其他STM32型号或扩展功能时,也可复用其中的驱动封装与协议处理思路,节省重复开发时间。
1. 先从“电子-modbusTest.zip”说起:一个 Modbus 测试包到底帮你省什么事
手头有一包设备要测,打开串口助手,输入十六进制帧,一边盯着返回报文一边掰着手指算 CRC,这是很多电子工程师都经历过的场景。尤其是刚接手一个 Modbus 协议设备时,寄存器长度、功能码、字节序、超时参数全部压在一个人脑子里,换个人就得重新捋一遍。我见过最夸张的情况是,A同学用串口调试工具点了一个下午,最后发现是设备空闲时自动进入低功耗,压根没在 listen。“电子-modbusTest.zip”这类压缩包乍看不起眼,但它通常是一个打包好的 Modbus 主站/从站测试脚本集,把协议帧构造、返回校验、批量巡检和日志回放都收敛到几个文件里。它解决的正是生产线调试、硬件联调、验收测试这三类场景里最费时间的通信验证问题。适合做硬件测试、嵌入式软件开发、以及设备选型评估的工程师直接用。
2. 把 Modbus 测试的基础说到能直接落地:协议三个要点和测试工具选型
2.1 三个必须搞清的协议要点:RTU/TCP、寄存器地址、功能码
Modbus 测试包再方便,也绕不开协议本身的三个基础概念,否则你连脚本里的参数不知道怎么填。
第一个是传输模式。RTU 走串口,以字节帧为单位,1 个起始位、8 个数据位、1 个停止位可选校验;TCP 走网络,端口默认 502。同一套寄存器读写逻辑在两个模式下的区别只在封装层,业务功能码是一样的。你拿到“电子-modbusTest.zip”的时候,第一件事就是看它的 config 里写的是"mode": "rtu"还是"tcp",这决定后面所有串口参数是否生效。
第二个是寄存器模型。Modbus 把数据分成四张表:线圈(可读写位)、离散输入(只读位)、保持寄存器(可读写 16 位)、输入寄存器(只读 16 位)。测试包里最常见的操作是读保持寄存器(功能码 0x03)和写单个保持寄存器(功能码 0x06)。设备手册一般会给一个地址映射表,比如40001是设备温度,40002是湿度。注意这里的 40001 是 PLC 习惯叫法,报文里真正的协议地址是 0x0000。这个“减 1”的转换,是新手最容易翻车的地方。
第三个是功能码。读线圈是 0x01,读离散输入 0x02,读保持寄存器 0x03,读输入寄存器 0x04,写单个线圈 0x05,写单个寄存器 0x06,写多个线圈 0x0F,写多个寄存器 0x10。测试包的核心逻辑不外乎构建这些帧、发送、解析响应的寄存器值。你只需要知道,当你点开某个脚本,看到function_code=3,它就是在读保持寄存器。
2.2 测试工具怎么选:脚本方案为什么比上位机更省事
市面上有很多 Modbus 测试工具,比如通用串口调试助手、Modbus Poll、或者专门的 GUI 测试上位机。它们的优点是上手快,点几个下拉框就能读取数据。但放到批量场景里就不好用了:你要测 20 台设备、每台设备读 30 个寄存器,GUI 工具只能一台一台手点;遇到某个寄存器越界,你还要肉眼判断哪个值不合理;更别提把测试记录导出成统一格式的报文日志,给现场同事做问题定位。
脚本方案“电子-modbusTest.zip”的做法,通常是把主站逻辑写成可重复执行的 Python 脚本,配合一个 CSV 或 JSON 配置表。这样做有三个好处:一是回归测试可重放,同一组报文反复执行,结果统一输出;二是参数集中化,修改从站地址、波特率、寄存器列表不需要改代码;三是日志标准化,脚本自动记录时间戳、原始帧、解析值,出了问题可以直接复盘。我一般先看包里有没有regmap.csv或config.json,有的话基本就是这个套路。
2.3 最小可运行环境:Windows 下搭建 Python 测试空间
最常见的运行环境是 Windows,因为公司生产测试电脑多半是 Windows。先把 Python 装好,然后用 pip 安装 Modbus 库和串口库。这里我建议用虚拟环境,避免污染系统 Python。
python -m venv modbus_env modbus_env\Scripts\activate pip install pymodbus pyserial这段命令创建了一个叫modbus_env的虚拟环境,并激活它。然后安装pymodbus和pyserial。pymodbus是 Modbus 协议实现库,支持 RTU 和 TCP;pyserial是串口通信底层依赖,在 Windows 上操作 COM 口全靠它。如果只做 TCP 测试,pyserial可以省掉,但多数标着“电子”的 Modbus 测试包都涉及 RS485 设备,所以装全更省心。
需要注意库版本。pymodbus从 3.0 开始 API 变化很大,老的 2.x 代码直接跑会报一堆 attribute error。如果“电子-modbusTest.zip”里的代码写的是from pymodbus.client.sync import ModbusTcpClient,那多半是基于 2.x;写成from pymodbus.client import ModbusTcpClient的是 3.x。我一般会根据源码的 import 语句反推该装哪个版本,必要时执行pip install pymodbus==2.5.3或pip install pymodbus==3.5.2。装错版本不会出红色大错误,而是跑到一半崩给你看,玄学得很。
3. 解压后的工程在讲什么:典型目录拆分和配置文件解析
3.1 一个 Modbus 测试包的经典文件结构
拿到压缩包,解压之后不要急着运行,先扫一眼目录。一个设计得比较规整的测试包会有这些文件:主入口脚本、协议封装模块、从站模拟器、配置文件和寄存器映射表。我看到的“电子-modbusTest.zip”案例基本是这个骨架:
modbusTest/ ├── main.py # 主入口,负责读取配置并调度测试 ├── modbus_master.py # 主站逻辑封装:读、写、批量巡检 ├── modbus_slave.py # 从站模拟器:没有硬件时模拟设备 ├── config.json # 串口/网络/超时/轮询间隔等参数 ├── regmap.csv # 寄存器列表:地址、名称、读写类型、缩放系数 ├── requirements.txt # 第三方依赖 └── README.md # 使用说明和注意事项这个结构对应了一种很常见的测试方法:配置驱动。main.py读取config.json拿到通信参数,再读取regmap.csv拿到要测的寄存器清单,然后通过modbus_master.py执行实际读写。而modbus_slave.py用于反向模拟,当你手里没有设备或者设备还没焊好时,先用它充当从站,验证主站脚本本身没写错。
我不建议去改动结构里的模块化设计,除非你只是为了快速验证某个寄存器。保持配置与逻辑分离,后面加测点会轻松很多。
3.2 配置文件里的角色参数:串口、从站地址、超时重试背后的意义
打开config.json,你会看到类似下面内容:
{ "mode": "rtu", "port": "COM5", "baudrate": 9600, "bytesize": 8, "parity": "N", "stopbits": 1, "timeout": 1.0, "retries": 3, "poll_interval": 0.5, "slave_id": 1 }每个字段都对应一个测试行为。mode是rtu还是tcp,如果是tcp,那port要改成 IP 地址加端口,比如"port": "192.168.1.30:502"。baudrate、bytesize、parity、stopbits是串口通信的物理参数,必须和设备一致,否则设备收到的是噪声,根本不会回帧。timeout是发送一帧后等待响应的最长时间,单位秒。这个值不能设得太大,否则单个寄存器读失败会拖慢整体巡检;也不能太小,某些慢速 PLC 或仪表可能在 200ms 之后才回。我一般先按 1.0 秒试,稳定了再往下压。
retries是单次请求失败后的重试次数。注意重试会引入额外耗时,比如超时 1 秒、重试 3 次,最坏情况一个寄存器要等 3 秒。poll_interval是连续两轮请求之间的间隔,这个参数直接决定测试节奏。slave_id是从站地址,范围 1-247,如果设备是 0,那多半是广播地址,不会回响应。基本配置改错任何一个,脚本都会表现出“发送正常、但收不到数据”的假象。
3.3 依赖清单和安装顺序:装错库版本会有什么后果
requirements.txt通常长这样:
pymodbus==3.5.2 pyserial==3.5安装顺序不太重要,重要的是版本锁定。为什么我要强调锁版本?因为pymodbus3.x 内部结构在 3.0-3.6 之间也有微调,比如某些线程模型和安全校验函数的签名变化。你在 A 机器上pip install pymodbus装了 3.6,在 B 机器上装了 3.2,同一个脚本可能一边正常一边报TypeError: write_registers() got an unexpected keyword argument 'slave'。
我见过最典型的一次翻车是:测试包代码里用了client.write_registers(address, values, slave=2),这写法的前提是安装的pymodbus版本在这个位置接受slave参数。但新版换成了device_id,结果报了TypeError。解决方案不是去改源码里所有的参数名,而是直接看 README 里要求的版本号,然后按pip install pymodbus==3.5.2安装。如果压缩包没有 README,就打开requirements.txt,上面写什么版本就装什么版本,不要装 latest。
另外一点,在 Windows 上如果装了多个 Python 环境,pip可能指向错误的环境。执行pip --version看清路径,或者直接用python -m pip install ...,避免出现“装了一半项目能用、一半报找不到模块”的尴尬。
4. 用代码把测试跑起来:主站、从站和批量巡检的三段可抄作业
4.1 主站读寄存器的最小 Python 脚本
不管包里的代码封装得多花哨,底层一定是最简单的主站读写。先把这段最小脚本跑通,后面再套用封装就心里有底。下面是一个基于pymodbus3.x 的读保持寄存器示例:
from pymodbus.client import ModbusSerialClient client = ModbusSerialClient( port='COM5', baudrate=9600, bytesize=8, parity='N', stopbits=1, timeout=1, ) client.connect() # 地址 0 对应手册里的 40001,读 2 个寄存器 result = client.read_holding_registers(address=0, count=2, slave=1) if not result.isError(): print("寄存器0:", result.registers[0]) print("寄存器1:", result.registers[1]) else: print("读取失败:", result) client.close()逻辑说明:ModbusSerialClient负责建立串口连接,read_holding_registers发送功能码 0x03。address是协议地址,从 0 开始;手册上的 40001 相当于地址 0。slave=1是从站 ID,要和设备侧一致。result.registers返回一个整数列表,长度为count。如果设备返回异常码,result.isError()为真,此时不要直接访问registers,否则会得到空列表。
参数说明里最坑的是address和slave。很多设备手册给的地址表是 PLC 风格的 40001、40002,容易让人直接填 40001 进去,结果实际访问的是协议地址 40001,设备会报非法地址。正确做法是手册地址减 1。另外在pymodbus3.x 中,unit参数已经改成slave,如果从 2.x 迁移代码,这里极易踩坑。
4.2 从站模拟器:没有硬件也能验证通信链路
当硬件还没到位时,可以先启动一个从站模拟器,用主站脚本去读它,验证整条链路是否通。很多“electron”项目的 Modbus 测试包都自带modbus_slave.py,原理是起一个线程,监听串口或 TCP 端口,按功能码返回预置数据。下面是用pymodbus自带的StartSerialServer实现的从站模拟:
from pymodbus.server import StartSerialServer from pymodbus.datastore import ModbusSequentialDataBlock, ModbusSlaveContext, ModbusServerContext # 初始化数据存储区,地址 0-9 的保持寄存器预置为 100-109 store = ModbusSlaveContext( di=ModbusSequentialDataBlock(0, [0]*10), co=ModbusSequentialDataBlock(0, [0]*10), hr=ModbusSequentialDataBlock(0, list(range(100, 110))), ir=ModbusSequentialDataBlock(0, [0]*10), ) context = ModbusServerContext(slaves=store, single=True) StartSerialServer( context, port='COM5', baudrate=9600, parity='N', stopbits=1, bytesize=8, )这段代码要点是:ModbusSequentialDataBlock创建连续地址的数据块,这里给保持寄存器(hr)地址 0-9 填了 100 到 109,主站读地址 0 会拿到 100,地址 1 会拿到 101。ModbusSlaveContext管理四张数据表,single=True表示所有从站 ID 共享同一份数据,测试单设备时序足够用了。启动后,主站脚本里的串口参数要和这段保持一致,尤其是 COM 口不能冲突。
我建议在正式连硬件之前,先用这个模拟器跑一遍主站脚本。一方面确认线序、驱动是否正常,另一方面把超时参数校准到一个稳定值。等真实设备连上后,如果报文格式有差异,再单独排查。
4.3 批量巡检:用一张表驱动几十个寄存器测试
真正的效率提升来自批量。regmap.csv的存在就是为了把测点从代码里剥离出来。一个典型表格如下:
address,name,type,scale,min_value,max_value 0,temperature,holding,0.1,-40,80 1,humidity,holding,0.1,0,100 2,status,input,1,0,255 3,firmware_version,holding,1,0,65535批量巡检脚本读取这张表,循环执行读取,并且可以根据min_value/max_value做简单范围判断。下面是一段可运行的示例:
import csv from pymodbus.client import ModbusSerialClient client = ModbusSerialClient(port='COM5', baudrate=9600, timeout=1) client.connect() with open('regmap.csv', newline='') as f: reader = csv.DictReader(f) for row in reader: addr = int(row['address']) name = row['name'] result = client.read_holding_registers(address=addr, count=1, slave=1) if result.isError(): print(f"[FAIL] {name}: 无响应") continue raw = result.registers[0] scaled = raw * float(row['scale']) print(f"[OK] {name}: raw={raw} scaled={scaled}") client.close()逻辑说明:脚本逐行读取 CSV,把name和scale都映射到读取结果上。scale是把原始整数换算成物理量的系数,比如温度寄存器原始值 250,乘以 0.1 就是 25.0 度。这个做法比在代码里手写每个寄存器地址要直观得多,也方便现场测试人员直接改 CSV 增删测点。
参数说明:csv.DictReader默认以第一行作为字段名,所以 CSV 里不要加多余表头。read_holding_registers的count这里固定为 1,但如果寄存器表是按连续块组织的,可以改成多个,避免逐地址请求拖慢巡检。对于输入寄存器要改成read_input_registers,功能码 0x04,别弄混。
批量巡检最大的价值不是省那几个“写代码”的时间,而是把测试记录变成可查的数据。你可以把每条结果追加到日志文件,或者输出成 JSON,后面做自动化回归就顺理成章了。
5. Modbus 测试避坑与排查:几个典型现场问题的现象、原因和解决
5.1 设备无响应但串口线是通的:地址和波特率之争
现象:主站脚本发送帧后一直超时,报No response。用示波器看串口 TX 端有波形,设备也上电了,但就是不回。
原因:最常见的是从站地址和波特率不匹配。某些设备出厂默认地址是 247 或 2,不是 1;有些设备 RS485 接口上电后要等 1-2 秒才进入通信状态。还有一个隐蔽原因是校验位不匹配,比如设备设置为偶校验 Even,而配置里写 None,结果设备把主站发送来的帧视为无效。
解决:先用一个简单的串口回环测试排除物理层问题。把 RS485 转换器的 A/B 短接,发一串数据自己收,能收到说明串口和线材没问题。然后逐个试从站地址列表[1, 2, 247];同时试parity的N/E/O三档。我习惯在config.json里把这三个配置做成可快速切换的参数,用脚本循环扫描地址,而不是手改代码。
5.2 读到一堆 0 或数值明显不对:寄存器大小端和功能码不匹配
现象:请求发送成功,也有响应帧,但解析出来的寄存器值是 0,或者数值和手册对不上,比如温度显示 2560 但实际只有 25 度。
原因:一个是大小端问题。Modbus 寄存器是 16 位,但设备可能将一个 32 位浮点数拆成两个寄存器,且字节序是先高后低还是先低后高,不同厂家标准不一样。另一个原因是寄存器地址偏移。你读保持寄存器读到的是别的信息,而真正的数据在输入寄存器(0x04)里。
解决:先用模拟器对着手册构造一个已知值,检查pymodbus返回的registers列表顺序。如果两个寄存器的值需要拼成一个 32 位整数,可以试验两种拼接顺序。下面是一段拼接示例:
raw_low = result.registers[0] raw_high = result.registers[1] value_big_endian = (raw_high << 16) | raw_low value_little_endian = (raw_low << 16) | raw_high逻辑说明:寄存器列表的顺序映射为地址递增。如果你的设备手册里说“地址 0 是低 16 位,地址 1 是高 16 位”,那value_big_endian是正确解。反之则是value_little_endian。至于功能码,打开设备的手册,看数据是放在“保持寄存器”还是“输入寄存器”里,然后把read_holding_registers换成read_input_registers。
5.3 频率一高就丢帧:轮询间隔和超时设置
现象:单次读取都成功,但把poll_interval调到 100ms 之后,开始出现偶发超时,甚至设备直接掉线。
原因:设备侧 MCU 处理 Modbus 请求需要时间,特别是 EEPROM 操作或滤波计算,不是每个主站请求都能立刻响应。频繁请求会导致设备来不及处理,或者设备侧有一个基础的通信错误恢复机制,触发后会自动忽略后续请求几秒钟。
解决:把poll_interval调回 500ms 以上,同时把timeout放宽到 1 秒。我一般先用二分法压poll_interval,从 1000ms 往下试,能稳定跑 100 次不掉帧的时间间隔才是可用值。另外一个容易忽略的点是retries,重试机制会和轮询间隔叠加,如果一轮请求超时 1 秒,重试 3 次,那么实际上已经占用了 4 秒,后面的请求全部堆积。这种情况下表面看是丢帧,其实是排队阻塞。
5.4 报文能发出去但从站模拟器收不到:半双工和串口复用问题
现象:同一个串口,主站脚本和从站模拟器同时跑,结果两边都超时,仿佛谁也没收到谁的帧。
原因:RS485 是半双工通信,同一时刻只能有一个发送方。如果你在电脑上同时打开两个串口工具,或者主站脚本和从站模拟器抢同一个 COM 口,那必然冲突。另一个常见场景是 USB 转 RS485 模块的自动收发切换电路没拉起来,导致发送结束后仍占着总线。
解决:确认物理层只有一个发送源。测试时先用从站模拟器和主站脚本各自单独占用 COM 口,不要同时启动两个实例。如果从站模拟器要用 COM5,主站脚本就必须用 COM6,通过两个 USB 转 RS485 适配器连同一对 A/B 线。要是只有一条 USB 转 485,那就把主站和从站改成 TCP 模式,走本机回环地址,绕开串口半双工限制。
6. 把测试做得更值钱:回放异常帧和自动化回归的进阶技巧
跑通基础读写、批量巡检,只说明你会用这个工具包。真正让测试有价值的部分,是把一次偶然的故障变成可反复验证的回归用例。我的做法是在测试包里加一个“异常帧回放”模块:当现场出现某帧导致设备死机或返回异常码时,先用带原始帧日志的主站脚本记录下完整的请求和响应,然后保存成一个 JSON 文件。回归测试时,用pymodbus的底层帧构造接口重新发送这组帧,断言返回码是否与预期一致。
from pymodbus.pdu import ModbusRequest from pymodbus.client import ModbusSerialClient client = ModbusSerialClient(port='COM5', baudrate=9600, timeout=1) client.connect() # 记录下来的异常请求帧,这里以读保持寄存器地址 0xFF 为例 request = ModbusRequest(function_code=0x03, address=0xFF, count=1) response = client.execute(request, slave=1) print("原始响应码:", response.function_code) client.close()逻辑说明:ModbusRequest可以构造任意功能码和地址的组合,client.execute直接执行这个请求,不经过高级封装。这样就能还原那些在高级接口里被校准得很正常、但真实设备却不接受的边界请求。比如地址越界、寄存器和功能码错配、超过协议最大长度等,这些用例才是回归测试的护城河。
进阶技巧是把整个测试过程做成一个带时间戳的回归脚本,每次改动固件后跑一遍 baseline,对比新旧日志里的错误码分布。我习惯把config.json里的slave_id和timeout做成命令行参数,这样同一条回归脚本可以在多个测试台之间切换,不用改代码。
说到底,Modbus 测试包的核心不是“能读几个寄存器”,而是“能不能在你离开现场之后,仍然复现和定位问题”。我从很多次踩坑里学到的教训就是:所有现场偶发故障,都要先把原始帧存成文件,再做任何重试或换线。这样就算这次没查明白,下一回测试还能拿着同一帧继续复现。希望这篇笔记能帮你在做 Modbus 调试时少走几次弯路,也祝你的测试台永远不会出现“刚才明明还好好的”这句话。
本文还有配套的精品资源,点击获取