简介:这是一份面向C#开发者的汇川PLC通信示例工程,覆盖Modbus TCP协议调用、Socket网络编程、寄存器/线圈读写、异常处理与界面绑定等关键环节,可用于工业自动化上位机开发、设备数据采集和协议对接。压缩包共624个文件,包含C#及C++源码、Visual Studio工程配置、编译后的exe/dll、调试符号pdb以及文档说明等,整体约214.54MB,既可直接运行演示,也能参考源码进行二次开发。目前已有580人学习下载。示例中完整提供了Modbus消息构造与解析、汇川PLC地址映射配置、实时数据刷新界面等实现,并附多种语言版本便于对照,可帮助开发者快速搭建稳定可靠的PLC通信模块、理解主从通信流程,显著降低从零开发的试错成本。
1. 写给谁的项目:C#上位机直连汇川PLC
产线调试台上最让人头疼的事,莫过于上位机软件写好了,却死活读不到PLC里的数据。某次去现场支持,设备供应商扔过来一台汇川PLC,说“你们上位机自己想办法连”,我翻遍手册发现对方连个现成的通信Demo都没有,只能对照Modbus协议一步步把报文抠出来。这个项目就是一套C#与汇川PLC通信的完整示例,覆盖了TCP连接、报文组包、数据读写、异常重连这几条最核心的链路。适合正在做上位机开发、MES数据采集、设备监控软件的朋友,尤其是第一次接触汇川PLC、翻协议文档翻到头疼的开发者。拿到手不是看概念,是能直接照着把通信跑通。
2. 技术选型与整体架构:Modbus TCP协议、分层设计、一个请求的完整链路
2.1 为什么选Modbus TCP:汇川PLC的网口通信路径
汇川PLC不同系列支持的通信方式不太一样,比如AM系列和H系列基本都带以太网口,往上走既可以走厂商私有协议,也可以走标准的Modbus TCP。这个示例选的是Modbus TCP,原因很朴素:协议文档公开、报文结构简单、C#里用Socket就能实现,不依赖任何厂商SDK。相比之下,走厂商私有协议虽然功能更全,但文档不一定拿得到,调试工具也得用厂家的,对二次开发并不友好。
Modbus TCP是标准的请求-响应模式,客户端发请求帧,服务端回响应帧。报文核心是MBAP报文头加PDU,MBAP头里包含事务标识符、协议标识符、长度和单元标识符,PDU里包含功能码和数据。实际操作中你会发现,多数情况下只需要关心事务标识符、功能码和寄存器地址这三件事。
这里要澄清一个常见误区:很多人以为连PLC必须装驱动或者厂商提供的通信库,其实走标准Modbus TCP的话,一个TcpClient就够了。汇川PLC作为Modbus TCP服务端,监听502端口,上位机作为客户端主动连接。当然前提是PLC侧启用了Modbus TCP功能,并且在参数里配置好了IP地址和端口,这部分在第4章会细说。
2.2 项目目录结构:从解决方案到核心类
这个示例采用的是经典的简单分层:一个控制台程序加一个通信类库,没有引入重量级框架。结构如下:
PlcModbusDemo/ ├── PlcModbusDemo.sln ├── PlcModbus.Core/ # 通信核心类库 │ ├── ModbusTcpClient.cs # 报文组包、发送、接收、解析 │ ├── ModbusDataConverter.cs # 寄存器值到C#类型的转换 │ ├── PlcConnectionOptions.cs # 连接参数模型 │ └── PlcException.cs # 自定义异常 ├── PlcModbus.Console/ # 控制台示例程序 │ ├── Program.cs # 演示调用入口 │ ├── appsettings.json # PLC地址、端口、超时配置 │ └── PlcModbus.Console.csproj └── README.md # 使用说明与协议参考核心是ModbusTcpClient.cs,它负责所有与PLC的网络交互;ModbusDataConverter.cs承担字节序转换和数据类型换算;PlcConnectionOptions.cs把IP、端口、超时时间收敛成一个强类型配置对象。控制台项目只做一件事:读取配置,调用核心类库完成任务。
这样一个拆分方式的好处是,后边你把控制台换成WinForm、WPF或者Web API,只需要换表现层,通信层完全不用动。我一般做这类采集项目时都这么分,避免后期界面和逻辑耦合在一起改起来头疼。
2.3 一个读请求的完整链路
读操作是最高频的通信场景,完整的链路一定要掰清楚:
第一段是建立连接。ModbusTcpClient内部维护一个TcpClient实例,调用ConnectAsync方法时传入PLC的IP和端口,建立TCP连接。连接建立后,客户端需要维护这个连接,而不是每次读写都重新握手,正因如此,连接管理是性能的关键。
第二段是组包。读保持寄存器时功能码是0x03,报文帧里事务标识符每次请求递增,协议标识符固定为0x0000,长度字段表示后续字节数,单元标识符一般填0x01或0xFF(取决于PLC侧配置)。数据部分要指定起始地址和读取数量。
第三段是发送与接收。通过NetworkStream写入报文,等待响应。这里的关键是超时控制——PLC在极端情况下可能不响应,不能让程序无限等下去。一般用CancellationTokenSource加Task.WhenAny来做超时中断。
第四段是解析。响应帧前六个字节和请求帧类似,从第七个字节开始是数据。读保持寄存器的响应里,数据区每个寄存器占两个字节,大端序排列,把字节拼接成ushort再按业务需求转换即可。
这些步骤里最容易出问题的点在组包和解析,尤其是长度字段算错、单元标识符对不上、超时时间给得太短,这三个坑会在第5章展开。
3. 核心模块代码拆解:C# Modbus TCP客户端的实现细节
3.1 报文组包:构建Modbus TCP帧
组包这个环节不复杂,但必须严谨,因为PLC侧是严格按字节解析的。一个读保持寄存器的请求帧,我用下面这个方法来构建:
// PlcModbus.Core/ModbusTcpClient.cs private byte[] BuildReadRequest(byte unitId, ushort startAddress, ushort quantity, byte functionCode) { // MBAP头:事务标识符(2字节) + 协议标识符(2字节) + 长度(2字节) + 单元标识符(1字节) // PDU:功能码(1字节) + 起始地址(2字节) + 寄存器数量(2字节) byte[] buffer = new byte[12]; // 事务标识符,每次请求递增,用于匹配响应 byte[] transactionId = BitConverter.GetBytes(_transactionId++); // Modbus TCP使用大端序,但BitConverter在小端环境返回的是小端序,需要反转 if (BitConverter.IsLittleEndian) { Array.Reverse(transactionId); } Buffer.BlockCopy(transactionId, 0, buffer, 0, 2); // 协议标识符固定为0x0000,表示Modbus协议 buffer[2] = 0x00; buffer[3] = 0x00; // 长度字段 = 单元标识符(1) + 功能码(1) + 起始地址(2) + 数量(2) = 6 buffer[4] = 0x00; buffer[5] = 0x06; // 单元标识符,默认0x01,需与PLC侧保持一致 buffer[6] = unitId; // 功能码:0x03读保持寄存器,0x06写单个寄存器 buffer[7] = functionCode; // 起始地址和数量,同样转大端序 byte[] addr = BitConverter.GetBytes(startAddress); byte[] qty = BitConverter.GetBytes(quantity); if (BitConverter.IsLittleEndian) { Array.Reverse(addr); Array.Reverse(qty); } Buffer.BlockCopy(addr, 0, buffer, 8, 2); Buffer.BlockCopy(qty, 0, buffer, 10, 2); return buffer; }这里的核心是字节序处理。BitConverter.GetBytes在当前平台上返回小端字节序,而Modbus TCP要求大端序,所以必须做反转。事务标识符的作用是匹配请求和响应——如果程序里并发发送多个请求,响应返回时可以依赖事务标识符找到对应的请求。
参数说明:unitId对应PLC侧Modbus从站地址,多台设备并联时用这个区分;startAddress是寄存器起始地址,寄存器编号从0开始;quantity是一次读取的寄存器个数。注意,Modbus协议里寄存器编号和实际地址差1,比如PLC手册里写“%MW100”,在报文里填的起始地址通常是99,这个偏移我一开始就吃过亏。
3.2 异步读写:TcpClient、CancellationToken、超时控制
通信不能是同步阻塞的,否则PLC响应慢的时候整个UI线程会卡死。这个示例的读写方法全部走异步,配合CancellationTokenSource做超时控制。核心的读方法如下:
// PlcModbus.Core/ModbusTcpClient.cs public async Task<byte[]> ReadHoldingRegistersAsync( byte unitId, ushort startAddress, ushort quantity, CancellationToken cancellationToken = default) { // 检查连接状态,未连接时先建立连接 if (_tcpClient == null || !_tcpClient.Connected) { await ConnectAsync(cancellationToken); } // 构建读保持寄存器请求帧 byte[] request = BuildReadRequest(unitId, startAddress, quantity, 0x03); var stream = _tcpClient.GetStream(); // 发送请求 await stream.WriteAsync(request, cancellationToken); await stream.FlushAsync(cancellationToken); // 响应头固定9个字节:MBAP头(7) + 功能码(1) + 字节数(1) byte[] responseHeader = await ReadExactlyAsync(stream, 9, cancellationToken); // 校验事务标识符,防止响应错配 if (responseHeader[0] != request[0] || responseHeader[1] != request[1]) { throw new PlcException("事务标识符不匹配,响应与请求不对应"); } // 异常响应:功能码最高位为1时表示错误 if ((responseHeader[7] & 0x80) != 0) { throw new PlcException($"PLC返回异常响应,错误码: 0x{responseHeader[8]:X2}"); } // 数据区长度 = 字节数,每个寄存器占2字节 int dataLength = responseHeader[8]; byte[] data = await ReadExactlyAsync(stream, dataLength, cancellationToken); return data; }这段代码里有两个细节值得留意。一是ReadExactlyAsync方法,它要确保读取到指定长度的字节才算完成,因为NetworkStream.ReadAsync一次不一定读满缓冲区,需要循环读取。二是超时控制,调用方传入的CancellationToken可以由CancellationTokenSource.CancelAfter触发,实现“超过500毫秒就放弃等待”。
连接管理方面,我一般会在ConnectAsync里做一次TCP连接超时保护,默认3秒连不上就抛异常,这个值可以通过PlcConnectionOptions配置。PLC的响应时间通常在几十毫秒,所以读操作超时设置200~500毫秒比较合理;连接超时要放长一些,因为现场网络环境不稳定,交换机端口协商也需要时间。
3.3 数据解析:字节序、偏移量、寄存器值转换
PLC寄存器的原始数据是16位无符号整数,但业务数据可能是浮点数、32位整数、布尔量或者字符串,所以需要一组转换工具类。这个示例里我把常见转换都封装在ModbusDataConverter中:
// PlcModbus.Core/ModbusDataConverter.cs public static class ModbusDataConverter { // 两个连续寄存器转32位浮点数(32位浮点数在汇川PLC中占2个寄存器) public static float ToFloat(byte[] data, int offset) { // 默认大端序,高字节在前 ushort high = (ushort)((data[offset] << 8) | data[offset + 1]); ushort low = (ushort)((data[offset + 2] << 8) | data[offset + 3]); // 转换为32位二进制:高16位寄存器在前 uint rawValue = (uint)((high << 16) | low); return BitConverter.ToSingle(BitConverter.GetBytes(rawValue), 0); } // 两个连续寄存器转32位有符号整数 public static int ToInt32(byte[] data, int offset) { ushort high = (ushort)((data[offset] << 8) | data[offset + 1]); ushort low = (ushort)((data[offset + 2] << 8) | data[offset + 3]); return (int)((high << 16) | low); } // 单个寄存器转16位无符号整数 public static ushort ToUInt16(byte[] data, int offset) { return (ushort)((data[offset] << 8) | data[offset + 1]); } }汇川PLC的32位数据有两种排列模式:一种是大端序(高字在前),一种是小端序(低字在前),具体取决于PLC内的数据存储格式设置。这个示例默认采用大端序,如果你的项目发现读出来的浮点数完全不对,优先怀疑寄存器顺序反了,把high和low对调一下再验证。
参数说明:offset是字节偏移量,不是寄存器偏移量。因为每个寄存器占2字节,所以第n个寄存器对应的字节偏移是n * 2。调用方拿到响应数据后,如果用寄存器索引去访问字节数组,极容易越界或取到错位数据。
另外,布尔量的读取方式不太一样。Modbus读线圈用功能码0x01,返回的数据是按位压缩的,一个字节表示8个线圈状态。如果业务需求是读单个开关量,用读保持寄存器的方式去读会失败,因为地址空间和功能码都不对。这块是很多人容易混淆的地方。
4. 运行与验证:从空目录到连上真实PLC
4.1 创建工程与依赖
在Visual Studio里新建解决方案,添加上述两个项目。目标框架建议选.NET 6或.NET 8,因为NetworkStream.ReadAsync和WriteAsync的CancellationToken重载在.NET 6里已经很好用了,不需要额外引包。
PlcModbusDemo.sln解决方案创建完成后,给控制台项目添加对核心类库的项目引用:
dotnet add PlcModbus.Console/PlcModbus.Console.csproj reference PlcModbus.Core/PlcModbus.Core.csproj控制台项目里引入命名空间后就能用了:
// PlcModbus.Console/Program.cs using PlcModbus.Core; // 从配置文件读取连接参数 PlcConnectionOptions options = new() { IpAddress = "192.168.1.10", // 汇川PLC的IP地址 Port = 502, // Modbus TCP默认端口 TimeoutMs = 500, // 单次请求超时 ConnectTimeoutMs = 3000 // TCP连接超时 }; ModbusTcpClient client = new(options); try { // 读 %MW100 开始的 2 个寄存器,解析为浮点数 byte[] data = await client.ReadHoldingRegistersAsync(0x01, 99, 2); float value = ModbusDataConverter.ToFloat(data, 0); Console.WriteLine($"当前温度:{value} ℃"); } catch (PlcException ex) { Console.WriteLine($"通信异常:{ex.Message}"); } finally { client.Dispose(); }这里的事务标识符、单元标识符等参数均采用默认值,如果你在PLC侧改了从站地址,记得同步修改unitId参数。启动方式是先编译:
dotnet build PlcModbusDemo.sln dotnet run --project PlcModbus.Console如果程序能正确打印出PLC里的寄存器值,说明通信链路已经通了。很多第一次上手的人卡在这一步——编译不过或者运行直接崩溃,多数是因为目标框架版本不对,或者控制台项目误用了Main方法返回Task但没配置async入口。
4.2 配置PLC侧的网络参数
PLC侧不配置,上位机怎么连都没用。汇川PLC的Modbus TCP配置通常在编程软件的系统参数里,需要设定IP地址、子网掩码,并启用Modbus TCP功能。核心参数如下:
| 配置项 | 典型值 | 说明 |
|---|---|---|
| IP地址 | 192.168.1.10 | 与上位机在同一网段 |
| 子网掩码 | 255.255.255.0 | 默认即可 |
| 端口号 | 502 | Modbus TCP标准端口,PLC侧一般不可改 |
| 从站地址 | 1 | 对应报文里的单元标识符0x01 |
| 寄存器起始地址 | %MW100 | 对应Modbus地址99 |
配置完成后把PLC程序下载到设备并运行,先用电脑ping一下PLC的IP地址,通了再跑上位机。如果ping不通,检查网线连接和IP网段,别急着查代码。
有一点要特别说明,汇川PLC的Modbus地址映射和HMI组态软件里看到的地址编号有可能不一致。比如组态软件里写“%MW100”,报文里实际填的是99,因为Modbus地址从0开始,而汇川的%MW号从1开始。如果你发现读出来的数据一直对不上,先做个地址偏移验证。
4.3 联调验证:读寄存器、写寄存器、监控报文
联调时建议分三步走。第一步只读不写,用一个已知寄存器地址读取数据,验证连接和读链路;第二步写单个寄存器,把特定值写入某个空闲寄存器再读回来,验证写链路;第三步模拟真实业务,连续读写多个寄存器。
读链路验证通过后,写一个测试方法:
// 写入单个保持寄存器,功能码0x06 public async Task WriteSingleRegisterAsync(byte unitId, ushort address, ushort value) { byte[] request = new byte[12]; // 事务标识符 byte[] transactionId = BitConverter.GetBytes(_transactionId++); if (BitConverter.IsLittleEndian) Array.Reverse(transactionId); Buffer.BlockCopy(transactionId, 0, request, 0, 2); // 协议标识符和长度 request[2] = 0x00; request[3] = 0x00; request[4] = 0x00; request[5] = 0x06; // 单元标识符和功能码 request[6] = unitId; request[7] = 0x06; // 寄存器地址 byte[] addr = BitConverter.GetBytes(address); if (BitConverter.IsLittleEndian) Array.Reverse(addr); Buffer.BlockCopy(addr, 0, request, 8, 2); // 写入值 byte[] val = BitConverter.GetBytes(value); if (BitConverter.IsLittleEndian) Array.Reverse(val); Buffer.BlockCopy(val, 0, request, 10, 2); var stream = _tcpClient.GetStream(); await stream.WriteAsync(request, CancellationToken.None); await stream.FlushAsync(CancellationToken.None); }写完寄存器后,用读请求把同一个地址的值读回来,比对是否一致。这是最直接的验证手段,不需要看PLC侧的程序运行状态。
调试阶段我建议用Modbus Poll这类工具做双保险。因为有时候代码逻辑没问题,但PLC侧的寄存器地址映射发生了变化——程序改过、设备重新上电、配置丢失都可能导致地址偏移。先用工具读通一个地址,再在代码里读同一个地址,两边数据一致了,就能确定是你的代码问题还是PLC配置问题。
5. 避坑记录:汇川PLC通信中我踩过的几个坑
5.1 事务标识符没递增,响应对不上
现象:程序第一次读数据是正常的,第二次开始偶发读到脏数据,甚至抛异常说响应长度不对。
原因:事务标识符固定为0,导致每次请求的报文完全一样。当PLC返回的响应在网络缓冲里没有及时读完时,下一次读操作会匹配到上一次残留的响应帧。
解决:每次请求前让事务标识符自增,并使用ReadExactlyAsync确保把响应数据读完整。从那以后我写通信代码时,这个机制都是默认自带,不给自己留偷懒的空间。
5.2 字节序反了,浮点数完全不对
现象:读出来的32位浮点数是一个天文数字,比如预计20.5,实际读出1.43E-41。
原因:数据排列方式与预期不符。Modbus寄存器本身是大端序,但在汇川PLC中,32位数据的字顺序可配置。有的设备配置成高字在前,有的低字在前,代码里写死了其中一种就会出错。
解决:先写一个已知浮点数到PLC,再读回来对比,确定字顺序。然后把转换方法扩展出ToFloatBigEndian和ToFloatLittleEndian两个版本,运行时通过配置选择。自那以后我每次对接新设备,第一件事就是用已知数据试通信,而不是直接上业务数据。
5.3 超时时间设得太短,PLC响应稍微慢点就断
现象:程序跑一段时间后频繁报超时,重启程序又正常,过一会儿又超时。
原因:PLC扫描周期不固定,某些复杂的控制逻辑会让PLC的响应时间突破300毫秒,而超时设的是200毫秒。另外,上位机系统繁忙时,线程调度也可能导致响应处理被拖延。
解决:超时时间设置为500~1000毫秒,并加入重试机制——首次超时后重试一次,如果重试成功,说明是瞬时抖动,不必断开连接。重试依然失败,再重新建立TCP连接。这个方法帮我解决了不少“通信不稳定”的伪问题,很多所谓的不稳定其实是超时设置太激进。
5.4 PLC上电重启后连接不自动恢复
现象:产线断电重启后,上位机软件一直报连接失败,必须人工重启软件才能恢复。
原因:TCP连接在PLC重启后已经失效,但客户端检测不到,继续往断开的连接上写数据。写入失败时如果没有触发重连逻辑,连接就卡死在半开状态。
解决:在每次读写前检查_tcpClient.Connected,一旦发现连接不可用就执行重连。另外通过心跳机制,每隔几秒读一个已知寄存器,如果连续三次失败就主动断开并重新连接。这个设计让采集程序的连续性有了基本保障,算是能上线的底线。
5.5 地址偏移:寄存器编号和实际地址差1
现象:PLC程序里明明写的是%MW100,上位机读了99和100两个寄存器,数值都对不上,但读101和102反而对了。
原因:%MW100里的100是寄存器编号,从1开始计数。而Modbus TCP报文里的起始地址从0开始,所以映射到报文地址是99。如果直接在报文里填100,访问的实际是%MW101。
解决:读文档里的地址映射表,确认编址方式。写代码时把“PLC寄存器编号 - 1”作为报文地址,并通过离线模拟验证一遍。这个偏移问题不只是汇川有,很多PLC都有同样的设计,养成习惯后就不容易翻车了。
6. 二次开发方向:从“能通信”到“能上线”
示例程序能跑通只是第一步,真正放到产线上还得做几件事。这里说四个最实用的改造方向。
第一个是轮询调度。示例里是一次读一个地址,但产线设备往往要采集几十个变量,逐个读效率太低。改成批量读,把连续的寄存器地址合并成一次读取,例如把%MW100到%MW120一次读完,然后按偏移量解析。对于确实不连续的寄存器,建立一张采集点位表,用一个后台循环按优先级轮询,高优先级的点位读得勤一些,低优先级的可以慢一些。
第二个是断线重连机制。我在第5章提过心跳和重连的思路,实际落地时可以封装一个ReconnectionPolicy类,内部维护连接状态、重试次数、退避时间。连续失败次数超过阈值时,延长重试间隔,避免PLC还没恢复就把日志刷屏。
第三个是数据上送。通信层拿到寄存器数据之后,结合业务场景,将数据推送到数据库或者Web API,这样就能支撑看板显示和报表统计了。这个环节要注意的是数据的时间戳应该由上位机生成,而不是依赖PLC的内部时钟——很多PLC不保持时间,重启后就回到出厂设置。
第四个是配方管理。场景是设备需要根据产品型号切换参数,可以在上位机维护多组配方,把参数一次性写入PLC的寄存器区域。注意写入时要做二次确认,先读到当前值,再写新值,写完再读回来校验,防止通信干扰导致数据不一致。
说到底,通信示例最核心的永远是报文和状态管理。我后面做每个采集项目时都会强制走一遍这套流程:先写死配置联调出数据,再封装连接生命周期,最后才做业务功能。设计上始终遵循一条原则——PLC通信这类底层模块,代码里想要换设备换协议,不至于推倒重来。希望这些经验能帮到你,让你少走几个我走过的弯路。
本文还有配套的精品资源,点击获取