简介:这是面向工业自动化开发者与 C# 程序员的实战资料包,围绕汇川 PLC 的通信集成展开。包内含采用 Modbus 协议与 C# 网络编程实现的数据交互示例,覆盖 Socket/TCP 通信、寄存器读写、报文构造与解析、界面展示以及异常处理等关键环节,适合需要快速搭建上位机与 PLC 联调环境的初学者和中级工程师。压缩包共有 624 个文件,大小约 214.54MB,包含多套可编译运行的 Visual Studio 工程:既含大量 cpp/h/cs/vb 源码及 vcxproj/sln 工程文件,也有编译生成的 exe/dll/pdb 可执行程序与调试文件,还带有 docx/pdf/txt 说明文档,结构完整、便于对照研究。资源已有 578 人学习下载。通过该示例,读者能够掌握 C# 与汇川 PLC 通信的完整链路,理解 Modbus 主从架构、设备地址映射、网络超时和 CRC 校验等核心技术,并灵活调用第三方 Modbus API,获得一套能直接扩展复用的通信方案。
1. C#与汇川PLC通信:先跑通一个Modbus TCP示例再说
做过上位机的人都知道,和PLC通信这件事,协议选错比代码写错更难受。C#联汇川PLC,最常见的网络方案就是走Modbus TCP:PLC侧开启以太网服务,上位机用网线按Modbus报文读写寄存器。某厂产线改造时,我接手过一个需求——把三台汇川PLC的转速和温度实时拉回C#画面刷新,先后试过串口、OPC UA,最后是用Modbus TCP跑通的。这份示例覆盖了报文封装、地址映射、超时重连和几个必踩的坑,适合用WinForms或WPF做上位机、又不想被厂家SDK绑死的工程师。先把基础流程立住,再谈坑。
2. 通信协议选型:为什么Modbus TCP是C#联汇川PLC的最短路径
2.1 汇川PLC常见的联网方式对比
先别急着写代码,先看汇川PLC(尤其是AM系列和部分H系列)常见的几种上位机通信方式。我这个表是按实际项目里见到的频率排的,不是官方手册顺序:
| 通信方式 | 物理层 | 数据组织 | 优点 | 缺点 |
|---|---|---|---|---|
| Modbus RTU | 串口RS232/RS485 | 字节流+CRC16 | 简单,老设备通用 | 速率低,接线麻烦,多站需手拉手 |
| Modbus TCP | 以太网 | MBAP+PDU | 简单,速率高,调试方便 | 无加密,数据量大时效率下降 |
| OPC UA | 以太网 | 服务端/客户端模型 | 语义丰富,有报警和历史数据 | 配置复杂,某些型号需要授权 |
| 厂家私有SDK | 以太网/串口 | 私有结构体 | 功能全,能读系统区 | 绑定语言和平台,升级可能变动 |
我在一个项目里就遇到过这种场景:PLC是某厂家的AM系列,上位机需要采集60多个模拟量,刷新周期要求500毫秒以内。供应商推荐用私有SDK,但SDK只有C++版,C#用起来要么包一层原生调用,要么用命令行工具,非常别扭。后来发现该PLC支持Modbus TCP,我直接把它自带的Modbus从站功能打开,把数据映射到保持寄存器区,C#这边用Socket读写,整个方案一下子透明了。
2.2 为什么优先选Modbus TCP
Modbus TCP的报头是MBAP,7个字节,后面跟PDU,没有CRC16——因为TCP的传输层已经承担了校验,不需要应用层再做一遍。相比RTU,省了CRC计算和地址冲突问题,而且以太网线比串口线好找得多。现场调试时,我一般直接用网线连PLC,把电脑IP设成同网段,telnet一下就知道通不通。
从时序上看,Modbus TCP是典型的主从请求/响应模式,C#作为主站发起请求,PLC作为从站响应。读保持寄存器用功能码0x03,写单个寄存器用0x06,写多个寄存器用0x10。这三个功能码覆盖了90%的采集和写入场景。报文长度固定,解析逻辑很直白,出了问题也容易抓包定位。
2.3 选型边界:什么时候别碰Modbus TCP
Modbus TCP不是万能。下面几种情况我建议绕开:
一、数据量极大且刷新频率高。比如要读上千个寄存器,每次轮询几百个点,主从一问一答的模型会占用大量带宽和PLC扫描周期。这种情况OPC UA的订阅推送或厂家SDK的事件驱动更合适。
二、需要跨网段、有防火墙和加密要求。Modbus TCP没有会话认证,数据明文传输,放在办公网里容易被别的设备扫到。某次我把PLC和上位机放在同一个企业网段,第二天发现有个摄像头在发包,吓得我赶紧换到隔离网段。
三、PLC侧固件版本太老,Modbus TCP功能不稳定。有些老固件的从站功能会把连续读的字节数限制得很小,超过就报异常码0x03。遇到这种,就要考虑升级固件或改用OPC UA。
一句话:项目周期短、点位在几百以内、网络环境可控,Modbus TCP是C#联汇川PLC的最短路径;反之,早点上OPC UA。
3. C#实现Modbus TCP客户端:从Socket到可用的读写类
3.1 自己封装还是用现成库
很多开源Modbus库能用,但引入第三方库有时要考虑授权和依赖。我习惯自己写一个极简的ModbusTcpClient类,几百行以内,依赖只有.NET自带的Socket。示例工程里就包含这个类,核心方法有Connect、ReadHoldingRegisters、WriteSingleRegister、WriteMultiRegisters,以及内部的重连逻辑。这样做有个好处:一旦现场出现诡异问题,你手上有完整源码,抓包、加日志、改超时都方便。
3.2 建立连接与读取保持寄存器的完整代码
先看最核心的读操作。以下代码基于.NET Core / .NET 5+,WinForms/WPF都能用:
using System; using System.Net.Sockets; using System.Net; public class ModbusTcpClient { private TcpClient _tcpClient; private NetworkStream _stream; private string _ip; private int _port; public ModbusTcpClient(string ip, int port = 502) { _ip = ip; _port = port; } public void Connect() { // 同步Connect没有超时,生产环境建议用异步+Task.Delay做超时控制 _tcpClient = new TcpClient(); _tcpClient.Connect(IPAddress.Parse(_ip), _port); _stream = _tcpClient.GetStream(); } public void Disconnect() { _stream?.Close(); _tcpClient?.Close(); } /// <summary> /// 读保持寄存器,功能码0x03 /// </summary> /// <param name="startAddress">起始地址,0基</param> /// <param name="quantity">数量,1~125</param> public ushort[] ReadHoldingRegisters(int startAddress, ushort quantity) { if (quantity <= 0 || quantity > 125) throw new ArgumentException("quantity must be 1~125"); byte[] request = new byte[12]; // MBAP头 7字节 request[0] = 0x00; // 事务ID高字节 request[1] = 0x01; // 事务ID低字节 request[2] = 0x00; // 协议ID高字节 request[3] = 0x00; // 协议ID低字节 request[4] = 0x00; // 长度高字节 request[5] = 0x06; // 长度低字节,后续字节数=6 request[6] = 0x01; // 单元ID // PDU request[7] = 0x03; // 功能码 request[8] = (byte)(startAddress >> 8); request[9] = (byte)(startAddress & 0xFF); request[10] = (byte)(quantity >> 8); request[11] = (byte)(quantity & 0xFF); _stream.Write(request, 0, request.Length); // 读MBAP响应头:7字节 byte[] header = new byte[7]; _stream.Read(header, 0, 7); // 第7个字段是后续字节数,即功能码1字节+字节数1字节+数据N字节 int bodyLength = header[6]; byte[] body = new byte[bodyLength]; _stream.Read(body, 0, bodyLength); if (body[0] == 0x83) // 异常响应 throw new InvalidOperationException($"Modbus exception, code: {body[1]:X2}"); if (body[0] != 0x03 || body[1] != (byte)(quantity * 2)) throw new InvalidOperationException("Unexpected response format"); ushort[] values = new ushort[quantity]; for (int i = 0; i < quantity; i++) { // Modbus报文是大端:高字节在前,低字节在后 values[i] = (ushort)((body[2 + i * 2] << 8) | body[3 + i * 2]); } return values; } }这段代码有几个要点:
- MBAP头里的长度字段是“后续字节数”,这里从单元ID开始算:1(单元ID)+1(功能码)+2(起始地址)+2(数量)=6,所以长度高低字节是0x00 0x06。
- 事务ID自己维护,同一请求必须和响应匹配。示例里固定为0x01,并发请求时就要自增了。
- 异常响应功能码会在原功能码上加0x80,比如0x83,后面跟一个异常码。比如异常码0x02表示非法数据地址,0x03表示非法数据值。
提示:事务ID在并发请求时必须自增,否则无法区分响应属于哪次请求。
3.3 写单个寄存器与写多个寄存器
写单个寄存器功能码0x06,报文更短,响应直接回显请求内容:
public void WriteSingleRegister(int startAddress, ushort value) { byte[] request = new byte[12]; request[0] = 0x00; request[1] = 0x02; // 事务ID request[2] = 0x00; request[3] = 0x00; request[4] = 0x00; request[5] = 0x06; request[6] = 0x01; request[7] = 0x06; // 功能码 request[8] = (byte)(startAddress >> 8); request[9] = (byte)(startAddress & 0xFF); request[10] = (byte)(value >> 8); request[11] = (byte)(value & 0xFF); _stream.Write(request, 0, request.Length); byte[] response = new byte[12]; _stream.Read(response, 0, response.Length); if (response[7] == 0x86) throw new InvalidOperationException($"Modbus exception, code: {response[8]:X2}"); }写多个寄存器用功能码0x10,报文里要带上字节数和数据:
public void WriteMultipleRegisters(int startAddress, ushort[] values) { ushort quantity = (ushort)values.Length; byte[] data = new byte[2 + 2 * values.Length]; int idx = 0; data[idx++] = (byte)(startAddress >> 8); data[idx++] = (byte)(startAddress & 0xFF); data[idx++] = (byte)(quantity >> 8); data[idx++] = (byte)(quantity & 0xFF); data[idx++] = (byte)(values.Length * 2); // 字节数 for (int i = 0; i < values.Length; i++) { data[idx++] = (byte)(values[i] >> 8); data[idx++] = (byte)(values[i] & 0xFF); } byte[] request = new byte[7 + 1 + data.Length]; request[0] = 0x00; request[1] = 0x03; request[2] = 0x00; request[3] = 0x00; request[4] = 0x00; request[5] = (byte)(1 + data.Length); request[6] = 0x01; request[7] = 0x10; Array.Copy(data, 0, request, 8, data.Length); _stream.Write(request, 0, request.Length); // 响应也是回显起始地址和数量,读12个字节够用 byte[] response = new byte[12]; _stream.Read(response, 0, response.Length); if (response[7] == 0x90) throw new InvalidOperationException($"Modbus exception, code: {response[8]:X2}"); }注意:写多寄存器时,PDU里的长度字段是“字节数”而不是“寄存器数”,这是最容易写错的地方。示例里我一般会加一个计数器把事务ID自增,避免粘包时匹配错。
3.4 超时与断线重连
PLC通信最怕的就是网络闪断。我一般在Connect里用异步方式加超时,比如3秒连不上就抛异常;在读取时用ReadAsync和CancellationToken,避免阻塞UI线程。重连我用一个简单的策略:每次读写前检查TcpClient.Connected,如果状态异常就关闭旧连接,重新Connect。但要注意,TcpClient.Connected在断网后不会立即变false,所以最好在异常时判断SocketException。
下面是一个重连模板:
public ushort[] ReadWithRetry(int startAddress, ushort quantity) { try { return ReadHoldingRegisters(startAddress, quantity); } catch (IOException) { Disconnect(); Thread.Sleep(200); Connect(); return ReadHoldingRegisters(startAddress, quantity); } }这个模板很粗暴,但现场够用。更稳妥的做法是引入一个后台线程周期地探活寄存器,3次无响应就重连,这个我放在第6章讲。
4. 汇川PLC地址映射与数据类型转换:把PLC变量变成C#能用的数值
4.1 保持寄存器与位地址的映射规则
汇川PLC的变量地址和Modbus地址不是一一对上的,得看PLC侧的手册。我以最常见的AM系列为例:
| PLC地址 | Modbus功能码 | 报文起始地址(0基) | 数据类型 |
|---|---|---|---|
| %MW0 | 0x03 / 0x06 | 0 | 16位无符号 |
| %MW1 | 0x03 / 0x06 | 1 | 16位无符号 |
| %MX0.0 | 0x01/0x05(线圈) | 0 | 位 |
| %MX0.1 | 0x01/0x05 | 1 | 位 |
注意:汇川的%MW是字地址,报文里的起始地址从0开始,对应%MW0。有些人习惯看PLC监视表里显示“400001”,其实那就是Modbus数据模型里的地址,对应报文地址0。
位地址更麻烦,%MX0.0在Modbus线圈里的偏移是0,%MX0.1是1,但%MX1.0就可能要偏移8了,因为一个字节8个位。实际项目里我很少用位操作,大部分是读写16位和32位字,所以重点看寄存器。
另外,汇川的模拟量通道有时映射到输入寄存器区(功能码0x04),和保持寄存器是两套空间。读模拟量时如果用了0x03,会得到异常响应0x86(非法数据地址)。所以联调前,先确认你读的是保持寄存器还是输入寄存器。
4.2 32位整数与浮点数的字节序
汇川PLC里32位数据(比如DINT、REAL)通常占两个连续寄存器,但哪个寄存器是高字、哪个是低字,不同型号不一样。我在一个项目里踩过坑:读到的浮点数全是-0.000几,试了半天才发现是字序反了。
正确的做法是先用PLC监视表写入一个已知浮点数,比如1.0(16进制3F800000),然后读两个寄存器,观察哪个寄存器是0x3F80、哪个是0x0000。如果一次读到的顺序是regs[0]=0x0000, regs[1]=0x3F80,说明低字在前,高字在后。转换代码:
public static float ConvertToFloat(ushort low, ushort high) { uint raw = (uint)(high << 16 | low); // 低字在前,高字在后(小端字序) if (BitConverter.IsLittleEndian) return BitConverter.ToSingle(BitConverter.GetBytes(raw), 0); else { // 大端平台需要反转 byte[] bytes = BitConverter.GetBytes(raw); Array.Reverse(bytes); return BitConverter.ToSingle(bytes, 0); } }这里为什么不用unsafe指针?因为跨平台考虑,BitConverter是标准API。但要注意BitConverter.GetBytes返回的是平台原序字节,在绝大多数Windows上x86/ARM都是小端,所以上面的high << 16 | low构造的uint在小端机器上转float刚好是对的。如果PLC是高字在前,就把low和high对调。
4.3 批量读取的偏移换算
一次读连续寄存器比逐个读快得多。比如PLC里定义了一个结构体:转速(DINT)占2个寄存器,温度(REAL)占2个,启停(BOOL)占1个。可以从%MW10开始连续读5个寄存器,然后按偏移解析:
int start = 10; ushort count = 5; ushort[] regs = client.ReadHoldingRegisters(start, count); float speed = ConvertToFloat(regs[0], regs[1]); // 转速 float temperature = ConvertToFloat(regs[2], regs[3]); // 温度 bool enable = (regs[4] & 0x0001) != 0; // 启停这里要注意:读回来的数组顺序和地址顺序一致,但字节序已经在ReadHoldingRegisters里处理成大端了,所以每个寄存器的值本身是正的。然而组合32位时,还要按PLC的字序重新组装。我一般建议把“PLC寄存器顺序→C#数值”的转换单独写成一个映射类,别散落在业务代码里,后面维护省很多事。
关于偏移,很多人容易把“PLC变量地址”和“报文地址”混淆。汇川手册里的%MW10,在Modbus报文里就是起始地址10,不需要减一。但某些PLC(比如西门子)的地址是1基的,报文里要减一。这就是为什么我每次接手新项目,第一件事是拿一个已知值去探测寄存器,而不是靠猜。
5. 联调避坑清单:和汇川PLC通信时踩过的5个坑
5.1 能Ping通但连不上502
现象:电脑和PLC能互相Ping通,但C#程序Connect时抛“目标计算机积极拒绝”。
原因:PLC的Modbus TCP服务没启动,或使用了非默认端口。某些老型号的固件默认关闭Modbus从站功能,需要在PLC编程软件里手动开启,并映射寄存器区。
解决:用telnet命令先试一下,比如telnet 192.168.0.10 502,如果被拒绝说明服务没开。在PLC软件中找到“Modbus TCP”或“以太网从站”配置,启用并下载到PLC。常见端口除了502,也有用自定义端口的,要确认程序里的端口和PLC一致。
我当时排查这个问题时,先抓包看了TCP SYN有没有回复,结果发现PLC根本没回SYN-ACK,才想到是服务没开。所以遇到“Ping通但端口不通”,先别怀疑代码,第一步永远是telnet。
5.2 读到的数值和PLC监视表差一倍
现象:PLC监视表里显示1000,C#读回来是500;或者反过来。
原因:寄存器单位不一致。PLC的某个变量是32位,但C#按16位读了一个寄存器,得到一半。比如转速变量用了DINT(32位),占%MW100和%MW101,你只读%MW100,自然只有低16位或高16位。
解决:先看PLC程序里变量类型。如果是DINT或REAL,必须读连续两个字,再组合。如果是INT,读一个寄存器。不要从数值大小猜类型,要打开PLC工程看变量声明。
这个坑特别常见,因为很多PLC程序为了效率,把32位变量连续存放,但上位机侧没意识到跨了两个字。我一般在程序里加一个“变量类型表”,用配置文件维护地址、类型、长度,避免硬编码。
5.3 浮点数读出来是“NaN”或乱码
现象:C#读回的两个寄存器,塞进float后全是NaN、Infinity或离谱的数。
原因:字序不对,或者PLC里存的是16位整数,C#当作浮点解了。浮点误用最常见。
解决:用PLC监视表写一个已知浮点数,比如1.0,看收到的两个字是什么。0x3F80 0x0000说明高字在前;0x0000 0x3F80说明低字在前。修改组合顺序即可。另外,确认PLC里真实类型是REAL而不是INT。
我在一个项目里读一个温度变量,PLC侧是REAL,我第一次按低字在前组合,读出来是8.54e-5,后来对调了高低字才正常。从那以后,我每次看到浮点值不合理,第一反应就是字节序。
5.4 程序跑一会儿就超时,但Ping正常
现象:程序启动后前几十秒正常,之后所有读写都超时,Ping PLC也通。
原因:PLC的Modbus TCP连接数有限,或者之前异常断开时连接没释放,占满了通道。有些PLC固件默认只允许4个连接,测试时反复Connect没Disconnect,很快耗尽。
解决:每次异常退出都要Disconnect,使用using或try/finally。也可以在PLC侧重启服务清连接。更稳妥的做法是控制全局只有一个TCP连接,读写都串行走同一个连接,别到处new客户端。
我见过最夸张的一次,一个测试程序里每次读寄存器都new一个TcpClient,跑了几分钟后PLC拒绝一切新连接。后来改成单例模式,这个问题再没出现过。
5.5 写入返回成功,PLC里却没变化
现象:WriteSingleRegister返回正常,但PLC监视表里的值没变,或者客户端刷新后值被改回去。
原因:PLC程序在周期性扫描中把该寄存器重新赋值了,你的写入被覆盖。或者写入的地址是只读的系统区。
解决:先读回刚写入的地址,确认写进去了;再用PLC监视表手动写一次,如果手动写也被覆盖,说明PLC里有其他逻辑在写这个地址。把那个逻辑的赋值改成只在初始化时执行,或者把数据放在专门的Modbus映射区。
有一次,我在一个产线上写一个启停标记,写进去立刻被PLC程序里的“复位逻辑”改回0。后来和PLC工程师一起查,才发现那个地址在程序里被一个定时器每秒清零。所以写入前,一定要确认这个地址是“上位机专属”的。
6. 验证与进阶:先过模拟器,再上产线
6.1 离线模拟验证的流程
没有PLC或不想在产线上调的时候,我会先在本机跑一个Modbus从站模拟器。打开模拟器,建立一个从站,设置好寄存器区和地址,然后运行上面的C#客户端去读。这一步能帮你排除90%的代码问题。模拟器上要特别注意“字节序”设置,很多模拟器默认大端,读出来的数值和PLC一致。
6.2 后台轮询与UI分离的进阶做法
不要在UI线程里同步读取,否则PLC一慢,界面就卡死。我一般用BackgroundWorker或Task循环轮询,把数据放在ConcurrentDictionary里,UI定时器每秒刷新一次。关键点:轮询间隔要大于一次读写耗时,避免重叠请求。比如单次读写耗时50ms,轮询间隔设为200ms。用锁保护Client对象,防止多个线程同时读写同一个TcpClient。
private async void PollingLoop() { while (!_cancellationToken.IsCancellationRequested) { try { var regs = await Task.Run(() => client.ReadHoldingRegisters(0, 10)); _cache["speed"] = regs[0]; _cache["temperature"] = ConvertToFloat(regs[1], regs[2]); } catch (Exception ex) { // 记录日志并重连 _logger.Error(ex, "Modbus read error"); client.Disconnect(); client.Connect(); } await Task.Delay(200); } }这段代码里的_cache是ConcurrentDictionary<string, object>,UI线程只是读取缓存,不和PLC直接交互。Task.Delay(200)保证轮询周期稳定。
从那以后,我每次联调汇川PLC都强制走一遍“模拟器离线验证 + 后台轮询 + 连接数检查”这个流程。有一次项目工期紧,我跳过模拟器直接上产线,结果发现模拟器和真实PLC的寄存器映射差了80个地址,排查了两小时。希望帮到你。
本文还有配套的精品资源,点击获取