
简介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 个寄存器 # 参数说明address0 表示起始地址values 列表长度决定可访问范围 store ModbusSlaveContext( diModbusSequentialDataBlock(0, [0] * 100), # 离散输入 1x 区 coModbusSequentialDataBlock(0, [0] * 100), # 线圈 0x 区 hrModbusSequentialDataBlock(0, [0] * 100), # 保持寄存器 4x 区 irModbusSequentialDataBlock(0, [0] * 100), # 输入寄存器 3x 区 ) # unit0x00 表示默认单元singleTrue 表示只模拟一个从站 context ModbusServerContext(slavesstore, singleTrue) # 监听所有网卡的 5020 端口改成 502 就是标准 Modbus 端口 StartTcpServer(contextcontext, 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( hrModbusSequentialDataBlock(0, [0] * 100), ) context ModbusServerContext(slavesstore, singleTrue) 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(targetupdate_registers, daemonTrue).start() StartTcpServer(contextcontext, address(0.0.0.0, 5020))这里的关键是setValues的调用方式。第一个参数 3 代表保持寄存器第二个参数 0 是起始地址第三个参数是要写入的值列表。客户端读 40001 和 40002 时就会拿到这两个动态变化的值。温度用 200~300 表示 20.0~30.0 度是工业里常见的放大 10 倍存整数的做法组态软件里再除以 10 显示。线程用daemonTrue是为了主程序退出时它自动结束不然你 CtrlC 之后进程可能还挂着。更新频率设 1 秒是折中太快了日志刷屏太慢了看不出变化。3. 把模拟器接到真实组态软件上连接参数与地址映射的实操3.1 组态软件侧的连接配置怎么填模拟器跑起来之后下一步是让组态软件连上来。以常见的几种组态环境为例新建设备时选 Modbus TCP 驱动IP 填运行模拟器那台机器的地址端口填 5020站号填 1 或者 0 都行取决于模拟器的single参数。如果模拟器设了singleTrue站号填什么都会被接受如果设了singleFalse就得用context[站号]的方式取数据。连接超时我一般设 3000 毫秒重试次数 3 次。工业现场网络抖动是常态超时太短会频繁断连太长又会让界面卡顿。这个值没有标准答案得根据实际网络质量调。3.2 地址映射的三种常见写法与对应关系组态软件里填地址的方式五花八门有的写 40001有的写 4x0001还有的写 4:00001。不管哪种写法核心都是「区号 偏移」。下面这张对照表能帮你快速定位组态写法区号含义模拟器内访问功能码40001保持寄存器偏移 0hr[0]0340002保持寄存器偏移 1hr[1]0330001输入寄存器偏移 0ir[0]0400001线圈偏移 0co[0]0110001离散输入偏移 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, encodingutf-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( hrModbusSequentialDataBlock(0, hr_values), coModbusSequentialDataBlock(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(addritem[address], loitem[min], hiitem[max], intervalitem[interval]): while True: value random.randint(lo, hi) context[0].setValues(3, addr, [value]) time.sleep(interval) threading.Thread(targetupdater, daemonTrue).start() if __name__ __main__: cfg load_config(modbus_config.json) store build_store(cfg) context ModbusServerContext(slavesstore, singleTrue) start_updaters(context, cfg) StartTcpServer(contextcontext, address(0.0.0.0, cfg[port]))这段代码把配置读取、数据块构建、更新线程启动串起来了。build_store根据配置里的地址把初始值填进数组start_updaters为每个random模式的寄存器开一个线程各自按自己的周期更新。这样你改配置就能改行为不用碰代码。参数方面interval设 1 秒适合模拟慢变过程量如果要模拟快速波动的信号可以设 0.1但要注意线程切换开销。min和max的取值要符合实际物理量范围不然组态软件里显示出来会很奇怪。5.3 用日志确认模拟器行为是否符合预期配置驱动之后出问题不好定位因为逻辑分散在配置和代码里。我的习惯是在关键路径上加日志记录每次读写请求和更新动作。import logging logging.basicConfig( levellogging.INFO, format%(asctime)s [%(levelname)s] %(message)s, handlers[ logging.FileHandler(modbus_server.log, encodingutf-8), logging.StreamHandler() ] ) # 在 updater 里加一行 logging.info(update hr[%d] %d, addr, value)日志里能看到每个寄存器的更新时间和值对照组态软件里的曲线就能判断是模拟器没更新还是组态软件没读到。这个习惯帮我省了很多来回猜的时间。5.4 一个我踩过的坑配置文件编码问题有次在 Windows 上编辑 JSON 配置保存成了 GBK 编码模拟器用 UTF-8 读就报错。现象是启动直接崩日志里一堆乱码。后来统一用encodingutf-8打开文件并且在编辑器里强制保存为 UTF-8这个问题再没出现过。如果你在中文环境下做配置驱动这个点值得注意。5.5 模拟器值不值得投入我的判断标准如果你只是临时验证一个地址手写几行代码就够了没必要上配置驱动。但如果你要反复测试多个项目、多个从站、多种数据变化模式那花半天把配置和日志搭好后面每次调试都能省下大量改代码的时间。我的习惯是同一个模拟器需求出现第三次就把它配置化。这个阈值帮我避免过度设计也避免重复劳动。希望帮到你。本文还有配套的精品资源点击获取