简介: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 Dictionary<string, string> _tagMap; public AllenBradleyDevice(string devicePrefix, OpcDaGroup group) { _group = group; _tagMap = new Dictionary<string, 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 路径格式 |
|---|---|---|
| 西门子 S7 | Siemens TCP/IP Ethernet | S7PLC.DB1,REAL0 |
| Allen-Bradley 系列 | Allen-Bradley Suite | ABPLC.Input.RealValue |
| Modbus 设备 | Modbus TCP/IP | ModbusDev.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 数据区 |
|---|---|---|
| bool | VT_BOOL | S7 DBX、AB BOOL |
| short | VT_I2 | Modbus 保持寄存器 |
| int | VT_I4 | AB DINT、S7 DINT |
| float | VT_R4 | S7 REAL |
| byte[] | 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,默认类型是double,KEPServerEX 会尝试把 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 Dictionary<string, 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) { Dictionary<string, object> copy; lock (_snapshotLock) { copy = new Dictionary<string, 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 表示 Good,64 表示 Bad,0 表示通信失败。我只在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 调用代码。
本文还有配套的精品资源,点击获取