C#与汇川PLC Modbus TCP通信实战:从报文、地址映射到断线重连

发布时间:2026/10/28 10:55:33
C#与汇川PLC Modbus TCP通信实战:从报文、地址映射到断线重连 简介面向已具备C#基础、希望打通上位机与汇川PLC通信的开发者这份示例工程合集围绕TCP/IP网络编程、Modbus协议与Modbus API调用展开可用于寄存器读写、设备地址映射、通信异常处理和WPF/WinForms界面集成等实践场景帮助理解自动化系统集成中的常见通信链路。压缩包共624个文件约214.54MB内含C#与VB示例源码、C/C头文件及工程配置、exe/dll/pdb等编译调试产物并配有docx/pdf/txt说明文档便于对照源码、运行结果与接口定义定位问题。目前已有583人学习下载。读者可从示例中梳理Modbus请求与响应报文的构造方式参考汇川PLC通信参数配置、超时重试、CRC校验与错误检测思路也能观察实时性优化和界面数据展示的组织方法适合作为项目联调前的可运行样例与排错参照。1. 从一条包装线说起C# 与汇川 PLC 通信的第一次翻车包装线上那台 H5U 通电第一天上位机 C# 程序读回来的 D100 一直是 65535。IP 能 ping 通502 端口 telnet 也通Socket 连上了数据就是不对。这个场景在 C# 与汇川 PLC 通信里太常见——链路通不代表协议对协议对不代表地址对地址对还可能栽在字序上。这份示例资源做的事很具体用最小可运行的 C# 代码把建链、读寄存器、写寄存器、断线重连四件事串成一条能跑通的链路同时给出手搓 Modbus TCP 报文和调用现成库两种写法。适合已经会写 C#、但第一次碰工业现场协议的开发者也适合被现场通信折腾过、想回头把参数和边界对一遍的老手。C# 网络编程那套东西在这里只占三成剩下七成是对端设备的约定。2. 链路选型Modbus TCP、RTU 和原生以太网怎么定2.1 三条链路在产线上的真实差别汇川 PLC 这一侧上位机接入基本落在三条路上串口 Modbus RTU、以太网 Modbus TCP、厂商自有的以太网协议。选型不是看哪个先进是看手头这台设备的物理口和固件支持什么。Modbus RTU 走 RS485两芯线接在 H3U、H5U 的 485 口上。优点是硬件成本几乎为零抗干扰在短距离内够用缺点是速率9600 波特率下一个 16 位寄存器往返大概十几毫秒读 100 个寄存器就接近一秒。做配方下发、参数设定这类低频操作完全够做实时曲线就吃力。Modbus TCP 走 RJ45端口 502是现在的主流。H5U、AM400、AM600 系列的以太网口基本都带 Modbus TCP 从站。它把 RTU 的帧套进 TCP 里去掉 CRC 校验改用 MBAP 头一帧最多能带 125 个保持寄存器实测单次往返在局域网里是 28ms。C# 侧用 Socket 或者任何 Modbus 库都能接。厂商自有以太网协议速度快、能直接按软元件名读写、支持批量打包但上位机侧要装对应的通信组件跨版本兼容要试。我的判断标准很朴素数据点数在 200 以内、刷新周期 100ms 以上Modbus TCP 一把梭点数上千、周期要压到 20ms 以内再考虑上原生协议。别为了省那几毫秒把整条链路绑死在某个版本上。注意三条链路不要在同一个项目里混用。现场见过一个上位机同时开 485 轮询和以太网轮询两边线程抢同一个设备对象跑两天必崩。2.2 软元件地址怎么落到 Modbus 寄存器上这是新手翻车最多的地方。PLC 编程里你写的是 D100、M50、X0Modbus 报文里传的是一串纯数字地址中间有一层映射表而这层表每个型号都不一样。Modbus 自己的数据模型分四个区线圈0x可读可写位、离散输入1x只读位、输入寄存器3x只读字、保持寄存器4x可读可写字。传统编号里保持寄存器从 40001 开始但 PDU 报文里传的地址是从 0 开始的所以 40001 在报文里是 0x000040002 是 0x0001。这个 1 基和 0 基的差别是第一个坑。汇川这一侧以常见的 H5U 为例D 寄存器一般映射到保持寄存器区M 线圈映射到线圈区X 输入映射到离散输入区。但起始偏移在不同系列、不同固件版本里会变有的从 0 起有的从 0x1000 起。唯一可信的来源是设备手册里那张「软元件与 Modbus 地址对照表」或者编程软件里以太网配置页的地址设置。网上抄来的偏移值十有八九对不上你手上这台。我一般的做法是先做一次最小验证在 PLC 里给 D0 写一个特征值比如 12345然后用 C# 从报文地址 0 开始读 10 个寄存器看这 10 个里哪一个等于 12345。找到了偏移就确定了后面所有地址按这个基准平移。这个动作五分钟能省掉半天猜。注意32 位数据DWORD、REAL占两个连续寄存器地址只能落在偶数上奇数起始地址读出来是两个寄存器各错一位的拼接结果排查起来非常费劲。2.3 建链前的四个现场参数代码之前先把四个值确认清楚写在配置文件里别硬编码。参数典型值确认方式IP 地址192.168.1.xPLC 编程软件在线后读设备信息端口502以太网配置页有的项目会改成 5020站号/单元标识1Modbus TCP 下多数为 1网关后面可能不同最大连接数48手册决定你能不能多开客户端站号这个字段在 Modbus TCP 里叫单元标识纯以太网直连时它经常被忽略但报文里必须有一个字节填错有的设备直接不回。我见过一个现场站号填 255 能通填 1 不通原因是中间过了一台协议转换网关网关按站号转发。最大连接数是另一个容易忽略的很多小型 PLC 的 Modbus TCP 服务端只允许 24 个并发连接。上位机、触摸屏、另一台相机如果同时连第四台就永远连不上而且不报错只是超时。这种情况要么加连接池统一出口要么把非关键设备改成串口。3. 手搓 Modbus TCPC# Socket 发出 12 个字节之后3.1 MBAP 头与 PDU一帧 12 字节的构成一次读保持寄存器的请求完整报文是 12 个字节7 字节 MBAP 头加 5 字节 PDU。MBAP 头四个字段事务标识 2 字节、协议标识 2 字节固定 0、长度 2 字节后面还有多少字节、单元标识 1 字节。事务标识由客户端自增服务端原样回传用来把响应和请求对上。单线程一问一答时它无所谓一旦上了异步或者多连接没有它就没法区分响应归属。PDU 部分功能码 1 字节后面跟参数。功能码 03 是读保持寄存器参数是起始地址 2 字节、寄存器数量 2 字节。功能码 06 写单个寄存器参数是地址 2 字节、值 2 字节。功能码 160x10写多个寄存器参数是起始地址、数量、字节数、数据。响应帧的结构要对上正常响应里功能码原样返回第二字节是字节数后面是数据。异常响应里功能码最高位置 103 变成 0x83第二字节是异常码01 表示非法功能02 表示非法数据地址03 表示非法数据值04 表示从站故障。看到 0x83 就知道是设备明确拒绝了不用再怀疑网络。C# 网络编程在这里的要点只有一个TCP 是流不是包。一次 Read 可能只拿到半个帧也可能一次拿到两个帧。必须按 MBAP 头里的长度字段精确读满不能指望一次读完。3.2 功能码 03读保持寄存器的完整实现下面这段是能直接拿去跑的最小实现去掉了一切封装只保留必要路径。using System; using System.IO; using System.Net.Sockets; public sealed class ModbusTcp : IDisposable { private readonly TcpClient _tcp new TcpClient(); private NetworkStream _stream; private ushort _tid; // 事务标识每帧自增 public void Connect(string ip, int port, int timeoutMs 2000) { var ar _tcp.BeginConnect(ip, port, null, null); if (!ar.AsyncWaitHandle.WaitOne(timeoutMs)) throw new TimeoutException($连接 {ip}:{port} 超时); _tcp.EndConnect(ar); _tcp.NoDelay true; // 关掉 Nagle小包不要攒 _stream _tcp.GetStream(); _stream.ReadTimeout timeoutMs; _stream.WriteTimeout timeoutMs; } // 组装 MBAP 头 PDU private byte[] Build(byte unitId, byte[] pdu) { _tid; var frame new byte[7 pdu.Length]; frame[0] (byte)(_tid 8); frame[1] (byte)(_tid 0xFF); frame[2] 0x00; // 协议标识固定 0 frame[3] 0x00; int len pdu.Length 1; // 单元标识 PDU frame[4] (byte)(len 8); frame[5] (byte)(len 0xFF); frame[6] unitId; Buffer.BlockCopy(pdu, 0, frame, 7, pdu.Length); return frame; } // TCP 是流必须读满 count 个字节 private void ReadExact(byte[] buf, int offset, int count) { while (count 0) { int n _stream.Read(buf, offset, count); if (n 0) throw new IOException(对端关闭连接); offset n; count - n; } } public ushort[] ReadHolding(byte unitId, ushort start, ushort count) { if (count 1 || count 125) throw new ArgumentOutOfRangeException(nameof(count), 单帧最多 125 个寄存器); var pdu new byte[5]; pdu[0] 0x03; pdu[1] (byte)(start 8); pdu[2] (byte)(start 0xFF); pdu[3] (byte)(count 8); pdu[4] (byte)(count 0xFF); var frame Build(unitId, pdu); _stream.Write(frame, 0, frame.Length); var head new byte[7]; ReadExact(head, 0, 7); int len (head[4] 8) | head[5]; var body new byte[len - 1]; ReadExact(body, 0, body.Length); if (body[0] 0x83) // 异常响应 throw new InvalidOperationException($设备异常异常码 {body[1]:X2}); if (body[0] ! 0x03) throw new InvalidOperationException($功能码不符 {body[0]:X2}); int bytes body[1]; var result new ushort[bytes / 2]; for (int i 0; i result.Length; i) result[i] (ushort)((body[2 i * 2] 8) | body[3 i * 2]); // 寄存器大端 return result; } public void Dispose() { _stream?.Dispose(); _tcp?.Dispose(); } }关键点三处。Build里的长度字段是「单元标识 PDU」的字节数不是整帧长度写错设备直接丢包表现为超时而不是报错。ReadExact是自制循环读因为NetworkStream.Read不保证一次读满尤其是响应帧被拆到两个 TCP 段里的时候。寄存器合并时先左移高位再或低位因为 Modbus 规定寄存器内部是大端传输。参数上count上限 125 是协议规定超了设备会回异常码 03。timeoutMs建议不低于 1000现场交换机有生成树收敛的时候短超时会造成大量假失败。unitId直连一般填 1。3.3 功能码 06 与 16写单个与批量写写单个寄存器的 PDU 是 5 字节结构和读一样只是后两字节是值。响应是原样回显所以校验方式就是比对返回的 5 个字节和发出去的是否一致。public void WriteSingle(byte unitId, ushort address, ushort value) { var pdu new byte[5]; pdu[0] 0x06; pdu[1] (byte)(address 8); pdu[2] (byte)(address 0xFF); pdu[3] (byte)(value 8); pdu[4] (byte)(value 0xFF); var frame Build(unitId, pdu); _stream.Write(frame, 0, frame.Length); var resp new byte[12]; // 06 的响应固定 12 字节 ReadExact(resp, 0, resp.Length); for (int i 0; i 5; i) if (resp[7 i] ! pdu[i]) throw new InvalidOperationException(写单个寄存器回显不一致); }批量写用功能码 160x10一次最多 123 个寄存器比循环调 06 快一个数量级。PDU 结构是功能码、起始地址 2 字节、数量 2 字节、字节数 1 字节、数据 N 字节。数量字段和字节数字段必须自洽字节数等于数量乘 2写错设备会回异常码 03。下发配方这种场景一包 60 个寄存器用 16 走一次耗时大概 6ms用 06 循环 60 次600ms 起步而且中间失败一半会出现半套参数生效的尴尬局面。要么全用 16要么在 PLC 侧加一个生效标志位先写数据再写标志别让中间态被扫描到。注意写操作前先确认目标地址没有被 PLC 程序占用为只读或者被系统区保护否则表现是回显正常但值不变很容易误判成通信成功。3.4 32 位整数与浮点的字序换算Modbus 一个寄存器 16 位32 位浮点要占两个。两个寄存器谁在前协议没规定由设备厂商定。汇川多数型号是小端字序也就是低 16 位在低地址但同一家的不同系列、甚至同一系列不同参数区都可能不一样。稳妥的写法是把两种顺序都留成开关第一次调试时各试一遍看哪个数值合理。// 两个寄存器合成 float // lowFirst true 表示低 16 位在低地址小端字序 public static float ToSingle(ushort r0, ushort r1, bool lowFirst true) { uint raw lowFirst ? ((uint)r1 16) | r0 // r0 是低位 : ((uint)r0 16) | r1; // r0 是高位 return BitConverter.Int32BitsToSingle((int)raw); } // float 拆回两个寄存器 public static (ushort low, ushort high) FromSingle(float v, bool lowFirst true) { uint raw (uint)BitConverter.SingleToInt32Bits(v); ushort a (ushort)(raw 0xFFFF); ushort b (ushort)(raw 16); return lowFirst ? (a, b) : (b, a); }判断顺序对不对有个笨办法但很管用让 PLC 侧写一个已知值比如 1.5、100.0、3.14 这种读回来对照。如果读出来是 1.2E-38 或者 3.5E15 这类离谱量级就是字序反了。如果读出来是 1.5 但精度只有几位那是被当成整数读了。整数同理32 位 INT 和 DINT 的拼接方式与浮点一致。位操作时注意一个 16 位寄存器里 16 个 bool 的位序也可能反做位解析前先用 0x0001 和 0x8000 各写一次确认方向。4. 换用现成库NModbus 与 HSL 的落地写法与性能对照4.1 NModbus三十行代码跑通读写手搓一遍的价值在于出问题时你知道该看哪一层但真到项目里我一般还是用库省下的是异常处理、重试、批量拆包这些琐碎活。NModbus 是 MIT 协议的开源实现只做 Modbus不掺别的东西装进项目没有授权顾虑。using System.Net.Sockets; using NModbus; public sealed class NModbusReader : IDisposable { private readonly TcpClient _tcp; private readonly IModbusMaster _master; public NModbusReader(string ip, int port, byte unitId, int timeoutMs 1000) { UnitId unitId; _tcp new TcpClient(); _tcp.Connect(ip, port); _tcp.NoDelay true; var factory new ModbusFactory(); _master factory.CreateMaster(_tcp); _master.Transport.ReadTimeout timeoutMs; _master.Transport.WriteTimeout timeoutMs; _master.Transport.Retries 2; // 失败重试次数 _master.Transport.WaitToRetryMilliseconds 50; } public byte UnitId { get; } // 自动按 125 拆包避免单帧超限 public ushort[] ReadAll(ushort start, ushort count) { var buffer new ushort[count]; int done 0; while (done count) { int take Math.Min(125, count - done); var part _master.ReadHoldingRegisters(UnitId, (ushort)(start done), (ushort)take); Array.Copy(part, 0, buffer, done, take); done take; } return buffer; } public void Dispose() { _master?.Dispose(); _tcp?.Dispose(); } }CreateMaster传 TcpClient 之后库内部接管了流读写和 MBAP 解析我只需要关心起始地址和数量。Retries 2是现场必须设的默认 0 表示一次失败直接抛异常交换机抖一下程序就报错。WaitToRetryMilliseconds别设太小50ms 起步太急了重试也是白重试。ReadAll里的拆包逻辑是必要的因为库本身不替你拆传 200 进去直接抛参数越界。这也是个常见误解以为换个库就自动处理了批量上限实际上大多数库只是把协议封了业务层的分片还得自己写。4.2 HSL 的写法与它的授权边界另一条路是用 HSL它对汇川、三菱、西门子这些品牌做了地址字符串的封装写起来最像人话。using HslCommunication.ModBus; var plc new ModBusTcpNet(192.168.1.10, 502, 1) { ConnectTimeOut 2000, ReceiveTimeOut 2000 }; // 直接按软元件名读写内部做地址映射 var r1 plc.ReadInt32(D100); if (!r1.IsSuccess) Console.WriteLine(r1.Message); var r2 plc.ReadFloat(D200).Content; plc.Write(D300, 123.45f); // 批量读一次拿到连续区域 var arr plc.ReadInt32(D400, 10).Content;它的好处是地址不用自己算偏移D100这种写法调试时改起来快批量接口也齐全。但有两个边界要清楚一是它的地址映射规则仍然要跟你的 PLC 型号对上不同系列的前缀支持不一样用之前翻一下它的地址说明二是授权个人学习和非商业用途可以直接用商业项目要走授权流程这一点必须在上项目前确认别等到验收了才发现。我的选择习惯内部工具、临时取数脚本用 HSL 提效要交付给客户、要长期维护的生产系统用 NModbus 或者自己那套依赖干净出问题能自己进源码看。4.3 轮询节拍、超时与断线重连通信代码跑通只是开始跑一周不出事才算数。工程上要控制三件事节拍、并发、重连。维度NModbusHSL自写 Socket依赖单一开源库单库商用需授权无地址写法数字偏移软元件名数字偏移内置重试有有需自己写批量拆包需自己做部分内置需自己写线程安全否部分接口否否节拍用固定间隔的循环不要用System.Timers.Timer。Timer 的回调如果执行时间超过间隔会重入两个线程同时操作一个 Master结果是报文串包读到的数据张冠李戴这种 bug 排查起来非常费劲。写法上用一个while加Stopwatch算剩余时间简单可靠。var sw System.Diagnostics.Stopwatch.StartNew(); long next 0; while (!cts.IsCancellationRequested) { try { var data reader.ReadAll(0, 100); OnData(data); } catch (Exception ex) { Log(ex); // 记录但不退出等下一拍 ReconnectIfNeeded(); } next 200; // 目标周期 200ms long wait next - sw.ElapsedMilliseconds; if (wait 0) await Task.Delay((int)wait, cts.Token); else next sw.ElapsedMilliseconds; // 落后了就重置基准别追帧 }next累加而不是每次从现在算是为了让周期稳定不随单次耗时漂移。落后时重置基准这一行很关键否则一旦某次卡了 2 秒后面会连续补发十帧把设备打满。超时和重连要分开设。读超时 1000ms 是常见值重连间隔 35 秒别一秒一次PLC 的连接资源释放没那么快频繁重连反而连不上。重连成功后不要立刻全量读先读一个已知寄存器确认链路真的通了再恢复全量轮询避免半通状态下把一堆 0 写进数据库。5. 排查现场连不上、读错值、跑几小时就断的五种情况5.1 建链阶段ping 通却连不上现象命令行 ping 得通telnet 502 超时C# 里TcpClient.Connect抛超时换台电脑又能连。原因最常见是 PLC 侧 Modbus TCP 从站没使能。以太网口能通、能在线编程不代表 Modbus 服务开着这两件事在配置里是两个开关。其次是端口被改过有的项目为了避开扫描会把 502 换成 5020 之类。第三是连接数占满触摸屏和另一台上位机已经把服务端的槽位用完了新连接直接被拒且不返回任何错误只能等到超时。解决先在 PLC 编程软件里确认 Modbus TCP 从站处于启用状态端口号抄下来再用netstat或者换一台没连过的机器单独连一次排除连接数问题如果确实不够用加一台网关做连接复用或者把非关键设备挪到串口。现象能连上但每帧都回异常码 02。原因地址越界或者映射不对。功能码 03 读了一个不存在的寄存器区或者起始地址加数量超过了设备允许的范围。解决把数量降到 1起始地址从 0 开始逐个往上试找到能正常返回的第一个地址再对照手册确认这个地址对应哪个软元件。别一次读一大片然后猜哪个错了。5.2 数据阶段值不对与跑到中途断现象读回来的浮点数是个天文数字或者整数正负号反了。原因字序。32 位数据两个寄存器的先后顺序和代码里的假设相反或者把有符号数当无符号读了。解决让 PLC 侧写一个特征值如 1.5用第 3.4 节的两个分支各读一次取数值合理的那个有符号整数用BitConverter.Int16ToInt32那套转换别直接强转ushort。现象程序跑两三个小时断一次重连后正常日志里是 Socket 异常。原因三种可能。一是短连接模式每次读都新建 TcpClientWindows 默认的 TIME_WAIT 端口堆积两小时后端口耗尽二是没有心跳中间交换机或者防火墙把空闲连接踢了三是现场电磁干扰导致网口瞬断。解决改成单条长连接加一个 30 秒的空闲心跳读一个固定寄存器即可网线换屏蔽双绞并做好接地交换机和 PLC 走同一个地。这三条一起上绝大多数间歇性断线能压下去。现象多线程下偶发读到别的地址的数据。原因多个线程共用一个 Master 或者一个 NetworkStream请求帧交错发送。Modbus 是一问一答的严格串行协议事务标识虽然能区分但库大多没做这层隔离。解决一个设备一个 Master所有读写走同一个队列单线程消费。要并发就按设备分别按功能分。现象批量读 150 个寄存器直接抛异常。原因功能码 03 单帧上限 125 个保持寄存器超过就不是设备的问题是协议的问题。解决按 125 拆包见 4.1 节的ReadAll。批量写功能码 16 的上限是 123别记混。6. 进阶封装把通信层做成能跑一年的采集服务单次读写跑通之后真正决定这套东西能不能上产线的是它有没有被抽成一层带状态的采集服务。我一般的做法是把通信层收在一个接口后面业务代码只认接口不认 Modbus。public interface IPlcChannel { bool IsConnected { get; } Taskushort[] ReadAsync(ushort start, ushort count, CancellationToken ct); Task WriteAsync(ushort address, ushort[] values, CancellationToken ct); }实现类里维护一个连接状态机四种状态足够用状态触发条件行为Disconnected初始、连续失败 3 次每隔 5 秒尝试建链Connecting发起连接超时 2 秒失败回 DisconnectedOnline建链成功正常轮询单次失败记一次Degraded单次失败未达 3 次继续轮询周期不变只记日志状态机的价值在于把「重连」这件事从业务里拿出去。业务侧只管调ReadAsync连不上就抛重连由后台的看护线程按状态机走两边职责清晰。再往上一层是数据缓存。轮询线程把每次读到的整块数据写进一个volatile引用的快照对象界面和数据库读快照不直接读 PLC。这样界面卡顿不会拖慢轮询轮询失败时界面显示的是最后一次有效值加一个时间戳而不是一片零。这个设计在演示时会被问「为什么数据没变」答案是「因为链路断了看时间戳」比数据突然跳零体面得多。验证方面我固定做三件事。一是断开网线跑十分钟看状态机有没有正确切到 Degraded 再切回 Online日志里有没有连续重连。二是用 PLC 编程软件在线改一个寄存器值确认上位机在 200ms 内跟上验证节拍没漂。三是连续跑 24 小时统计失败次数和重连次数正常现场应该是个位数。这三件事做完再交付能挡掉八成现场事故。血泪经验是别把通信和界面放一个线程也别信「先这样上线后面再优化」。我吃过一次亏采集和 UI 耦在一起现场操作员拖一下窗口轮询就停了半分钟。从那以后我每次封通信层都强制先写状态机和快照对象哪怕只是个小工具。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询