☰
Modbus TCP通讯测试实战:从协议解析到稳定采集的完整指南
2026/10/2 7:31:32 网站建设 项目流程

简介:这份资源是汇川AM402 PLC与C# Winform程序通过Modbus TCP通信的完整测试工程,面向工控上位机开发初学者及需要对接汇川PLC的C#工程师,帮助解决协议封装、Socket连接与数据读写等实际问题。压缩包共47个文件、约922KB,以15个cs源码文件为核心,配合sln与csproj工程文件、resx与resources界面资源、config配置及exe可执行程序,另含pdb调试符号与suo等IDE状态文件,结构完整可直接用Visual Studio打开运行。内容覆盖Modbus TCP功能码构造与解析、非阻塞Socket连接、异常重连与数据解析显示等关键环节,并附带AM402_Test1 PLC程序,便于对照调试。目前已有2254人学习下载,适合作为上位机与PLC联调的参考范例,快速理解通信流程与排错思路。

1. 从 InproModebusTCP通讯测试.rar 说起:一个压缩包背后要跑通的三件事

如果你手里拿到一个叫InproModebusTCP通讯测试.rar的包,第一反应大概率是「解压看看里面是什么」。但真正要把它跑起来,你面对的其实是三件独立的事:Modbus TCP 协议本身怎么走、上位机怎么当客户端去读写寄存器、以及现场设备(PLC、仪表、网关)的 IP 和端口怎么对上。这三件事任何一件没弄明白,测试就会卡在「连不上」或者「连上了读回来全是 0」。

Modbus TCP 是工业现场最常见的一种以太网通讯方式,它把传统的 Modbus RTU 帧塞进 TCP 报文里,默认端口 502,走的是标准的 TCP 三次握手建立连接,然后一问一答。所谓「通讯测试」,本质就是验证你的客户端能不能正确发出功能码请求、设备能不能按协议返回数据。这个方向适合做自动化、工控上位机、设备集成的工程师,也适合刚接触 Modbus 想找个能跑的样例练手的人。下面我按「协议先立住 → 环境搭起来 → 代码跑通 → 坑排掉」的顺序,把这类测试包该怎么用讲清楚。

2. Modbus TCP 协议先立住:报文结构、功能码和端口那些事

在动手解压之前,先把协议本身搞清楚,否则后面抓包看到一堆十六进制你也不知道哪段是哪段。Modbus TCP 的报文结构和 RTU 最大的区别是去掉了 CRC 校验,换成了一个 7 字节的 MBAP 头(Modbus Application Protocol Header),后面才是真正的 PDU。

2.1 MBAP 头 7 个字节分别是什么

MBAP 头是理解 Modbus TCP 的钥匙,它固定 7 个字节,结构如下:

字段长度说明典型值
事务标识符 Transaction ID2 字节客户端自增,用来匹配请求和响应0x0001
协议标识符 Protocol ID2 字节Modbus 固定为 00x0000
长度 Length2 字节后面还有多少字节(单元标识+PDU)0x0006
单元标识 Unit ID1 字节从站地址,TCP 下常填 1 或 0xFF0x01

后面紧跟的就是 PDU,第一个字节是功能码,比如 0x03 读保持寄存器、0x04 读输入寄存器、0x06 写单个寄存器、0x10 写多个寄存器。举个例子,读从站 1 的保持寄存器,起始地址 0,读 2 个寄存器,完整报文是:

00 01 00 00 00 06 01 03 00 00 00 02 |事务ID|协议ID|长度 |单元|功能码|起始地址|数量|

响应会带回同样的 Transaction ID,功能码后面跟一个字节的字节数,然后是数据。搞懂这个结构,你抓包的时候就能一眼看出请求和响应是否配对。

2.2 功能码和寄存器地址的对应关系

新手最容易翻车的地方是地址。Modbus 有四种数据区,每种对应不同的功能码和地址范围:

  • 线圈 Coil,可读可写,功能码 01/05/15,地址从 0 开始
  • 离散输入 Discrete Input,只读,功能码 02
  • 保持寄存器 Holding Register,可读可写,功能码 03/06/16,地址从 0 开始
  • 输入寄存器 Input Register,只读,功能码 04

注意一个高频坑:很多设备手册上写的是「40001」这种 5 位地址,而协议里实际发的是 0。40001 对应保持寄存器偏移 0,40002 对应偏移 1,以此类推。你在代码里填地址时,要么填 0,要么填 40001 让库去转换,取决于你用的库怎么定义。这个不统一,是 Modbus 测试里最常见的「读回来全是 0」的原因之一。

