简介:这份资源面向具备一定C#基础、希望打通上位机与工业控制器数据链路的开发者,聚焦C#与汇川PLC之间的通信实现,覆盖网络编程、Modbus协议应用与异常处理等关键环节,适合自动化系统集成入门与工程实践参考。压缩包共624个文件、约214.54MB,包含12个cs与12个vb源码文件、6个sln解决方案、多个csproj与vcxproj工程文件,以及dll、lib、exe等编译产物和xml、config配置、docx说明文档,另有大量tlog、cache、pdb等构建中间文件,完整保留了示例工程的原始开发痕迹。已有582人学习下载。读者可从中获得可直接运行的通信示例工程,理解Socket建立TCP连接、构造与解析Modbus报文、读写线圈与保持寄存器、CRC校验及超时重试等排错思路,并参考汇川PLC地址映射与参数配置细节,为界面集成与性能优化提供可复用的代码骨架。
1. 从一条产线调试的深夜说起:C# 与汇川 PLC 通信到底在解决什么
凌晨两点,产线还在跑,上位机界面上的轴位置突然不刷新了。现场工程师第一反应是网线松了,第二反应是 PLC 程序改了,第三反应才想到——上位机那套 C# 通信代码,可能压根没处理断线重连。这个场景在工控上位机开发里太常见了:设备端是汇川 PLC,上位机是 C# 写的 WinForm 或 WPF 程序,中间靠以太网通信把寄存器、轴状态、报警码读上来,再下发启动、停止、参数修改指令。这份「C# 与汇川 PLC 通信 示例」资源,做的就是把这层通信链路讲清楚、跑起来、能改。它适合两类人:一类是刚接手上位机项目、手里有汇川 PLC 但不知道怎么用 C# 连上的新手;另一类是已经能连上、但代码写得零散、断线就崩、想整理成可复用通信模块的熟手。通信本身不玄学,玄学的是现场网络抖动和寄存器地址对不上,后面几章会把这些血泪经验拆开讲。
2. 通信架构拆解:从 TCP 连接到寄存器读写的一条完整链路
2.1 为什么汇川 PLC 上位机通信大多走 Modbus TCP
汇川的 PLC 产品线里,H3U、H5U、AM400、AM600 这些型号在上位机通信场景下,最常见的落地方式是 Modbus TCP。原因不复杂:它跑在标准以太网上,不需要额外装厂商驱动,C# 侧用现成的 Socket 或者第三方 Modbus 库就能发请求,调试时还能用 Modbus Poll 这类工具先验证寄存器地址对不对。相比串口通信,TCP 少了波特率、校验位这些参数,现场布线也简单,一根网线进交换机就行。
但要注意,Modbus TCP 只是协议层,汇川 PLC 内部还有自己的软元件地址体系,比如 M 区、D 区、X 区、Y 区。上位机读的时候,发出去的是 Modbus 的功能码加起始地址加数量,PLC 那边再映射到自己的软元件上。这个映射关系如果搞错,现象就是「连上了但读出来全是 0」或者「读出来的值和触摸屏上对不上」。常见做法是先在 PLC 编程软件里确认软元件地址,再对照通信手册换算成 Modbus 寄存器地址,不要凭感觉填。
2.2 示例项目的分层结构:连接层、协议层、业务层
一份能二次开发的通信示例,代码结构不能是一坨 Form 里塞满 Socket 调用。合理的拆法是三层:连接层管 TCP 连接的建立、保持、断开和重连;协议层管 Modbus 报文的组包和解析;业务层管具体读哪个地址、写什么值、怎么转成工程单位。下面这个目录结构是这类示例项目里比较典型的组织方式,实际资源里文件命名可能略有差异,但分层思路一致。
PlcCommDemo/ ├── Comm/ │ ├── TcpConnection.cs # TCP 连接管理,含重连 │ ├── ModbusTcpClient.cs # Modbus 报文组包解析 │ └── PlcAddress.cs # 软元件地址换算 ├── Models/ │ └── PlcTag.cs # 点位定义:地址、类型、系数 ├── Services/ │ └── PlcService.cs # 业务层:读轴状态、写参数 ├── Forms/ │ └── MainForm.cs # 界面绑定与定时刷新 └── Config/ └── tags.json # 点位配置文件这样拆的好处是,换一个 PLC 型号或者换一种协议,只需要动 Comm 层,业务层和界面层不用大改。新手容易犯的错是把 Socket 直接写在按钮点击事件里,读一次建一次连接,现场跑几天就出现端口耗尽。连接层单独抽出来,用长连接加心跳,才是能上产线的写法。
2.3 一次读寄存器的完整调用链
把链路串起来看:界面定时器触发 → 业务层调用 PlcService.ReadTags → 协议层按点位列表组 Modbus 请求帧 → 连接层通过 Socket 发送 → 收到响应后协议层解析字节 → 业务层按系数换算成工程量 → 界面更新。这条链上任何一环出问题,现象都不一样:连接层断了是超时异常,协议层错了是返回异常码,业务层错了是数值不对但通信正常。排查时按链路分段定位,比盲目改代码快得多。
3. 核心代码拆解:TCP 连接、Modbus 组包与点位读写
3.1 TCP 长连接与断线重连的实现
先看连接层。工控现场的网络不像机房那么稳,交换机重启、网线被叉车压断、PLC 断电,都会导致连接断开。如果代码里没有重连逻辑,上位机就只能重启。下面这段是连接管理的核心写法,用后台线程维持连接状态,断线后按间隔重试。
public class TcpConnection { private TcpClient _client; private NetworkStream _stream; private readonly string _ip; private readonly int _port; private volatile bool _running; private readonly object _lock = new object(); public TcpConnection(string ip, int port) { _ip = ip; _port = port; } public void Start() { _running = true; Task.Run(KeepAliveLoop); // 后台维持连接 } private async Task KeepAliveLoop() { while (_running) { try { if (_client == null || !_client.Connected) { Connect(); } await Task.Delay(1000); // 每秒检查一次连接状态 } catch { Close(); await Task.Delay(3000); // 断线后等 3 秒再重试 } } } private void Connect() { lock (_lock) { _client = new TcpClient(); _client.ReceiveTimeout = 2000; // 读超时 2 秒 _client.SendTimeout = 2000; // 写超时 2 秒 _client.Connect(_ip, _port); _stream = _client.GetStream(); } } public byte[] SendAndReceive(byte[] request, int expectLen) { lock (_lock) { if (_stream == null) throw new IOException("连接未建立"); _stream.Write(request, 0, request.Length); var buffer = new byte[expectLen]; int read = 0; while (read < expectLen) { int n = _stream.Read(buffer, read, expectLen - read); if (n == 0) throw new IOException("连接被关闭"); read += n; } return buffer; } } }这段代码有几个参数值得说清楚。ReceiveTimeout和SendTimeout设成 2000 毫秒,是因为现场 PLC 响应一般在几十毫秒内,超过 2 秒基本可以判定链路异常,设太长会让界面卡住。重连间隔 3 秒是经验值,太短会在 PLC 重启期间疯狂重试刷日志,太长又恢复慢。lock保证同一时刻只有一个线程在收发,避免多线程同时写同一个流导致报文错位。注意_running用了volatile,是为了让后台线程能及时看到停止标志。
3.2 Modbus TCP 请求帧的组包与解析
协议层的关键是报文格式。Modbus TCP 的请求帧由 MBAP 头和 PDU 组成,MBAP 头 7 个字节,PDU 里功能码 1 个字节加数据。读保持寄存器用功能码 0x03,写单个寄存器用 0x06,写多个用 0x10。下面以读为例。
public static byte[] BuildReadRequest(ushort transactionId, ushort startAddr, ushort count) { var frame = new byte[12]; // MBAP 头 frame[0] = (byte)(transactionId >> 8); // 事务标识高字节 frame[1] = (byte)(transactionId & 0xFF); // 事务标识低字节 frame[2] = 0; // 协议标识固定 0 frame[3] = 0; frame[4] = 0; // 长度高字节 frame[5] = 6; // 长度低字节:后续 6 字节 frame[6] = 1; // 单元标识,一般填 1 // PDU 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; }transactionId每发一次请求递增,用来匹配响应,虽然很多简单示例不校验它,但现场多线程读写时校验一下能避免响应错配。startAddr是 Modbus 寄存器地址,注意它和汇川软元件地址之间通常有一个偏移,常见做法是 D 区地址直接对应,M 区需要按手册换算,具体偏移以所用型号的通信手册为准,不要照搬别的型号。count一次读的数量不要超过 125 个寄存器,这是 Modbus 协议限制,超了 PLC 会返回异常码。
解析响应时,前 7 字节是 MBAP 头,第 8 字节是功能码,第 9 字节是字节数,后面才是数据。每个寄存器占 2 字节,高字节在前。如果功能码最高位是 1,说明是异常响应,第 9 字节是异常码,常见 0x02 是地址非法,0x03 是数量非法,看到异常码先查地址和数量,不要怀疑网络。
3.3 点位配置与工程量换算
业务层不要硬编码地址,用配置文件把点位定义出来,改地址不用重新编译。下面是一个点位定义的模型和读取后的换算示例。
public class PlcTag { public string Name { get; set; } // 点位名,如 Axis1Pos public ushort Address { get; set; } // Modbus 寄存器地址 public string DataType { get; set; } // Int16 / UInt16 / Int32 public double Scale { get; set; } = 1; // 换算系数 public string Unit { get; set; } // 工程单位 } // 读取后换算:32 位整数占两个寄存器,需要合并 public static int ToInt32(byte[] data, int offset) { return (data[offset] << 24) | (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3]; } // 工程量 = 原始值 * 系数 double engineering = rawValue * tag.Scale;Scale这个系数在现场很关键。PLC 里存的可能是放大 10 倍或 100 倍的整数,比如温度存 253 表示 25.3 度,上位机不除系数显示出来就是错的。32 位数据要占两个连续寄存器,合并时注意字节序,汇川 PLC 一般是高字在前,但不同型号可能有差异,调试时用一个已知值验证一下最稳。DataType字段决定解析方式,有符号和无符号在负值场景下结果完全不同,这一点新手很容易翻车。
4. 跑起来:环境准备、连接配置与第一个读取测试
4.1 开发环境与依赖准备
跑这个示例需要的东西不多:Visual Studio 2019 或以上,.NET Framework 4.7.2 或 .NET 6 都可以,示例代码本身不依赖特殊 NuGet 包,Socket 和字节操作都是基础库。如果资源里用了第三方 Modbus 库,按项目里的 packages 配置还原即可。PLC 侧需要确认三件事:PLC 的 IP 地址、Modbus TCP 端口(一般是 502)、以及 PLC 是否开启了 Modbus TCP 从站功能。有些型号默认不开启,需要在编程软件里配置以太网参数并下载。
网络连通性先用 ping 验证,再用 telnet 测端口,两步都通了再跑代码。现场常见的情况是 ping 通但端口不通,那多半是 PLC 侧没开从站或者防火墙拦了。上位机电脑如果有安全软件,也可能拦掉出站连接,调试阶段可以先放行。
4.2 连接参数配置与首次读取
把 PLC 的 IP 和端口填到配置里,先只配一个点位做测试。下面是一段最小可运行的调用代码,读一个保持寄存器并打印。
var conn = new TcpConnection("192.168.1.10", 502); conn.Start(); Thread.Sleep(500); // 等连接建立 var request = ModbusTcpClient.BuildReadRequest(1, 0, 1); // 读地址 0 的 1 个寄存器 var response = conn.SendAndReceive(request, 11); // 读 1 个寄存器响应共 11 字节 int value = (response[9] << 8) | response[10]; // 第 9、10 字节为数据 Console.WriteLine($"读到值:{value}");响应长度 11 是这样算的:MBAP 头 7 字节 + 功能码 1 字节 + 字节数 1 字节 + 数据 2 字节。如果实际收到的长度不对,先检查expectLen有没有算错,长度算错会导致Read一直等或者读多。第一次测试建议读一个已知值的寄存器,比如在 PLC 里给 D0 写个 123,上位机读出来也是 123,说明地址映射和解析都对。读出来是 0 或者乱值,先换地址试,再查字节序。
4.3 用定时器做周期刷新与界面绑定
实际项目里不会手动点一次读一次,而是定时刷新。WinForm 里用System.Windows.Forms.Timer,间隔设 200 到 500 毫秒比较合适,太快会增加 PLC 通信负担,太慢界面看起来卡顿。刷新逻辑放在后台线程读,读完再Invoke回 UI 线程更新控件,不要在 UI 线程里直接做阻塞的 Socket 读,否则界面会假死。批量读的时候把连续地址合并成一次请求,比如 10 个连续寄存器一次读完,比读 10 次效率高得多,这也是现场调优的一个基本点。
5. 避坑与排查:现场通信最容易翻车的五个地方
5.1 连上了但读出来全是 0
现象是 Socket 连接正常,请求也发出去了,返回的数据全是 0。原因通常是寄存器地址映射错了,或者读的是未使用的区域。解决方法是先在 PLC 编程软件里确认软元件当前值,再对照通信手册换算 Modbus 地址,用一个已知非零的地址做对照测试。不要拿触摸屏上显示的地址直接填,两者可能差一个偏移。
5.2 偶发超时但网络看着正常
现象是大部分时间正常,偶尔报超时,重连后又恢复。原因可能是交换机负载高、网线质量差,也可能是上位机同时开了太多连接。解决方法是检查是否有多个线程同时收发,确保连接层加了锁;把超时时间适当放宽到 3 秒观察是否还频繁出现;现场有条件的话换一根网线或者换个交换机端口对比。血泪经验是,这类问题不要先改代码,先排除物理链路。
5.3 32 位数据高低字颠倒
现象是读 32 位数值时,结果和预期差了一个数量级或者符号不对。原因是高低字顺序或者字节序理解反了。解决方法是写一个已知值到 PLC,比如 0x00010002,上位机读出来看是 0x00010002 还是 0x00020001,据此调整合并顺序。不同型号的 PLC 可能不一样,不要假设所有汇川型号都一致。
5.4 写值不生效但读正常
现象是读没问题,写指令发出去 PLC 没反应。原因可能是功能码用错,写单个寄存器应该用 0x06,写多个用 0x10;也可能是写的地址是只读区。解决方法是先用 Modbus 调试工具手动写一次,确认地址可写,再对照代码里的功能码和报文长度。写多个寄存器时数量字段和字节数字段都要对,少一个字节 PLC 就返回异常。
5.5 程序跑几天后连接数暴涨
现象是任务管理器里看到大量连接处于等待状态,最后连不上。原因是每次操作都 new 一个 TcpClient 且没有关闭,或者异常路径里没释放。解决方法是连接层用单例长连接,所有异常路径都走统一的 Close 方法,TcpClient用完必须Dispose。这个问题在调试阶段看不出来,上产线跑一周才暴露,属于典型的后悔药场景。
6. 二次开发改造:把示例变成可复用的通信组件
示例代码能跑通只是起点,真正要落地到项目里,得把它改造成能复用的组件。我一般会做三件事。第一,把点位配置从代码里彻底抽出来,用 JSON 或数据库存,界面上做一个点位管理页,现场工程师自己能改地址和系数,不用找开发重新发版。第二,给通信层加一个状态事件,连接断开、重连成功、读写异常都往外抛事件,界面顶部做一个连接状态灯,出问题一眼能看到,而不是等数据不刷新了才去查日志。第三,加一个简单的通信日志,记录每次请求的地址、数量、耗时和异常码,日志按天切分,现场排查时直接看日志比远程猜快得多。
改造后的验证方法也有讲究。不要只在办公室用一台 PLC 测,要模拟断线:拔网线看是否自动重连、PLC 断电再上电看数据是否恢复、连续跑 24 小时看内存和连接数是否稳定。下面这个表格是我常用的验证清单,每次改完通信层都过一遍。
| 验证项 | 操作 | 预期结果 |
|---|---|---|
| 断线重连 | 拔掉网线 10 秒再插回 | 3 秒内自动重连,数据恢复刷新 |
| PLC 重启 | 给 PLC 断电再上电 | 上位机不崩溃,重连后数据正常 |
| 长时间运行 | 连续运行 24 小时 | 连接数稳定,内存无明显增长 |
| 异常地址 | 配置一个非法寄存器地址 | 返回异常码并记录日志,不影响其他点位 |
| 并发读写 | 同时触发读和写 | 报文不错位,读写结果正确 |
还有一个技巧值得单独说:把通信层做成接口,比如IPlcClient,读、写、连接状态各定义方法,业务层只依赖接口。这样以后换别的协议或者别的品牌 PLC,只要新写一个实现类,业务代码一行不用改。这个设计在项目初期多花半天,后期省下的返工时间远不止半天。
从那以后我每次接新的上位机项目,都强制先把连接层和协议层单独跑通、单独压测,再往上叠业务逻辑,绝不在界面代码里直接写 Socket。通信这层看着简单,现场一跑起来问题全在细节里,早拆早省心。希望帮到你。
本文还有配套的精品资源,点击获取