
简介这份资源面向具备一定C#基础、希望打通上位机与工业控制设备数据链路的开发者围绕C#与汇川PLC之间的通信展开重点解决TCP/IP连接建立、Modbus协议报文构造与寄存器读写等实际问题适合自动化系统集成、工控上位机开发等场景的初中级学习者参考。压缩包共624个文件、约214.54MB包含12个cs源码、12个vb示例、6个sln解决方案及配套csproj工程文件另有大量h、cpp、lib、dll等底层库与编译产物以及docx说明、xml配置和txt日志便于对照源码理解通信库的调用方式。资源已有582人学习下载。内容覆盖C#网络编程、Modbus主从架构与ASCII/RTU/TCP传输模式、汇川PLC地址映射与命令集配置并涉及CRC校验、异常处理与超时重试、通信性能优化及WinForms/WPF界面集成等要点读者可据此搭建可运行的通信示例掌握请求响应解析与排错思路。1. 从一条产线调试现场说起C# 对接汇川 PLC 到底难在哪产线联调最怕的不是 PLC 逻辑写错而是上位机连不上。某次现场一台工控机跑着 C# 写的上位机网线插好、IP 也配了点“连接”就是没反应换成调试软件却能读到数据。折腾半天才发现问题出在端口号和站号上——调试软件默认走的是另一套参数而代码里写死的是另一套。这类“玄学”问题在 C# 与汇川 PLC 通信里太常见了。这份示例资源要解决的就是让 C# 上位机稳定读写汇川 PLC 的寄存器、线圈和状态位。它适合两类人一是刚接手产线上位机、需要快速跑通通信的 C# 开发者二是想把通信层封装成可复用模块、后续接 MES 或数据采集的老手。下面按“协议选型 → 代码落地 → 排错避坑 → 进阶验证”的顺序拆开讲每一步都能照着复现。2. 协议选型与连接参数先把 Modbus TCP 这条链路理清楚汇川 PLC 的以太网通信主流走的是 Modbus TCP。它把 Modbus 的 RTU 帧塞进 TCP 载荷里去掉了 CRC 校验靠 TCP 本身保证可靠性。对 C# 来说这意味着不需要额外驱动用System.Net.Sockets就能直接发字节流。选它的理由很实在协议公开、调试工具多、跨平台工控机上跑 .NET Framework 或 .NET 8 都行。但“能通”和“稳定”是两回事。连接参数里藏着几个必须对齐的点错一个就连不上或者读回脏数据。2.1 端口、站号与寄存器地址的三组对齐Modbus TCP 默认端口是 502但汇川部分型号的以太网口会映射到其他端口现场一定要以 PLC 侧配置为准。站号Unit ID在 TCP 模式下通常填 1可有些网关或双机场景会改成别的值代码里写死 1 就可能被拒。寄存器地址是最容易翻车的地方。汇川 PLC 的软元件地址如 D100、M10和 Modbus 寄存器地址之间有一个偏移映射常见做法是 D 寄存器从 0 开始对应保持寄存器 40001 之后的区域但不同系列偏移规则不完全一样。我一般会先拿调试软件读一个已知值记下它实际发出的寄存器地址再把这个地址写进代码而不是凭手册硬推。参数常见取值说明协议Modbus TCP汇川以太网口主流支持端口502以 PLC 侧实际配置为准站号1网关或多机场景需核对功能码03/06/16读保持寄存器、写单寄存器、写多寄存器超时10003000 ms产线网络抖动大时适当放宽2.2 用 Socket 手搓一帧请求看清字节序再动手不急着上库先用最原始的方式发一帧能帮你把协议吃透。下面这段代码构造一个读保持寄存器的请求读站号 1、起始地址 0、数量 2。using System; using System.Net.Sockets; byte[] BuildReadHolding(int unitId, ushort startAddr, ushort count) { // Modbus TCP 报文头事务号(2) 协议号(2) 长度(2) 站号(1) byte[] frame new byte[12]; frame[0] 0x00; frame[1] 0x01; // 事务号任意响应会原样带回 frame[2] 0x00; frame[3] 0x00; // 协议号固定 0 frame[4] 0x00; frame[5] 0x06; // 后续字节数 站号1 PDU5 frame[6] (byte)unitId; // 站号 frame[7] 0x03; // 功能码读保持寄存器 frame[8] (byte)(startAddr 8); // 起始地址高字节 frame[9] (byte)(startAddr 0xFF); // 起始地址低字节 frame[10] (byte)(count 8); // 数量高字节 frame[11] (byte)(count 0xFF); // 数量低字节 return frame; } using var client new TcpClient(); client.Connect(192.168.1.10, 502); // PLC 实际 IP 与端口 var stream client.GetStream(); var req BuildReadHolding(1, 0, 2); stream.Write(req, 0, req.Length); byte[] resp new byte[256]; int n stream.Read(resp, 0, resp.Length); Console.WriteLine($收到 {n} 字节功能码 {resp[7]:X2});逻辑上分三层报文头负责事务匹配和长度声明PDU 里的功能码决定操作类型地址和数量按大端序排列。参数说明unitId填 PLC 站号startAddr是 Modbus 寄存器地址而非软元件编号count一次别超过 125 个寄存器否则部分设备会直接回异常码。响应里resp[7]是功能码若最高位为 1如 0x83说明是异常响应resp[8]就是异常码常见 0x02 是地址非法、0x03 是数量超限。2.3 封装成可复用类把连接、重连、超时收进一个壳手搓帧适合理解协议真上产线得封装。我一般会做一个ModbusTcpClient类内部持有TcpClient对外暴露ReadHolding、WriteSingle、WriteMultiple三个方法连接状态用IsConnected属性暴露重连逻辑放在调用方或定时器里。public class ModbusTcpClient : IDisposable { private TcpClient _tcp; private NetworkStream _stream; private readonly string _ip; private readonly int _port; private readonly int _timeoutMs; public ModbusTcpClient(string ip, int port 502, int timeoutMs 2000) { _ip ip; _port port; _timeoutMs timeoutMs; } public bool Connect() { try { _tcp new TcpClient(); var task _tcp.ConnectAsync(_ip, _port); if (!task.Wait(_timeoutMs)) return false; // 连接超时保护 _stream _tcp.GetStream(); _stream.ReadTimeout _timeoutMs; _stream.WriteTimeout _timeoutMs; return true; } catch { return false; } } public ushort[] ReadHolding(byte unitId, ushort start, ushort count) { var req BuildReadHolding(unitId, start, count); _stream.Write(req, 0, req.Length); byte[] head ReadExact(7); // 先读 7 字节头 int len (head[4] 8) | head[5]; // 头里声明的后续长度 byte[] body ReadExact(len - 1); // 再读剩余部分 if ((body[0] 0x80) ! 0) throw new Exception($异常码 {body[1]:X2}); int byteCount body[1]; var result new ushort[byteCount / 2]; for (int i 0; i result.Length; i) result[i] (ushort)((body[2 i * 2] 8) | body[3 i * 2]); return result; } private byte[] ReadExact(int n) { byte[] buf new byte[n]; int off 0; while (off n) { int r _stream.Read(buf, off, n - off); if (r 0) throw new IOException(连接被对端关闭); off r; } return buf; } public void Dispose() { _stream?.Dispose(); _tcp?.Dispose(); } }这里的关键是ReadExactTCP 是流式协议一次Read不保证读满必须循环读到指定长度否则解析会错位。Connect里用Wait加超时避免网络不通时界面卡死。参数上timeoutMs建议 2000 起步产线交换机负载高时调到 3000。写操作同理功能码 06 写单寄存器、16 写多寄存器注意写多寄存器时字节数要按“数量×2”填。3. 读写寄存器与线圈把软元件映射成 C# 里的强类型协议通了下一步是把 PLC 里的 D、M、X、Y 这些软元件映射成 C# 里能直接用的变量。这一步做得好上层业务代码就不用关心字节序和地址偏移做得糙后面每加一个点位都要改通信层。3.1 D 寄存器与 M 线圈的地址换算表汇川 PLC 的软元件和 Modbus 地址之间不是一一对应常见映射关系如下表。注意这只是常见做法具体型号要以手册或调试软件实测为准。软元件类型Modbus 区域常见起始地址读写功能码D0Dn数据寄存器保持寄存器003 / 06 / 16M0Mn辅助继电器线圈001 / 05 / 15X0Xn输入离散输入002只读Y0Yn输出线圈001 / 05 / 15换算时最容易错的是“软元件编号”和“Modbus 地址”混用。比如 D100 不一定对应寄存器地址 100有的系列会整体偏移。我的习惯是先在调试软件里读 D100抓包看它实际请求的地址再把这个地址写进配置表而不是在代码里做加减。3.2 批量读写的分块策略与字节序处理一次读太多寄存器PLC 可能回异常或响应变慢。常见做法是按 100 个寄存器一块切分循环读取再拼接。字节序方面Modbus 寄存器内部是大端但 32 位浮点数跨两个寄存器时有的设备高字在前、有的低字在前读回来要按实际顺序拼。// 分块读取 0299 共 300 个保持寄存器 var all new Listushort(); for (ushort start 0; start 300; start 100) { ushort count (ushort)Math.Min(100, 300 - start); all.AddRange(client.ReadHolding(1, start, count)); } // 两个寄存器拼一个 float注意字序 float ToFloat(ushort high, ushort low) { byte[] b new byte[4]; BitConverter.GetBytes(high).CopyTo(b, 0); BitConverter.GetBytes(low).CopyTo(b, 2); if (BitConverter.IsLittleEndian) Array.Reverse(b); return BitConverter.ToSingle(b, 0); }分块大小 100 是经验值既不会触发数量上限又能减少往返次数。ToFloat里先按大端拼字节再转若现场读出的浮点数明显不对比如变成极小值或 NaN多半是字序反了把high和low对调再试。线圈读写类似功能码 01 读、05 写单个、15 写多个读回来按位解析第 0 位对应起始地址那个线圈。3.3 用定时器做轮询别让界面线程背锅上位机要实时刷新数据常见做法是开一个后台线程或System.Threading.Timer按固定周期读一批点位读到后更新缓存界面只读缓存。千万别在 UI 线程里同步调通信方法网络一抖界面就假死。private readonly object _lock new object(); private ushort[] _cache Array.Emptyushort(); var timer new System.Threading.Timer(_ { try { var data client.ReadHolding(1, 0, 50); lock (_lock) { _cache data; } // 只换引用读侧无锁竞争 } catch (Exception ex) { // 记录日志标记连接异常交给重连逻辑 Console.WriteLine($轮询失败{ex.Message}); } }, null, 0, 500); // 500ms 一轮轮询周期 500ms 适合大多数监控场景点位多时降到 1000ms避免把 PLC 通信口打满。缓存用引用替换而不是逐元素拷贝读侧加锁时间极短。异常不要吞掉至少记日志并置一个连接状态标志重连逻辑根据这个标志决定是否重建连接。4. 避坑与排查连不上、读回脏数据、断线重连的现场记录通信层写完只是开始现场跑起来才是真正的考验。下面几条是反复踩过的坑按“现象 → 原因 → 解决”记下来遇到时能少走弯路。4.1 现象连接成功但读回全 0 或异常码 0x02原因通常是寄存器地址算错或者站号不对。有的 PLC 在 TCP 模式下站号必须与配置一致填错时设备会回异常而不是断开。解决先用调试软件读同一地址抓包对比请求帧里的站号和地址字段逐字节核对确认地址偏移规则后把映射表固化成配置不要散落在代码里。4.2 现象偶发读到半截数据解析错位这是典型的 TCP 粘包/半包问题。一次Read可能只拿到报文头的一部分直接按固定长度解析就会错位。解决严格按报文头里的长度字段循环读满像前面ReadExact那样读到 0 字节说明对端关闭要触发重连而不是继续解析。4.3 现象运行几小时后连接假死重启程序才恢复原因多是网络闪断后TcpClient处于半开状态Read一直阻塞或返回 0 却没被处理。解决设置ReadTimeout/WriteTimeout捕获IOException后主动Dispose旧连接并重建轮询线程里加一个“连续失败 N 次则重连”的计数器N 取 3 比较稳。4.4 现象写寄存器成功但 PLC 侧数值没变先确认写的是不是保持寄存器、功能码对不对再确认 PLC 程序里有没有对该寄存器做覆盖写。有些场景下 HMI 和上位机同时写同一地址后写的覆盖先写的。解决写之前读一次确认当前值写之后回读校验失败则重试一次并记录避免“以为写成功”的假象。4.5 现象多线程同时读写导致响应串包多个线程共用一个NetworkStream且没有加锁请求和响应会交错。解决通信层内部加一把锁保证“发一帧、收一帧”原子完成或者每个连接只由一个轮询线程使用其他线程通过队列提交请求。我一般选后者结构更清晰。5. 进阶验证用回环测试和抓包把通信层钉死通信层交付前我习惯做两件事一是本地回环测试二是在真实链路上抓包核对。回环测试不依赖 PLC用一个本地 TCP 服务端模拟响应能验证解析逻辑和异常分支抓包则能看清实际发出的每一帧地址、站号、功能码一目了然。回环测试可以这样搭起一个TcpListener监听本地端口收到请求后按协议拼一个响应回去重点覆盖正常响应、异常响应、半包三种情况。半包场景手动把响应拆成两次发送验证ReadExact是否还能正确拼回。抓包用常见的网络分析工具过滤目标 IP 和 502 端口对照代码里的请求帧逐字段看地址偏移对不对、站号有没有被改一眼就能看出来。验证项方法通过标准正常读回环返回合法响应解析出的寄存器值与预期一致异常响应回环返回 0x83 异常码抛出带异常码的异常不崩溃半包响应分两次发送仍能拼出完整帧断线重连中途关闭服务端计数触发重连恢复后继续读有个细节值得单独说浮点数和 32 位整数跨寄存器时字序问题在回环测试里就要覆盖到别等到现场才发现读出来是天文数字。我一般会在回环里准备两组字序的样本确认代码能按配置切换。从那以后我每次交付通信模块都强制走一遍“回环 抓包 断线重连”三件套哪怕工期再紧也不省这一步——现场返工的成本远比多花半天验证高。希望帮到你。本文还有配套的精品资源点击获取