
简介这是一套面向工业自动化工程师、物联网开发者及高校学生的PLC上位机程序源码基于工业标准Modbus TCP协议开发采用C#实现可支持与多种品牌PLC通信解决设备数据读写、实时监控与异常报警等上位机通讯需求。资源包共56个文件约641KB以cs源码文件为核心配合json、xml、config等配置与数据文件以及dll依赖库、resx资源文件和sln、csproj工程文件整体结构清晰便于在Visual Studio中直接打开与二次开发。源码附带详细注释及使用文档涵盖Modbus TCP通讯封装、界面交互与业务逻辑分层读者可据此快速理解协议实现思路掌握上位机与PLC通讯的完整流程并在此基础上扩展报警、监控等功能。目前已有100人学习下载适合作为工业自动化与物联网方向的实战参考。1. 从一根网线到稳定采集Modbus TCP 上位机到底解决什么问题车间里一台 PLC 跑着产线逻辑工艺工程师想在中控室电脑上实时看数据、存历史、导出报表最省事的路径往往不是上 SCADA而是自己写一个 C# 上位机。标题里的「基于 Modbus TCP 协议的 PLC 上位机程序源码C# 实现含详细注释」说的就是这件事用 C# 通过 Modbus TCP 跟 PLC 建立连接周期性读写寄存器把设备运行状态落到界面上。它适合两类人——刚接触上位机开发、想找一个能跑通的完整骨架的工程师以及手里有 FX5U、S7-200 Smart、汇川等支持 Modbus TCP 的设备、需要快速做数据采集原型的从业者。核心难点不在协议本身而在连接管理、寄存器映射和异常重连这三件事上源码的价值也正在于此。2. Modbus TCP 报文结构与 C# 侧的最小实现路径2.1 为什么选 Modbus TCP 而不是 OPC UA在动手写代码之前先把选型理由说清楚否则后面调参数会没有方向。Modbus TCP 和 OPC UA 都能读 PLC 数据但两者的落地成本差别很大。OPC UA 有完整的信息模型、订阅机制和安全策略适合跨厂商、跨层级的大型系统代价是需要 OPC Server 或设备原生支持配置项多调试链路长。Modbus TCP 则是「裸协议」一条 TCP 连接一段固定结构的报文没有会话协商没有证书PLC 侧通常只要在组态里勾选「Modbus TCP 从站」并分配寄存器地址就能用。对于单台或少量 PLC、采集点位在几百个以内的上位机Modbus TCP 的性价比明显更高。它的报文结构固定头部 7 字节 MBAP事务标识 2 字节、协议标识 2 字节、长度 2 字节、单元标识 1 字节后面跟 PDU功能码 1 字节 数据。读保持寄存器用功能码 0x03写单个寄存器用 0x06写多个用 0x10。理解这 7 字节头部是后面排查「连上了但读不到数」的关键。常见做法是用现成的库比如 NModbus4 或 FluentModbus它们把报文封装好了。但标题强调「源码 详细注释」我一般会建议至少手写一遍报文拼装和解析这样出问题时你能看懂抓包结果。下面这段是最小可运行的读保持寄存器实现不依赖第三方库。// 手写 Modbus TCP 读保持寄存器功能码 0x03 // 参数ip PLC地址port 502slaveId 单元标识startAddr 起始寄存器count 寄存器数量 public static ushort[] ReadHoldingRegisters(string ip, int port, byte slaveId, ushort startAddr, ushort count) { using (var client new TcpClient()) { // 连接超时单独控制避免默认 20 秒卡死界面 var ar client.BeginConnect(ip, port, null, null); if (!ar.AsyncWaitHandle.WaitOne(TimeSpan.FromSeconds(3))) throw new TimeoutException($连接 {ip}:{port} 超时); client.EndConnect(ar); var stream client.GetStream(); stream.ReadTimeout 2000; stream.WriteTimeout 2000; // 组装 MBAP 头 PDU var frame new byte[12]; frame[0] 0x00; frame[1] 0x01; // 事务标识可自增 frame[2] 0x00; frame[3] 0x00; // 协议标识Modbus 固定 0 frame[4] 0x00; frame[5] 0x06; // 后续字节数 6 frame[6] slaveId; // 单元标识 frame[7] 0x03; // 功能码 frame[8] (byte)(startAddr 8); // 起始地址高字节 frame[9] (byte)(startAddr 0xFF);// 起始地址低字节 frame[10] (byte)(count 8); // 数量高字节 frame[11] (byte)(count 0xFF); // 数量低字节 stream.Write(frame, 0, frame.Length); // 响应MBAP 7 字节 功能码 1 字节数 1 数据 var header new byte[9]; int read 0; while (read 9) read stream.Read(header, read, 9 - read); if (header[7] ! 0x03) throw new Exception($异常响应功能码0x{header[7]:X2}异常码0x{header[8]:X2}); int dataLen header[8]; var data new byte[dataLen]; read 0; while (read dataLen) read stream.Read(data, read, dataLen - read); var result new ushort[count]; for (int i 0; i count; i) result[i] (ushort)((data[i * 2] 8) | data[i * 2 1]); return result; } }这段代码的逻辑分四步建连接、拼请求、读响应头、解析数据。参数上要特别注意三点。第一slaveId在 Modbus TCP 里通常填 1但部分网关设备要求填 0xFF 或实际站号填错会返回异常码 0x0B网关目标设备无响应。第二startAddr是协议地址不是 PLC 组态里的软元件编号比如三菱 FX5U 的 D100 在 Modbus 映射表里往往对应 4100 之类的偏移必须查设备手册的映射表。第三count单次不要超过 125超过会返回异常码 0x03数据值非法需要分批读。2.2 寄存器地址映射从 PLC 软元件到协议地址地址映射是新手翻车最多的地方。PLC 厂商的软元件命名D、M、X、Y、DB 块和 Modbus 的四种寄存器线圈、离散输入、输入寄存器、保持寄存器不是一一对应的中间隔着一张映射表。以常见设备为例映射关系大致如下。PLC 软元件Modbus 寄存器类型功能码典型协议地址数据寄存器 D保持寄存器0x03 / 0x1040001 起辅助继电器 M线圈0x01 / 0x0500001 起输入 X离散输入0x0210001 起模拟量输入输入寄存器0x0430001 起注意表里的 40001 是「文档地址」实际报文里要减 1 变成 0。很多库的 API 直接收 0 基地址也有库收 1 基地址混用就会出现「读到的值整体偏移一个寄存器」的玄学现象。我的习惯是在配置文件里统一存 0 基地址界面上显示时再加 1避免来回换算。对于西门子 S7-200 Smart 这类设备要特别注意它并不原生支持 Modbus TCP需要通过指令库做 Modbus TCP 服务器映射或者加网关模块。热词里提到的「西门子 PLC200 不能实现 Modbus TCP 通讯」就是这个原因——S7-200 只有 PPI 和自由口S7-200 Smart 才带以太网口并支持 Modbus TCP 服务器指令。选型阶段就要确认清楚否则代码写得再好也连不上。2.3 用 NModbus4 快速搭出可维护的采集层手写报文适合理解原理但生产代码我一般用 NModbus4省去连接管理和字节序处理的重复劳动。下面是一个带重连的采集封装重点看连接复用和异常处理。// 基于 NModbus4 的采集封装连接复用 断线重连 public class PlcClient { private TcpClient _tcp; private IModbusMaster _master; private readonly string _ip; private readonly int _port; private readonly object _lock new object(); public PlcClient(string ip, int port 502) { _ip ip; _port port; } private void EnsureConnected() { if (_tcp ! null _tcp.Connected) return; lock (_lock) { if (_tcp ! null _tcp.Connected) return; _tcp?.Close(); _tcp new TcpClient(); _tcp.Connect(_ip, _port); // 生产环境建议用带超时的异步连接 _master ModbusIpMaster.CreateIp(_tcp); _master.Transport.ReadTimeout 2000; _master.Transport.WriteTimeout 2000; _master.Transport.Retries 2; // 重试 2 次避免偶发丢包直接报错 } } public ushort[] ReadRegisters(byte slaveId, ushort start, ushort count) { EnsureConnected(); try { return _master.ReadHoldingRegisters(slaveId, start, count); } catch (Exception) { _tcp?.Close(); // 标记连接失效下次调用自动重连 throw; } } }这里的关键设计是「连接失效即关闭下次调用自动重建」而不是在 catch 里立刻重连。原因是 PLC 断线往往伴随网络抖动立刻重连容易陷入重连风暴把 PLC 的连接资源占满。参数上Retries设 2 比较稳妥设太大在真断线时会拖长单次采集耗时ReadTimeout设 2000ms 是经验值车间网络差可以放宽到 3000ms但不要超过采集周期否则会堆积。3. 上位机界面与采集线程的协作方式3.1 采集线程与 UI 线程的解耦上位机最容易出的问题不是协议而是界面卡死。如果把 Modbus 读写直接放在按钮事件里一次超时就是 2 秒白屏操作工以为程序崩了。正确做法是采集跑在后台线程或定时器里通过队列把数据交给 UI。// 采集线程固定周期读取数据入队 private BlockingCollectionPlcData _queue new BlockingCollectionPlcData(100); private void PollLoop(CancellationToken token) { while (!token.IsCancellationRequested) { var sw Stopwatch.StartNew(); try { var values _plc.ReadRegisters(1, 0, 20); _queue.Add(new PlcData { Time DateTime.Now, Values values }); } catch (Exception ex) { _queue.Add(new PlcData { Time DateTime.Now, Error ex.Message }); } // 固定周期 200ms扣除本次耗时避免周期漂移 int wait 200 - (int)sw.ElapsedMilliseconds; if (wait 0) Thread.Sleep(wait); } }队列容量设 100 是防止 UI 卡顿时内存无限增长满了就丢弃最旧数据。UI 侧用定时器每 100ms 从队列取一批刷新而不是每来一条刷一次这样即使采集周期是 200ms界面也不会频繁重绘。参数上采集周期要根据 PLC 扫描周期和点位数量定点位少可以 100ms点位上百建议 500ms 以上否则 PLC 的通信负载会明显上升。3.2 数据落库与历史查询的轻量方案采集到的数据要存下来才有价值。小规模场景用 SQLite 或 Access 就够不必上时序数据库。建表时把时间戳和点位值分开存避免宽表字段爆炸。-- 历史数据表按点位存便于扩展 CREATE TABLE HistoryData ( Id INTEGER PRIMARY KEY AUTOINCREMENT, PointName TEXT NOT NULL, Value REAL, CollectTime DATETIME NOT NULL ); CREATE INDEX idx_point_time ON HistoryData(PointName, CollectTime);写入时用事务批量提交每 100 条或每秒提交一次比逐条插入快一个数量级。查询时按点位和时间范围走索引界面上的趋势图只查最近一段不要一次拉全表。这里有个血泪经验早期我把所有点位塞进一张宽表后来加一个点位就要改表结构迁移一次数据花了大半天改成纵表后再没遇到过这个问题。4. 避坑与排查连不上、读不到、值不对怎么办4.1 连接被拒绝或超时现象是TcpClient.Connect抛异常或等待超时。原因通常有三个PLC 的 Modbus TCP 服务器没使能、端口不是 502、防火墙拦了。解决顺序是先 ping 通 PLC 的 IP再用 telnet 或端口扫描工具确认 502 端口开放最后检查 PLC 组态里 Modbus 服务器是否勾选、单元标识是否匹配。注意有些设备默认端口是 500 或 8502不要想当然填 502。4.2 返回异常码 0x02 或 0x03现象是响应功能码变成0x83异常码 0x02 表示地址非法0x03 表示数据值非法。原因基本是地址映射错了或数量超限。解决方法是查设备手册的 Modbus 地址表确认起始地址和数量单次读取数量控制在 125 以内写多个寄存器控制在 123 以内。如果读的是输入寄存器却用了 0x03 功能码也会报 0x02。4.3 读到的值明显不对现象是数值翻倍、缩小或完全对不上。原因多是字节序和数据类型问题。Modbus 寄存器是 16 位32 位浮点要占两个寄存器高低字顺序因设备而异。有的设备高字在前有的低字在前还有的字节内部也要交换。解决办法是先用已知值比如写入 1.0读回来看四个字节的排列再决定是否交换。下面这个转换函数覆盖了常见情况。// 两个寄存器合成 32 位浮点swapWords 控制高低字顺序 public static float ToFloat(ushort high, ushort low, bool swapWords) { byte[] bytes new byte[4]; if (swapWords) { bytes[0] (byte)(low 8); bytes[1] (byte)(low 0xFF); bytes[2] (byte)(high 8); bytes[3] (byte)(high 0xFF); } else { bytes[0] (byte)(high 8); bytes[1] (byte)(high 0xFF); bytes[2] (byte)(low 8); bytes[3] (byte)(low 0xFF); } if (BitConverter.IsLittleEndian) Array.Reverse(bytes); return BitConverter.ToSingle(bytes, 0); }4.4 运行一段时间后连接断开现象是程序跑几小时后采集全部失败重启又正常。原因是 PLC 对空闲连接或连接数有限制长时间不通信会被服务端主动断开而客户端没感知。解决办法是加心跳即使没有采集需求也定期读一个寄存器同时在每次读写失败时关闭连接并重建不要复用已失效的 socket。另外注意不要频繁创建新连接每次采集都 new 一个 TcpClient 会快速耗尽 PLC 的连接资源。4.5 多线程同时读写导致数据错乱现象是偶发读到别人的响应或异常码。原因是 Modbus 是请求-响应模型一条连接上同时发多个请求会串包。解决办法是给通信加锁保证同一连接上同一时刻只有一个请求在途或者每个线程用独立连接。我一般用一把锁把读写串起来采集周期 200ms 的情况下完全够用。5. 把源码改造成能长期运行的生产级采集器前面几章的代码能跑通但离「长期无人值守」还差几步。这一章讲三个具体技巧都是我在现场反复验证过的。第一个是配置外置。把 IP、端口、单元标识、点位表、采集周期全部写进 JSON 配置文件程序启动时加载。这样换设备、加点位不用重新编译。点位表建议用数组每项包含名称、功能码、地址、数据类型、字节序采集层按配置动态读取。{ plc: { ip: 192.168.1.10, port: 502, slaveId: 1, intervalMs: 200 }, points: [ { name: 温度, func: 3, addr: 0, type: float, swap: true }, { name: 运行状态, func: 1, addr: 0, type: bool } ] }第二个是日志分级。通信异常记 Warning解析异常记 Error正常采集记 Debug 并限制文件大小。现场排查时日志里能看到「几点几分哪个点位读失败、异常码多少」比任何猜测都快。日志按天滚动保留 7 天即可避免占满磁盘。第三个是看门狗。用一个独立线程监控采集线程的最后心跳时间超过 3 个采集周期没更新就记录并尝试重启采集线程。这个机制能兜住那些「线程卡在某个 socket 读上」的极端情况是无人值守的最后一道保险。验证方法上我习惯用 Modbus Poll 或 Modbus Slave 模拟器先对测确认地址和数据类型无误再连真实 PLC。模拟器能构造异常响应方便测试异常分支。上线前至少跑 24 小时观察内存和连接数是否稳定。最后说个习惯每次现场调试我都会把抓包结果和配置文件一起存档。下次遇到「同样的代码换个车间就不行」翻出当时的抓包对比往往几分钟就能定位是地址映射还是字节序的差异。这套源码骨架不大但把连接管理、地址映射、异常重连这三块吃透换成 OPC UA 或别的协议也是同样的思路。希望帮到你。本文还有配套的精品资源点击获取