☰
Modbus TCP 服务端模拟器:从零搭建与调试避坑指南
2026/10/12 3:20:03 网站建设 项目流程

简介:ModbusTcpServer1.zip 是一款面向工业自动化与工业物联网开发者的 Modbus TCP 服务端模拟器,适合需要在无真实 PLC 硬件条件下调试通信程序的 C# 工程师与学习者。它可模拟输入寄存器、保持寄存器、离散输入与线圈等寄存器映射,帮助验证客户端读写逻辑,降低联调成本。压缩包共 9 个文件,约 928KB,以 exe 可执行程序为主,辅以 dll 通信库、pdb 调试符号与 xml 配置说明文档,开箱即可启动服务端并配置寄存器映射。目前已有 3276 人学习下载,说明其在工控通信入门与测试场景中具备一定参考价值。借助该模拟器,读者可结合 C# 的 Socket 编程或 NModbus 等开源库,完成连接建立、请求报文封装、响应解析与异常排查的完整练习,为实际工控项目中的 Modbus TCP 通信开发打下基础。

1. 为什么一个 ModbusTcpServer 模拟器能省掉整条调试链路

做工业数据采集的同行大概都遇到过这种局面:现场 PLC 还没到货,上位机组态软件已经装好了,通信协议写的是 Modbus TCP,可就是没有从站可以连。你总不能对着空气调寄存器地址,更没法验证 40001 和 30001 到底映射到哪个功能码。这时候一个能跑在本地、能自定义寄存器值的 Modbus 协议服务端模拟器,价值就出来了——它把「等硬件」这件事从关键路径上摘掉。

ModbusTcpServer1.zip 这个标题指向的东西很明确:一个基于 Modbus TCP 协议的服务端模拟程序。它要解决的核心问题是让开发者在没有真实从站设备的情况下,拥有一个行为可控、数据可改的 Modbus 服务端。适用人群包括上位机开发、SCADA 组态调试、网关协议转换测试,以及做工业物联网平台接入的工程师。你不需要懂嵌入式,只要能在 PC 上跑起来,就能把整条采集链路先打通。

2. Modbus TCP 服务端到底在做什么:从报文结构到寄存器映射

2.1 先搞清楚客户端发过来的那串字节是什么意思

Modbus TCP 的报文比 RTU 多了一个 7 字节的 MBAP 头,去掉这个头之后,剩下的就是和 RTU 一样的 PDU。很多新手第一次抓包看到00 01 00 00 00 06 01 03 00 00 00 0A会懵,其实拆开看很清晰:前两字节是事务标识,接着两字节协议标识固定为 0,然后两字节长度表示后面还有多少字节,再一字节单元标识,最后才是功能码和数据。

服务端模拟器要做的就是解析这个结构,根据功能码去查对应的寄存器区,把值拼成响应报文发回去。听起来简单,但坑在于字节序和寄存器地址的偏移。Modbus 协议里寄存器地址从 0 开始算,但组态软件里经常写 40001,这个 4 是区号,001 才是偏移。模拟器如果不做这层映射,客户端读上来的数据就会整体错位。

我一般会建议在模拟器里把四个区都显式建出来:线圈(0x)、离散输入(1x)、输入寄存器(3x)、保持寄存器(4x)。每个区用独立的数组存值,功能码 01/02/03/04/05/06/15/16 分别对应读写操作。这样客户端无论读哪个区,都能拿到确定的数据。

2.2 功能码与寄存器区的对应关系表

下面这张表是我在排查通信问题时最常翻的,贴在显示器边上那种:

功能码操作作用区常见组态地址数据单位
01读线圈0x 区00001~09999位
02读离散输入1x 区10001~19999位
03读保持寄存器4x 区40001~4999916 位字
04读输入寄存器3x 区30001~3999916 位字
05写单个线圈0x 区00001~09999位
06写单个寄存器4x 区40001~4999916 位字
15写多个线圈0x 区00001~09999位
16写多个寄存器4x 区40001~4999916 位字

这张表的关键在于:客户端读 40001 时,模拟器内部实际访问的是保持寄存器数组的第 0 个元素。如果你在模拟器里把数组下标直接当成地址用,那 40001 就会变成访问第 40001 个元素,直接越界。这个偏移量减 1 的操作,是模拟器实现里最容易翻车的地方。

2.3 用 Python 搭一个最小可用的 Modbus TCP 服务端

下面这段代码是我常用的最小实现,依赖pymodbus库,跑起来就能被组态软件连上。注意端口用 502 需要管理员权限,测试时我一般换成 5020。

