
简介C#开发者或工业自动化工程师如果需要在.NET环境中与OPC Server通信这份源码提供了一套开箱即用的解决方案。它基于KEPServerEX V5.14完成亲测能够通过OPC DA方式读写多种品牌PLC数据代码按抽象设备统一封装无需额外安装或引用第三方组件。压缩包共772个文件包含323个cs源码、96个dll运行库、32个txt说明文档以及SCC、PDB、缓存和工程配置文件完整OpcDaNet库源码一并开放便于阅读底层实现。整个资源包仅3.79MB结构清晰携带方便目前已有835人学习或下载。项目带有可运行的WinApp测试程序和完整的VS解决方案新手可借助它快速理解OPC客户端建立连接、读写点位的基本流程有经验的开发人员则可以直接复用其架构作为多PLC统一接入的底层通讯模块。1. 为什么 C# 访问 KEPServerEX 比直接怼 PLC 协议更省事做上位机的人碰到多 PLC 混采最头疼的不是 C# 的语法而是每个厂商的通信协议都在教做人西门子 S7 要看 PUT/GET 和数据类型对齐AB 的 ControlLogix 要解析 CIP 报文Modbus 虽然简单但寄存器地址习惯又不一样。我拿到这个源码包时最感兴趣的一点是它把 OPC 客户端这一层直接做成了可运行的库而不是让人先去啃一遍 COM 规范。项目基于 KEPServerEX V5.14 测试OpcDaNet 库源代码全开放不依赖 OpcNetApi 等第三方托管封装并且按抽象设备统一封装换 PLC 型号时只需要改配置和标签映射。适合新手快速跑通 C# 读写 PLC 的流程也适合有经验的开发拿它的 COM Interop 声明做对照遇到采集卡顿或 DCOM 权限问题时知道去哪一层找原因。2. OpcDaNet 库的封装思路与 COM 接口拆解2.1 OPC DA 的 Server/Group/Item 三层模型OPC DA 2.0 的客户端视图其实只有三个对象OPCServer 负责建立连接OPCGroup 负责按固定周期批量维护一批标签OPCItem 是单个 PLC 变量的读写入口。很多新手会把 KEPServerEX 里的 Channel、Device 和这里的 Group 混为一谈实际上 OPC 客户端一侧看不到 Channel 和 Device它感知到的只有 Server、Group、Item 三层。KEPServerEX 的作用是把不同 PLC 协议翻译成统一的 OPC 标签树而 Group 是客户端自己创建的订阅容器。OPC DA 对象作用KEPServerEX 侧对应OPCServer建立连接、枚举组、错误处理Kepware.KEPServerEX.V5 实例OPCGroup按刷新周期成批读写标签客户端自定义与通道/设备无关OPCItem单个 PLC 变量的读写入口通道/设备下的 Tag一个 KEPServerEX 可以同时连接多个 PLC但在 OPC 客户端视角里只有一个 Server。不同 PLC 的标签通过完整的 Item 路径区分比如S7PLC.DB1,REAL0和ABPLC.Input.RealValue。因此封装库时最值得做的事就是把这套字符串路径从业务代码里隔离开。2.2 OpcDaNet 库的 COM Interop 声明方式OpcDaNet 库选择直接用[ComImport]声明 OPC COM 接口而不依赖 OpcNetApi 这类托管包装所以源码里能看到一整套 OPC DA 2.0 接口定义。这种做法的好处是完全透明可以自己控制 vtable 调用、错误处理和内存释放。下面是 IOPCServer 的部分声明来自 OpcDaNet 源码用于添加组[ComImport, Guid(39C13A50-011E-11D0-9675-0020AFD8ADB3), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] public interface IOPCServer { void AddGroup( [In, MarshalAs(UnmanagedType.LPWStr)] string szName, [In] int bActive, [In] int dwRequestedUpdateRate, [In] int hClientGroup, [In] int dwTimeBias, [In] float fDeadband, [In] int dwLCID, [Out] out IOPCGroupStateMgt ppAddGroup, [Out] out int pActualUpdateRate, [In] ref Guid riid); void RemoveGroup([In] int hServerGroup, [In] int bForce); // 其它方法略 }这里的关键在于[ComImport]的接口方法顺序必须和 C vtable 完全一致增删一个方法都会让 COM 调用崩溃。AddGroup的dwRequestedUpdateRate是请求的刷新间隔单位毫秒pActualUpdateRate是服务器实际采纳的间隔KEPServerEX V5.14 通常会拒绝过小的值。hClientGroup是客户端自定义句柄一般传 0真正的组句柄在ppAddGroup里返回。riid必须传IOPCGroupStateMgt的 GUID否则组对象无法正常创建。2.3 抽象设备层为什么要包一层 PlcDevice源码里可以看到Mesnac.Equips和Mesnac.Equip.AllenBradley这类项目名这暗示了作者把设备层和库本身拆开了。常见做法是定义一个抽象基类然后在派生类里重写标签映射和读写逻辑public abstract class PlcDevice : IDisposable { protected OpcDaGroup _group; public string DeviceName { get; set; } public abstract ReadResult Read(string logicalTag); public abstract WriteResult Write(string logicalTag, object value); public abstract void Refresh(); public virtual void Dispose() { _group?.Dispose(); } }到了 Allen-Bradley 设备只需要把逻辑标签翻译成 AB 风格的 OPC Item 路径public class AllenBradleyDevice : PlcDevice { private readonly Dictionarystring, string _tagMap; public AllenBradleyDevice(string devicePrefix, OpcDaGroup group) { _group group; _tagMap new Dictionarystring, string { [Input.RealValue] ${devicePrefix}.Input.RealValue, [Output.DINT10] ${devicePrefix}.Output.DINT10 }; } public override ReadResult Read(string logicalTag) { return _group.Read(_tagMap[logicalTag]); } }这样 UI 层只关心device.Read(Input.RealValue)不需要知道底层是 OPC Item 还是将来换成 OPC UA 节点。这也是源码里“按抽象设备统一封装”这句话的含义把最容易变化的部分隔离在设备类的实现里。2.4 阅读源码时值得注意的三个细节第一是 COM 释放顺序。OpcDaNet 的组对象里通常维护IOPCItemMgt和IOPCGroupStateMgt两个指针释放时应该先调用RemoveGroup再断开服务器否则会留下野指针重连时报RPC_E_DISCONNECTED。第二是组内 Item 数量KEPServerEX 单组超过 1000 个标签后同步读取的耗时明显上升源码里建议按 500 到 1000 拆组每个组用独立线程采集。第三是质量戳OPC 返回的数据自带 Quality 字段源码里的ReadResult有没有保留它是一个关键分水岭只看 Value 的系统在 PLC 停机时会拿到“看似正常”的冻结数据。3. KEPServerEX V5.14 下的多种 PLC 读写实战3.1 通道、设备和标签的选型与命名在 KEPServerEX V5.14 里添加设备之前要先确认驱动选型。常见的几种驱动与 PLC 对应关系如下PLC 类型KEPServerEX 驱动典型 Item 路径格式西门子 S7Siemens TCP/IP EthernetS7PLC.DB1,REAL0Allen-Bradley 系列Allen-Bradley SuiteABPLC.Input.RealValueModbus 设备Modbus TCP/IPModbusDev.MR0驱动选型决定了 Item 路径的编码规则所以不要在同一个 Device 里混用不同品牌的地址段。命名方面通道名和设备名建议只用英文字母、数字和下划线避免中文和空格否则跨语言环境的 OPC 浏览树解析容易出问题。我一般还会在通道属性里把Runtime Model设为None减少服务器内部状态存储提升采集吞吐。完成配置后最好先用 KEPServerEX 自带的 Quick Client 做一次连通性验证。Quick Client 能看到服务器端实际的 Item 路径直接复制过来放进 C# 代码最稳反之自己在记事本里拼路径很容易漏掉设备名前缀。3.2 连接 OpcServer 并创建组OpcDaNet 连接 KEPServerEX 的 ProgID 是Kepware.KEPServerEX.V5连接和建组代码如下var server new OpcDaServer(Kepware.KEPServerEX.V5); server.Connect(); var group server.CreateGroup(MainGroup, 200); group.AddItem(S7PLC.DB1,REAL0); group.AddItem(S7PLC.DB1,REAL4); group.AddItem(ABPLC.Input.RealValue); group.AddItem(ModbusDev.MR0);CreateGroup的第二个参数 200 表示请求 200ms 刷新一次但这不是硬保证。如果服务器端设备轮询间隔是 150ms实际回调周期会接近 300ms所以代码里不要假设updateRate就是数据变化周期应该以时间戳为准。AddItem返回失败时要检查两个地方一是 Item 路径是否和 Quick Client 完全一致二是该 Tag 的访问权限是否允许客户端读写。KEPServerEX 的 Tag 属性里有一个Access Rights选项默认是Read/Write被改成Read Only后Write调用会返回错误码。3.3 同步读写时的类型映射写数据比读数据容易踩坑因为 OPC DA 的写操作本质是把 COM VARIANT 写入 PLC 数据区类型必须精确匹配。OpcDaNet 的Write方法接收object它内部会按Marshal.GetNativeVariantForObject转换类型不匹配时服务器返回TYPE_MISMATCH。常见映射关系如下C# 类型OPC VT 类型典型 PLC 数据区boolVT_BOOLS7 DBX、AB BOOLshortVT_I2Modbus 保持寄存器intVT_I4AB DINT、S7 DINTfloatVT_R4S7 REALbyte[]VT_UI1字符串、AB STRING代码写成这样var result group.Write(S7PLC.DB1,REAL0, 25.6f); // float - VT_R4 group.Write(ABPLC.Output.DINT10, (int)100); // int - VT_I4 group.Write(ModbusDev.MR0, Convert.ToInt16(300)); // short - VT_I2注意 S7 的 REAL 在 C# 里对应float不是double。如果源码里写成25.6默认类型是doubleKEPServerEX 会尝试把 VT_R8 转换成 VT_R4虽然有时能成功但某些固件版本会直接报类型错误。更稳妥的做法是在封装层维护一张类型表写之前先校验请求的 C# 类型和 OPC Item 期望类型是否一致。3.4 三种取数方式怎么选OPC DA 提供同步读、异步读和订阅回调三种取数方式。同步读适合一次性快照比如启动时读取设备参数异步读适合少量点的周期轮询但并发量上来后回调线程会变成瓶颈订阅回调适合变化频繁的数据KEPServerEX 只在值变化时推送。实际项目里我不会让业务代码直接选其中一种而是封装成ReadValue、BatchRead和EventChanged三组接口。这样后续如果从 OPC DA 迁移到 OPC UA只需要替换设备层实现UI 和报表层完全不动。一个容易忽略的点是同步读不能在 UI 线程里直接调用。OPC DA 的 COM 调用会进入消息泵在 WinForms 主线程里长时间阻塞会让界面假死而且 DCOM 断开时可能抛出未处理异常。所以我一般在后台线程里做采集再通过事件或者快照把数据抛给 UI这也是下一章要展开的部分。4. 循环采集卡顿与 UI 刷新的多线程处理方案4.1 在 UI 线程里循环读取的错误示范很多人第一次做 PLC 数据展示时会在按钮点击事件里写一个 while 循环private void BtnStart_Click(object sender, EventArgs e) { while (true) { var value group.Read(S7PLC.DB1,REAL0); txtValue.Text value.ToString(); Thread.Sleep(50); } }这段代码把 UI 线程完全占死按钮事件永远不返回窗口拖动和点击全部卡住Thread.Sleep(50)又让采集周期严重漂移。更糟的是txtValue.Text的赋值会引起控件重绘在循环里每次赋值都触发一次布局导致刷新一卡再卡。这就是 C# 上位机里“循环数据采集和 UI 刷新卡顿”最常见的出身。4.2 用批量读取和快照解耦采集与显示正确的做法是把采集循环放到后台线程并且用批量读取替代逐项读取。OpcDaNet 组对象里通常会有一个BatchRead方法它接收一组 OPC Item 路径通过IOPCSyncIO一次性读取整组数据只发起一次 COM 调用。我在源码上扩展了快照机制private readonly Dictionarystring, object _snapshot new(); private readonly object _snapshotLock new(); private void PollLoop(CancellationToken token) { while (!token.IsCancellationRequested) { var values _group.BatchRead(_opcTagList); lock (_snapshotLock) { foreach (var item in values) { _snapshot[item.Key] item.Value.Value; _quality[item.Key] item.Value.Quality; } } token.WaitHandle.WaitOne(100); } }BatchRead返回的字典里每个元素都带有Value、Quality和Timestamp我在快照里把质量和数据同时存下来这样 UI 侧不仅能显示数值还能区分正常值、故障值和冻结值。锁的作用是保证 UI 线程读取快照时不会看到写了一半的字典锁粒度很小不会影响采集性能。4.3 节流刷新与 BeginInvoke 的正确用法拿到快照后不能每轮都无脑刷新 UI。我把刷新循环独立成一个定时任务按 20 帧的节奏取快照private void RefreshUiLoop(CancellationToken token) { var sw Stopwatch.StartNew(); double refreshIntervalMs 50; while (!token.IsCancellationRequested) { if (sw.ElapsedMilliseconds refreshIntervalMs) { Dictionarystring, object copy; lock (_snapshotLock) { copy new Dictionarystring, object(_snapshot); } txtValue.BeginInvoke(() DisplayValues(copy)); sw.Restart(); } token.WaitHandle.WaitOne(5); } }BeginInvoke是异步投递采集线程不会等 UI 处理完。即使 UI 线程因为布局而慢了 100ms采集线程也只会丢弃这次显示不会阻塞 PLC 数据读取。这里的refreshIntervalMs不是越小越好如果你用 DataGridView 展示几百个标签50ms 刷新依然会卡需要降到 100ms或者只刷新当前可见单元格。4.4 一次实测里的性能对比采集方案100 个标签单轮耗时UI 表现UI 线程逐项 Read 直接赋值约 500 到 800ms窗口假死后台线程逐项 Read 每次 Invoke约 120 到 200ms卡顿明显后台线程 BatchRead 节流刷新约 20 到 40ms流畅这里的耗时是我在虚拟机里跑 KEPServerEX V5.14 模拟器得到的相对量级真实 PLC 上会受网络和设备轮询周期影响。但结论是稳定的批量读取比逐项读取快一个数量级节流刷新比每次 Invoke 快一个数量级。如果采集点数超过 500还可以把标签拆成多个 OPC Group每个组跑一个后台线程最后在快照层合并避免单组数据量过大拖累 COM 调用。5. 多 PLC 型号切换的配置化封装与质量校验5.1 用 JSON 配置驱动设备切换设备抽象封装的最大收益是切换 PLC 型号不用改 UI 代码。我用 JSON 配置设备映射运行时根据设备类型创建对应的 PlcDevice 实例{ Devices: [ { Name: PLC1, Type: SiemensS7Device, OpcServer: Kepware.KEPServerEX.V5, GroupName: S7Group, TagMap: { Temperature: S7PLC.DB1,REAL0, Status: S7PLC.DBX0.0 } }, { Name: PLC2, Type: AllenBradleyDevice, OpcServer: Kepware.KEPServerEX.V5, GroupName: ABGroup, TagMap: { Temperature: ABPLC.Input.RealValue, Status: ABPLC.Input.DINTStatus } } ] }启动时用Type.GetType(Namespace. config.Type)反射创建设备实例再把 TagMap 注入构造函数。这里有个边界要留心不同驱动下 Item 路径的分隔符习惯完全不同西门子用逗号表示数据块偏移AB 用点访问结构体成员Modbus 直接写寄存器地址。一旦Type选错路径解析会莫名其妙地失败所以我在配置里强制要求Type和 TagMap 的路径风格必须匹配并在加载时做一次 Quick Client 风格的路径预检。5.2 读回数据先看质量再看数值OPC DA 的数据质量是判断读写是否成功的关键。Quality 值为 192 表示 Good64 表示 Bad0 表示通信失败。我只在ReadResult里保留质量并且要求 UI 绑定之前先过滤public class ReadResult { public bool IsGood Quality 192; public int Quality { get; set; } public object Value { get; set; } public DateTime Timestamp { get; set; } }一个实测技巧当 PLC 停机或网络中断时KEPServerEX 往往还能返回 Item 数据但 Quality 会掉到 64 或 0而且 Value 停留在最后一次正常值。如果页面只看数值不看质量就会出现“采集正常数据还是昨天”的假象。我把质量一并写进日志每次采集周期都检查质量分布一旦出现连续 Bad立即触发报警事件。5.3 释放 OpcDaNet COM 对象的正确顺序最后分享一个在源码基础上修正最多的点COM 释放顺序必须严格遵循“先删组、再断服务器”。如果先断开服务器组对象的 COM 指针会变成野指针下次重连时几乎所有调用都会抛RPC_E_DISCONNECTED。设备层释放代码这样写最稳public void Dispose() { _group?.Dispose(); _server?.Disconnect(); _server null; }另外OpcDaNet 的Read和Write返回的非基础类型数组比如byte[]要立即复制到托管堆因为 COM 接口返回的数组可能在引用计数释放后被回收。我习惯在设备层就做value.ToArray()这样上层拿到的永远是稳定对象。换采样频率时只需要重新计算refreshIntervalMs不需要动任何 COM 调用代码。本文还有配套的精品资源点击获取