2.3 为什么默认端口是 502,能不能改

Modbus TCP 的官方分配端口是 502,这是 IANA 注册的。但实际项目里,很多网关和串口服务器会把 Modbus TCP 映射到别的端口,比如 5020、8502,甚至 10002。所以拿到一个测试包,第一件事是确认目标设备的端口号,而不是无脑填 502。端口填错的表现是 TCP 连接直接被拒绝(Connection refused),和协议层没关系,用 telnet 或 nc 一试就知道。

提示:用telnet 192.168.1.10 502能连上说明 TCP 层通了,连不上就是 IP 或端口问题,先别怀疑协议。

3. 把测试环境搭起来:从解压到第一个能跑的客户端

协议清楚之后,就可以动手了。InproModebusTCP通讯测试.rar这类包,通常包含一个上位机测试程序、可能的源码、以及一份说明文档。不管里面具体是什么,你要跑通的核心是「一个能发 Modbus TCP 请求的客户端」加「一个能响应的服务端或真实设备」。

3.1 解压后先看什么,别急着双击 exe

解压之后,先别急着运行可执行文件。按这个顺序看:

  1. 看目录结构,找readme、说明、doc之类的文件,里面通常写了默认 IP、端口、从站地址
  2. 找配置文件,比如.ini、.json、.xml,里面往往有连接参数
  3. 如果有源码,先看主程序里连接部分的代码,确认用的是哪个库
  4. 最后再运行程序,用默认参数试连

如果包里只有 exe 没有源码,那你就把它当成一个黑匣子客户端,重点放在「怎么配参数让它连上我的设备」上。如果带源码,那更好,可以直接改参数重新编译。

3.2 没有真实设备时,用 Python 起一个 Modbus TCP 服务端

大多数时候你手边没有 PLC,这时候可以自己起一个模拟服务端。用 Python 的pymodbus库几行就能搞定:

# 安装:pip install pymodbus from pymodbus.server import StartTcpServer from pymodbus.datastore import ModbusSequentialDataBlock, ModbusSlaveContext, ModbusServerContext # 初始化数据区,从地址0开始,给100个寄存器,初值都设为0 store = ModbusSlaveContext( hr=ModbusSequentialDataBlock(0, [0] * 100), # 保持寄存器 ir=ModbusSequentialDataBlock(0, [0] * 100), # 输入寄存器 co=ModbusSequentialDataBlock(0, [0] * 100), # 线圈 di=ModbusSequentialDataBlock(0, [0] * 100), # 离散输入 ) # unit=1 表示从站地址为1,single=False 表示可以有多个从站 context = ModbusServerContext(slaves=store, single=True) # 监听所有网卡的502端口 StartTcpServer(context=context, address=("0.0.0.0", 502))

这段代码的逻辑是:先建四个数据块,每个块从地址 0 开始给 100 个位置,全部初始化为 0;然后把这些块打包成一个从站上下文,从站地址设为 1;最后在 502 端口启动 TCP 服务。跑起来之后,你的客户端连127.0.0.1:502、从站地址 1,就能读到全 0 的寄存器。想验证写入,可以先写再读。

参数说明:ModbusSequentialDataBlock(0, [0]*100)的第一个参数是起始地址,第二个是初始值列表,列表长度就是寄存器数量。single=True表示所有单元标识都映射到同一个上下文,测试够用了。如果端口 502 被占用或没权限(Linux 下 1024 以下端口需要 root),改成 5020 之类的。

3.3 用客户端读一次寄存器,确认链路通了

服务端起好之后,写个最小客户端验证:

from pymodbus.client import ModbusTcpClient client = ModbusTcpClient('127.0.0.1', port=502) # 连接服务端 client.connect() # 读保持寄存器,从站地址1,起始地址0,读2个 rr = client.read_holding_registers(address=0, count=2, slave=1) if not rr.isError(): print("读取成功:", rr.registers) # 应该输出 [0, 0] else: print("读取失败:", rr) client.close()

逻辑很直白:建客户端、连、读、判断、关。address=0是协议地址,不是 40001。count=2表示读两个寄存器。slave=1是从站地址,要和服务端的 unit 对上。如果这里报错,先看服务端有没有收到请求,再看是不是地址或从站号填错了。

注意:pymodbus不同大版本 API 有差异,2.x 和 3.x 的read_holding_registers参数名可能不同,报unexpected keyword argument时先查你装的版本。

