ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C#上位机通过ModbusTCP与汇川PLC通信实战指南

2026/10/8 2:17:00 拓冰建站 浏览量
C#上位机通过ModbusTCP与汇川PLC通信实战指南 简介一份基于C#与ModbusTCP协议实现汇川PLC通信的完整工程资源面向工业自动化开发者、上位机软件工程师及PLC调试人员适用于需要在上位机与汇川小型PLC之间建立可靠ModbusTCP通信的项目。程序采用C#语言在.NET Framework 4.5框架下编写开发环境为VS2013同时整合AutoShop v4.10.1.1配置环境通用于各类ModbusTCP通信调试与应用场景。压缩包共148个文件包体约275KB以C#源码、PLC工程文件含指令块与程序文件、配置文件、动态库及调试缓存等类型为主目录结构清晰便于直接加载与二次开发。目前已有2034人学习使用。资源内包含通信端口配置、TcpModbus参数设置、PLC指令块定义等关键文件并保留PLC侧程序组织与编译信息可帮助读者快速理解上位机与汇川PLC的握手流程、报文组帧及地址映射方法从而搭建属于自己的ModbusTCP通信调试工具有效缩短项目开发周期无论用于教学演示还是现场调试均具参考价值。1. 为什么是C# ModbusTCP 汇川PLC上位机选型的现实考量C# 做上位机开发汇川 PLC 做现场控制ModbusTCP 做通信桥梁这个组合在中小型产线里几乎是标配。我拆过不少项目从单台设备的数据采集到整条产线的集中监控这套方案最大的优势不是性能而是落地快——C# 的 Socket 编程和类库生态让协议栈实现成本极低汇川全系列 PLC 都内置 ModbusTCP 从站功能不需要额外买通信模块一根网线插上就能跑。这篇文章我会把 C# 通过 ModbusTCP 与汇川 PLC 通信从报文结构、参数配置到踩坑排查完整走一遍适合正在做上位机数据采集、设备监控或者 MES 对接的工程师。看完你至少能自己写一个稳定的通信客户端并且知道出了问题往哪儿查。2. 从报文到代码ModbusTCP协议栈的C#落地2.1 MBAP头与PDU以太网上的Modbus报文长什么样ModbusTCP 的报文可以拆成两层看MBAP 头Modbus Application Protocol 头和 PDUProtocol Data Unit。MBAP 头一共 7 个字节包含事务标识符、协议标识符、长度字段和单元号。事务标识符是客户端每次发送请求时递增的计数器服务端响应时会原样返回——这是在同一 TCP 连接上并发发送多个请求时把响应匹配到对应请求的关键。协议标识符在 Modbus 里固定是 0x0000如果响应里出现了非 0 值说明对方返回的不是 Modbus 协议。字段字节数说明请求示例值事务标识符2请求端自增响应原样返回0x0001协议标识符2Modbus 固定为 0x00000x0000长度字段2单元号 PDU 的字节数0x0006单元号1从站地址通常为 10x01PDU 部分更直接功能码 1 字节加数据区。比如读保持寄存器功能码 0x03数据区是起始地址 2 字节加读取数量 2 字节一共 5 字节。所以一个完整的读保持寄存器请求是 7 字节 MBAP 5 字节 PDU 12 字节。响应报文的结构稍有不同MBAP 头之后是功能码然后是 1 字节的字节数用来告诉客户端后面跟了多少字节的数据最后才是寄存器值。2.2 写一个能跑的ModbusTCP客户端核心类与报文封装直接给一份我项目里精简过的核心类注释保留复制到你的工程里改改命名空间就能用。这个类实现了 TCP 连接、读保持寄存器和写单个寄存器三个最基本的方法涵盖了 80% 的现场需求。public class ModbusTcpClient : IDisposable { private TcpClient _tcpClient; private NetworkStream _stream; private int _transactionId 0; private readonly object _lock new object(); // 连接 PLCtimeout 控制 TCP 握手等待时间 public bool Connect(string ip, int port 502, int timeout 3000) { try { _tcpClient new TcpClient(); var ar _tcpClient.BeginConnect(ip, port, null, null); if (!ar.AsyncWaitHandle.WaitOne(timeout)) throw new TimeoutException($连接 {ip}:{port} 超时); _tcpClient.EndConnect(ar); _stream _tcpClient.GetStream(); _stream.ReadTimeout timeout; _stream.WriteTimeout timeout; return true; } catch (Exception ex) { Console.WriteLine($[ModbusTcp] 连接失败: {ex.Message}); return false; } } // 读保持寄存器功能码 0x03 public ushort[] ReadHoldingRegisters(byte unitId, ushort startAddr, ushort quantity) { lock (_lock) { byte[] req BuildReadRequest(unitId, 0x03, startAddr, quantity); _stream.Write(req, 0, req.Length); int dataLen quantity * 2; int responseLen 9 dataLen; // MBAP 7 功能码 1 字节数 1 数据 byte[] resp new byte[responseLen]; int read 0; while (read responseLen) { int n _stream.Read(resp, read, responseLen - read); if (n 0) throw new IOException(PLC 关闭了连接); read n; } // 异常响应: 功能码最高位置 1 if ((resp[7] 0x80) ! 0) throw new Exception($Modbus 异常码: 0x{resp[8]:X2}); ushort[] values new ushort[quantity]; for (int i 0; i quantity; i) values[i] (ushort)((resp[9 i * 2] 8) | resp[10 i * 2]); return values; } } // 写单个寄存器功能码 0x06 public void WriteSingleRegister(byte unitId, ushort addr, ushort value) { lock (_lock) { _transactionId; byte[] req new byte[12]; req[0] (byte)(_transactionId 8); req[1] (byte)(_transactionId 0xFF); req[2] 0x00; req[3] 0x00; req[4] 0x00; req[5] 0x06; // 长度固定 6 req[6] unitId; req[7] 0x06; // 功能码 req[8] (byte)(addr 8); req[9] (byte)(addr 0xFF); req[10] (byte)(value 8); req[11] (byte)(value 0xFF); _stream.Write(req, 0, req.Length); byte[] resp new byte[12]; // 写单寄存器的响应是完整回声 int read 0; while (read 12) { int n _stream.Read(resp, read, 12 - read); if (n 0) throw new IOException(PLC 关闭了连接); read n; } if ((resp[7] 0x80) ! 0) throw new Exception($Modbus 异常码: 0x{resp[8]:X2}); } } private byte[] BuildReadRequest(byte unitId, byte funcCode, ushort startAddr, ushort quantity) { _transactionId; byte[] req new byte[12]; req[0] (byte)(_transactionId 8); req[1] (byte)(_transactionId 0xFF); req[2] 0x00; req[3] 0x00; req[4] 0x00; req[5] 0x06; // unitId 1 功能码 1 地址 2 数量 2 req[6] unitId; req[7] funcCode; req[8] (byte)(startAddr 8); req[9] (byte)(startAddr 0xFF); req[10] (byte)(quantity 8); req[11] (byte)(quantity 0xFF); return req; } public void Dispose() { _stream?.Dispose(); _tcpClient?.Dispose(); } }逻辑说明分三点。第一所有读写操作都套了lock (_lock)因为事务标识符必须串行分配多线程同时调用会让响应和请求对不上数据全乱。第二BeginConnect配合AsyncWaitHandle.WaitOne(timeout)是为了避免 TCP 握手卡死界面很多新手直接用_tcpClient.Connect()PLC 掉了 IP 不通时那行代码能卡十几秒。第三读响应的循环while (read responseLen)是必须的NetworkStream.Read不保证一次把整个报文读完尤其是数据量大的时候TCP 分包是常态。参数说明也补一句。startAddr是协议报文里的地址不是寄存器编号后面第 3 章会细说。quantity一次最多 125这是 Modbus 协议的硬限制超出会收到异常码 0x03。实际使用中我一般控制在 64 以内一方面照顾 PLC 的扫描周期另一方面响应报文控制在 140 字节左右TCP 分包概率小。2.3 功能码与寄存器映射03/04/06/16怎么配合汇川PLC用功能码名称汇川PLC常用场景对应寄存器区0x03读保持寄存器读 %MW 数据、设备状态4x 区0x04读输入寄存器读模拟量输入3x 区0x06写单个寄存器写控制字、启停命令4x 区0x10写多个寄存器批量下配方参数4x 区汇川 AM 系列和 H5U 里4x 区和 3x 区最终都映射到 %MW 区区别只在于你组态时把变量绑定到哪个保持寄存器。一般项目里设备启停、模式切换这种控制字用 0x06 写单个寄存器配方参数这种连续数据用 0x10 批量写。读的话 0x03 和 0x04 区分不大大部分情况用 0x03 就够。你如果发现 0x03 读到的是缓存值0x04 能读到实时值那就反过来用这属于 PLC 固件实现差异不是协议层面的标准问题。3. 汇川PLC侧配置与寄存器换算通信参数与地址映射实战3.1 汇川AM系列与H5U的ModbusTCP服务配置汇川的 PLC 分两类H3U、H5U 用自家 InoProShop 编程AM 系列走 Codesys 环境。配置 ModbusTCP 从站的入口不太一样坑也就在这。H5U 在 InoProShop 里新建工程后要进到系统参数把内置以太网口的 ModbusTCP 服务打开默认端口是 502这个服务默认是不开的你不打开它TCP 能连上但报文没人处理。我见过不止一个项目现场工程师拿网线一连、ping 也通但上位机读不到数据最后发现是服务压根没启用。AM 系列在 Codesys 里的做法更直观在设备树里以太网口下面挂一个 ModbusTCP Slave 设备然后把需要暴露的 PLC 变量映射到保持寄存器上。这里有个细节Codesys 里变量名是 %MW0、%MW100 这种但映射表允许你自定义 Modbus 地址不是自动一一对应。你映射到哪个保持寄存器外部上位机就用哪个地址去读。AM 系列和威纶通触摸屏通信时也是在触摸屏工程里选 ModbustCP 驱动填同样的寄存器地址两边对得上就行。3.2 %MW地址与Modbus寄存器的对应关系假设你在 PLC 里建了一个设备状态字 %MW10Modbus 侧的协议地址就是 10上位机用功能码 0x03、起始地址 10 去读。这里有个经典误区调试工具里显示的是 40001 这种寄存器编号但协议报文里传的是地址等于编号减 1。也就是说编号 40001 在报文里起始地址是 040101 起始地址是 100。你如果用编号直接当地址发出去读到的永远是旁边那个变量的值数据看起来就是乱的。32 位数据是另一个容易出问题的地方。Modbus 寄存器是 16 位一个浮点数要占两个寄存器。汇川 PLC 存储浮点数遵循 IEEE 754但字序上高低字的排列和常见的 Modbus 大端模式可能不一致。我之前读一个压力值按大端解析出 2.45实际压力是 9.8后来发现汇川在标准协议映射下浮点数高字在前需要把两个寄存器交换顺序再拼成 4 字节解析。这个没有统一规律AM 系列和 H5U 的表现不完全一样拿到现场设备先用已知值测一遍。PLC地址协议地址寄存器编号功能码%MW00400010x03 / 0x06%MW1010400110x03 / 0x06%MW100100401010x03 / 0x063.3 通信参数选型超时、重试与批量读取的取舍超时时间这块局域网内我一般设 1000 到 2000 毫秒。设太短现场变频器一启动造成干扰偶发丢包就会疯狂超时报错设太长PLC 真死机了你得等半天才反应过来产线停在那里很被动。重试次数我习惯设 3 次超过 3 次直接判定通信故障上位机弹窗告警而不是无限重试把线程卡死。批量读取是性能的关键。Modbus 协议一次最多读 125 个保持寄存器但汇川 PLC 是在程序扫描周期里处理通信报文的你一次读太多PLC 的扫描周期会被拉长伺服控制就可能出问题。我一般控制在上位机一次读 64 个寄存器以内64 个字对应 128 字节数据这在绝大多数产线上都不会对 PLC 造成明显压力。你要读的变量如果分散在不同区间宁可多读一段连续区间再在上位机里截取也别拆成十几次单点读——报文往返的开销远比多传几个字节大。4. 踩坑排查五个我在现场遇到过的ModbusTCP通信问题4.1 现象连接超时ping得通但报文发不进去现象上位机 ping PLC 的 IP 完全通防火墙也关了Socket 连接能建立成功但发读请求没有任何响应直到ReadTimeout抛超时。原因TCP 连上只是第一步PLC 侧 ModbusTCP 从站服务根本没跑起来。有一次我把程序下载到 H5U 里就以为能通信了忘了在系统参数里勾选 ModbusTCP 服务。还有一次是单元号不匹配PLC 里配的从站单元号是 2我代码里发的是 1请求直接被丢弃。这两个问题从报文上看都是请求发出去了但没回应很容易混在一起排查。解决先在 PLC 侧确认服务已启用再看单元号配置。如果没有 Modbus 调试工具就用我上面那段代码对着一个已知地址发请求把unitId从 1 到 255 循环试一遍能通则说明单元号问题全不通就回去查 PLC 的从站配置。4.2 现象读上来的数据完全不对数值大得离谱现象读回来的值不是 0 就是 65535或者是一些毫无逻辑的中间量。监控画面上的温度显示成三万度压力时不时冲顶。原因大多数情况是地址错位。报文的起始地址和 PLC 侧映射的保持寄存器对不上读到了别的变量。另一个高频原因是数据类型没配对——PLC 里是 32 位整数你按 16 位读读出来的必然是半个数或者 PLC 变量是浮点数你按整数去解析精读就全歪了。解决先做一个基准测试。把 PLC 里某个 %MW 手动写一个固定值比如 1234然后上位机去读通了再逐个加地址验证。浮点数的字序问题用同样的方式验证写一个已知浮点数比如 3.14去读解析如果出来的值对不上就把高低字交换再试试出来了记到项目文档里。4.3 现象程序跑几小时连接就断开重连也失败现象系统刚启动时一切正常跑三五个小时后第一次请求超时重连时报错或者一直连不上重启上位机程序又好了。原因TCP 连接没有优雅关闭。网络异常时客户端程序崩溃Socket 没释放PLC 侧还认为连接有效连接资源被慢慢占满。另一种情况是长时间没有数据交互中间的路由器或交换机把空闲连接置空了但两端程序都以为自己还连着。解决上位机侧加心跳机制周期性发读请求哪怕数据没变化也要发保持连接活跃。Socket 层面可以设置 KeepAlive但工业交换机对这种 TCP 空闲连接的处理各不相同最保险的还是应用层心跳。重连逻辑里注意先Dispose掉旧的TcpClient再重新Connect不要直接 new 一个新实例残留的旧连接会继续占用 PLC 的资源。4.4 现象读取频率一超过20ms就超时现象读取频率低的时候一切正常把轮询周期调到 20ms 甚至更短开始频繁超时偶尔成功一次响应时间也没有明显下降。原因同步 Socket 的Read方法是阻塞的加上如果没做批量读取优化一次只读一两个寄存器报文往返次数多20ms 周期内根本跑不完。还有个隐藏问题——TCP 的 Nagle 算法会把小报文攒在一起发导致响应延迟增加协调不好就会把读取周期拉长到 50ms 以上。解决把多次单点读合并成一次批量读。要读 20 个寄存器就发一条起始地址加数量 20 的 0x03 请求响应时间能小一个数量级。如果确实需要高频读取用异步 Socket 配合生产者消费者模式把轮询周期和报文收发解耦这个思路在第 5 章展开。4.5 现象两台上位机同时连PLC通信卡死现象一台工控机读数据没问题加上第二台之后触摸屏也开始卡了PLC 的通信指示灯异常整个链路像被堵住一样。原因PLC 侧 ModbusTCP 的连接数有限每个连接都会占用通信资源。如果每台上位机里每次读取都new TcpClient()读完就关频繁的连接建立和断开会耗尽 PLC 的通信资源最后新连接进不来老连接也悬在半空。解决上位机侧做连接复用一台机器只保持一到两个长连接禁止每次读取都重新建连。把所有读取任务集中到一个后台线程统一从这个线程发请求这样即使有多个窗口或模块要读 PLC对 PLC 来说只有这一条连接在活动。如果项目必须多台机器同时读一台 PLC那就在 PLC 侧把连接数限制调大并且所有客户端都做好连接复用。5. 把读取频率稳定做到50ms异步Socket与心跳机制的结合50ms 读取周期在产线数据采集中是个很实用的指标趋势曲线、产量统计、报警联动基本都要求这个级别。要做到稳定 50ms核心是异步 Socket、心跳维持和批量读取三个点配合。异步的ReadAsync不阻塞线程心跳定时器每 50ms 触发一次批量请求用Stopwatch记录往返时间超过 45ms 就告警。下面是精简骨架直接套进你的轮询线程里。public async Task StartPollingAsync(CancellationToken token) { var timer new PeriodicTimer(TimeSpan.FromMilliseconds(50)); var sw new Stopwatch(); while (await timer.WaitForNextTickAsync(token)) // 定时 50ms { sw.Restart(); try { ushort[] data await ReadHoldingRegistersAsync(1, 100, 32); // 在这里做数据缓存和界面刷新 OnDataReceived(data); } catch (Exception ex) { Console.WriteLine($第 {_count} 轮读取异常: {ex.Message}); await TryReconnectAsync(); // 断线自动重连 } sw.Stop(); if (sw.ElapsedMilliseconds 45) Console.WriteLine($超时告警: 本轮耗时 {sw.ElapsedMilliseconds} ms); _count; } }PeriodicTimer是 .NET 6 以上才有的定时器它比Thread.Sleep精准很多不会因为系统繁忙而顺延。如果你还在用 .NET Framework 4.x用System.Threading.Timer配合AutoResetEvent也能实现类似效果。ReadHoldingRegistersAsync需要把第 2 章那个类里的同步方法改成async Task版本本质是NetworkStream.ReadAsync替换掉Read。验证方法很直接连续跑一小时统计 2000 次以上的请求耗时看 P95 是否低于 45msP99 是否低于 50ms。如果 P95 超了优先检查是不是读了太多寄存器——把 quantity 从 32 减到 16 再测响应时间通常能砍半。如果 P95 正常但偶发超时重点排查网络层面有没有瞬时拥塞或 PLC 扫描周期波动。从那以后我每次写 ModbusTCP 客户端都会强制走一遍三件事先用抓包工具确认报文格式再用单寄存器验证字节序和地址映射最后按实际生产数据量压测批量读取的频率上限。这三步走完项目里通信这块基本不会翻车。希望帮到你。本文还有配套的精品资源点击获取