简介:面向工业自动化与上位机开发人员,这份基于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 个寄存器。对应关系如下:
| 汇川变量表类型 | 占用寄存器数 | 说明 |
|---|---|---|
| BOOL | 1(按位) | 多数读整字回来再按位与 |
| INT / UINT / SINT | 1 | 16 位,符号由上层解释 |
| DINT / INT32 / UINT32 | 2 | 两个 16 位寄存器拼 32 位 |
| REAL / FLOAT | 2 | 按 IEEE754 拼成 32 位 |
| STRING | N | 每个寄存器存两个字符,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 字节,响应里带回来的数据区是大端序,需要转成 ushort:
private 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; // 长度:unitId+func+addr+count 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 Task<ushort[]> 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 Task<byte[]> 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 分开返回,方便上层解析时直接看 body:body[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.5;C# 读回来两个寄存器是 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.Empty<ushort>(); public event Func<PlcPoint, Task>? ValueChanged; internal async Task PushAsync(ushort[] value) { if (Last.AsSpan().SequenceEqual(value)) return; Last = value; if (ValueChanged != null) await ValueChanged(this); } }这个模型的数据流很干净:轮询循环读完寄存器,调用 PushAsync,Last 保存最近一次的值,值有变化才触发事件。界面刷新、写数据库、报警判断全部注册到 ValueChanged 上,互不干扰。Last 里存原始 ushort 数组,拼 float、拼 DINT 的解析放到订阅方或一个统一转换器里,点表本身保持简单,换 PLC 时只改配置不改代码。
5.2 队列缓冲与多台PLC并发:一个上位机盯住几台设备
现场经常是一台工控机同时接管多台汇川 PLC。每台 PLC 各自持有一个 ModbusTcpClient 实例和一条独立轮询 Task,用 Task.WhenAll 统一管理启停。轮询读回来的数据放进队列,业务侧异步消费,避免业务卡顿拖垮采集:
var pointBus = Channel.CreateBounded<PlcPoint>( 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 配置,业务只订阅事件。这个习惯救过我很多次,希望帮到你。
本文还有配套的精品资源,点击获取