4. 用真实设备跑通:IP、端口、从站号、地址四个参数怎么对

模拟服务端跑通只证明你的代码没问题,真正难的是连真实设备。现场设备千奇百怪,参数对不上就是连不上或者读回垃圾数据。这一章把四个关键参数逐个拆开。

4.1 IP 和端口:先 ping 再 telnet,别跳过

拿到设备,第一步永远是确认网络层通不通。设备 IP 通常在铭牌上或者用厂商配置软件设。确认步骤:

  1. 把电脑网卡设成和设备同网段,比如设备是 192.168.1.10,电脑设 192.168.1.100
  2. ping 192.168.1.10,通了说明 IP 层没问题
  3. telnet 192.168.1.10 502,通了说明 TCP 端口开着

如果 ping 不通,检查网线、网段、设备是否上电。如果 ping 通但 telnet 不通,说明端口不对或者设备没开 Modbus TCP 服务。这一步能过滤掉一大半「连不上」的问题,别一上来就怀疑代码。

4.2 从站号和单元标识:TCP 下到底填几

Modbus TCP 里,Unit ID 这个字段在纯 TCP 设备上经常被忽略,很多设备不管你填几都响应。但在网关场景下,一个网关后面挂多个 RTU 从站,这时候 Unit ID 就用来区分具体是哪个从站。常见做法是:

  • 直连单个 TCP 设备:Unit ID 填 1 或 0xFF 都行,看设备手册
  • 通过网关连 RTU 设备:Unit ID 填 RTU 从站的实际地址,比如 1、2、3

如果读回来报异常码 0x0B(Gateway Target Device Failed to Respond),基本就是从站号填错了,网关找不到对应的 RTU 设备。

4.3 寄存器地址:40001 和 0 的换算别搞混

前面提过,手册上的 40001 对应协议地址 0。但不同库的处理方式不一样:

库/工具地址填法说明
pymodbus填 0直接用协议地址
Modbus Poll可配有选项决定是否偏移
部分组态软件填 40001软件内部自动减 1
某些 PLC填 1从 1 开始计数

所以当你读不到数据时,先把地址加 1 或减 1 试试,这是血泪经验。另外注意「起始地址」和「数量」是两个参数,读 10 个寄存器是从起始地址开始连续读 10 个,不是读到地址 10。

4.4 数据类型:读回来两个寄存器怎么拼成一个浮点数

Modbus 寄存器是 16 位的,但现场数据可能是 32 位整数或浮点数,占两个寄存器。这时候就涉及字节序和字序问题。常见组合有:

  • 大端字序 + 大端字节序(ABCD)
  • 小端字序 + 大端字节序(CDAB)
  • 大端字序 + 小端字节序(BADC)
  • 小端字序 + 小端字节序(DCBA)

读回来一个浮点数不对,先别怀疑设备,把四种组合都试一遍,基本能对上。用 Python 转换:

import struct # 假设读到两个寄存器 regs = [0x4148, 0x0000] regs = [0x4148, 0x0000] # ABCD:高字在前,字内大端 val_abcd = struct.unpack('>f', struct.pack('>HH', regs[0], regs[1]))[0] # CDAB:低字在前 val_cdab = struct.unpack('>f', struct.pack('>HH', regs[1], regs[0]))[0] print(val_abcd, val_cdab) # 12.5 12.5(示例)

这段代码用struct把两个 16 位整数按不同顺序打包成 4 字节,再解成浮点数。实际调试时把四种都打印出来,哪个是合理值就用哪个。

5. 通讯测试避坑:连不上、读回 0、断连的排查清单

前面把正常流程走完了,这一章专门讲翻车现场。以下 5 条是我在实际项目里反复遇到的,每条按「现象 → 原因 → 解决」写。

5.1 现象:TCP 连接被拒绝,程序报 Connection refused

原因:目标 IP 或端口不对,或者设备根本没开 Modbus TCP 服务。也可能是防火墙拦了。

解决:先用telnet IP 端口确认。连不上就检查设备配置里 Modbus TCP 是否启用、端口是不是 502。Windows 防火墙有时候会拦出站,临时关掉测试。如果是网关,确认网关的 TCP 服务已开启。

5.2 现象:连上了,但读寄存器返回异常码 0x02(Illegal Data Address)

原因:请求的地址超出了设备实际支持的寄存器范围。比如设备只有 100 个保持寄存器,你从地址 200 开始读。

