C#与汇川PLC通信示例:Modbus TCP报文结构及地址映射详解
简介一套C#与汇川PLC通信开发示例资源主要面向工业上位机软件开发者与自动化系统集成人员重点呈现基于Modbus TCP协议的通信流程、网络编程要点以及上位机界面如何与PLC数据进行交互。压缩包内共有624个文件整体约214.54MB以C#与C源文件、工程文件、可执行程序与动态链接库为核心并配有说明文档、日志和调试记录便于按模块阅读和实践。整体目录结构较完整示例函数封装与调用分离便于对照理解协议细节。目前已有580人学习包内多个测试工程演示了Modbus寄存器读写、I/O状态获取、TCP连接建立、异常处理与性能优化等关键环节编译好的程序可直接运行验证效果。通过研读源码和接口调用过程可以快速理清汇川PLC的地址映射、报文构造及校验规则并在此之上改造复用缩短自动化系统集成中的通信调试周期。1. C# 与汇川 PLC 通信示例先解决三个影响成败的参数C# 与汇川 PLC 通信示例下载下来最容易出现的情况是代码能编译、TcpClient 也能连上但第一条读寄存器的指令就超时或者返回一堆对不上的数据。问题通常不在 C# 语法而在三个协议层面的参数——MBAP 报文的 Length 字段、PLC 点表地址到报文 PDU 地址的偏移、以及 Modbus 大端字节序。这份示例的价值在于它把「C# 网络通信」和「PLC 寄存器读写」之间的黑匣子拆开不是调一个封装好的控件双击绑定而是从 Socket 连接、报文拼接、响应解析一步步走完。适合刚接触上位机开发的工程师也适合那些用库调通了但心里没底、一换 PLC 型号就翻车的人。按这个思路复现一次后续换网口、换 PLC 品牌你都能用同一套报文分析能力去排查。2. 通信方式选型为什么示例默认走 Modbus TCP 而不是 OPC UA2.1 三条理由透明、兼容、易调试汇川带以太网口的 PLC 型号常见都内置 Modbus TCP 服务器不需要额外授权也不需要加网关。对做示例来说这是最优先考虑的一条链路。相比 OPC UAModbus TCP 的报文结构固定用一个 Socket 加 byte 数组就能完成全部收发Wireshark 抓到什么就是什么不存在协议栈内部的隐含状态。OPC UA 在数据模型和安全策略上强很多但代价是初始化复杂证书、端点、命名空间、节点 ID 映射一套跑通的时间够把 Modbus TCP 示例跑三遍。裸 Socket 直连自定义协议则更麻烦汇川专用协议不在公开文档里第三方系统去适配成本太高也不适合作为通用示例。对比项Modbus TCPOPC UA裸 Socket 自定义协议学习成本低报文结构固定高先过证书和数据模型中但需私有协议文档调试手段Wireshark 直接抓包看要用 UA Expert 等工具基本靠双方日志PLC 侧配置多数带网口型号直接支持部分型号需额外使能取决于 PLC 固件适合场景快速打通数据链路、中小点数跨厂商、跨车间数据集成厂商深度定制我一般建议把 Modbus TCP 作为项目的默认起点等遇到多客户端并发、数据语义需要自描述时再考虑 OPC UA。示例遵循同一原则先跑通一条读写链路再看需不需要升级。2.2 Modbus TCP 报文结构MBAP 头 6 个字节决定成败Modbus TCP 请求和响应都以 MBAP 头开始6 个字节固定不变事务 ID 2 字节、协议 ID 2 字节恒为 0、长度 2 字节。长度字段计算的是它自身之后的所有字节数也就是从单元 ID 开始到报文结束。很多人第一次自己拼报文长度字段多算 1 或少算 1PLC 侧直接不响应。字段长度含义Transaction ID2事务标识请求响应需一致Protocol ID2固定 0x0000Length2后续字节数Unit ID1从站号常见为 1Function Code1读/写功能码DataN地址、数量或数据功能码在示例里最常用到四个0x03 读保持寄存器、0x01 读线圈、0x06 写单个寄存器、0x10 写多个寄存器。响应里的功能码如果带了最高位比如 0x83说明 PLC 返回异常码常见原因不是数量越界就是地址越界。组装请求时顺序不能乱先是起始地址高字节、低字节再是寄存器数量高字节、低字节。PLC 内部严格按大端解析C# 的 BitConverter 默认小端这两者如果不做转换读回的数据就会出现高低字节对调。2.3 汇川侧地址映射D 区、M 区与 PDU 地址的换算PLC 组态软件里显示的是点表地址报文里用的是从 0 开始的 PDU 物理地址两者差着一个基准偏移这是最常见的翻车点。以某型号带网口汇川 PLC 为例Modbus 映射表里 D 区对应保持寄存器区基准是 400001M 区对应线圈区基准是 000001。举例PLC 侧点表 D100对应 Modbus 显示地址 400101。但报文 PDU 里要填的是 0x0064也就是 100。换算规则是PDU 地址 面板显示地址 - 区基准地址。保持寄存器区显示 400101减去 400001 得到 0x0064线圈区显示 000033减去 000001 得到 0x0020。这个换算在不同软件上呈现方式不同有的显示 400101有的直接显示 0x0064还有库会自动补 1。拿到一台新 PLC 时第一件事是在 PLC 组态软件里打开 Modbus 映射表确认基准偏移和起始地址不要直接照抄别人项目的参数。3. 工程落地用一套可复用的 C# 通信类把读写打通3.1 为什么不用现成 Modbus 库示例阶段自己拼报文更值第三方库确实能省时间但会把地址、端序、异常码全部包进封装层出了问题只能看库文档猜。示例的目的不是「调通一次」而是「搞清楚每一步在做什么」。自己拼报文事务 ID、长度字段、地址偏移都在手里现场排查时能直接对着抓包结果说话。等这套逻辑跑稳定后再决定要不要用库重写。下面的实现不依赖 .NET 8 的新 API连老工控机上常用的 .NET Framework 4.x 也能编译。每个字节都是手工移位拼接这样无论目标框架是什么逻辑都一样。3.2 通信类核心代码连接、读保持寄存器、防拆包using System.Net.Sockets; public class ModbusTcpClient : IDisposable { private TcpClient _tcp; private NetworkStream _stream; private ushort _transactionId; public string Ip { get; set; } 192.168.1.10; public int Port { get; set; } 502; public byte UnitId { get; set; } 1; public void Connect() { _tcp new TcpClient(); // 注意必须设置超时否则PLC没响应时Read会一直阻塞 _tcp.ReceiveTimeout 3000; _tcp.SendTimeout 3000; _tcp.Connect(Ip, Port); _stream _tcp.GetStream(); } private byte[] ReadExactly(int count) { byte[] buffer new byte[count]; int offset 0; while (offset count) { int read _stream.Read(buffer, offset, count - offset); if (read 0) throw new IOException(连接被PLC关闭); offset read; } return buffer; } private byte[] BuildFrame(byte functionCode, byte[] pdu) { int len 1 1 pdu.Length; // UnitID 功能码 PDU byte[] frame new byte[6 len]; _transactionId; frame[0] (byte)(_transactionId 8); frame[1] (byte)_transactionId; frame[2] 0; frame[3] 0; // 协议ID固定0 frame[4] (byte)(len 8); frame[5] (byte)len; frame[6] UnitId; frame[7] functionCode; Array.Copy(pdu, 0, frame, 8, pdu.Length); return frame; } private void ValidatePrefix(byte[] prefix) { int responseId (prefix[0] 8) | prefix[1]; if (responseId ! _transactionId) throw new InvalidDataException($事务ID不匹配: 请求{_transactionId} 响应{responseId}); // prefix[7]是功能码带了最高位就是异常响应 if ((prefix[7] 0x80) ! 0) throw new IOException($PLC返回异常码: 0x{prefix[7]:X2}); } public ushort[] ReadHoldingRegisters(ushort startAddress, ushort quantity) { byte[] pdu new byte[4]; pdu[0] (byte)(startAddress 8); pdu[1] (byte)startAddress; pdu[2] (byte)(quantity 8); pdu[3] (byte)quantity; byte[] req BuildFrame(0x03, pdu); _stream.Write(req, 0, req.Length); byte[] prefix ReadExactly(8); // 头6 UnitID 功能码 ValidatePrefix(prefix); int byteCount ReadExactly(1)[0]; byte[] data ReadExactly(byteCount); ushort[] values new ushort[quantity]; for (int i 0; i quantity; i) { // Modbus是大端手工拼回无符号短整型 values[i] (ushort)((data[i * 2] 8) | data[i * 2 1]); } return values; } public void Dispose() { _stream?.Dispose(); _tcp?.Dispose(); } }这段代码里最关键的是ReadExactly。TCP 是流协议一次Read不一定返回完整报文可能半包也可能粘包。循环读到指定长度才不会出现「报文没读完就当响应解析」的问题。事务 ID 校验同样重要请求和响应必须匹配否则说明通信链路里报文错位了。超时设置建议不低于 3000 毫秒PLC 在大数据量响应时偶尔会慢太短会误判故障。3.3 写寄存器0x06 和 0x10 两条路径写单个寄存器用功能码 0x06适合启停信号、模式切换批量写参数用 0x10一次可以下发多个寄存器值效率更高。批量写请求的数据区结构是起始地址 2 字节、数量 2 字节、字节数 1 字节、寄存器数据按大端排列。public void WriteSingleRegister(ushort startAddress, ushort value) { byte[] pdu new byte[4]; pdu[0] (byte)(startAddress 8); pdu[1] (byte)startAddress; pdu[2] (byte)(value 8); pdu[3] (byte)value; byte[] req BuildFrame(0x06, pdu); _stream.Write(req, 0, req.Length); byte[] prefix ReadExactly(8); ValidatePrefix(prefix); // 0x06响应会重复请求PDU这里可读可不读但最好读完保持流干净 ReadExactly(4); } public void WriteMultipleRegisters(ushort startAddress, ushort[] values) { int byteCount values.Length * 2; byte[] pdu new byte[5 byteCount]; pdu[0] (byte)(startAddress 8); pdu[1] (byte)startAddress; pdu[2] (byte)(values.Length 8); pdu[3] (byte)values.Length; pdu[4] (byte)byteCount; for (int i 0; i values.Length; i) { pdu[5 i * 2] (byte)(values[i] 8); pdu[5 i * 2 1] (byte)values[i]; } byte[] req BuildFrame(0x10, pdu); _stream.Write(req, 0, req.Length); byte[] prefix ReadExactly(8); ValidatePrefix(prefix); ReadExactly(4); // 起始地址2 数量2 }0x06 的响应会把请求里的地址和值再原样返回一次所以 Send 完之后必须把这次响应读完否则下一次ReadHoldingRegisters会读到旧数据。0x10 的响应只返回起始地址和数量不返回具体值。两个方法写完都要求把尾部读完保持流同步这是很多人忽略的细节。3.4 控制台调用用点表地址验证通信链路static void Main() { var client new ModbusTcpClient { Ip 192.168.1.10, UnitId 1 }; client.Connect(); // 读取D100开始的8个字PDU地址1000x0064 var regs client.ReadHoldingRegisters(0x0064, 8); for (int i 0; i regs.Length; i) { Console.WriteLine($D{100 i} {regs[i]}); } // 写D100为1234 client.WriteSingleRegister(0x0064, 1234); }注意调用时传的是0x0064不是 PLC 组态里显示的400101。如果示例返回的值全为 0先确认 PLC 侧对应寄存器地址里真的有数据用 PLC 软件在线监控看一眼。控制台输出正常说明链路是通的接下来再去接界面、接数据库。4. 常见问题排查示例翻车的四个高频点位4.1 网络层排查连不上、间隔断流现象Connect()成功但第一次读数据就抛超时异常或者程序跑几分钟后通信断开重启又恢复。原因多数情况是 Socket 没设超时PLC 响应稍慢客户端永远卡在Read上。另一类是 PLC 侧 Modbus TCP Server 没启用或者端口 502 被其他软件占用。间隔断流则一般是上位机没有按长连接管理每次请求都新建连接把 PLC 的并发连接数打满了。解决TcpClient一定要设置ReceiveTimeout和SendTimeout生产环境一个站维持一个长连接不要频繁断开重连。现场先用抓包工具确认 TCP 三次握手是否完成再检查 PLC 侧服务使能状态。端口被占用的情况在 PLC 组态软件里改一个备用端口客户端同步改就行。4.2 数据层排查读到乱码先查字节序现象读回寄存器值后用BitConverter.ToUInt16转换得到的结果和 PLC 监控面板上显示的差很多比如面板显示 100程序读到 25600。原因C# 的BitConverter在小端架构上按低字节在前解析而 Modbus 报文是大端。收到0x00 0x64小端解析会变成0x64 0x00也就是 25600。这是示例里最典型的「代码看着没问题结果全错」的场景。解决读寄存器时不要用BitConverter改成手动拼接(ushort)((data[i * 2] 8) | data[i * 2 1])。如果数据区是 32 位浮点数还要注意字序问题有的 PLC 高字在前有的低字在前先用模拟器写入一个已知浮点数读回来对比字节顺序就能确认。4.3 报文层排查寄存器数量越界现象读连续寄存器超过某个数量后PLC 返回异常码 0x02 或 0x03或者返回的数据从中间某段开始全为 0。原因Modbus 协议单次读保持寄存器上限是 125 个读线圈上限是 1968 个。即使没超过上限如果 PLC 地址区有保留段未映射也会返回异常。很多工程师把整个数据区从 0 一直读到 65535一次请求就把 PLC 打回异常。解决每次读取不超过 120 个寄存器给协议余量。不确定地址段是否连续时分段读每段独立捕获异常。返回 0x02 表示地址越界0x03 表示数据量非法这两类错误码在自我排查时就已经能定位问题方向。4.4 PLC 侧排查点表地址和 PDU 地址差 1现象读400101传地址时故意直接填400101结果数据错位甚至报地址非法改成100后又能读了。原因PLC 组态软件里显示的是 1 基地址报文 PDU 从 0 开始两者差 1。示例代码里读D100用的是0x0064也就是 100而不是400101。解决统一用 PDU 0 基地址传参在 PLC 侧用「显示地址减区基准地址」换算。写代码前在组态软件里先查清楚 D 区基准是 400001 还是 400001 附近的其它偏移每个固件版本可能有差异。这个换算规则在示例代码注释里写清楚能省掉后面接手同事的大量排查时间。5. 验证与进阶没有汇川 PLC 时先把 Demo 跑通再上产线5.1 用 Modbus TCP 从站模拟器做离线验证手头没有 PLC 的时候可以用任意一款 Modbus TCP 从站模拟器先跑通这套 C# 代码。模拟器监听本机或局域网 IP 的 502 端口把模拟器的寄存器区填入一组已知数值比如地址 100 填 1234、101 填 5678然后让示例程序去连模拟器的 IP。两端在同一台电脑时IP 填127.0.0.1也能跑。这一步非常值得做。它能在完全没有硬件的情况下验证报文的字节序、地址偏移、超时机制是否正确。等到了现场再接真实 PLC只需要改 IP 和站号。我习惯把模拟器里填的测试值和程序读回的值做成一张核对表每个寄存器地址、期望值、实际值列出来真机调试时直接对照。5.2 轮询引擎与断线重连示例到产线的最后一步示例里的同步读写适合功能验证长时间跑在产线上还需要一层轮询和重连。下面是一个最小可用的轮询骨架CancellationTokenSource _cts new(); Task.Run(() PollLoop(_cts.Token)); async Task PollLoop(CancellationToken ct) { while (!ct.IsCancellationRequested) { try { using var client new ModbusTcpClient { Ip 192.168.1.10 }; client.Connect(); while (true) { ct.ThrowIfCancellationRequested(); var regs client.ReadHoldingRegisters(0x0064, 32); OnDataReceived(regs); await Task.Delay(300, ct); } } catch (Exception ex) { // 断线后等1秒自动重连避免高频重试打爆PLC连接数 await Task.Delay(1000, ct); } } }内层while负责持续读数据读不到就抛异常落到外层catch等待 1 秒后重新Connect()。这个结构虽然简单但避免了两类常见问题一是断线后立即重连导致的重试风暴二是 PLC 重启后客户端自动恢复。轮询间隔根据工艺要求调整一般不低于 200 毫秒太快的轮询会把 PLC 通信任务占满反而影响设备本身的控制逻辑。有次我在某现场调试一套 32 个工位的数据采集程序跑起来整体数值都正常唯独每隔几分钟凭空多出几条乱码。排查了半天最后发现是上一个版本的通信类没做防拆包处理响应报文被截断后错误地按新报文解析。从那以后我每次集成前都在模拟器上强制跑一遍完整链路确认字节序、地址偏移、超时三项参数固定了才上真机。希望帮到你这套 C# 与汇川 PLC 通信示例值得你照着复现一次踩过一遍坑比读十遍文档都管用。本文还有配套的精品资源点击获取