简介:这份资源是面向工业自动化上位机开发者的VB.NET与汇川PLC Modbus TCP通讯示例项目,适合已具备一定.NET基础、希望打通上位机与PLC数据交互的工程师或学生。压缩包共33个文件,约141KB,以vb源代码、vbproj工程文件、sln解决方案、resx资源文件、dll与exe可执行程序为主,另含xml配置、pdb调试符号及manifest清单等,结构完整可直接编译运行。项目围绕Modbus TCP协议展开,涵盖请求报文构建、功能码与寄存器地址解析、读写PLC寄存器与线圈、响应处理及通信稳定性保障等关键环节,并配有窗体界面与文件操作辅助模块,便于对照调试。目前已有1147人学习下载。通过研读源码,读者可掌握VB.NET集成Modbus通信库的方法、报文结构与命令格式,理解上位机与PLC互联的完整交互流程,并借鉴错误处理与调试技巧,提升工业自动化领域的编程实践能力。
1. 汇川 PLC Modbus 通讯 Demo:从寄存器映射到 .NET 交互的完整落地
车间里一台汇川 H5U 或 AM400 系列 PLC 跑着产线逻辑,上位机这边用 C# 写的 MES 客户端要实时拿产量、写配方参数,中间就靠 Modbus TCP 这根线连着。很多人第一次接这个需求时,以为拿个 Modbus 库连上就行,结果卡在寄存器地址对不上、数据类型解析错位、批量读写超时这几个坑里,调两天都通不了。这份汇川 PLC Modbus 通讯 Demo 就是冲着这个场景来的——它把 PLC 侧的寄存器规划、通讯配置和 .NET 侧的读写封装串成了一条能直接跑的链路,适合做上位机开发、设备数据采集、产线联网的工程师拿来当起点。下面按「先搞懂协议和地址、再动手配、最后避坑」的顺序拆开讲。
2. Modbus 寄存器映射与汇川地址体系:先搞懂再动手
2.1 Modbus 四种寄存器与汇川软元件的对应关系
Modbus 协议本身定义了四类数据区,很多人背得出但一到汇川 PLC 上就懵,因为汇川的软元件命名(M、D、X、Y、R)和 Modbus 的 0x/1x/3x/4x 地址不是一一对应的,中间隔着一层映射表。
| Modbus 数据区 | 功能码 | 汇川常见软元件 | 读写属性 | 典型用途 |
|---|---|---|---|---|
| 线圈 Coil (0x) | 01/05/15 | M、Y | 读写 | 启停信号、指示灯 |
| 离散输入 (1x) | 02 | X | 只读 | 限位、按钮输入 |
| 输入寄存器 (3x) | 04 | 部分 R | 只读 | 模拟量采集值 |
| 保持寄存器 (4x) | 03/06/16 | D、R | 读写 | 配方参数、产量计数 |
关键点在于:汇川 PLC 做 Modbus TCP 从站时,D 寄存器通常映射到保持寄存器区,M 线圈映射到线圈区,但起始偏移量取决于 PLC 的通讯配置,不是固定从 0 开始。我见过有人直接拿 D100 当 40100 去读,结果读回来全是 0,就是因为偏移没算对。
2.2 地址偏移的两种算法:0-based 与 1-based
这是最容易翻车的地方。Modbus 协议文档里写的 40001 是 1-based 的「第 1 个保持寄存器」,但实际报文里的地址字段是 0-based 的 0x0000。不同库的处理方式不一样:
- 有的库(如 NModbus)要求你传 0-based 地址,读 40001 要传 0;
- 有的库(如 EasyModbus)允许你传 1-based,内部自己减 1;
- 汇川 PLC 侧的映射表如果写的是「D100 对应 40101」,那 40101 的 1-based 地址是 101,0-based 是 100。
我一般会先在 PLC 编程软件里导出软元件注释表,找到目标 D 寄存器的绝对地址,然后统一用 0-based 传给库,避免中间层再转换。Demo 里也是这么处理的,下面代码会体现。
2.3 数据类型解析:16 位寄存器怎么拼成 float 和 int32
Modbus 一次读写的最小单位是 16 位寄存器,但实际数据可能是 32 位整数、32 位浮点、甚至 64 位。汇川 PLC 里 D 寄存器是 16 位的,一个 float 占两个连续 D(比如 D200、D201),一个 int32 也占两个。拼的时候有两个变量:
- 字节序:大端还是小端;
- 字序:高字在前还是低字在前。
汇川默认是「高字在前、大端字节序」,但有些配置会改。Demo 里封装了一个ParseFloat方法,把两个 ushort 按指定字序拼成 float,参数可配,这样换 PLC 型号时不用改代码逻辑。
// 将两个16位寄存器拼成float,wordOrder决定高低字顺序 public static float ParseFloat(ushort high, ushort low, bool highWordFirst = true) { byte[] bytes = new byte[4]; if (highWordFirst) { // 高字在前:high 放前两字节,low 放后两字节 bytes[0] = (byte)(high >> 8); bytes[1] = (byte)(high & 0xFF); bytes[2] = (byte)(low >> 8); bytes[3] = (byte)(low & 0xFF); } else { bytes[0] = (byte)(low >> 8); bytes[1] = (byte)(low & 0xFF); bytes[2] = (byte)(high >> 8); bytes[3] = (byte)(high & 0xFF); } // 大端字节序,直接转float if (BitConverter.IsLittleEndian) Array.Reverse(bytes); return BitConverter.ToSingle(bytes, 0); }逻辑说明:先按字序把两个 ushort 拆成 4 个字节,再根据当前机器的字节序决定是否反转,最后用BitConverter.ToSingle转成 float。参数highWordFirst默认 true,对应汇川常见配置;如果读出来是乱码,先把这个参数翻一下试试,比改 PLC 快。
3. .NET 侧 Modbus 客户端搭建:从 NuGet 包到读写封装
3.1 选库:NModbus vs EasyModbus vs 自研
.NET 生态里做 Modbus TCP 的库不少,我实际用下来主要三个选择:
- NModbus:老牌,API 偏底层,需要自己管理
TcpClient和ModbusFactory,但灵活,适合需要精细控制超时和重连的场景; - EasyModbus:API 简单,
ModbusClient一个类搞定连接和读写,但异常处理偏弱,批量读大块数据时偶尔会卡; - 自研:基于
Socket直接拼 Modbus TCP 报文,适合对性能有极致要求或需要支持非标功能码的情况。
Demo 里用的是 NModbus,因为它的IModbusMaster接口清晰,配合TcpClient能自己控制连接池和重试。如果你只是快速验证,EasyModbus 更省事,但上生产我建议 NModbus 或自研。
3.2 连接与超时配置:别让一个卡死拖垮整个采集线程
Modbus TCP 默认端口 502,但汇川 PLC 可以改。连接超时和读写超时必须分开设,因为 TCP 连接建立和报文往返是两回事。
// 创建TCP连接,设置连接超时和读写超时 var tcpClient = new TcpClient(); tcpClient.ReceiveTimeout = 1000; // 读超时1秒 tcpClient.SendTimeout = 1000; // 写超时1秒 // 异步连接,避免UI线程卡死 var connectTask = tcpClient.ConnectAsync(ip, port); if (!connectTask.Wait(2000)) // 连接超时2秒 { throw new TimeoutException($"连接PLC {ip}:{port} 超时"); } // 用NModbus工厂创建master var factory = new ModbusFactory(); IModbusMaster master = factory.CreateMaster(tcpClient); master.Transport.ReadTimeout = 1000; master.Transport.WriteTimeout = 1000; master.Transport.Retries = 2; // 失败重试2次参数说明:ReceiveTimeout和SendTimeout是 TCP 层的,Transport.ReadTimeout是 Modbus 层的,两层都要设。Retries设 2 次足够,设太多会在 PLC 响应慢时堆积请求。连接用ConnectAsync加Wait超时,比同步Connect安全,不会因为网络不通卡死主线程。
3.3 批量读写与寄存器地址计算
汇川 PLC 对单次读写的寄存器数量有限制,一般一次最多读 125 个保持寄存器,写 123 个。超过要分批。Demo 里封装了ReadHoldingRegisters的分批逻辑:
// 分批读取保持寄存器,每批最多100个 public ushort[] ReadHoldingRegistersBatch(IModbusMaster master, ushort startAddr, ushort count) { const int maxPerBatch = 100; var result = new List<ushort>(); int remaining = count; ushort currentAddr = startAddr; while (remaining > 0) { int batch = Math.Min(remaining, maxPerBatch); // NModbus要求0-based地址,startAddr已经是0-based ushort[] data = master.ReadHoldingRegisters(1, currentAddr, (ushort)batch); result.AddRange(data); currentAddr += (ushort)batch; remaining -= batch; } return result.ToArray(); }逻辑说明:master.ReadHoldingRegisters第一个参数是从站地址(汇川默认 1),第二个是起始地址(0-based),第三个是数量。分批时每批不超过 100,留点余量避免 PLC 边界问题。读回来的数据按顺序拼成一个数组,调用方按偏移取就行。
3.4 写入与数据类型转换:int32 和 float 怎么写回去
写比读多一步:要把 C# 的数据类型拆成 16 位寄存器。以写 float 为例:
// 将float拆成两个ushort,按高字在前写入 public void WriteFloat(IModbusMaster master, ushort startAddr, float value) { byte[] bytes = BitConverter.GetBytes(value); if (BitConverter.IsLittleEndian) Array.Reverse(bytes); // 转成大端 ushort high = (ushort)((bytes[0] << 8) | bytes[1]); ushort low = (ushort)((bytes[2] << 8) | bytes[3]); // 写两个连续寄存器 master.WriteMultipleRegisters(1, startAddr, new ushort[] { high, low }); }参数说明:startAddr是 0-based 起始地址,value是要写的 float。先转字节数组再反转成大端,然后拼成高低字。写多个寄存器用WriteMultipleRegisters,功能码 16,比单个写效率高。注意写入前确认 PLC 侧该寄存器没有被其他逻辑占用,否则会冲突。
4. 避坑与排查:汇川 Modbus 通讯的五个血泪经验
4.1 读回来全是 0 或 65535
现象:连接成功,功能码返回正常,但数据全是 0 或 65535。
原因:地址偏移算错,读到了未映射的寄存器区;或者 PLC 侧该寄存器确实没赋值。
解决:先用 PLC 编程软件在线监控目标 D 寄存器的值,确认有数据;然后核对 Modbus 映射表的起始地址,把 0-based 和 1-based 都试一遍。Demo 里有个AddressOffset配置项,就是干这个的。
4.2 浮点数读出来是乱码
现象:两个寄存器拼出来的 float 是1.234e-38这种离谱值。
原因:字序或字节序不对。汇川默认高字在前,但有些型号或配置是低字在前。
解决:把ParseFloat的highWordFirst参数翻一下,或者用 Modbus Poll 工具先读原始寄存器值,手动拼一下验证。别在代码里反复改,先用工具确认。
4.3 批量读超时或返回异常码 0x03
现象:读少量寄存器正常,读多了就超时或返回Illegal Data Value。
原因:单次读取数量超过 PLC 限制,或者起始地址加数量越界了。
解决:分批读,每批不超过 100 个;检查startAddr + count是否超过 PLC 映射的最大地址。Demo 里的分批逻辑就是为此。
4.4 连接频繁断开
现象:跑一段时间后连接断开,重连才能继续。
原因:PLC 侧对空闲连接有超时回收,或者网络抖动。
解决:加心跳,定期读一个固定寄存器;或者用连接池,每次读写前检查连接状态,断了就重连。NModbus 的Transport没有自动重连,要自己封装。
4.5 写入不生效
现象:写寄存器返回成功,但 PLC 侧值没变。
原因:PLC 程序里该寄存器被其他逻辑覆盖,或者写的是只读区。
解决:确认目标寄存器在 PLC 程序里没有被重复赋值;检查是不是写到了输入寄存器(3x)而不是保持寄存器(4x)。写入前最好先读一次确认当前值,写完再读一次验证。
5. 进阶:用 .NET 交互做数据采集与配方下发
5.1 定时采集与数据缓存
实际产线上,上位机要每秒采集一次产量、状态、报警码。Demo 里用System.Threading.Timer做定时,采集到的数据放进ConcurrentQueue缓存,UI 线程从队列取,避免跨线程访问控件。
// 定时采集,每秒读一次产量和状态 private Timer _pollTimer; private ConcurrentQueue<PlcData> _dataQueue = new(); _pollTimer = new Timer(async _ => { try { var产量 = ReadHoldingRegistersBatch(master, 100, 2); // D100-D101 var状态 = ReadHoldingRegistersBatch(master, 200, 1); // D200 _dataQueue.Enqueue(new PlcData { Output = (产量[0] << 16) | 产量[1], // int32 Status = 状态[0] }); } catch (Exception ex) { // 记录日志,不抛出,避免Timer崩溃 Log.Error($"采集失败: {ex.Message}"); } }, null, 0, 1000);参数说明:Timer的第三个参数是首次触发延迟,第四个是间隔,单位毫秒。采集逻辑放在 try-catch 里,任何异常只记日志不抛出,否则 Timer 会停掉。数据用ConcurrentQueue缓存,UI 侧用另一个 Timer 或事件去取。
5.2 配方下发:批量写与校验
配方下发是写多寄存器,比如 10 个 float 参数写到 D300-D319。Demo 里先拼成一个 ushort 数组,一次性写,然后回读校验。
// 批量下发配方,写完后回读校验 public bool WriteRecipe(IModbusMaster master, ushort startAddr, float[] recipe) { var registers = new List<ushort>(); foreach (var val in recipe) { byte[] bytes = BitConverter.GetBytes(val); if (BitConverter.IsLittleEndian) Array.Reverse(bytes); registers.Add((ushort)((bytes[0] << 8) | bytes[1])); registers.Add((ushort)((bytes[2] << 8) | bytes[3])); } master.WriteMultipleRegisters(1, startAddr, registers.ToArray()); // 回读校验 var readBack = master.ReadHoldingRegisters(1, startAddr, (ushort)registers.Count); for (int i = 0; i < registers.Count; i++) { if (readBack[i] != registers[i]) return false; } return true; }逻辑说明:先把 float 数组转成寄存器数组,一次性写入,然后立即回读比对。如果某个寄存器不匹配,说明写入被 PLC 逻辑覆盖或通讯丢包,返回 false 让上层重试。这个校验步骤在配方场景里很关键,别省。
5.3 验证方法:用 Modbus Poll 先通再写代码
我自己的习惯是:拿到 PLC 后先用 Modbus Poll 或 QModMaster 这类工具连一下,手动读几个寄存器,确认地址、数据类型、字序都对,再开始写 C# 代码。这样能把「协议层问题」和「代码层问题」分开,省得在代码里反复试。Demo 里附了一个寄存器映射表的 Excel,填好地址和数据类型,代码里直接按表配置,比硬编码强。
从那以后我每次接新的 PLC 型号,都强制先用工具把地址和字序验证一遍,再动代码。希望帮到你。
本文还有配套的精品资源,点击获取