from pymodbus.server import StartTcpServer from pymodbus.datastore import ModbusSequentialDataBlock from pymodbus.datastore import ModbusSlaveContext, ModbusServerContext # 初始化四个区的数据块,每个区先给 100 个寄存器 # 参数说明:address=0 表示起始地址,values 列表长度决定可访问范围 store = ModbusSlaveContext( di=ModbusSequentialDataBlock(0, [0] * 100), # 离散输入 1x 区 co=ModbusSequentialDataBlock(0, [0] * 100), # 线圈 0x 区 hr=ModbusSequentialDataBlock(0, [0] * 100), # 保持寄存器 4x 区 ir=ModbusSequentialDataBlock(0, [0] * 100), # 输入寄存器 3x 区 ) # unit=0x00 表示默认单元,single=True 表示只模拟一个从站 context = ModbusServerContext(slaves=store, single=True) # 监听所有网卡的 5020 端口,改成 502 就是标准 Modbus 端口 StartTcpServer(context=context, address=("0.0.0.0", 5020))

这段代码的逻辑很直白:先给四个寄存器区各分配 100 个 16 位空间,初始值全 0,然后启动 TCP 监听。客户端连上来读 40001 时,pymodbus内部会自动把地址偏移处理好,你不需要手动减 1。但要注意,ModbusSequentialDataBlock的第一个参数是起始地址,如果你写成 1,那客户端读 40001 就会访问到数组的第 1 个元素,数据整体后移一位。

参数方面,address填"0.0.0.0"表示接受任意网卡进来的连接,调试阶段这样最方便。如果只想本机测试,改成"127.0.0.1"更安全。端口选 5020 是为了避开 502 的权限问题,组态软件里把目标端口改成 5020 就行。

2.4 让寄存器值动起来:模拟真实设备的数据变化

静态的 0 值只能验证连通性,要模拟真实设备,得让寄存器值周期性变化。我通常开一个后台线程,每隔一秒改一次保持寄存器的值,模拟温度、压力这类模拟量。

import threading import time import random from pymodbus.server import StartTcpServer from pymodbus.datastore import ModbusSequentialDataBlock, ModbusSlaveContext, ModbusServerContext store = ModbusSlaveContext( hr=ModbusSequentialDataBlock(0, [0] * 100), ) context = ModbusServerContext(slaves=store, single=True) def update_registers(): """每秒更新一次保持寄存器,模拟传感器数据""" while True: # 第 0 个寄存器模拟温度,范围 200~300,对应 20.0~30.0 度 temp = random.randint(200, 300) # 第 1 个寄存器模拟压力,范围 1000~1500 pressure = random.randint(1000, 1500) # setValues 参数:功能码 3 表示保持寄存器,地址 0,值列表 context[0].setValues(3, 0, [temp, pressure]) time.sleep(1) # 守护线程随主程序退出 threading.Thread(target=update_registers, daemon=True).start() StartTcpServer(context=context, address=("0.0.0.0", 5020))

这里的关键是setValues的调用方式。第一个参数 3 代表保持寄存器,第二个参数 0 是起始地址,第三个参数是要写入的值列表。客户端读 40001 和 40002 时,就会拿到这两个动态变化的值。温度用 200~300 表示 20.0~30.0 度,是工业里常见的放大 10 倍存整数的做法,组态软件里再除以 10 显示。

线程用daemon=True是为了主程序退出时它自动结束,不然你 Ctrl+C 之后进程可能还挂着。更新频率设 1 秒是折中,太快了日志刷屏,太慢了看不出变化。

3. 把模拟器接到真实组态软件上:连接参数与地址映射的实操

3.1 组态软件侧的连接配置怎么填

模拟器跑起来之后,下一步是让组态软件连上来。以常见的几种组态环境为例,新建设备时选 Modbus TCP 驱动,IP 填运行模拟器那台机器的地址,端口填 5020,站号填 1 或者 0 都行,取决于模拟器的single参数。如果模拟器设了single=True,站号填什么都会被接受;如果设了single=False,就得用context[站号]的方式取数据。

连接超时我一般设 3000 毫秒,重试次数 3 次。工业现场网络抖动是常态,超时太短会频繁断连,太长又会让界面卡顿。这个值没有标准答案,得根据实际网络质量调。

3.2 地址映射的三种常见写法与对应关系

