
简介这是一套基于工业标准 Modbus TCP 协议开发的 PLC 上位机程序源码采用 C# 编写面向工业自动化工程师、物联网开发者、高校学生及协议学习者可用于与汇川等多种品牌 PLC 建立通信解决数据采集、设备监控与异常报警等实际需求。压缩包共 56 个文件约 641KB以 cs 源码、json 配置、dll 依赖库、xml 与 resx 资源文件为主另含 sln 解决方案、csproj 工程文件及少量 txt 说明文档整体结构清晰便于在 Visual Studio 中直接打开调试。源码基于 NModbus 库实现协议封装功能覆盖寄存器读写、实时监控与报警处理并附带详细注释方便二次开发与学习。目前已有 100 人学习下载适合希望快速掌握 Modbus TCP 通信流程、积累上位机项目经验的开发者参考借鉴。1. 从一根网线到产线数据Modbus TCP 上位机到底在解决什么问题车间里一台 FX5U 或者 S7-200 Smart 刚上电PLC 侧梯形图跑得好好的但你要把产量、节拍、报警记录实时抓到办公室的电脑上还要存数据库、出报表——这时候卡住大多数人的不是 PLC 编程而是上位机这一侧协议怎么组帧、寄存器地址怎么映射、断线了怎么重连、32 位浮点数高低字怎么拼。基于 Modbus TCP 协议的 PLC 上位机程序源码C# 实现含详细注释这个标题说的就是用 C# 从零搭一个能跟 PLC 稳定对话的上位机把 Modbus TCP 这条链路吃透。它适合两类人一类是刚转上位机开发、被寄存器地址和字节序折磨过的工程师另一类是想把现有组态软件替换成自研轻量客户端的熟手。下面按「协议怎么立住 → 代码怎么落地 → 坑在哪 → 怎么验证」推一遍源码结构可以直接照着搭。2. Modbus TCP 协议栈拆解从 MBAP 报文到寄存器映射2.1 为什么工业现场偏爱 Modbus TCP 而不是 RTUModbus RTU 走 RS485 串口一条总线挂十几台设备轮询周期一长实时性就崩。Modbus TCP 把 RTU 的帧头换成了 MBAPModbus Application Protocol报文头跑在标准以太网上天然支持多客户端并发、交换机级联、跨网段路由。现场常见的 FX5U 内置以太网口、西门子 S7-1200 加 CP1241 模块、汇川 AM 系列都能直接开 Modbus TCP 从站。对上位机来说最大的好处是不用再管串口的波特率、校验位、超时重传Socket 一开报文一发一收逻辑干净很多。但要注意一个反直觉的点Modbus TCP 并不是「把 RTU 帧塞进 TCP 包」那么简单。RTU 帧尾有 CRC16 校验TCP 帧头有 7 字节 MBAP两者的事务标识、协议标识、长度字段完全不同。很多新手拿 RTU 的报文直接往 TCP 里灌PLC 侧根本不响应这就是没搞清 MBAP 结构。2.2 MBAP 报文头的 7 个字节到底怎么填Modbus TCP 的每一帧请求和响应前面都带 7 字节 MBAP 头结构如下字段字节数含义典型值事务标识符2请求与响应的配对 ID递增如 0x0001协议标识符2固定为 0 表示 Modbus0x0000长度2后续字节数单元标识PDU0x0006单元标识符1从站地址TCP 下常为 0xFF 或 10x01事务标识符是排查问题的关键。上位机发一批请求响应回来时靠这个字段匹配是哪一条。如果你并发发多条请求但事务 ID 全填 0响应就分不清谁是谁数据错位是必然的。我一般用一个 ushort 计数器每发一帧自增溢出归零。单元标识符在 TCP 场景下容易被忽略。有些网关设备比如串口服务器后面挂多个 RTU 从站这时候单元标识符就是真正的从站地址如果 PLC 本身带以太网口做 Modbus TCP 从站这个字段填 0xFF 或 1 都行具体看手册。FX5U 做 Modbus TCP 从站时单元标识符一般填 1。2.3 功能码与寄存器地址的映射关系上位机读 PLC 数据最常用的四个功能码0x01 读线圈Coil对应 PLC 的 Y/M 输出点位操作0x03 读保持寄存器Holding Register对应 D 寄存器、V 区16 位0x04 读输入寄存器Input Register对应模拟量输入通道0x10 写多个保持寄存器用于下发配方、设定值地址映射是翻车重灾区。Modbus 协议里的地址是从 0 开始的但 PLC 手册上写的 D100、40001 这些是「逻辑地址」。以三菱 FX5U 为例D100 在 Modbus 映射表里对应的保持寄存器偏移量可能是 0x0064十进制 100但如果你用 40001 这种 Modbus 传统地址就要减 1 再算偏移。西门子 S7-200 Smart 做 Modbus TCP 从站时V 区地址和保持寄存器的对应关系又不一样V100 可能对应 40101。没有统一公式必须查对应 PLC 的 Modbus 地址映射表。提示拿到一台新 PLC先用 Modbus Poll 或 Modbus Slave 这类调试工具手动读一次确认地址偏移和字节序再写代码。这一步省不得。3. C# 实现 Modbus TCP 客户端Socket 封装与读写核心3.1 用 TcpClient 还是原生 SocketC# 里做 Modbus TCP 客户端底层就两条路TcpClient封装好的流式接口或者原生Socket自己管收发。TcpClient写起来快NetworkStream直接 Read/Write适合中小型上位机。原生Socket控制粒度细能设NoDelay、能自己处理粘包适合高频轮询场景。我一般用TcpClient起步但必须设NoDelay true。Modbus TCP 报文很短通常十几字节Nagle 算法会攒包再发导致响应延迟忽大忽小现场表现就是「数据偶尔卡一下」。关掉 Nagle 后每帧立即发出轮询周期稳定很多。using System; using System.Net.Sockets; public class ModbusTcpClient { private TcpClient _tcpClient; private NetworkStream _stream; private ushort _transactionId 0; public bool Connect(string ip, int port 502) { try { _tcpClient new TcpClient(); // 关闭 Nagle 算法避免小报文被攒包 _tcpClient.NoDelay true; _tcpClient.Connect(ip, port); _stream _tcpClient.GetStream(); _stream.ReadTimeout 2000; // 读超时 2 秒 _stream.WriteTimeout 2000; return true; } catch (Exception ex) { Console.WriteLine($连接失败: {ex.Message}); return false; } } }这段代码做了三件事建立 TCP 连接、关闭 Nagle、设置读写超时。ReadTimeout和WriteTimeout必须设否则 PLC 断电或网线拔掉时Read会永久阻塞上位机界面直接卡死。2 秒是个经验值现场交换机层级多可以放到 3 秒。3.2 组帧读保持寄存器的完整请求构造以功能码 0x03 读保持寄存器为例请求 PDU 是功能码(1字节) 起始地址(2字节) 寄存器数量(2字节)。加上 MBAP 头完整帧如下public byte[] BuildReadHoldingRegisters(ushort startAddr, ushort count) { _transactionId; byte[] frame new byte[12]; // MBAP 头 frame[0] (byte)(_transactionId 8); // 事务 ID 高字节 frame[1] (byte)(_transactionId 0xFF); // 事务 ID 低字节 frame[2] 0x00; // 协议 ID 高字节 frame[3] 0x00; // 协议 ID 低字节 frame[4] 0x00; // 长度高字节 frame[5] 0x06; // 长度低字节后续 6 字节 frame[6] 0x01; // 单元标识符从站地址 // PDU frame[7] 0x03; // 功能码读保持寄存器 frame[8] (byte)(startAddr 8); // 起始地址高字节 frame[9] (byte)(startAddr 0xFF); // 起始地址低字节 frame[10] (byte)(count 8); // 寄存器数量高字节 frame[11] (byte)(count 0xFF); // 寄存器数量低字节 return frame; }长度字段填 0x0006因为单元标识符(1) 功能码(1) 起始地址(2) 数量(2) 6 字节。这个值写错PLC 直接丢弃报文不返回任何东西调试时容易误以为是网络问题。起始地址和数量都是大端序高位在前C# 里用移位操作拼。3.3 收帧与解析处理粘包和半包TCP 是流式协议一次Read不保证读到完整的一帧。可能读到半帧也可能一次读到两帧粘在一起。稳妥的做法是先读 7 字节 MBAP 头从中解析出长度字段再按长度读剩余字节。public byte[] ReadResponse() { byte[] mbap new byte[7]; int read 0; // 先读满 7 字节 MBAP 头 while (read 7) { int n _stream.Read(mbap, read, 7 - read); if (n 0) throw new Exception(连接已关闭); read n; } int length (mbap[4] 8) | mbap[5]; // 后续字节数 byte[] pdu new byte[length]; read 0; while (read length) { int n _stream.Read(pdu, read, length - read); if (n 0) throw new Exception(连接已关闭); read n; } // 拼接完整响应 byte[] full new byte[7 length]; Array.Copy(mbap, 0, full, 0, 7); Array.Copy(pdu, 0, full, 7, length); return full; }这个循环读法是处理粘包的标准套路。_stream.Read返回 0 表示对端关闭连接必须抛异常或触发重连不能继续死等。解析响应时功能码 0x03 的正常响应是功能码(1) 字节数(1) 数据(N)。如果功能码最高位是 1比如 0x83说明是异常响应后面跟一个异常码常见的有 0x02非法地址、0x03非法数据值。3.4 32 位浮点数的字节序拼接PLC 里的浮点数占两个连续寄存器但高低字顺序各品牌不同。三菱 FX5U 通常是低字在前西门子 S7-200 Smart 是高字在前。读回来两个 ushort 后拼接方式// 假设 regs[0] 是第一个寄存器regs[1] 是第二个 // 低字在前三菱常见 byte[] bytes new byte[4]; bytes[0] (byte)(regs[1] 0xFF); // 高字低字节 bytes[1] (byte)(regs[1] 8); // 高字高字节 bytes[2] (byte)(regs[0] 0xFF); // 低字低字节 bytes[3] (byte)(regs[0] 8); // 低字高字节 float value BitConverter.ToSingle(bytes, 0);如果读出来是乱码或者数量级完全不对先把两个寄存器交换再试。这个没有捷径查手册或者用调试工具确认。我一般会在代码里留一个bool swapWords配置项现场调试时改一下就行不用重新编译。4. 上位机工程化落地轮询调度、断线重连与数据缓存4.1 轮询周期怎么定才不丢数据上位机读 PLC 数据本质是轮询。轮询周期太短PLC 响应不过来报文堆积太长实时性差。FX5U 做 Modbus TCP 从站时单次读 10 个寄存器的响应时间通常在 5~15ms。如果轮询 20 个数据点每个点一次请求一轮下来 200~300ms。这时候周期设 100ms 就是自欺欺人请求会越积越多。我的做法是合并请求把地址连续的寄存器合并成一次读。比如 D100~D120 连续一次读 21 个寄存器比读 21 次快一个数量级。地址不连续的按功能码分组每组一次请求。轮询周期根据实际响应时间动态调整设一个最小间隔比如 50ms跑一轮 sleep 剩余时间。private async Task PollingLoop(CancellationToken token) { while (!token.IsCancellationRequested) { var sw System.Diagnostics.Stopwatch.StartNew(); try { // 合并读取一次读 20 个保持寄存器 var frame BuildReadHoldingRegisters(100, 20); _stream.Write(frame, 0, frame.Length); var resp ReadResponse(); ParseAndCache(resp); } catch (Exception ex) { Console.WriteLine($轮询异常: {ex.Message}); Reconnect(); } // 补足到 100ms 周期 int elapsed (int)sw.ElapsedMilliseconds; if (elapsed 100) await Task.Delay(100 - elapsed, token); } }Stopwatch用来算实际耗时Task.Delay补足剩余时间。这样即使某次响应慢了下一轮也不会立刻发给 PLC 喘息时间。如果连续多次超时触发重连逻辑。4.2 断线重连别让一次拔网线搞崩整个程序现场最怕的是网线松动或者交换机重启。如果上位机没有重连机制Read抛异常后线程直接退出界面数据冻结操作工以为程序死了。重连逻辑要处理三件事检测断线、释放旧连接、重建连接。private bool Reconnect() { // 先释放旧资源 try { _stream?.Close(); } catch { } try { _tcpClient?.Close(); } catch { } _stream null; _tcpClient null; // 重试 3 次每次间隔 2 秒 for (int i 0; i 3; i) { if (Connect(_ip, _port)) { Console.WriteLine(重连成功); return true; } Thread.Sleep(2000); } Console.WriteLine(重连失败等待下一轮); return false; }重连不要无限快速重试否则网络风暴。间隔 2 秒、重试 3 次失败后等下一轮轮询再试。Close()要包在 try-catch 里因为连接已经断开时 Close 可能抛异常不处理会中断重连流程。4.3 数据缓存与界面解耦轮询线程读到数据后不要直接更新 UI 控件。WinForms 和 WPF 都要求 UI 操作在 UI 线程执行跨线程直接赋值会抛InvalidOperationException。正确做法是轮询线程把数据写入一个线程安全的缓存比如ConcurrentDictionary或者加锁的DictionaryUI 用 Timer 定时从缓存取。// 线程安全缓存 private ConcurrentDictionarystring, object _dataCache new(); private void ParseAndCache(byte[] response) { // 跳过 MBAP(7) 功能码(1) 字节数(1) int dataStart 9; ushort reg100 (ushort)((response[dataStart] 8) | response[dataStart 1]); _dataCache[D100] reg100; // 解析更多寄存器... } // UI 定时器里读缓存 private void UiTimer_Tick(object sender, EventArgs e) { if (_dataCache.TryGetValue(D100, out var val)) lblD100.Text val.ToString(); }这样轮询和 UI 完全解耦轮询卡住不影响界面刷新界面卡住也不影响数据采集。缓存里存原始值格式化在 UI 层做方便后期改显示格式。5. 避坑与排查Modbus TCP 上位机最常见的 5 个翻车现场5.1 现象连接成功但读不到任何数据PLC 无响应原因MBAP 头的长度字段或单元标识符填错。长度字段多一字节少一字节PLC 直接丢弃单元标识符如果 PLC 设了站号 1你填 0xFF部分品牌不响应。解决用 Wireshark 抓包对比请求帧和 PLC 手册的示例帧。重点看第 5 字节长度低字节和第 7 字节单元标识符。FX5U 做 Modbus TCP 从站时单元标识符填 1长度字段按实际 PDU 长度算。5.2 现象读上来的数值忽大忽小或者完全对不上原因字节序或字序搞反。32 位数据占两个寄存器高低字顺序各品牌不同16 位数据也可能高低字节颠倒。解决先读一个已知值的寄存器比如 PLC 里设 D100 100读回来如果是 25600说明高低字节反了如果是两个寄存器拼浮点数交换寄存器顺序再试。代码里留配置项别硬编码。5.3 现象程序跑几小时后卡死界面无响应原因NetworkStream.Read没有设超时或者超时后没有正确处理异常线程阻塞在 Read 上。另一种可能是轮询线程和 UI 线程死锁。解决ReadTimeout和WriteTimeout必须设Read 抛IOException后触发重连不要吞掉异常继续循环。UI 更新走缓存 Timer不要跨线程直接操作控件。5.4 现象同时读写多个地址时数据错位原因事务标识符没有递增或者并发发送请求时没有加锁两个请求的响应互相串了。解决事务 ID 用Interlocked.Increment保证原子递增如果多线程同时发请求给发送和接收加锁保证一问一答的原子性。简单场景就单线程轮询别搞并发。5.5 现象PLC 断电重启后上位机连不上必须重启程序原因TCP 连接处于半开状态TcpClient不知道对端已经断开Read一直阻塞直到超时但重连逻辑没有正确释放旧 Socket。解决重连前先Close()旧连接再new TcpClient()。不要复用旧的TcpClient实例因为它的内部状态可能已经乱了。重连成功后重新初始化NetworkStream和超时设置。6. 进阶验证用单元测试和模拟从站锁住协议正确性写完客户端代码怎么确认组帧和解析没问题总不能每次都连真 PLC。我一般用两个手段单元测试验证组帧字节模拟从站验证收发闭环。组帧的单元测试很直接给定起始地址和数量断言生成的字节数组每一位都对[TestMethod] public void BuildReadHoldingRegisters_Addr100_Count20_ReturnsCorrectFrame() { var client new ModbusTcpClient(); byte[] frame client.BuildReadHoldingRegisters(100, 20); Assert.AreEqual(12, frame.Length); Assert.AreEqual(0x03, frame[7]); // 功能码 Assert.AreEqual(0x00, frame[8]); // 起始地址高字节 Assert.AreEqual(0x64, frame[9]); // 起始地址低字节 100 Assert.AreEqual(0x00, frame[10]); // 数量高字节 Assert.AreEqual(0x14, frame[11]); // 数量低字节 20 }模拟从站可以用 C# 再写一个TcpListener收到请求后按 Modbus 协议拼响应返回。这样不依赖真实 PLCCI 里也能跑。模拟从站还能故意返回异常码测试客户端的异常处理分支。// 模拟从站收到读保持寄存器请求返回固定数据 private void MockSlaveLoop() { var listener new TcpListener(System.Net.IPAddress.Loopback, 5020); listener.Start(); var client listener.AcceptTcpClient(); var stream client.GetStream(); byte[] buffer new byte[256]; int n stream.Read(buffer, 0, buffer.Length); // 构造响应MBAP 功能码03 字节数 数据 byte[] resp new byte[9 40]; // ... 填充 MBAP 和 PDU stream.Write(resp, 0, resp.Length); }验证时重点看三处事务 ID 是否原样返回、功能码是否正确、数据字节数是否等于寄存器数乘 2。这三处对了协议层基本没问题。剩下的就是现场联调用真实 PLC 确认地址映射和字节序。我自己的习惯是每换一个品牌的 PLC先花半小时用调试工具把地址表和字节序摸清写进一个配置表再改代码里的映射参数。这个半小时省不得否则后面调试数据对不上查起来就是黑匣子。希望帮到你。本文还有配套的精品资源点击获取