简介:ModbusTcpServer1.zip 提供一款基于 Modbus TCP 协议的服务端模拟器,专为工业物联网与工控软件开发调试人员准备。Modbus 协议是工业自动化领域常见的通信规范,常用于 PLC 和现场设备间的数据交换。本模拟器可在没有实际硬件时,模拟线圈、离散输入、输入寄存器与保持寄存器等多种数据区,并允许配置寄存器映射,从而帮助开发者验证客户端通信逻辑。压缩包共包含九个文件,核心是主程序 exe,另有用于通信和 JSON 处理的 dll 文件、XML 接口说明、TXT 日志与使用说明,以及 PDB 调试符号,整体容量仅 928KB,轻便易用。结合 C# 语言,开发者可使用 Socket 或 NModbus 库快速编写客户端,完成连接、请求帧构造、响应解析等完整流程,有效缩短工控应用开发周期。目前已有三千二百七十二人学习下载,适合需要深入理解 Modbus TCP 通信机制、进行设备模拟或联调验证的工程技术人员。
1. 调试通信不用等真设备:先用服务端模拟器把链路打通
做工业物联网上位机或者 PLC 通信调试的人多半经历过这种场面:程序写好了,报文格式也对,可现场设备就是没反应。查到最后发现,要么是寄存器地址搞错,要么是功能码用错,甚至只是网线松了。问题在于真实设备是个黑匣子,你没法确定到底是程序错还是设备错。Modbus TCP 服务端模拟器就是干这个用的:它在电脑上模拟一个 Modbus 从站,监听 502 端口,把线圈、寄存器全部放进内存,让你在没有真实 PLC 和传感器的情况下,先把通信链路完整跑通。这次拆的是打包好的 ModbusTcpServer1.zip,解压即用的那种,适合做上位机开发、PLC 调试、网关联调以及刚入门 Modbus 协议的人。
2. Modbus TCP 协议要点:从寄存器模型到 MBAP 报文的对应关系
2.1 数据模型与功能区:四个存储区决定读写边界
Modbus 协议把从站设备的数据划分成四个存储区,很多人读文档时容易绕晕,但只要抓住一点就行:每个存储区有独立的地址空间和对应的功能码。线圈(Coil)和离散输入(Discrete Input)是按位操作的,一个地址对应一个 bit;输入寄存器(Input Register)和保持寄存器(Holding Register)是按 16 位字操作的,一个地址对应一个寄存器。这四类存储区在协议层都用 0 起始的地址编号,但在 PLC 传统习惯里,保持寄存器被人为排到 40001 往后。我一般记一个口诀:线圈 0xxxx,离散输入 1xxxx,输入寄存器 3xxxx,保持寄存器 4xxxx。协议地址 0 对应 40001,这个偏移关系在后面避坑部分还会反复提到。
从站地址也很关键。Modbus 是典型的一主多从结构,一个主站轮询多个从站,每个从站在总线上有唯一的地址。在 Modbus TCP 里,这个地址被放进 MBAP 头的单元标识符字段,取值范围 1 到 247。模拟器启动时通常会让你填一个从站地址,客户端请求里带的 unit id 必须和它对上,否则模拟器会返回异常响应或者直接忽略。
功能码和存储区的对应关系,是读写能不能成功的核心。下面这张表是我每次调试前都会扫一眼的对照关系,模拟器也是按这套规则实现的内存读写。
| 存储区 | 读功能码 | 写功能码 | 单位 | 标志位/寄存器含义 |
|---|---|---|---|---|
| 线圈 | 0x01 | 0x05(单线圈) / 0x0F(多线圈) | bit | 可读可写,代表开关量输出 |
| 离散输入 | 0x02 | 不支持 | bit | 只读,代表开关量输入 |
| 输入寄存器 | 0x04 | 不支持 | 16 位字 | 只读,代表模拟量输入 |
| 保持寄存器 | 0x03 | 0x06(单寄存器) / 0x10(多寄存器) | 16 位字 | 可读可写,代表参数或模拟量输出 |
模拟器的本质,就是把这四个存储区在内存里各自开一段数组。你启动的时候可以指定每个区域的大小,比如保持寄存器 100 个、线圈 128 个。客户端发来读请求,模拟器就去数组里取对应下标的数据包成响应返回。这也解释了为什么模拟器比真设备更适合做第一站测试:它读写的是内存,结果完全确定,不存在接线压降、供电不稳这种物理层干扰。
2.2 MBAP 报文头与 PDU:请求响应如何对上
Modbus TCP 报文比 RTU 简单,因为它把 RTU 里的从站地址和 CRC 校验都换成了 MBAP 头。报文结构是:事务处理标识符(2 字节)、协议标识符(2 字节)、长度(2 字节)、单元标识符(1 字节),后面跟功能码和数据。事务处理标识符由主站生成,每次请求自增,用来匹配请求和响应;协议标识符固定为 0000 表示 Modbus;长度字段是从单元标识符开始到报文末尾的字节数,这一点很多人会算错,它不是整个报文的长度。
举个实际例子,读保持寄存器的请求报文长这样:事务 ID 0001,协议 ID 0000,长度 0006,单元 ID 01,功能码 03,起始地址 0000,寄存器数量 000A。长度为什么是 6?因为单元标识符 1 字节加上功能码 1 字节加上起始地址 2 字节加上数量 2 字节,正好 6 字节,它不包含前面的事务 ID、协议 ID 和长度字段本身。模拟器收到后,会返回同样的事务 ID,长度变成 5 加 2 乘以寄存器数量,数据区按顺序排列每个寄存器的值,每寄存器 2 字节,高位在前。
Modbus RTU 的老朋友在这里会问:CRC 校验去哪了?TCP 报文不需要再算 CRC,因为 TCP/IP 协议栈本身有校验和确认重传机制。这也是为什么学习协议时,我建议从 TCP 版本入门,报文直观、没有 CRC 计算干扰,等你把功能码和地址搞清楚,再去看 RTU 的报文就轻松很多。模拟器在解析层做的事情,就是不停读取 TCP 连接里的字节流,按 MBAP 头拆帧,识别功能码,操作对应的存储区数组,再按规则拼响应。如果请求里带了不支持的地址,它会拼一帧异常响应,功能码最高位置 1,异常码 02 表示非法数据地址,03 表示非法数据值。
2.3 为什么联调前先用模拟器:把故障面收窄到只剩程序逻辑
选模拟器不是因为它能替代真实设备,而是为了在联调阶段把故障面切开。真实场景里出现问题,可能的原因有十几个:设备地址不对、波特率不匹配、寄存器地址算错、功能码不支持、甚至屏蔽层接地不良导致干扰。这些因素叠加在一起,排查成本很高。模拟器的价值在于它只工作在协议层,物理层就是本机回环或者局域网,能排除掉九成以上的硬件干扰。
我一般会这样做:先用模拟器立一个基线,确定我的客户端代码、寄存器映射、轮询逻辑都是对的,然后再把真实设备接上来。如果换真设备后出现问题,问题范围就天然指向设备配置或物理链路,而不是代码逻辑。这比直接拿真设备从头调要快得多,特别是遇到那种现场设备地址被改过、寄存器起始地址和你代码里假设不一致的情况,模拟器的确定性会让问题瞬间暴露。下一章就进入正题,把这个模拟器跑起来。
3. ModbusTcpServer1 实战:从启动配置到读写闭环
3.1 启动与初始化:端口、从站地址和寄存器预置值
把 ModbusTcpServer1.zip 解压到一个单独的目录,运行主程序。这类模拟器通常会在界面上提供一个监听 IP 和端口设置,端口默认是 502,这是 Modbus TCP 的标准端口。如果你的电脑上 502 已经被占用,或者没有管理员权限,可以改成 1502 这类高位端口,客户端连接时同步改成对应端口就行,我建议调试阶段直接改用高位端口,省去权限和冲突的麻烦。从站地址一般填 1,与大多数客户端默认值一致。
越是接近真实工况,越要在启动前把寄存器预置值设好。模拟器界面里通常有寄存器表,可以手动填写保持寄存器的初始值。比如我要验证一个温控逻辑,会把 40001 填成 250,代表当前温度 25.0 度;40002 填成 450,代表湿度 45.0 度。这个步骤很有用,读回的值不是零或者随机值,而是你自己设的确定数,客户端那边读出什么、算对没有,一眼就能判断。
配置项里还有一个容易被忽略的部分:存储区范围。有些模拟器默认只开放一部分寄存器,如果你在客户端读 40000 号地址之外的区域,会收到异常码 02(非法数据地址)。我一般会把保持寄存器范围设成 100 个,线圈设成 128 个,足够覆盖绝大多数测试场景。常见配置参数整理如下,启动前按这个表过一遍:
| 参数 | 示例值 | 说明 |
|---|---|---|
| 监听 IP | 127.0.0.1 或 0.0.0.0 | 本机调试用 127.0.0.1,局域网联调用 0.0.0.0 |
| 端口 | 502 或 1502 | 高位端口避免权限问题 |
| 从站地址 | 1 | 必须与客户端 unit id 一致 |
| 保持寄存器范围 | 100 | 决定 03/06/16 功能码可访问区域 |
| 线圈范围 | 128 | 决定 01/05/15 功能码可访问区域 |
| 字节序 | 大端(AB) | Modbus 协议默认大端,32 位数据需单独确认 |
启动成功之后,可以用 netstat 命令确认服务在监听。Windows 下在命令行执行 netstat -ano | findstr 502,看到 LISTENING 状态和对应 PID 就说明服务起来了。这一步虽然简单,但能帮你把"模拟器没启动成功"和"客户端连不上"两个问题区分开。
3.2 用 modbus poll 人工验证链路
modbus poll 是 Modbus 调试中最常用的客户端工具,它以一个主站的角色去轮询从站,界面里能直观看到寄存器数值的变化。新建连接,填上 IP 127.0.0.1、端口 502 或你改的端口、从站地址 1,功能码选 03(读保持寄存器),起始地址填 0,数量填 10,然后点连接按钮。如果前面寄存器预置值填了 40001 等于 250,此时界面上第一个寄存器应该显示 250。
这一步等于做了个最基础的链路验证:TCP 能连通、MBAP 头解析正常、功能码和寄存器区域匹配、地址换算没有偏差。modbus poll 里还有一个优势是它能同时打开多个窗口,一个窗口读保持寄存器,另一个窗口写线圈,这样你能直观地看到读和写是独立的两个通道。比如用一个窗口通过功能码 06 写单个寄存器,把 40001 的值改成 300,另一个窗口按 03 功能码读同一个地址,数值应该立刻刷新成 300。
手动验证有一个容易被忽略的好处:它让你记住了功能码和寄存器区域的对应关系。很多人直接用程序写,错了就改代码,绕来绕去效率低。先用手动工具把链路走通,再走进自动化环节,相当于给后续排错立了一个参照物。我自己的习惯是任何新环境、新 IP、新端口,都会先用 modbus poll 摸一遍,确认能读到值才上脚本。
3.3 用 pymodbus 自动化验证与一主多从轮询
人工验证只能说明链路通,真正要做回归测试,还是得靠脚本。pymodbus 是 Python 生态里最常用的 Modbus 库,支持 TCP 和串口两种传输方式。下面这段代码用 ModbusTcpClient 模拟一个主站,连接本机模拟器,依次完成读、写、批量写、回读验证。这段代码是每一轮联调的起手式,覆盖了最核心的读写操作。
from pymodbus.client import ModbusTcpClient # 连接模拟器,IP 127.0.0.1,端口 502,超时 3 秒 client = ModbusTcpClient("127.0.0.1", port=502, timeout=3) if not client.connect(): raise SystemExit("连接模拟器失败,请确认服务已启动且端口未被占用") # 从协议地址 0 开始读 10 个保持寄存器,对应 PLC 地址 40001~40010 # unit=1 必须与模拟器启动时配置的从站地址一致 rr = client.read_holding_registers(address=0, count=10, unit=1) if rr.isError(): raise SystemExit(f"读取失败: {rr}") print("初始寄存器值:", rr.registers) # 写单寄存器:往 40001 写入 12345 wr = client.write_register(address=0, value=12345, unit=1) if wr.isError(): raise SystemExit(f"写单个寄存器失败: {wr}") # 批量写:往 40101 开始的 3 个寄存器写入 111/222/333 wr = client.write_registers(address=100, values=[111, 222, 333], unit=1) if wr.isError(): raise SystemExit(f"批量写失败: {wr}") # 写后回读,验证数据是否真正落到模拟器的存储区 rr2 = client.read_holding_registers(address=0, count=103, unit=1) print("写后回读:", rr2.registers) print("验证结果:", "通过" if rr2.registers[0] == 12345 and rr2.registers[100:103] == [111, 222, 333] else "失败") client.close()参数和逻辑分成几点说。address 是协议地址,从 0 开始,映射到 PLC 习惯里的 40001,如果你在建表的时候把数据放在 40001,那 address 就填 0。count 是读取的寄存器数量,最大一般不超过模拟器配置的存储区边界。unit 参数是 Modbus TCP 里的单元标识符,相当于 RTU 的从站地址,模拟器端配置成几,这里就填几,不匹配时请求会被拒绝。客户端超时 timeout 参数在排查时特别有用,如果模拟器响应慢或者根本没收到,pymodbus 会在超时后抛出异常,这个值默认是 3 秒,局域网环境够用。
对于一主多从的场景,pymodbus 也支持得很直接。只要模拟器端支持多从站模式,你可以启动多个实例分别占用不同端口,或者用同一个模拟器监听不同单元标识符。代码里把 unit 分别改成 1 和 2,对每个连接单独发请求即可。这种测试可以提前验证你上位机轮询多台设备的逻辑,省去现场同时接多台设备的麻烦。我再补一个习惯:脚本里每次写完必须回读,不回读的写入验证是不完整的,你只能证明数据发出去了,不能证明模拟器确实存进去了。
4. 模拟器避坑指南:五个让读写对不上的细节
4.1 地址偏移:协议地址 0 和 PLC 地址 40001 的换算
现象:客户端往地址 40001 写值,模拟器返回异常码 02(非法数据地址),或者写入成功但读回来是 0,数据不知道落在哪个坑里。
原因:地址体系没有对齐。Modbus TCP 协议层的地址是从 0 开始的,40001 这种带区域的地址是 PLC 编程传统延续下来的显示习惯。模拟器内部用数组下标存寄存器,0 号下标对应协议地址 0,也对应 PLC 概念里的 40001。如果你把 PLC 地址 40001 直接填进报文的地址字段,实际访问的是数组第 40001 个位置,早就超出存储区范围了。
解决:统一换算规则,协议地址 = PLC 地址 - 40001。我在模拟器里建数据表时,会把寄存器都标成协议地址,旁边注释写上对应的 PLC 地址,这样两套体系不会混。另外在 modbus poll 里如果勾选了 PLC 地址模式,它显示的是 40001 起始,但填入报文时发送的是 0 起始,这个转换是工具帮你做的,而在 pymodbus 这类库里,所有地址都按协议地址填,工具不会帮你换算,你得自己减掉 40001 这个偏移量。
4.2 功能码与存储区不匹配
现象:能连上模拟器,读保持寄存器正常,但读输入寄存器全是 0,或者永远报非法功能码。
原因:四个存储区在协议层是独立的地址空间,功能码决定了访问哪个空间。用 03 读的是保持寄存器,用 04 读的是输入寄存器,两者地址都从 0 开始,但指向不同的内存区域。模拟器初始化时如果只给保持寄存器预置了值,输入寄存器区域的数组是全 0,读出来当然全是 0。更常见的是很多人拿着三菱或者西门子的例程,原封不动把功能码搬过来,结果指令期望的是 03,实际发的是 04,数据对不上。
解决:写代码前先对照功能码和存储区的映射表,明确这个设备的数据是放在哪个区域。一般情况下,设备的参数、设定值、累计值放在保持寄存器(03/06/16),传感器的实时值放在输入寄存器(04)。如果设备手册只说地址,没说功能码,那就先试用 03 读一次,再用 04 读一次,能读到合理值的那组就是对的。模拟器端一样,你在预置数据时就要把值和区域对应好,保持寄存器填一份,输入寄存器填另一份,不要都堆在同一个区域。
4.3 502 端口被占用,模拟器起不来
现象:双击模拟器程序后界面闪退,或者提示 bind 失败、地址已被使用,客户端连接直接被拒绝。
原因:502 端口已经被其他进程占用了。最常见的场景是之前调试时开过另一个模拟器没关干净,或者装了其他工业软件抢占了 502。Windows 下端口检查很简单,命令行执行 netstat -ano | findstr :502,看到的第二列是本地地址,最后一列是占用进程的 PID。
解决:先确认是谁占用了端口,再决定是杀掉进程还是换端口。用 tasklist | findstr PID 查看对应进程名,如果确实是残留的模拟器进程,taskkill /F /PID PID 强制结束。如果换端口更省事,就在模拟器配置里把端口改成 1502,客户端连接地址同步改成 1502。从那以后我养成了一个习惯:启动模拟器后先刷一次 netstat,确认监听成功再写代码,这个动作 10 秒不到,能省掉后面一大轮排查。
4.4 32 位数据的字节序组合
现象:往模拟器写一个浮点数或者超过 65535 的整数,读回来完全对不上。比如写入 65536,寄存器里看到的是 65535 和 1 的组合,或者高低字颠倒,变成 1 和 65536,数值翻了几番。
原因:Modbus 寄存器最小单位是 16 位,一个 32 位数据要拆进两个连续的寄存器里。协议本身只规定 16 位寄存器内部是大端字节序,但没有规定 32 位数据的字序。于是出现了字节序(Byte Order)和字序(Word Order)四种组合:ABCD、DCBA、CDAB、BADC。模拟器端和客户端如果各自用默认设置,很可能一个按 AB CD 拼,另一个按 CD AB 拆,数据全乱。
解决:先把数据格式统一成大端,再逐字节核对。我在 pymodbus 里读回两个连续寄存器后,会手动按大端拼合成 32 位值:高 16 位左移 16 位,与低 16 位按位或。这比依赖库的自动转换更可控,因为你能看见每一步字节操作。模拟器端同样也提供字节序设置,如果两边拼接方式对不上,就手动把一端的字节序改成和另一端一致。测试时用一个已知的浮点数比如 123.456,读回后转成十六进制肉眼对比,能确定是哪种顺序组合,就不要去猜。
4.5 连接超时与自动断开
现象:客户端连上模拟器后,短时间不通信连接就断开,或者读写偶尔超时,请求发出去收不到响应。
原因:这类模拟器为了模拟真实设备的资源占用,通常会限制最大连接数,并设置空闲超时。如果客户端建了连接但长时间不发请求,服务端会把连接回收。另外,如果你的客户端轮询周期设置得很短,比如 10ms 一次,再加上 timeout 也是 10ms,网络抖动一次就会触发超时误判。
解决:留意模拟器界面上的连接数和超时配置,如果有限制就把客户端轮询间隔调到 100ms 以上,给网络和模拟器处理留出余量。客户端的 timeout 参数不要压得太紧,我一般局域网环境设 1 秒到 3 秒。排查超时问题时,先用 modbus poll 手动点几轮读写,如果手动操作正常但脚本超时,问题基本在脚本的轮询和 timeout 参数上。另外防火墙也可能拦截 502 端口的入站连接,Windows 首次运行程序弹窗时要点允许,否则外部设备连不进来。
5. 进阶:用原生 socket 构造 MBAP 报文,不依赖任何库
5.1 手工拼一帧读保持寄存器请求
pymodbus 这类库里把报文封装得足够好,但封装也意味着你对协议的理解容易停留在调用层面。当遇到一些特殊场景,比如验证网关转发逻辑、给嵌入式设备做协议联调,工具库不一定支持你想要的报文格式,这时候手工构造报文就很有价值。下面用 Python 的 socket 直接构造并发送一帧读保持寄存器请求,全程不依赖任何 Modbus 库。
import socket # 构造 Modbus TCP 读保持寄存器请求 # 事务ID(2B) + 协议ID(2B) + 长度(2B) + 单元ID(1B) + 功能码(1B) + 起始地址(2B) + 数量(2B) req = bytes.fromhex("0001 0000 0006 01 03 0000 000A".replace(" ", "")) s = socket.create_connection(("127.0.0.1", 502), timeout=3) s.sendall(req) resp = s.recv(1024) print("响应原始字节:", resp.hex()) # 解析响应:长度字段在偏移 4~5,字节计数字段在偏移 8 byte_count = resp[8] registers = [ int.from_bytes(resp[9 + i * 2: 11 + i * 2], "big") for i in range(byte_count // 2) ] print("解析出寄存器:", registers) s.close()这段代码把协议细节暴露得很清楚:事务 ID 0001 是客户端自己定的,响应里会原样返回,用来匹配请求和响应;协议 ID 0000 表示 Modbus 协议;长度 0006 是从单元标识符开始往后数的字节数;单元标识符 01 相当于从站地址;功能码 03 读保持寄存器;起始地址 0000 表示从协议地址 0 开始;寄存器数量 000A 表示读 10 个。响应解析时 byte_count 告诉你数据区有几个字节,一定要用这个字段来遍历寄存器,而不是硬编码数量,因为异常响应没有数据区,结构完全不同。
5.2 接收与解析的各种边界情况
手工构造报文还有一个好处,就是你能亲眼看到异常响应长什么样。比如把起始地址改成超出存储区范围的值,模拟器会返回事务 ID 相同、功能码变成 0x83(最高位置 1)、数据区只有一个异常码 0x02 的响应。这种细节在库封装里看不太到,但真实设备调试时,你收到 0x83 或 0x02 就要立刻反应过来是什么含义。这也是我一再强调先用模拟器验证的原因:它让你在可控环境里见过所有异常形态,到了现场就不会慌。
从那以后,我每次做 Modbus 相关调试都强制走一遍固定流程:先起模拟器,用 modbus poll 手动读一轮,再用脚本自动化回归,最后用 socket 构造原始报文做一次协议级确认。这套流程把链路分成三层逐层验证,哪一层有问题当场就能定位,比直接拿着真设备从头摸要高效得多。希望你以后调试 Modbus 时也能少踩几个坑,希望帮到你。
本文还有配套的精品资源,点击获取