组态软件里填地址的方式五花八门,有的写 40001,有的写 4x0001,还有的写 4:00001。不管哪种写法,核心都是「区号 + 偏移」。下面这张对照表能帮你快速定位:

组态写法区号含义模拟器内访问功能码
40001保持寄存器,偏移 0hr[0]03
40002保持寄存器,偏移 1hr[1]03
30001输入寄存器,偏移 0ir[0]04
00001线圈,偏移 0co[0]01
10001离散输入,偏移 0di[0]02

填错区号是最常见的翻车原因。比如你想读保持寄存器却填了 30001,客户端会发功能码 04,模拟器去输入寄存器区找数据,结果全是 0,你还以为是模拟器没跑起来。排查时先抓包看功能码,功能码对了再看地址偏移,基本能定位到问题。

3.3 用命令行工具快速验证模拟器是否正常

在接组态软件之前,我习惯先用命令行工具确认模拟器本身没问题。pymodbus自带一个客户端命令行,或者用mbpoll这类工具也行。

# 读取保持寄存器 40001 开始的 10 个值,站号 1,端口 5020 mbpoll -m tcp -a 1 -p 5020 -t 4 -r 1 -c 10 127.0.0.1 # 参数说明: # -m tcp 使用 Modbus TCP 模式 # -a 1 从站地址为 1 # -p 5020 目标端口 5020 # -t 4 寄存器类型为保持寄存器 # -r 1 起始地址为 1(对应 40001) # -c 10 连续读取 10 个寄存器

如果返回的 10 个值和你模拟器里设的一致,说明服务端没问题,接下来再去调组态软件。如果返回超时,先检查防火墙有没有放行 5020 端口,再确认模拟器监听的是0.0.0.0而不是127.0.0.1。这两个点排查完,九成连接问题都能解决。

4. 避坑指南:Modbus TCP 模拟器调试中最容易踩的五个坑

4.1 现象:客户端读上来的数据整体偏移一位

原因:模拟器内部数组下标和协议地址的偏移没对齐。Modbus 协议地址从 0 开始,但组态软件里 40001 对应的是第 0 个寄存器。如果模拟器初始化时把起始地址设成了 1,或者手动做了减 1 操作,就会导致整体错位。

解决:统一约定。要么全部用协议地址(从 0 开始),要么全部用组态地址(从 1 开始),在模拟器入口处做一次转换,内部只用一种。我一般是在接收请求时把地址减 1,内部数组从 0 开始存,这样最不容易乱。

4.2 现象:写单个寄存器成功,但写多个寄存器失败

原因:功能码 06 和功能码 16 的处理逻辑不同。06 只写一个寄存器,报文里直接带地址和值;16 写多个,报文里带起始地址、寄存器数量和字节计数。如果模拟器只实现了 06 没实现 16,组态软件批量下发参数时就会失败。

解决:确认模拟器支持的功能码列表。pymodbus默认支持 01/02/03/04/05/06/15/16,但如果你自己手写解析逻辑,很容易漏掉 15 和 16。测试时用组态软件分别做单点写入和批量写入,两个都通过才算完整。

4.3 现象:模拟器跑一段时间后无响应,重启又正常

原因:TCP 连接没有正确关闭,导致文件描述符耗尽。Modbus TCP 是长连接协议,客户端异常断开时,服务端如果没设置超时回收,连接会一直挂着。

解决:在StartTcpServer里设置timeout参数,或者在操作系统层面调小 TCP keepalive 时间。我一般会在模拟器外面包一层,定期检查连接数,超过阈值就重启服务。更稳妥的做法是用asyncio版本的服务端,连接管理更精细。

4.4 现象:寄存器值写入后读出来还是旧值

原因:写操作和读操作访问了不同的数据块。比如写的时候用了context[0].setValues(3, ...),读的时候却从context[0].getValues(4, ...)取,功能码 3 和 4 对应不同的寄存器区,数据自然对不上。

解决:写和读必须用同一个功能码。保持寄存器用 3,输入寄存器用 4,线圈用 1,离散输入用 2。组态软件里读地址和写地址也要在同一个区,不能读 40001 写 30001。

4.5 现象:模拟器在本地能连,从另一台机器连不上

原因:模拟器监听地址绑到了127.0.0.1,只接受本机连接。或者运行模拟器的机器防火墙没放行对应端口。

解决:把监听地址改成0.0.0.0,然后在防火墙里添加入站规则放行 5020 端口。如果是 Linux 环境,还要检查iptables或firewalld的状态。测试时可以先telnet 目标IP 5020,通了再上组态软件。

