C#与汇川PLC Modbus TCP通信:稳定读写与断线重连实践
简介C#与汇川PLC通信示例是一份工业自动化集成方向的实战资料包面向需要实现上位机与PLC数据交换的.NET开发者、自动化工程师及中高级学员。资源围绕C#网络编程、Modbus TCP/RTU协议及汇川PLC地址映射展开结合Modbus API封装了读取线圈、保持寄存器及异常处理等关键流程并附有界面集成与实时性优化参考。压缩包共624个文件约214.54MB以cpp、h、cs、exe、dll、pdb及sln等工程文件为主包含完整的Visual Studio解决方案、可执行Demo、调试符号、文档说明和编译中间文件可直接打开工程对照学习或二次改造。已有580人学习下载资料体量完整目录结构接近实际开发项目适合希望在工业通信场景中快速掌握C#与汇川PLC交互方法的开发者参考实践。1. C#与汇川PLC通信示例从“连得上”到“稳定跑起来”汇川PLC在中小型设备和产线上出现频率很高C#上位机要和它对上话Modbus TCP是最省事的一条路。这份示例资源不是给你一段能ping通就算完的连接代码而是一套从TCP握手到点位读写、再到断线重连的完整做法。适合两类人一类是刚接触汇川PLC、不知道地址到底怎么对齐的开发者另一类是已经在用串口调试、想切换到以太网通信的现场工程师。它能解决的痛点是怎么建立连接会话、怎么读写线圈和保持寄存器、浮点数怎么不是乱码、PLC端组态怎么配合、掉线后怎么自动恢复。先说结论通信本身不难坑全藏在字节序、地址偏移和会话保活三个细节里。下面把每一段代码和每一个参数都摊开讲。2. 协议选型与开发准备为什么Modbus TCP是C#对接汇川PLC的首选入口2.1 汇川PLC的通信方式不是只有一条路汇川PLC对外通信大体有三条线传统串口Modbus RTU带网口型号支持的Modbus TCP以及厂家偏组态专用的协议。从C#网络编程角度来看首选必然是Modbus TCP理由是标准、开放报文格式公开不需要额外授权调试工具一大堆上位机拿到IP和端口就能通信不用关心串口号、波特率、数据位这些底层参数。串口Modbus RTU也有它的场景比如老设备没有网口、现场震动大、距离短且布线全是RS485。这时候C#用SerialPort类也能跑但速度上限低排查时要带USB转串口工具笔记本上还得确认驱动兼容性开发体验远不如以太网。专用协议链路效率确实高但文档往往不对外完整开放新人接手要啃半天后期换人维护成本会明显放大。所以我接到“C#与汇川PLC通信”这类需求时默认方案就是Modbus TCP除非被现场条件明确约束。这里的判断逻辑不是技术情怀而是风险控制标准协议出了问题网上随便一搜就能找到答案非标协议出了问题只能自己对着报文慢慢猜。2.2 通信库选择手写Socket还是现成Modbus库这里有个很现实的问题从底层Socket开始自己写还是用现成的Modbus TCP库还是上工业通信中间件。自己写的好处是每一行都在掌控之中坏处是MBAP头、功能码、事务ID匹配这些细节太多任何一个字段对不上就白调试半天。现成库能覆盖大多数场景但引入第三方依赖后版本更新、授权方式都要留意。工业通信中间件最省事但在只有两三台设备的中小项目里等于杀鸡用牛刀。我一般这么定项目点数少、追求可控性就用自己的极简客户端扩展项目点数多、未来还要接多品牌PLC就用封装好的通信组件。不管走哪条路底层协议都是同一套。下面示例按“自己发报文”来讲你只要把这层原理吃透了换成任何库都只是API名称不同。方案可控性上手成本维护成本适用场景手写Socket Modbus报文高高中点数少、需要掌控协议细节现成Modbus TCP库中低低标准功能码够用、快速交付工业通信中间件低低中多品牌多协议、点数多、统一对接2.3 开发环境与PLC端准备开发环境方面VS 2022配合.NET 6或.NET 8都能跑控制台、WinForms、WPF都适用。汇川PLC编程软件里需要做三件事把PLC设成固定IP和工控机保持同网段在通信配置里启用Modbus TCP从站功能端口默认502确认单元号通常默认1但如果一台上位机要连多台PLC单元号分配就要提前规划。然后回到工控机先ping一下PLC的IP通了再开始写代码。这个习惯能帮你省掉一大半无效排查。这类项目里最容易翻车的就是PLC组态这一侧。有些PLC项目默认根本没勾选Modbus TCP功能代码写得再对也连不上。调试前先在PLC编程软件里把该启用的服务启用下载运行后再用第三方Modbus诊断工具扫一下寄存器确认PLC侧真的能响应了再回来调C#。这样分层验证问题边界会非常清晰。还有点位规划也得提前做。比如PLC程序里转速存在数据块D100温度在D110那就应该在C#点位表里先把这些地址登记好。规划原则很简单连续数据集中放一片线圈和寄存器分开管理浮点变量预留两个连续寄存器位。我见过不少连不上的现场最后发现是地址表本身就抄错了跟代码一点关系都没有。3. 动手实现C#与汇川PLC通信示例的三段核心代码3.1 带超时的TCP连接封装C#里最基础的TcpClient连接很简单但直接Connect会阻塞一旦PLC没开机或IP错了界面就可能卡死。常见做法是ConnectAsync配合Wait实现超时控制再显式设置读写超时保证后续请求不会无限等下去。public class PlcModbusClient { private TcpClient _tcpClient; private NetworkStream _stream; private readonly string _ip; private readonly int _port; private readonly byte _unitId; private readonly int _timeoutMs; private ushort _transactionId 0; public PlcModbusClient(string ip, int port 502, byte unitId 1, int timeoutMs 3000) { _ip ip; _port port; _unitId unitId; _timeoutMs timeoutMs; } public bool Connect() { _tcpClient?.Close(); _tcpClient new TcpClient(); // 异步连接 Wait超时连接失败不会导致界面卡死 var task _tcpClient.ConnectAsync(_ip, _port); if (!task.Wait(_timeoutMs) || !_tcpClient.Connected) { _tcpClient.Close(); return false; } _stream _tcpClient.GetStream(); _stream.ReadTimeout _timeoutMs; _stream.WriteTimeout _timeoutMs; return true; } public void Disconnect() { _stream?.Close(); _tcpClient?.Close(); } }这段代码里要注意两点ConnectAsync返回的Task如果用Wait等超时超时后必须把TcpClient关掉否则底层Socket还占着资源连接成功后ReadTimeout和WriteTimeout都设置是为了防止PLC响应异常时调用线程被长时间挂住。端口默认用502这是Modbus TCP的标准端口PLC端如果改成其他端口构造函数里传进去就行。3.2 读保持寄存器12字节请求帧逐字段解释Modbus TCP的请求帧结构是固定的读保持寄存器功能码是0x03请求帧一共有12字节7字节的MBAP头包含事务ID、协议ID和后续长度然后紧跟单元号、功能码、起始地址和寄存器数量。手写报文时最容易错的就是长度字段和地址拆分。public byte[] ReadHoldingRegisters(int startAddress, ushort count) { if (_stream null) throw new InvalidOperationException(连接未建立); byte[] request new byte[12]; _transactionId; // 每次请求递增响应时用来匹配 request[0] (byte)(_transactionId 8); // 事务ID高字节 request[1] (byte)(_transactionId 0xFF); // 事务ID低字节 request[2] 0x00; // 协议ID固定为0 request[3] 0x00; request[4] 0x00; // 后续长度高字节 request[5] 0x06; // 后续长度 单元号1 功能码1 起始地址2 数量2 request[6] _unitId; // 单元号对应PLC从站地址 request[7] 0x03; // 功能码读保持寄存器 request[8] (byte)(startAddress 8); // 地址高字节 request[9] (byte)(startAddress 0xFF); // 地址低字节 request[10] (byte)(count 8); // 寄存器数量高字节 request[11] (byte)(count 0xFF); // 寄存器数量低字节 _stream.Write(request, 0, request.Length); // 读7字节MBAP头 byte[] header new byte[7]; ReadFully(_stream, header, 7); // 后续长度 单元号 功能码 字节数 数据减2得到PDU长度 int pduLen ((header[4] 8) | header[5]) - 2; byte[] pdu new byte[pduLen]; ReadFully(_stream, pdu, pduLen); // 功能码最高位为1表示异常响应 if ((pdu[0] 0x80) ! 0) throw new Exception($读寄存器异常异常码0x{pdu[1]:X2}); byte[] data new byte[pdu[1]]; Array.Copy(pdu, 2, data, 0, pdu[1]); return data; } private void ReadFully(NetworkStream stream, byte[] buffer, int count) { int offset 0; while (offset count) { int read stream.Read(buffer, offset, count - offset); if (read 0) throw new IOException(连接已断开); offset read; } }这里要解释几个容易晕的地方。事务ID每一次请求都递增响应里的事务ID应该和请求一致生产环境必须校验示例里省略了是为了让主干清晰真实项目建议加回去。起始地址是按0-based编址的和PLC组态软件里显示的1-based地址差1这个细节放到第4章专门讲。ReadFully是循环读取因为NetworkStream的单次Read不保证一次收满所有字节这是Socket编程的基本功。3.3 浮点数读取两个寄存器拼出IEEE 754一个32位浮点数在Modbus里占两个连续寄存器也就是4个字节。PLC端存储浮点常见的是高字在前低地址寄存器存高位字后面跟一个低字寄存器。C#这边用BitConverter默认小端解析所以不能直接拿回4个字节就转要先交换字序。public float ReadFloat(int startAddress) { byte[] data ReadHoldingRegisters(startAddress, 2); if (data.Length ! 4) throw new InvalidOperationException(浮点数必须连续读取两个寄存器); // 低地址寄存器 浮点的高16位高地址寄存器 浮点的低16位 ushort highWord BitConverter.ToUInt16(data, 0); ushort lowWord BitConverter.ToUInt16(data, 2); // 组合成32位无符号整数再按IEEE 754解析 uint raw ((uint)highWord 16) | lowWord; return BitConverter.ToSingle(BitConverter.GetBytes(raw), 0); }这里的“高字在前”不是绝对的和PLC工程里的浮点存储格式有关。如果读出来的数值完全不是合理范围或者明显差了好几个数量级大概率就是字序反了。解决办法很简单把highWord和lowWord的拼装顺序对调一下再试大多数情况下能解决。第4章会再展开讲。3.4 写单个寄存器功能码0x06生产场景里写入比读取更敏感至少要有确认机制。写单个保持寄存器用功能码0x06请求帧同样是12字节只是功能码和数据段不同。public void WriteSingleRegister(int address, ushort value) { if (_stream null) throw new InvalidOperationException(连接未建立); byte[] request new byte[12]; _transactionId; request[0] (byte)(_transactionId 8); request[1] (byte)(_transactionId 0xFF); request[2] 0x00; request[3] 0x00; request[4] 0x00; request[5] 0x06; // 单元号1 功能码1 地址2 值2 request[6] _unitId; request[7] 0x06; // 功能码写单个保持寄存器 request[8] (byte)(address 8); request[9] (byte)(address 0xFF); request[10] (byte)(value 8); request[11] (byte)(value 0xFF); _stream.Write(request, 0, request.Length); // 正常响应会回显同样的帧这里只读响应并检查功能码 byte[] header new byte[7]; ReadFully(_stream, header, 7); byte[] echo new byte[5]; ReadFully(_stream, echo, 5); if ((echo[0] 0x80) ! 0) throw new Exception($写寄存器失败异常码0x{echo[1]:X2}); }写入值的注意点就是USHORT类型是0到65535如果上位机界面上让用户输入负值或超过范围的值必须在调用前拦截。还有一点写入操作和读取操作不要在多个线程里同时发否则事务ID和响应可能错位。后面第5章会讲到用队列串行化所有请求。4. 避坑与常见问题排查地址偏移、字节序和断线重连的五个翻车点4.1 读出的数据全对不上地址像是错位了一位现象用PLC组态软件看到的寄存器地址是40001程序里也传了40001但读回来的数值明明是另一个变量的值。原因Modbus协议在报文里使用的是0-based地址而PLC组态软件和点位表通常显示为1-based地址。解决把地址减1再发出去。也就是说组态软件里看到40001代码里传40000。这个规则在汇川和大多数PLC上都适用。如果用了现成库先确认库内部是不是已经做了减1处理很多库的API希望直接传1-based地址两个一叠加反而又偏了。4.2 浮点数读出来是天文数字或者负零现象读回来的浮点变量明显是乱码数值范围完全不合理甚至出现NaN。原因一是字序反了高字低字拼装顺序和PLC不一致二是字节序没处理BitConverter默认小端和Modbus网络字节序不同。解决先用固定值测试往PLC里写一个已知浮点数比如1.0然后看读回来的字节。按高位字在前尝试一次不对就反过来。这也是我为什么坚持在示例里保留浮点转换逻辑不隐藏这一步因为这种问题看起来特别玄学实际上就是字节顺序错位。4.3 程序跑几分钟后连接自动断掉现象刚刚启动时通信正常大概过几十秒或几分钟后读写全部超时程序异常。原因PLC端的Modbus TCP服务有闲置会话超时机制长时间没有请求会自动断开连接并且不会自动恢复。解决做一个低频心跳定时读取任意一个固定寄存器间隔建议不超过PLC断线超时时间的一半常见做法是1到3秒一次。另外要捕获IO异常一旦连接断开就把TcpClient和NetworkStream都释放掉下次请求前重建连接而不是尝试在旧流上继续读写。4.4 读取正常写入却报异常码现象读线圈、读寄存器都正常但写单个寄存器时返回异常码0x02或0x03。原因功能码和地址类型不匹配。举例来说向只有输出的线圈地址用0x06写寄存器或者PLC侧通信组态里没有给写入权限从站只读。解决先确认目标地址是保持寄存器还是线圈写寄存器用0x06写线圈用0x05再去PLC组态里检查读写权限有些安全配置默认禁止从外部写入。4.5 一台上位机连多台PLC偶尔串数据现象同时连接两台或更多PLC时某一路请求的响应在另一个连接里收到导致数值随机跳变。原因多连接共用一个请求发送通道或者事务ID生成不唯一响应匹配失败。解决每个PLC一个独立的PlcModbusClient实例每个实例维护自己的事务ID序列和流对象不要全局共享。发送请求后必须把事务ID和响应做匹配对不上就丢弃或重新读取。这个匹配逻辑看似多余但多连接场景下是刚需。5. 从示例到实战用点位表驱动封装通信服务5.1 点位表把地址散落在代码里改为一张表初期写Demo时地址都是散落在各个方法里的魔法数字能跑但没法维护。现场一加设备、一改点位就要满屏找数字替换。点位表的作用是把“变量名、地址、数据类型、读写权限”集中定义读写逻辑跟具体点位解耦。public enum PlcDataType { Coil, // 线圈对应功能码0x01/0x05 HoldingRegister, // 保持寄存器对应功能码0x03/0x06/0x10 Float32 // 32位浮点占用两个连续保持寄存器 } public class PlcPoint { public string Name { get; set; } public ushort Address { get; set; } public PlcDataType DataType { get; set; } public bool ReadOnly { get; set; } } public class PlcPointTable { private readonly Dictionarystring, PlcPoint _points new(); public void Add(string name, ushort address, PlcDataType type, bool readOnly false) { _points[name] new PlcPoint { Name name, Address address, DataType type, ReadOnly readOnly }; } public PlcPoint Get(string name) _points[name]; public IEnumerablePlcPoint GetAll() _points.Values; }这样工程里外部只需要按业务名称读取比如需要“设备温度”就调用ReadValueByPoint(设备温度)至于它对应保持寄存器还是浮点、地址是多少全部由点位表决定。新增点位只是表里多一行Add读写代码完全不用动。5.2 自动重连与请求串行化实战中不能每次读写都现场Connect也不能在断线后直接抛异常。一般做法是维护一个连接状态每次操作前检查连接断了就自动重连重连失败才向外层报告。同时把读和写请求放入同一把锁或队列里串行执行避免并发请求导致事务ID错位。private readonly object _lockObj new object(); public void EnsureConnection() { if (_client ! null _client.IsConnected()) return; for (int retry 0; retry 3; retry) { if (_client.Connect()) return; Thread.Sleep(500 * (retry 1)); // 退避重试0.5s, 1s, 1.5s } throw new TimeoutException(PLC连接恢复失败); }锁的粒度要注意把“确保连接 读写请求 读取响应”放到同一个锁块里不能只锁发送部分。否则两个线程同时进入一个发完请求还没读完响应另一个线程又发出新请求响应就全乱了。这是并发通信里最容易踩的坑。5.3 轮询采集与变化上报设备监控场景里最常见需求是周期性采集所有点位只在数值变化时通知业务层。示例代码里可以这样组织一个后台任务每隔固定时间跑一轮采集保存上一次的值对比后发现变化就触发事件。public void StartPolling(int intervalMs) { _cts new CancellationTokenSource(); Task.Run(async () { var lastValues new Dictionarystring, float(); while (!_cts.IsCancellationRequested) { try { lock (_lockObj) { EnsureConnection(); foreach (var point in _pointTable.GetAll()) { float current ReadPointValue(point); if (!lastValues.TryGetValue(point.Name, out var old) || Math.Abs(current - old) 0.001f) { OnPointChanged?.Invoke(point.Name, current); lastValues[point.Name] current; } } } } catch (Exception ex) { OnLog?.Invoke($轮询异常{ex.Message}); } await Task.Delay(intervalMs, _cts.Token); } }, _cts.Token); }这个模式的优点是业务层拿到的永远是“有变化才通知”的事件流界面不会每秒刷几百次。要注意的是变化阈值0.001针对浮点整数点位直接用不等于比较即可。如果点位特别多不要一个点一个点地单发请求而是把地址连续的寄存器合并成一次批量读这是第6章要讲的进阶点。6. 验证与进阶先用仿真器跑通流程再压榨批量读写6.1 用Modbus从站仿真器做预验证拿到真实PLC之前先用Modbus从站仿真器把整个流程跑一遍是最划算的验证方式。在本机启动仿真器设置从站地址为1在对应寄存器里填入已知测试值比如在地址0写12345在地址10和11写一个浮点数3.14然后把C#程序里PLC的IP指向127.0.0.1端口保持502。跑起来之后先验证连接是否建立再验证读到的整数等于12345、浮点数接近3.14最后验证写入后仿真器里的值确实变化。这一步能把99%的代码级问题前置真正到现场时只需要关心IP、网线和PLC组态。我通常还会在仿真器里故意把某些寄存器设置为异常状态测试C#端对异常响应的处理是否符合预期而不是看到Exception就任由程序崩溃。6.2 批量读把连续寄存器合并成一次请求当点位表里有几十个连续地址时逐点读会非常浪费。Modbus协议允许用功能码0x03一次读取最多125个保持寄存器把连续的地址区间合并成一条请求网络往返次数从几十次压到几次。合并的规则是先从点位表里按地址排序把相邻地址且类型一致的点位聚成区间区间内包含的浮点变量要保证起始地址和数量对齐。我习惯让点位表在初始化时就按地址排序并提供“按区间取点”的接口这样轮询代码几乎不用改只把单点读换成区间读。批量读完成后再按区间偏移把字节数组切分还原成每个点位的数据。这样一套下来几百个点的轮询周期能从秒级降到百毫秒级。从那以后我每次接到新PLC项目都会强制走一遍先仿真、再单点、后批量、最后压重连的流程这套顺序能挡住绝大多数现场通信事故。希望帮到你。本文还有配套的精品资源点击获取