解决:查设备手册确认寄存器地址范围。先用一个已知存在的地址(比如手册里明确写的某个参数地址)测试,通了再扩展。别一上来就读一大片。

5.3 现象:读回来全是 0,但设备明明有数据

原因:地址偏移搞错了(40001 vs 0),或者读错了数据区(该读输入寄存器却读了保持寄存器),或者从站号不对读到了空设备。

解决:先确认功能码对不对,再看地址。把地址 ±1 试一遍。用抓包工具看请求报文里的地址字段,和设备手册对照。如果设备支持,用厂商自带的调试软件先读一次,确认能读到,再回来调自己的代码。

5.4 现象:跑一段时间后连接断开,报 Connection reset by peer

原因:设备侧有连接空闲超时,长时间不发请求就被断开;或者网络不稳定;或者客户端没做重连。

解决:加心跳,定期读一个寄存器保持连接活跃。客户端加自动重连逻辑,捕获异常后重新 connect。如果是 lwIP 这类嵌入式协议栈,注意 TCP 的 keepalive 配置,默认可能很长。

import time from pymodbus.client import ModbusTcpClient def read_with_retry(client, address, count, slave, retries=3): for i in range(retries): try: rr = client.read_holding_registers(address=address, count=count, slave=slave) if not rr.isError(): return rr.registers except Exception as e: print(f"第{i+1}次失败: {e}") client.close() time.sleep(1) client.connect() return None

这段重试逻辑在异常时关闭连接、等待、重连,再试。retries=3是重试次数,time.sleep(1)是重试间隔,实际项目里可以按需调整。

5.5 现象:Wireshark 抓到 TCP 三次握手成功,但 Modbus 请求发出去没响应

原因:请求报文格式错误,比如 MBAP 头的长度字段填错,或者 Transaction ID 没对上导致客户端丢弃响应。也可能是设备只接受特定 Unit ID。

解决:在 Wireshark 里过滤tcp.port == 502,看请求报文和响应报文。如果只有请求没有响应,检查 MBAP 头。长度字段应该等于「Unit ID + PDU」的字节数。用现成的库一般不会错,手搓报文才容易出这个问题。确认设备要求的 Unit ID,有些设备只认 0xFF 或 1。

提示:抓包时把请求和响应的 Transaction ID 对比一下,对不上就是客户端匹配逻辑有问题,不是设备没回。

6. 进阶:把测试脚本变成能长期跑的采集程序

测试跑通只是第一步,真正投入项目要的是能稳定跑几天不出问题的采集程序。这里说几个我踩过坑之后固定下来的做法。

第一是连接管理。别每次读都新建连接,Modbus TCP 建立连接有开销,频繁建连断连设备也受不了。用一个长连接,配合心跳和重连。心跳不用太频繁,10 到 30 秒读一次就行,读什么不重要,保持连接活跃即可。

第二是超时设置。默认超时可能很长,设备掉线时程序会卡住。把 socket 超时设短一点,比如 3 秒,超时就重试。pymodbus 可以在建客户端时传timeout=3。

第三是数据校验。读回来的值要做范围检查,比如温度不可能 60000 度,出现明显异常值大概率是字节序或地址错了,别直接入库。加一层合理性判断,异常时告警而不是写脏数据。

第四是日志。每次请求和响应都记下来,出问题时能回溯。不用记全部数据,记时间、地址、功能码、结果状态就够。日志按天切分,别让一个文件无限增长。

import logging from logging.handlers import TimedRotatingFileHandler # 按天切分日志,保留7天 handler = TimedRotatingFileHandler('modbus.log', when='D', backupCount=7, encoding='utf-8') handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)s %(message)s')) logger = logging.getLogger('modbus') logger.addHandler(handler) logger.setLevel(logging.INFO) # 采集循环里记录 logger.info(f"read addr=0 count=2 result={registers}")

这段日志配置按天切文件,保留 7 天,编码指定 utf-8 避免中文乱码。采集循环里把关键结果记进去,出问题时翻日志比重新抓包快得多。

最后说个验证方法:拿一个已知值的寄存器,比如设备上某个固定参数,反复读 1000 次,统计成功率和耗时。成功率低于 99% 就说明链路或超时设置有问题,别急着上线。我自己习惯是先在实验室跑一晚上,第二天看日志有没有断连和异常,没问题再上现场。这个习惯帮我省过好几次现场返工。

希望帮到你。

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

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

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

立即咨询