
简介面向工业自动化与上位机开发人员这份基于C#的MODBUS TCP通讯源码专门解决汇川PLC与上位机之间的以太网数据交互问题由工控老马出品并实测通过适合从入门到有一定经验的开发者参考。资源包共38个文件约322KB以C#源文件cs、可执行程序exe、动态库dll及config/ini等配置为主并附带说明文档与示例工程结构便于直接打开调试。目前已有2049人学习下载热度反映出该方案在汇川PLC通讯场景中的实用价值。内容包含完整的Visual Studio工程从Form界面到底层通讯逻辑均有清晰分层可对照源码理解TCP连接建立、报文读写、寄存器映射等关键环节。对于正在开发PLC数据采集或设备监控系统的工程师这份源码能显著缩短Modbus TCP通讯模块的搭建时间也便于在此基础上二次扩展。1. 一套能落地的方案MODBUS TCP C# 汇川PLC通讯源码到底解决什么问题「MODBUS TCP C# 汇川PLC通讯源码」这个标题对应的是工厂里每天都在发生的一个需求一台装 Windows 的工控机想把汇川 PLC 里的温度、转速、产量、报警状态读回来或者把配方、启停命令写下去。没有组态软件授权也不想用厂家封闭的 DLL于是用 C# 自己写通讯层。先给结论MODBUS TCP 是一个跑在 TCP 502 端口上的二进制帧协议比 RTU 少一道 CRC 校验比 OPC UA 轻得多C# 里用 TcpClient 就能实现核心读写代码也就两三百行。真正的难点不在怎么写请求而在地址怎么映射、32 位数据怎么拼、断线怎么恢复。下面按「立住模型 → 最小客户端 → 避坑记录 → 收成框架」的顺序展开适合刚接手 C# 上位机对接汇川 PLC 的人也适合拿到源码但不敢改的人。2. 建连之前先把三件事钉死报文格式、汇川寄存器映射、数据类型占用2.1 MODBUS TCP 报文没有 CRC但有 7 字节 MBAP 头先打破一个流传很广的误解MODBUS TCP 的报文里没有 CRC 校验字段。RTU 因为走串口链路层不可靠才在报文尾部加两个字节的循环冗余校验TCP 本身是字节流加重传、带校验的可靠传输链路层已经兜底MODBUS TCP 协议直接把 CRC 去掉了。第一次对接的工程师习惯性照抄 RTU 帧尾巴汇川 PLC 会当成非法帧直接丢弃这就是最常见的「我发了他没回」的原因之一。TCP 版报文由 MBAP 头加数据部分组成MBAP 是 MODBUS Application Protocol 的缩写总共 7 字节字段长度说明事务处理标识符2 字节每次请求递增响应原样回送用于匹配请求与响应协议标识符2 字节MODBUS TCP 固定为 0x0000其他值不是本协议长度2 字节从单元标识符开始到报文结束的字节数单元标识符1 字节相当于 RTU 里的从站地址单播一般填 0 或 1举例往下读保持寄存器请求一共 12 字节事务ID 00 01、协议ID 00 00、长度 00 06、单元ID 01、功能码 03、起始地址 00 00、寄存器数量 00 01。响应里会多一个「字节数」字段比如长度 00 05、数据 00 64那 00 64 就是十进制的 100。事务ID 在每次请求时自增响应会原样带回来凭它把「这包响应对应哪一包请求」对上号。为什么选 MODBUS TCP 而不是别的对于汇川中大型 PLC网口默认支持 MODBUS TCP 从站上位机不用买硬件也不用买授权。OPC UA 功能强但要配置信息模型、证书和端点只为了读十几个寄存器是大材小用MODBUS RTU 走串口速率低还要处理 USB 转 485 的驱动多台设备轮询时排队明显。MODBUS TCP 一条网线一个 502 端口C# 原生 Socket 就能跑工业现场大量在用选它不丢人。2.2 汇川PLC的寄存器映射与数据类型先把变量表翻译成地址汇川产品线里AM 系列、AC 系列和 H5U 走 CODESYS 平台保持寄存器一般映射到 %MW 区H3U 这类小型内置以太网口的机型D 区对应保持寄存器也就是 40001 起那个区。协议地址从 0 开始编号调试软件里显示成 40001 起两者差 1。我一般把地址表放配置文件不在代码里写死因为现场换一台 PLC 型号映射可能整体平移。变量表数据类型占用多少个寄存器是这个项目最容易搞错的地方BOOL 在 MODBUS 侧通常按一个字读回来再按位取INT/UINT 占 1 个寄存器DINT、REAL、DWORD 占 2 个寄存器。对应关系如下汇川变量表类型占用寄存器数说明BOOL1按位多数读整字回来再按位与INT / UINT / SINT116 位符号由上层解释DINT / INT32 / UINT322两个 16 位寄存器拼 32 位REAL / FLOAT2按 IEEE754 拼成 32 位STRINGN每个寄存器存两个字符ASCII 高位在前给一个配置文件的结构我习惯用 JSON 驱动不用硬编码{ plc: { ip: 192.168.1.10, port: 502, unitId: 1 }, points: [ { name: 产线速度, address: 0, count: 2, type: REAL, wordOrder: highFirst }, { name: 设备状态字, address: 10, count: 1, type: UINT, wordOrder: highFirst } ] }这里的 wordOrder 就是坑 4.1 的预演控制两个寄存器拼 32 位时的顺序默认 highFirst 符合协议大端如果现场读出来不对把选项改 lowFirst 再验证一次。地址换算记住规则PLC 侧 %MW10、调试软件显示 40011协议地址填 10部分旧固件可能要平移写代码前用调试软件先点一下最稳。常有人问有 NModbus4 这类现成库为什么不直接用库确实省时间但它在 .NET 6 下的异步超时行为和内部缓存不够透明出一次「偶发卡死」就很难剥。自己维护两三百行客户端每个字节都可控现场定位问题快得多这也是这个方向的核心价值协议不黑匣子。3. 用 C# 写一个最小可用的 MODBUS TCP 客户端连接、读写与数据拼装3.1 建立连接与读写超时TcpClient 的三个必调参数先看连接层。注意 .NET 6 之后的 TcpClient.ConnectAsync 支持 CancellationToken老框架就要用 Task.Run 加超时组合这里以新版写法为例public class ModbusTcpClient : IDisposable { private TcpClient? _tcp; private NetworkStream? _stream; private readonly object _writeLock new(); private int _txId; public async Task ConnectAsync(string ip, ushort port 502, int timeoutMs 3000) { var cts new CancellationTokenSource(timeoutMs); var tcp new TcpClient(); try { await tcp.ConnectAsync(ip, port, cts.Token); } catch (OperationCanceledException) { tcp.Dispose(); throw new TimeoutException($连接 {ip}:{port} 超时{timeoutMs}ms); } tcp.NoDelay true; _tcp tcp; _stream tcp.GetStream(); } public void Disconnect() { _stream?.Dispose(); _tcp?.Dispose(); _stream null; _tcp null; } public void Dispose() Disconnect(); }端口 502 是 MODBUS TCP 的约定端口连接和读写共用。timeoutMs 默认给 3000现场碰过 500ms 就超时的设备太激进反而容易误判。NoDelay 是必调参数默认的 Nagle 算法会把小包合并凑够一个 MSS 才发对读写寄存器这种交互式短报文延迟明显打开后小包立即发出。重连时不要复用旧 NetworkStream直接 new TcpClient旧的 Dispose 掉。3.2 拼帧与读帧读保持寄存器的 12 字节请求怎么组成读保持寄存器用功能码 0x03。请求帧固定 12 字节响应里带回来的数据区是大端序需要转成 ushortprivate byte[] BuildRequest(byte unitId, byte func, ushort addr, ushort count) { ushort tx (ushort)Interlocked.Increment(ref _txId); var buf new byte[12]; buf[0] (byte)(tx 8); buf[1] (byte)tx; // 事务ID buf[2] 0; buf[3] 0; // 协议ID buf[4] 0; buf[5] 6; // 长度unitIdfuncaddrcount buf[6] unitId; buf[7] func; buf[8] (byte)(addr 8); buf[9] (byte)addr; buf[10] (byte)(count 8); buf[11] (byte)count; return buf; } public async Taskushort[] ReadHoldingRegistersAsync( byte unitId, ushort startAddress, ushort count, int timeoutMs 1000) { var req BuildRequest(unitId, 0x03, startAddress, count); (_, byte[] body) await TransactAsync(req, timeoutMs); if ((body[1] 0x80) ! 0) throw new InvalidOperationException($MODBUS从站返回异常码 0x{body[2]:X2}); int byteCount body[2]; if (byteCount ! count * 2) throw new InvalidOperationException(响应字节数与请求寄存器数不匹配可能有半包残留); var values new ushort[count]; for (int i 0; i count; i) values[i] (ushort)(body[3 i * 2] 8 | body[4 i * 2]); return values; }事务ID 用 Interlocked.Increment 保证多线程下唯一这行代码直接决定后面会不会出现 4.2 的响应串线。解析响应前做两个判断功能码最高位置 1 表示异常响应body[2] 是异常码要查 MODBUS 异常码表字节数不等于 count 乘以 2 时必须立刻抛错不要继续往下解析否则后续数据全错位。大端转 ushort 就是body[3 i * 2] 8 | body[4 i * 2]高位字节在低地址。3.3 半包粘包的正确打开方式ReadExactly 按长度读满TCP 是流不是包一次 ReadAsync 读回来的可能只是一个响应的一半也可能是两个响应粘在一起。这个项目八成以上的「偶发数据错乱」都出在这解法是循环读读满期望字节为止private async Task(byte[] header, byte[] body) TransactAsync( byte[] request, int timeoutMs) { lock (_writeLock) { _stream!.Write(request, 0, request.Length); _stream.Flush(); } byte[] header await ReadExactlyAsync(6, timeoutMs); if (header[2] ! 0 || header[3] ! 0) throw new InvalidOperationException(协议标识符非 0x0000可能不是 MODBUS TCP 设备); if (header[0] ! request[0] || header[1] ! request[1]) throw new InvalidOperationException(事务ID不匹配响应串线了); int length (header[4] 8) | header[5]; byte[] body await ReadExactlyAsync(length, timeoutMs); return (header, body); } private async Taskbyte[] ReadExactlyAsync(int count, int timeoutMs) { using var cts new CancellationTokenSource(timeoutMs); var buffer new byte[count]; int offset 0; while (offset count) { int n await _stream!.ReadAsync( buffer.AsMemory(offset, count - offset), cts.Token); if (n 0) throw new IOException(对端关闭了连接读返回 0); offset n; } return buffer; }这里的逻辑按 MODBUS TCP 的响应结构走先读 6 字节头部因为头部最后 2 字节是 length告诉整帧从单元标识符开始还有多少字节再按 length 把剩余读满。header 和 body 分开返回方便上层解析时直接看 bodybody[0] 是单元ID、body[1] 是功能码、body[2] 是字节数、数据从 body[3] 开始。超时用 CancellationToken 打断 ReadAsync不会让线程饿死。3.4 写寄存器FC06 和 FC16 的帧结构差异写单个寄存器用 0x06写多个用 0x10十六进制。两者的请求长度不一样响应也不一样FC06 的响应是整个请求原样回显FC16 的响应只回起始地址和数量不回数据public async Task WriteSingleRegisterAsync( byte unitId, ushort address, ushort value, int timeoutMs 1000) { var req BuildRequest(unitId, 0x06, address, value); (_, byte[] body) await TransactAsync(req, timeoutMs); if ((body[1] 0x80) ! 0) throw new InvalidOperationException($写单寄存器异常 0x{body[2]:X2}); } public async Task WriteMultipleRegistersAsync( byte unitId, ushort start, ushort[] values, int timeoutMs 1000) { var req BuildWriteMultiple(unitId, start, values); (_, byte[] body) await TransactAsync(req, timeoutMs); if ((body[1] 0x80) ! 0) throw new InvalidOperationException($写多寄存器异常 0x{body[2]:X2}); } private byte[] BuildWriteMultiple(byte unitId, ushort start, ushort[] values) { ushort tx (ushort)Interlocked.Increment(ref _txId); int n values.Length; var buf new byte[13 n * 2]; int len 7 n * 2; buf[0] (byte)(tx 8); buf[1] (byte)tx; buf[2] 0; buf[3] 0; buf[4] (byte)(len 8); buf[5] (byte)len; buf[6] unitId; buf[7] 0x10; buf[8] (byte)(start 8); buf[9] (byte)start; buf[10] (byte)(n 8); buf[11] (byte)n; buf[12] (byte)(n * 2); for (int i 0; i n; i) { buf[13 i * 2] (byte)(values[i] 8); buf[14 i * 2] (byte)values[i]; } return buf; }写多个寄存器时length 字段是 7 加上数据字节数unitId、func、起始地址 2 字节、数量 2 字节、字节数字段 1 字节再加 n 乘以 2。写单个寄存器复用 BuildRequest把 value 当 count 参数传进去这在阅读时容易产生歧义建议正式代码里拆成单独方法。调试的时候用 Wireshark 抓 502 端口对照请求响应一眼就能看出长度字段填没填对。3.5 32 位数据拼装REAL/DINT 在寄存器里怎么摆REAL 是 IEEE754 单精度浮点正好拆成两个 16 位寄存器DINT 同理只是最后转 Int32 而不是 Single。拼装的代码核心是字顺序public static float ReadFloat(ushort[] regs, int offset, bool lowWordFirst false) { ushort w0 regs[offset], w1 regs[offset 1]; uint raw lowWordFirst ? (uint)(w1 16) | w0 : (uint)(w0 16) | w1; return BitConverter.UInt32BitsToSingle(raw); } public static ushort[] WriteFloat(float value, bool lowWordFirst false) { uint raw BitConverter.SingleToUInt32Bits(value); ushort high (ushort)(raw 16); ushort low (ushort)raw; return lowWordFirst ? new[] { low, high } : new[] { high, low }; }标准 MODBUS 协议要求高字在前寄存器 0 存高 16 位但汇川部分机型为了兼容第三方设备把低字放在前面读回来就会得到「数值接近 0 的假象」。遇到 34.5 读成 2.98e-41把配置里的 lowWordFirst 反过来再试。注意还有一种更隐蔽的字节交换也就是低字再带字节颠倒少见用调试软件写一个已知浮点数对比寄存器内容一次就能确认。提示用调试软件往 D0 写一个已知浮点数再用驱动读回来对比寄存器顺序确定 wordOrder不要靠猜。4. MODBUS TCP C# 通讯的5个避坑点字节序、半包、断线与请求串位4.1 数值变成天文数字字序和字节序对不上现象汇川变量表里 D0-D1 是 REAL值 34.5C# 读回来两个寄存器是 0x0000、0x420A按标准拼法得到 2.98e-41界面显示一个荒谬的数。原因汇川部分系列默认低字在前存储 32 位数据。寄存器 0 是低 16 位 0x0000寄存器 1 是高 16 位 0x420A代码按高字先拼自然全错。解决读回两个寄存器后按 lowWordFirst 重拼uint raw (uint)(w1 16) | w0再转 float。建议每个数据点都在配置里暴露 wordOrder平台差异不是单个型号的事我见过一台工控机同时对接三种汇川机型三种拼法都出现过写死必翻车。4.2 响应张冠李戴事务ID只自增不校验等于没写现象两个 Task 同时读不同地址偶发 A 请求读到的却是 B 地址的值或者连续运行几小时后第一次超时后面数据全乱。原因请求并发发出后底层 TCP 重传或 PLC 忙时响应回来的顺序和请求顺序不一致。代码只负责自增事务ID却没有在收到响应时校验它谁先到就解析谁出错了只怪「网络不行」。解决三层防护。事务ID 用 Interlocked.Increment 保证并发唯一写请求加锁同一连接同一时刻只允许一个未完成请求TransactAsync 里比对 header 前两字节和请求的事务ID不一致就丢弃响应继续读下一包。把这三条做齐九成的偶发性数据错乱都根治了。4.3 半包粘包ReadAsync 一次读不满后面全错位现象系统运行平稳但每次重启或高频读写后抛「响应字节数与请求寄存器数不匹配」进程不退出就持续报错。原因TCP 把数据切成多个段传输一次 ReadAsync 读到的长度不确定。响应拆成两段到达时是半包一次读回两个响应时是粘包。代码用单次 ReadAsync 当「读一条报文」用必然错位。解决ReadExactly 循环读满期望字节先读 6 字节 MBAP 头从 length 字段算出整帧长度再读满它。读完一帧后如果流里还有数据说明是粘包剩余字节必须保留给下一次请求解析不能直接丢弃。这是 TCP 编程的基本功不是 MODBUS 特有。4.4 偶发超时与断线网线和半开连接的恢复策略现象系统跑一段时间后报「远程主机强迫关闭了一个现有的连接」或者请求超时重新连接后可能马上又断重启 PLC 才恢复。原因常见三种。拔网线后 TCP 并不知道对端已死形成半开连接PLC 侧 KeepAlive 超时主动关闭了连接PLC 固件看门狗或升级重启后本地 TcpClient 还认为连接存活直到下次写入才报错。解决每个读调用都带 timeoutMs捕获 IOException 和 TimeoutException 后必须走「Disconnect 加重新 ConnectAsync」不要只重试 Write。重连用指数退避0.5 秒、1 秒、2 秒封顶 5 秒避免打爆 PLC。再加一层心跳周期轮询读一个固定寄存器比如 PLC 版本号或心跳计数器既验证链路又不让连接空闲。重连成功后要重新挂载轮询任务旧的 NetworkStream 一律丢弃。4.5 功能码选错读输入寄存器用了 FC03现象想读模拟量或编码器位置请求返回异常码 02非法数据地址同一个地址用调试软件却能看到值。原因MODBUS 地址区有区分保持寄存器用 FC03 读写、FC06/FC16 写输入寄存器用 FC04 只读。汇川变量表里一部分只读映射比如模拟量输入 AI落在输入寄存器区拿 FC03 读自然地址非法。解决配置点里加 region 字段读保持寄存器选 0x03读输入寄存器选 0x04区分「只读区」和「读写区」。收到异常码 0x02 别只报「地址错」把功能码和区域一起打出来现场一看就知道是代码选错还是 PLC 映射不对。5. 把通讯源码收成上位机通用框架委托回调、队列与多台PLC并发5.1 用 C# 委托把寄存器数据变成事件业务层不碰协议轮询代码写完后下一步就是解耦。我一般把每个数据点封装成一个 PlcPoint用 C# 委托把「数据变化」变成事件业务层只关心事件不关心报文public class PlcPoint { public string Name { get; init; } ; public ushort Address { get; init; } public ushort Count { get; init; } public ushort[] Last { get; private set; } Array.Emptyushort(); public event FuncPlcPoint, Task? ValueChanged; internal async Task PushAsync(ushort[] value) { if (Last.AsSpan().SequenceEqual(value)) return; Last value; if (ValueChanged ! null) await ValueChanged(this); } }这个模型的数据流很干净轮询循环读完寄存器调用 PushAsyncLast 保存最近一次的值值有变化才触发事件。界面刷新、写数据库、报警判断全部注册到 ValueChanged 上互不干扰。Last 里存原始 ushort 数组拼 float、拼 DINT 的解析放到订阅方或一个统一转换器里点表本身保持简单换 PLC 时只改配置不改代码。5.2 队列缓冲与多台PLC并发一个上位机盯住几台设备现场经常是一台工控机同时接管多台汇川 PLC。每台 PLC 各自持有一个 ModbusTcpClient 实例和一条独立轮询 Task用 Task.WhenAll 统一管理启停。轮询读回来的数据放进队列业务侧异步消费避免业务卡顿拖垮采集var pointBus Channel.CreateBoundedPlcPoint( new BoundedChannelOptions(200) { FullMode BoundedChannelFullMode.DropOldest }); foreach (var client in plcClients.Values) { _ Task.Run(() PollLoopAsync(client, pointBus, ct)); }队列满时丢最旧的数据保证采集循环不堆积。这里的正解是分清主从上位机当主站就是多个 TcpClient 多连接只有极少数「上位机被 PLC 当从站读」的场景才需要 TcpListener 监听 502别搞反了。5.3 验证方法与我现在的习惯新写好的通讯层我按这个顺序验证先在 PLC 里写一个固定值 0x12345678读回来比对字节顺序再写一个 REAL 类型的 34.5读回来验证 wordOrder然后用 Wireshark 抓 502 端口过滤 modbus确认事务ID、长度字段、字节序都对得上全部通过了再接心跳和自动重连。早期我图省事把读写逻辑直接写进 Form 的事件里换一台 PLC 就要改代码重新编译翻车翻到被同事嫌弃。后来定死规矩通讯层独立成类地址、寄存器数量、wordOrder 全部进 JSON 配置业务只订阅事件。这个习惯救过我很多次希望帮到你。本文还有配套的精品资源点击获取