5. 进阶技巧:用配置文件驱动模拟器,一次搭好反复用

5.1 把寄存器初始值和变化规则抽到 JSON 里

每次改模拟器都要动代码太麻烦,我后来习惯把寄存器配置抽成 JSON 文件,模拟器启动时读取。这样换一个项目只需要换配置文件,代码不用动。

{ "port": 5020, "unit_id": 1, "registers": { "holding": [ {"address": 0, "name": "temperature", "initial": 250, "mode": "random", "min": 200, "max": 300, "interval": 1}, {"address": 1, "name": "pressure", "initial": 1200, "mode": "random", "min": 1000, "max": 1500, "interval": 1}, {"address": 2, "name": "setpoint", "initial": 0, "mode": "static"} ], "coils": [ {"address": 0, "name": "pump_start", "initial": 0, "mode": "static"} ] } }

这个配置里,每个寄存器有地址、名称、初始值、变化模式和变化范围。random模式表示在 min 和 max 之间随机取值,static表示保持不变。interval是更新周期,单位秒。

5.2 读取配置并动态注册更新任务

import json import threading import time import random from pymodbus.server import StartTcpServer from pymodbus.datastore import ModbusSequentialDataBlock, ModbusSlaveContext, ModbusServerContext def load_config(path): with open(path, "r", encoding="utf-8") as f: return json.load(f) def build_store(config): """根据配置构建数据块,默认每个区 100 个寄存器""" hr_values = [0] * 100 co_values = [0] * 100 for item in config["registers"].get("holding", []): hr_values[item["address"]] = item["initial"] for item in config["registers"].get("coils", []): co_values[item["address"]] = item["initial"] store = ModbusSlaveContext( hr=ModbusSequentialDataBlock(0, hr_values), co=ModbusSequentialDataBlock(0, co_values), ) return store def start_updaters(context, config): """为每个 random 模式的寄存器启动独立更新线程""" for item in config["registers"].get("holding", []): if item.get("mode") != "random": continue def updater(addr=item["address"], lo=item["min"], hi=item["max"], interval=item["interval"]): while True: value = random.randint(lo, hi) context[0].setValues(3, addr, [value]) time.sleep(interval) threading.Thread(target=updater, daemon=True).start() if __name__ == "__main__": cfg = load_config("modbus_config.json") store = build_store(cfg) context = ModbusServerContext(slaves=store, single=True) start_updaters(context, cfg) StartTcpServer(context=context, address=("0.0.0.0", cfg["port"]))

这段代码把配置读取、数据块构建、更新线程启动串起来了。build_store根据配置里的地址把初始值填进数组,start_updaters为每个random模式的寄存器开一个线程,各自按自己的周期更新。这样你改配置就能改行为,不用碰代码。

参数方面,interval设 1 秒适合模拟慢变过程量,如果要模拟快速波动的信号可以设 0.1,但要注意线程切换开销。min和max的取值要符合实际物理量范围,不然组态软件里显示出来会很奇怪。

5.3 用日志确认模拟器行为是否符合预期

配置驱动之后,出问题不好定位,因为逻辑分散在配置和代码里。我的习惯是在关键路径上加日志,记录每次读写请求和更新动作。

import logging logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", handlers=[ logging.FileHandler("modbus_server.log", encoding="utf-8"), logging.StreamHandler() ] ) # 在 updater 里加一行 logging.info("update hr[%d] = %d", addr, value)

日志里能看到每个寄存器的更新时间和值,对照组态软件里的曲线,就能判断是模拟器没更新还是组态软件没读到。这个习惯帮我省了很多来回猜的时间。

5.4 一个我踩过的坑:配置文件编码问题

有次在 Windows 上编辑 JSON 配置,保存成了 GBK 编码,模拟器用 UTF-8 读就报错。现象是启动直接崩,日志里一堆乱码。后来统一用encoding="utf-8"打开文件,并且在编辑器里强制保存为 UTF-8,这个问题再没出现过。如果你在中文环境下做配置驱动,这个点值得注意。

5.5 模拟器值不值得投入:我的判断标准

如果你只是临时验证一个地址,手写几行代码就够了,没必要上配置驱动。但如果你要反复测试多个项目、多个从站、多种数据变化模式,那花半天把配置和日志搭好,后面每次调试都能省下大量改代码的时间。我的习惯是:同一个模拟器需求出现第三次,就把它配置化。这个阈值帮我避免过度设计,也避免重复劳动。

希望帮到你。

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

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

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

立即咨询