
简介emModbus_EasyModbus_ 是一套面向工业通信开发的 Modbus 协议实现工具适合需要快速搭建 Modbus RTU/TCP 主从机通信的嵌入式开发人员或上位机开发者。压缩包共 23 个文件约 14.34MB文件以 C 语言源文件、Visual Studio 工程配置、可直接运行的 exe 演示程序以及 PDF/TXT 说明文档为主既包含源码也包含编译好的工具便于直接体验与二次开发。资源基于 EasyModbus 库提供了完整的主从机通信示例覆盖读取保持寄存器、输入寄存器、线圈状态等常用操作并展示了 RTU 串口与 TCP 网络的参数配置方法有助于理解 Modbus 报文结构和功能码含义。压缩包内目录清晰包含演示程序、工程文件和说明文档方便对照学习也适合在真实项目中快速集成。已有 656 人学习使用是快速掌握 Modbus 通信并落地项目的实用参考资料。1. 拆一个开箱即用的 Modbus TCP 主从机示例做工业通信调试的人应该都遇到过这种局面PLC 侧程序还没写好上位机要先联调或者设备端只给了 Modbus 寄存器表手头却连一个能发报文的工具都找不到。我拿到这个emModbus_EasyModbus_压缩包时第一反应是先看它到底是不是又一个空壳示例。解压之后发现里面是完整的 Visual C 6.0 工程同时带了Start_ModbusTCP_Master.exe和Start_ModbusTCP_Slave.exe两个已经编译好的可执行文件加上源码、License.txt和Doc文档目录。这意味着你不需要先搭一套开发环境直接双击 exe 就能把 Modbus TCP 主站和从站跑起来验证报文格式、测试寄存器读写、甚至临时充当虚拟从站给上位机提供数据。这个包解决的问题很明确在没有真实 PLC 或仪表的情况下用软件模拟 Modbus TCP 通信链路。它适合三类人一是刚接触 Modbus 协议、想抓包看请求响应结构的嵌入式工程师二是做上位机开发、需要临时起一个从站模拟器来调试读写逻辑的软件工程师三是做系统集成、需要快速验证网关或 SCADA 配置是否正确的现场工程师。从Master.dsw和Slave.dsp这些文件能看出工程年代较早但 Modbus 协议本身二十多年没大变TCP 端口 502 上的报文结构依然和当年一致所以这套代码的可复现性并不因工程老而打折扣。下面从协议机制开始逐步拆到具体报文和调试技巧。2. Modbus TCP 帧结构与报文解析的底层逻辑2.1 从 RTU 到 TCP同样的功能码不同的封装Modbus 协议在串行链路上有 RTU 和 ASCII 两种模式而在以太网上则是 Modbus TCP。RTU 帧在串口上是这样的结构从机地址1 字节 功能码1 字节 数据N 字节 CRC162 字节。这里的从机地址是 0~247其中 0 是广播地址1~247 是单播地址。而 Modbus TCP 去掉了从机地址和 CRC因为 IP 和 TCP 已经解决了寻址和校验的问题取而代之的是 MBAP 报文头Modbus Application Protocol Header事务处理标识符2 字节 协议标识符2 字节 长度2 字节 单元标识符1 字节 功能码1 字节 数据N 字节单元标识符Unit ID在 TCP 里默认填 0x00 或 0xFF用于网关把 TCP 请求转发到串行总线上时指定下游从机地址。协议标识符固定为 0x0000表示这是 Modbus 协议不是其他应用协议。长度字段是从单元标识符开始到报文结束的字节数。用 Wireshark 抓包时如果看到 TCP 端口 502、协议标识符 0x0000基本可以断定这是 Modbus TCP 流量。从 RTU 到 TCP 的迁移有一个常见误区有人以为把 CRC 去掉、加上 MBAP 头就是完整的 TCP 报文忽略了 Modbus TCP 也必须保留功能码和数据区的完整结构。功能码 0x03 读保持寄存器、0x06 写单个寄存器、0x10十进制 16写多个寄存器这些在 RTU 和 TCP 中是完全一致的。本包里的 Master 和 Slave 示例工程走的都是 TCP 模式所以报文解析的关键落在事务 ID 的匹配上。2.2 主从机请求响应模型与时序约束Modbus 是严格的主从模型这意味着从站永远不会主动发数据所有通信都由主站发起。一次完整的请求响应周期是主站发送请求帧 → 从站处理并返回响应帧 → 主站校验事务 ID 和功能码。如果从站检测到异常比如访问了不存在的寄存器地址它会在响应帧中把功能码的最高位置 1即功能码 0x80并在数据区携带异常码。0x01 表示非法功能码0x02 表示非法数据地址0x03 表示非法数据值0x04 表示从站设备故障。这些异常码在调试时价值极高可以说 80% 的联调失败都能从异常码里直接定位原因。时序上有一个关键参数叫响应超时Response Timeout。标准的 Modbus 规范建议从站在 10ms 内响应请求但在实际 TCP 链路上考虑到网络延迟和从站 CPU 负载主站的超时设置通常在 100ms 到 3s 之间。本包里的 Master 工程在创建客户端时要求传入 IP 和端口默认端口是 502你如果在本机同时启动 Master 和 Slave可以用127.0.0.1作为目标地址。还有一点要特别注意同一个 TCP 连接上如果并发发送多个请求事务 ID 必须错开才能对应响应如果每发一帧就新建一个 TCP 连接则不需要担心事务 ID 冲突但连接建立和断开的开销会让通信效率明显降低高频率轮询场景下不推荐这么写。2.3 字节序问题Modbus 寄存器的大端陷阱这是 Modbus 开发里最容易踩的坑值得单独展开。Modbus 协议规定寄存器值是 16 位传输顺序是高字节在前、低字节在后也就是大端序Big-Endian。如果从站是一个 32 位浮点数它占用两个相邻寄存器比如地址 0x0000 和 0x0001那么在 Modbus 线上先传的是寄存器 0x0000 的高字节还是低字节答案依赖于从站内部的存储顺序也就是 Word 序Word Order问题。常见的有两种排列方式第一种是 Big-Endian Word Order即高字在前浮点数的高 16 位放在地址较小的寄存器里第二种是 Little-Endian Word Order即低字在前。更麻烦的是有些设备厂商会在文档里用 ABCD 或 CDAB 来表示字节序。比如一个浮点数 1.0 的 IEEE 754 表示是0x3F800000如果从站在两个寄存器里返回的是0x3F80和0x0000那是 ABC 序如果返回0x0000和0x3F80则是 CDAB 序。上位机解析时如果搞反读出来的数值要么极小要么极大而且看起来毫无规律。在本包的 Slave 示例工程里保持寄存器的数据是用一个整数数组维护的。你手动写入寄存器地址 0 的值为 0x1234再用 Master 去读返回的字节流一定是12 34这个顺序。这提供了一条验证字节序的基准路径先用已知的十六进制值写入从站再用主站读取并对照原始值就能确认整条链路没有翻转字节。我在实际调试中会先读取保持寄存器 0~9 的原始十六进制值再计算每个寄存器的高低位组合判断当前设备的字节序策略。3. 把压缩包跑起来工程结构与主从机启动全流程3.1 解压目录里的关键文件角色这个压缩包解压后根目录下有一堆 Visual C 6.0 时代的工程文件。Start_ModbusTCP_Master.dsw和Start_ModbusTCP_Slave.dsw是工作区文件双击用 VC6 打开可以同时加载两个项目.dsp是项目文件.vcxproj和.sln是后来用 Visual Studio 打开时自动转换生成的说明有人在更新的 VS 版本里打开过这个工程。Start_ModbusTCP_Slave.sdf是 VS 的 IntelliSense 数据库文件可以删除不影响编译。CleanUp.bat脚本的作用是清理编译产生的中间文件我建议在重新编译前先跑一遍它避免旧的.obj文件干扰。Doc目录里应该是协议说明或 API 参考Application目录存放主程序源码Inc是头文件目录。看到MB这个文件猜测是 Modbus 协议栈的核心封装。整个工程的核心价值在于它把 Modbus TCP 的 Master 和 Slave 两个角色做成了独立的可执行程序而这两个 exe 是用 VC6 编译的静态链接版本理论上可以在 Win7 到 Win11 的 64 位系统上运行。如果双击 exe 提示缺少 DLL那是系统缺 VC 运行库装上对应版本的 redistributable 即可不用改代码。3.2 先启动从站再启动主站正确顺序是先启动 Slave因为它要监听 502 端口Master 连接时才能立即建立 TCP 会话。启动Start_ModbusTCP_Slave.exe后它会把默认的保持寄存器区初始化成一组测试值通常是 0 到 99 的递增序列并且持续监听端口。你可以在命令行窗口看到类似Listening on port 502的输出。如果 502 端口被占用说明已有其他程序占用了这个端口需要先找到占用进程并结束它。然后启动Start_ModbusTCP_Master.exe程序会弹出一个对话框或命令行交互界面要求输入从站 IP 和端口。这里填127.0.0.1和502。连接成功后你可以用界面上的按钮或命令来读取保持寄存器。假设你要读从机地址 1 的保持寄存器起始地址 0数量 10核心调用代码如下// 主站读取保持寄存器示例 ModbusClient* client new ModbusClient(127.0.0.1, 502); bool connected client-Connect(); if (connected) { // unitId: 从机地址, startAddress: 起始寄存器地址, quantity: 读取数量 int result client-ReadHoldingRegisters(1, 0, 10, regBuffer); if (result 0) { // result 是成功读取的寄存器个数 for (int i 0; i result; i) { printf(Register[%d] 0x%04X\n, i, regBuffer[i]); } } } client-Close();这段代码里ReadHoldingRegisters的四个参数分别是从机地址、起始地址、读取数量和存放结果的缓冲区。返回值是实际读取到的寄存器个数如果返回值为负说明通信失败常见原因是超时或从站返回了异常码。要注意unitId在这个本机直连场景下填 0x01 即可但如果你的 TCP 从站其实是个网关后面挂着串口设备unitId必须和串口从站地址一致。3.3 用命令行自测主从通信链路如果你不想点界面也可以用命令行直接验证链路是否打通。打开两个终端窗口一个窗口运行从站另一个窗口用 Python 脚本模拟主站快速验证。Python 的pymodbus库是这类测试的常用工具下面这段代码用 TCP 模式连接到本地从站并读取保持寄存器from pymodbus.client import ModbusTcpClient # ip: 从站IP, port: 502, timeout: 响应超时时间(秒) client ModbusTcpClient(127.0.0.1, port502, timeout3) if client.connect(): # unit1 对应从站单元标识符, address0 起始地址, count10 读取寄存器个数 response client.read_holding_registers(address0, count10, unit1) if not response.isError(): # response.registers 是列表, 每个元素是一个寄存器的 16 位值 print(Registers:, response.registers) else: print(Modbus Error:, response) client.close() else: print(TCP connection failed)这里的address是寄存器起始地址0 基count是连续读取的数量unit是从站地址。timeout3表示超过 3 秒没有响应就算失败在局域网内这个值通常保持在 1 秒以内。如果这段代码能打印出和从站初始化值一致的寄存器列表说明主从链路完全正常抓包分析可以进入下一阶段。4. 寄存器读写实例与通信参数调优的取舍4.1 四种数据对象的完整读写操作Modbus 协议定义了四种数据对象线圈Coil、离散输入Discrete Input、输入寄存器Input Register、保持寄存器Holding Register。其中线圈和离散输入都是位操作一个寄存器地址对应一个 bit输入寄存器和保持寄存器都是 16 位字操作。区别在于输入寄存器和离散输入是只读的保持寄存器和线圈是可读写的。工业现场最常用的是保持寄存器因为 PLC 的保持寄存器区域既可以存模拟量采集值也可以存控制设定值。本包的 Slave 示例默认启用了保持寄存器和线圈区域输入寄存器区域可能需要修改宏定义才能启用。下面用 Python 把四种数据对象的读写都执行一遍并打印原始响应值from pymodbus.client import ModbusTcpClient client ModbusTcpClient(127.0.0.1, port502, timeout3) client.connect() unit 1 # 写单个线圈, address0, valueTrue(通电) result client.write_coil(address0, valueTrue, unitunit) print(Write Coil:, result) # 读线圈状态, address0, count8 coils client.read_coils(address0, count8, unitunit) print(Coils:, coils.bits) # 写单个保持寄存器, address0, value12345 result client.write_register(address0, value12345, unitunit) print(Write Register:, result) # 读保持寄存器 regs client.read_holding_registers(address0, count10, unitunit) print(Holding Registers:, regs.registers) # 读输入寄存器(只读区域) inputs client.read_input_registers(address0, count10, unitunit) print(Input Registers:, inputs.registers) client.close()每次write_*调用后建议紧跟着一次read_*来确认写入生效。我在调试时发现一个常见问题是有些从站把写入请求的响应延迟到下一次轮询周期才更新寄存器所以写入后立即读取可能拿到旧值。这种情况下需要加上 10~50ms 的延时再读或者轮询读取直到数据稳定。4.2 通信参数如何取舍Modbus TCP 的可调参数比 RTU 少因为波特率、数据位、校验位都由 TCP 协议承载的字节流接管了不需要显式配置。真正影响通信质量的是三个参数响应超时Timeout、重试次数Retries、轮询间隔Polling Interval。响应超时的设置要看从站类型。PLC 的 Modbus TCP 响应通常在 5~20ms 内而一些国产仪表或网关的响应可能高达 200ms。如果你把超时设成 100ms可能频繁读到超时错误但这不代表链路有问题只是从站处理慢。一个合理的做法是先抓包统计正常响应的最大耗时再在这个基础上加 50% 的余量设置超时。TCP 连接参数里还有 KeepAlive 选项默认是禁用。对于 7x24 小时运行的采集程序建议开启 KeepAlive 并设置 30 秒的心跳间隔这样链路断了最多 30 秒就能感知而不是等下一次请求才发现。重试次数的逻辑是超时后重试 N 次如果 N 次都失败才判定通信故障。重试次数不宜设置过大因为一次故障可能意味着从站断电或网线松动反复重试只是延长故障发现时间。我的经验是超时 1 秒、重试 2 次既能容忍瞬态网络抖动又不会让故障影响持续过久。4.3 多寄存器批量读写的性能差异当要读取的寄存器数量超过 10 个时批量读和逐字读的性能差距是数量级的。Modbus TCP 单帧最多能读 125 个保持寄存器0x03 功能码限制或者写最多 123 个寄存器0x10 功能码限制。如果你用单寄存器读的方式连续调用 100 次即使每次往返只有 1ms总耗时也是 100ms而一次性读 100 个寄存器往返一次就能完成耗时可以压缩到 10ms 以内。这里给出一个批量写多个寄存器的代码示例from pymodbus.client import ModbusTcpClient client ModbusTcpClient(127.0.0.1, port502, timeout3) client.connect() unit 1 # 批量写从地址 0 开始的 5 个寄存器 registers [100, 200, 300, 400, 500] result client.write_registers(address0, valuesregisters, unitunit) print(Batch Write Result:, result) # 回读校验 response client.read_holding_registers(address0, count5, unitunit) print(Batch Read Back:, response.registers) client.close()批量操作的代码同样要注意address和values的对应关系这里values的第一个元素写入地址 0第二个写入地址 1依次类推。写入完成后务必回读因为有些从站在写入后会把数据存储在缓存中重启后才真正写入 EEPROM回读能发现这类隐形问题。5. 进阶排查思路抓包定位、并发处理与常见误用边界5.1 Wireshark 过滤语法与实际报文比对当代码逻辑看起来没问题但数据不对时抓包是唯一能一锤定音的方案。Wireshark 打开后选择对应的网卡在过滤栏输入tcp.port 502就能看到所有 Modbus TCP 流量。如果从站不在本机而是远程设备需要把过滤条件改成ip.addr 设备IP tcp.port 502。看抓包结果时关注三个点第一是 TCP 三次握手是否成功如果只有 SYN 没有 SYN-ACK说明对端端口未监听或防火墙拦截第二是请求帧的 MBAP 头里事务 ID 是否和响应帧一致如果事务 ID 不一致说明客户端封装有问题第三是功能码和寄存器地址的字节值和代码里传的参数对照排查地址偏移错误。一个典型的错误场景是请求帧显示起始地址是 0x0000数量是 0x000A10 个响应帧返回 20 个字节的数据但数据值全为 0。这有两种可能一是从站寄存器区本来就是零值二是地址偏移错了你读的是空白区。此时手动修改起始地址抓包确认请求帧的变化就能快速排除。5.2 并发轮询下的事务 ID 管理如果你的采集程序需要同时轮询多台从站设备并且复用一个 TCP 连接事务 ID 的管理就变得重要了。最简单的做法是每发送一个请求就把事务 ID 加 1并且维护一个未响应请求的列表。响应帧返回时按事务 ID 找到对应的等待队列把结果放进去。如果某个事务 ID 超过超时时间仍未响应要主动超时清理。更稳健的做法是多线程下为每个从站单独建一个 TCP 连接。这样每个连接的读写天然串行事务 ID 冲突的概率为零。虽然连接数增加占用了更多文件描述符但现代操作系统轻松支持几千个并发连接对绝大多数现场场景完全够用。我倾向于用连接池的方式每个从站一个长连接客户端维护这个连接的锁同一时刻只允许一个请求在途。5.3 典型误用边界不要把 Modbus TCP 当普通 TCP 裸用有些开发者为了追求极致的响应速度会在同一个 TCP 连接上同时发送多个请求而不等待响应这就是所谓的流水线Pipelining。这在 Modbus TCP 规范里是不推荐的因为从站设备大多不支持并发请求处理。如果对端从站只处理一个请求多余的请求会被丢弃或阻塞导致事务 ID 对应不上。规范建议的稳健做法是请求-响应严格一对一下一帧必须等上一帧响应或超时后再发。只有少数高端 PLC 支持多请求并发而且需要在文档里明确说明。另一个边界是 Modbus TCP 与 Modbus RTU 在报文边界处理上的差异。RTU 依靠帧间空闲时间分隔报文TCP 则依靠长度字段。在编写自定义解析器时不能以收到 TCP 数据就认定一帧完整必须先解析 MBAP 头里的长度字段再按长度收齐剩余字节。如果长度是 6说明后续只有一个功能码加零字节或异常码加一个字节数据区为空如果是 25则代表功能码 1 字节加数据 24 字节。这个解析逻辑用 Python 实现的话每次要从 socket 缓冲里先收 7 个字节的头再根据长度决定接下来还要收多少。本文还有配套的精品资源点击获取