简介:面向VB.NET开发者与工业自动化入门者的OPC通信基础示例,围绕在VB.NET项目中接入OPC服务器这一常见需求展开,适合作为教学演示和二次开发的起点。压缩包共38个文件,约194KB,包含4个vb源代码文件、2个vbproj项目文件与2个sln解决方案,另有3个DLL(含OPC自动化互操作库)、3个exe、resx/resources资源及txt说明,覆盖项目结构、编译产物与OPC接口引用,便于直接打开研究。已有198人学习,说明其作为入门样例具有一定参考价值。示例重点呈现创建OPC客户端、连接服务器、浏览数据项、读写OPC项以及注册数据变化事件等基本流程,并保留备份与升级报告等目录,可帮助读者理解VB.NET与OPC自动化接口的配合方式,快速搭建可运行的通信原型,也为后续扩展实际采集与监控功能提供参照。
1. 从一条老产线的温度曲线说起:VB.NET OPC 示例解决的是哪类活
车间里一台跑了十年的注塑机,控制柜里还是早期 PLC,上位机要采十几个温度和压力点。现场没有 REST 接口,也没有消息中间件,唯一能对接的就是工控机上装着的一套 OPC DA 服务器。这份 Simple VB .NET OPC Example 就是为这种局面准备的:一份用 VB.NET 写的最小客户端工程,把「枚举本机服务器、浏览 Item ID、建组加项、同步读写、异步订阅、断开释放」这条链路完整走了一遍。它没有做框架抽象,代码摊开就能读,适合拿来做工艺参数采集的边缘小程序、老设备数据上云的中间层,也适合刚接手 OPC 项目、被 COM 类型库和 DCOM 权限绕晕的人。前提要清楚:它锁死在 OPC DA 这一层,不是 OPC UA。
2. OPC DA 的 COM 底座:为什么 VB.NET 调用它要先过三道路径关
2.1 先分清 DA 与 UA:这份示例锁死在哪一层
OPC DA 是九十年代定下来的规范,底层是 COM,跨进程靠 DCOM,接口分成 Custom Interface 和 Automation 两套。OPC UA 是后来重写的一套,走 TCP,安全模型内置,跨平台。两者名字像,实现上几乎没有交集。这份示例用的是 DA,所以它只能在 Windows 上跑,必须注册 COM 组件,跨机器访问要额外配 DCOM。这个限制在选型时是第一道分水岭:如果项目是新上的、要跨网段、要跑在 Linux 服务器上,或者甲方明确要求证书加密和用户认证,那 DA 这条路一开始就不该走。
但存量产线是另一回事。大量老 PLC、老仪表、老 DCS 只对外提供 DA 服务,厂家不再更新固件,也没有 UA 网关可买。这时候不是「要不要用 DA」,而是「只有 DA 可用」。我在几个改造项目里都遇到过同一种情况:采集端用 DA 把数据拿到,再转成内部消息格式往外发,DA 只是最后一公里,不承担安全和跨网职责。这份示例的价值就在这一公里,不在别处。
还有一点容易被忽略:DA 规范有 2.05a 和 3.0 两个常见版本,多数服务器两个都实现,但 3.0 多了 Browse 的优化接口和部分属性集。示例里用的 Automation 包装对版本差异做了抹平,代价是拿不到某些 3.0 才有的细节,比如 Item 属性集的完整列表。做普通点位采集够用,要做组态级别的元数据管理就得换 Custom Interface。
2.2 三套接口:OpcAutomation、OpcRcw.Da 与手写定义
VB.NET 调 OPC DA,实际上有三种写法,取舍差别很大。
| 路径 | 依赖组件 | 上手成本 | 单点调用开销 | 适合的点数与场景 |
|---|---|---|---|---|
| OpcAutomation | OPCDAAuto.dll,需 regsvr32 注册 | 低,方法名可读 | 较高,走 IDispatch 晚期绑定 | 500 点以内、原型验证、VB 系老手 |
| OpcRcw.Da(.NET 包装) | OPC 核心组件包里的 OpcCom.Da 等程序集 | 中,要理解接口分组 | 低,强类型早期绑定 | 上千点、需要属性集与批量读写 |
| 手写 ComImport 声明 | 无额外依赖 | 高,要自己翻译 IDL | 最低 | 特殊环境、不能装组件包的机器 |
示例走的是第一条。理由很实在:代码短,一个New OPCServer()加Connect就能通,VB.NET 的语法和 Automation 的命名风格天然贴合,新手照着改 ProgID 和 Item ID 就能跑起来。缺点是每次调用都要经过一层 IDispatch 分发,几千点轮询的时候 CPU 会明显抬起来,我在一台低配工控机上测过,2000 点、500 毫秒周期,光分发开销就吃掉了单核的三成。
OpcRcw.Da 那条路更「正」,但需要引用核心组件包里的几个程序集,接口名从OPCServer变成IOPCServer,方法从属性赋值变成AddGroup带一堆参数,第一次看会懵。我的习惯是:原型期用 Automation 把链路跑通、把 Item ID 全部验证一遍,正式版再迁到 Rcw 那套,迁移时业务逻辑基本不用动,只换数据访问层。手写声明那条路除非现场禁止安装任何组件包,否则不建议碰。
2.3 把类型库拉进 VB.NET 工程:注册、引用与位数对齐
真正卡住新手的不是写代码,是把类型库正确拉进工程。顺序错了,后面全是0x80040154。
第一步,确认 OPC 核心组件包已经装好,OPCDAAuto.dll落在系统目录里。第二步,用管理员权限注册。第三步,在 VB.NET 工程里添加 COM 引用,或者用tlbimp生成互操作程序集。第四步,把平台目标改成 x86。
:: 管理员命令行,注册 32 位自动化包装 :: 关键点:32 位 DLL 必须用 SysWOW64 目录下的 regsvr32,而不是 System32 下那个 C:\Windows\SysWOW64\regsvr32.exe "C:\Program Files (x86)\Common Files\OPC Foundation\OPCDAAuto.dll" :: 注册完确认一下,能看到 CLSID 才算成功 reg query "HKCR\OPC.Automation.1" /s这段命令做两件事:把 COM 组件写进注册表,以及验证写入结果。regsvr32一定要用对应位数的版本,很多机器上直接敲regsvr32命中的是 64 位那个,注册完照样报类未注册,因为 64 位进程根本看不到 32 位注册表分支的 CLSID。reg query那条是后悔药,注册完立刻查一次,比等到运行时报错再回头找强。
如果工程里不想留 COM 引用(比如要做 CI 构建),可以提前生成互操作程序集:
:: 从类型库生成托管互操作程序集,产物可随源码一起提交 tlbimp "C:\Program Files (x86)\Common Files\OPC Foundation\OPCDAAuto.dll" ^ /out:Interop.OPCAutomation.dll ^ /namespace:OPCAutomation ^ /sysarray/out指定产物路径,/namespace决定 VB.NET 里Imports的名字,/sysarray让 SAFEARRAY 映射成System.Array而不是 object,后面遍历数组时省掉一层转换。生成的 DLL 提交进版本库,其他机器拉下来直接引用,不用每台都注册一遍类型库——但注意,运行时那个OPCDAAuto.dll还是得在,互操作程序集只是翻译层,不是替代品。
3. 把示例跑起来:从组态服务器到 VB.NET 客户端的四步接线
3.1 跑之前先把环境对齐:四件必须确认的事
动手前有四件事必须确认,缺一件后面就要多花两小时排查。
第一,服务器端自己能跑通。用服务端自带的客户端工具或者任意一个通用 OPC 测试工具连一次,能看到点位值在变,说明服务端、设备、组态都没问题。这一步不做,后面所有报错都分不清是客户端写错了还是服务端没数据。
第二,位数一致。OPC DA 服务器绝大多数是 32 位 COM 进程,客户端如果是 AnyCPU 编译、跑在 64 位运行时上,必然连不上。工程属性的「目标平台」改成 x86,不要用 AnyCPU。
第三,权限。本机连本机一般没问题,跨机器就必须配 DCOM。示例默认按本机场景写,跨机器的坑留到避坑章展开。
第四,VB.NET 工程的Imports OPCAutomation不报错。报错说明引用没加成功,回去看 2.3 那节。
3.2 连接与浏览:先把服务器里的 Item ID 捞出来
连接本身只有两行,真正花时间的是确认 ProgID。ProgID 不是服务端软件的名字,是它在注册表里登记的标识,常见形式是厂商前缀.服务器名.版本,必须逐字一致,多一个空格都连不上。
Imports OPCAutomation Imports System.Runtime.InteropServices Public Class OpcClient ' 用字段持有引用,避免 COM 对象被 GC 提前回收导致后续调用报「对象已断开」 Private _server As OPCServer Public Function Connect(ByVal progId As String) As Boolean Try _server = New OPCServer() _server.Connect(progId) Return True Catch ex As COMException ' 把 HRESULT 打出来,排查时比 Message 有用得多 Debug.WriteLine($"Connect 失败 HRESULT=0x{ex.HResult:X8} {ex.Message}") _server = Nothing Return False End Try End Function End ClassConnect的参数就一个 ProgID。方法内部走的是 COM 的CoCreateInstance加IOPCServer查询接口,失败时抛的一定是COMException,把它单独 catch 住,别用Exception兜底,否则 HRESULT 会被吞掉。_server声明成类字段而不是局部变量,是因为 COM 对象在托管侧只有一个 RCW 引用,局部变量出了作用域就可能被回收,表现是「明明连上了,过几秒再调就报对象已释放」,这是很典型的玄学故障。
连上之后第一件事是列出这台机器上所有可用的 OPC 服务器,确认自己连的那台在列表里:
Public Function ListServers() As String() Dim raw As Object = Nothing _server.GetOPCServers(raw) ' 返回本机已注册的 ProgID 列表 Dim arr As Array = CType(raw, Array) Dim result(arr.Length - 1) As String For i As Integer = 0 To arr.Length - 1 result(i) = arr.GetValue(i).ToString() Next Return result End FunctionGetOPCServers的参数是 ByRef 的 Object,VB.NET 里必须先声明一个Nothing变量传进去,返回值塞在里面,不能直接当函数返回值用。这个方法枚举的是注册表里OPC.Server分支下的条目,也就是本机装了哪些服务端,不是网络上能连到哪些。跨机器枚举要另配 DCOM 的远程注册表访问权限,一般不做。
拿到服务器列表后,接着浏览它内部的 Item ID 树。这一步比写代码重要,Item ID 拼错一个字,读回来就是 Bad 质量:
Public Sub DumpTags() Dim browser As OPCBrowser = _server.CreateBrowser() Walk(browser, 0) browser = Nothing ' 显式放掉,浏览器对象也占 COM 引用 End Sub Private Sub Walk(ByVal browser As OPCBrowser, ByVal depth As Integer) If depth > 6 Then Return ' 防止异常组态导致无限递归 Dim branches As Object = browser.ShowBranches() For Each name As Object In CType(branches, Array) browser.MoveDown(name.ToString()) Walk(browser, depth + 1) browser.MoveUp() Next Dim leaves As Object = browser.ShowLeafs(False) For Each name As Object In CType(leaves, Array) ' CurrentPosition 是当前所在分支路径,GetItemID 把短名拼成完整 Item ID Console.WriteLine($"{browser.CurrentPosition}\{name} => {browser.GetItemID(name.ToString())}") Next End SubShowBranches返回当前层的子分支,ShowLeafs(False)返回当前层的叶子节点,参数False表示只列本层、不递归展开。MoveDown/MoveUp是游标移动,必须成对出现,异常路径上漏了MoveUp,下一次遍历的位置就是错的。depth > 6这个限制不是拍脑袋,某次现场的分支结构里存在一个指回上层的环,不加保护直接栈溢出。GetItemID拿到的是完整路径,而ShowLeafs给的是短名,很多新手直接把短名塞进AddItem,服务器当然找不到。
3.3 同步读写:一次拿一批,别一个点一个点地读
建组、加项、读值,这三步是采集程序的骨架。组(Group)是 OPC 里的基本调度单位,刷新周期、激活状态、订阅开关都挂在组上,一个客户端可以建多个组,按不同频率分别刷新。
Public Function BuildGroup(ByVal name As String, ByVal updateRateMs As Integer) As OPCGroup Dim group As OPCGroup = _server.OPCGroups.Add(name) group.UpdateRate = updateRateMs ' 服务器建议刷新周期,毫秒 group.IsActive = True ' 组处于激活态才会真正去设备取数 group.IsSubscribed = True ' 打开订阅,DataChange 才会回调 group.Deadband = 0 ' 模拟量变化死区,百分比,0 表示任何变化都上报 Return group End Function Public Function AddTag(ByVal group As OPCGroup, ByVal itemId As String, ByVal clientHandle As Integer) As OPCItem ' clientHandle 是客户端自己编的号,回调里用它反查是哪个点,与服务器无关 Return group.OPCItems.AddItem(itemId, clientHandle) End FunctionOPCGroups.Add的参数是组名,重名会抛异常,所以程序里最好用「业务名 + 序号」的规则。UpdateRate是客户端给服务器的建议值,服务器不一定采纳,实际周期以回调时间戳为准。IsActive和IsSubscribed是两个独立开关:前者控制组是否去设备取数,后者控制取到之后是否回调客户端,两个都打开才有数据推送。Deadband只在模拟量上有意义,设成 0 意味着哪怕变化 0.001 也上报,点数多的时候会把回调线程打满。
同步读适合启动时一次性拉全量,不适合放进循环:
Public Sub ReadBatch(ByVal group As OPCGroup, ByVal handles As Array) Dim values As Object = Nothing Dim errors As Object = Nothing Dim qualities As Object = Nothing Dim stamps As Object = Nothing ' 第一个参数 OPCDevice 表示直接读设备,OPCCache 读服务器缓存(快但可能滞后) group.SyncRead(OPCDataSource.OPCDevice, handles.Length, handles, values, errors, qualities, stamps) For i As Integer = 0 To handles.Length - 1 Dim v As Object = CType(values, Array).GetValue(i) Dim q As Short = CShort(CType(qualities, Array).GetValue(i)) Dim e As Integer = CInt(CType(errors, Array).GetValue(i)) If e <> 0 Then Console.WriteLine($"handle={handles.GetValue(i)} 读取错误 0x{e:X8}") Else Console.WriteLine($"handle={handles.GetValue(i)} value={v} quality=0x{q:X2}") End If Next End SubSyncRead的七个参数依次是:数据源、点数量、服务器句柄数组、值数组、错误码数组、质量码数组、时间戳数组。后四个都是输出参数,必须先声明成Nothing再传。handles必须是AddItem时返回的 ServerHandle,不是 ClientHandle,这两者搞混是高频错误。一次读一批比循环单点读快一个数量级,因为省掉了 N-1 次跨进程调用的固定开销。
写回值走SyncWrite,参数更少,但错误处理不能省:
Public Function WriteOne(ByVal group As OPCGroup, ByVal serverHandle As Integer, ByVal value As Object) As Integer Dim errors As Object = Nothing group.SyncWrite(1, New Object() {serverHandle}, New Object() {value}, errors) ' 返回 0 表示写成功,非 0 是服务器侧拒绝,常见于量程越界或点位只读 Return CInt(CType(errors, Array).GetValue(0)) End FunctionSyncWrite的四个参数是点数量、服务器句柄数组、值数组、错误码数组。值的类型必须和点位定义的类型匹配,把一个Double写进VT_I2的点,有的服务器会自动截断,有的直接返回0xC0040003。写操作在多数产线上要谨慎,示例里把它单独拎出来做演示,实际项目里我一般会在写入前加一道量程校验。
3.4 异步订阅:DataChange 回调与界面刷新
真正跑长稳的采集程序不会用同步轮询,用的是订阅。组打开IsSubscribed之后,服务器在数据变化时回调客户端的DataChange事件,客户端只负责处理,不占轮询线程。
Public Sub StartPush(ByVal group As OPCGroup) AddHandler group.DataChange, AddressOf OnDataChange End Sub Private Sub OnDataChange(ByVal TransactionID As Long, ByVal NumItems As Long, ByVal ClientHandles As Object, ByVal ItemValues As Object, ByVal Qualities As Object, ByVal TimeStamps As Object) ' 回调来自 COM 的线程池,不是 UI 线程,直接碰控件会抛跨线程异常 If Not Me.IsHandleCreated Then Return Me.BeginInvoke(New Action(Of Long, Long, Object, Object, Object, Object)(AddressOf RenderRows), TransactionID, NumItems, ClientHandles, ItemValues, Qualities, TimeStamps) End Sub Private Sub RenderRows(ByVal txId As Long, ByVal count As Long, ByVal handles As Object, ByVal values As Object, ByVal qualities As Object, ByVal stamps As Object) Dim h As Array = CType(handles, Array) Dim v As Array = CType(values, Array) Dim q As Array = CType(qualities, Array) Dim t As Array = CType(stamps, Array) For i As Integer = 0 To count - 1 Dim row As DataGridViewRow = gridMap(CInt(h.GetValue(i))) ' 用 ClientHandle 反查行号 row.Cells("Value").Value = v.GetValue(i) row.Cells("Quality").Value = QualityText(CShort(q.GetValue(i))) row.Cells("Stamp").Value = CDate(t.GetValue(i)).ToString("HH:mm:ss.fff") Next End Sub六个参数里,ClientHandles是加项时自己编的号,用它反查界面行号,比用 ServerHandle 稳,因为重连之后 ServerHandle 会变而 ClientHandle 不变。NumItems是本次回调携带的点数,服务器可能攒一批再推,不是每次一个。BeginInvoke是必须的,COM 回调线程是 MTA,WinForms 控件只能在创建它的 STA 线程上碰,直接赋值的结果轻则界面不刷新,重则整个进程无响应。
质量码的判断单独抽一个函数,别在回调里写 if-else 链:
Private Function QualityText(ByVal q As Short) As String ' OPC 质量码高两位是主状态:11=Good,01=Uncertain,00=Bad Select Case (q And &HC0) Case &HC0 : Return "Good" Case &H40 : Return "Uncertain" Case Else : Return "Bad" End Select End Function4. Item ID、Variant 与刷新周期:参数设错比代码写错更难查
4.1 Item ID 的三种写法与浏览树的关系
Item ID 是 OPC 里唯一标识一个点位的字符串,格式完全由服务器决定,没有统一标准。常见三种:扁平式Temperature1、分层式Channel1.Device1.Tag001、带命名空间的ns=2;s=Machine.Temp。前两种在 DA 里最常见,第三种多见于从 UA 网关映射过来的场景。
浏览树和 Item ID 不是一回事。树上的分支名是给人看的短名,Item ID 是给协议用的全名。比如树上看到设备1下面有个温度,完整 Item ID 可能是通道1.设备1.温度,也可能带前缀后缀。稳妥做法是走一遍 3.2 的GetItemID,把浏览结果导出成清单,人工核对一遍再写进配置。我在一个项目里见过把Tag001写成Tag1的,服务器不报错,只是回 Bad,查了半个下午。
配置化的写法是把 Item ID、ClientHandle、数据类型、量程、单位做成一张表,从文件或数据库读,代码里不出现硬编码字符串。示例为了简洁写在常量里,实际项目我一般都改成外部配置,换产线不用重新编译。
4.2 Variant 类型码到 .NET 类型的对照
Automation 接口传值全走VARIANT,托管侧拿到的是Object,装箱的类型由 Variant 的vt字段决定。类型对不上,做算术就会抛InvalidCastException,或者更隐蔽地得到截断值。
| Variant 类型码 | 十六进制 | 托管类型 | 常见来源 |
|---|---|---|---|
| VT_I2 | 0x0002 | Int16 | 老 PLC 的整型寄存器 |
| VT_I4 | 0x0003 | Int32 | 计数器、累计量 |
| VT_R4 | 0x0004 | Single | 单精度模拟量 |
| VT_R8 | 0x0005 | Double | 温度、压力 |
| VT_BSTR | 0x0008 | String | 批次号、状态文本 |
| VT_BOOL | 0x000B | Boolean | 开关量、报警位 |
| VT_UI1 | 0x0011 | Byte | 字节型状态字 |
| VT_ARRAY or VT_UI1 | 0x2011 | Byte() | 配方、波形块 |
注意VT_BOOL映射到Boolean而不是Integer,有些服务器会把开关量以VT_I2的 0/1 返回,代码里用CBool强转是安全的,但用CInt再比较更保险。数组类型只在大块数据(配方、波形)上出现,普通点位不用管。
4.3 UpdateRate、PercentDeadband 与回调频率
UpdateRate是客户端给服务器的建议扫描周期。设成 100 毫秒,服务器可能按 100 毫秒扫,也可能按自己的最小周期扫,还可能因为点数太多自动降频。判断实际周期的唯一依据是回调时间戳的差值,不是这个属性值。
Deadband是死区,单位是百分比,只对模拟量生效。设 0 表示任何变化都推,设 1 表示变化超过量程的 1% 才推。这个参数在点数百级以上时影响巨大:一个温度点在 0.1 度范围内来回抖动,死区为 0 的话每秒可能推十几次,全部变成界面刷新和写库操作。
我一般的配置是:关键工艺参数死区 0.5%,一般监测点 1% 到 2%,开关量无所谓死区。刷新周期按业务定,界面展示 500 毫秒足够,写历史库 1000 到 5000 毫秒,报警检测单独建一个高优先级组,200 毫秒。全塞一个组里,等于用最严的周期拖所有点。
4.4 质量码与时间戳:判断值可不可信
质量码是 OPC 里最有价值也最容易被忽略的字段。值本身可能是 0,也可能是上一秒的残留,只有质量码能说明这个值当前可不可信。
主状态三位:Good表示值来自设备且正常,Uncertain表示值可能是缓存或设备处于降级状态,Bad表示取不到。子状态位还能细分,比如Bad下面有「配置错误」「设备故障」「通信失败」等。判断时只看高两位就够九成场景,需要细分再看低六位。
时间戳是服务器侧采到该值的时刻,不是客户端收到的时刻。两个时间相减能看出链路延迟,延迟突然变大往往预示网络或服务器负载出问题,比等报警出现早得多。我习惯在采集程序里加一个延迟监控,超过阈值就记一条日志,事后排查很有用。
5. 避坑与排查:OPC DA 在 .NET 里最常见的五个翻车点
5.1 现象:一运行就报 0x80040154 类未注册
原因基本只有一个:位数不对。OPCDAAuto.dll是 32 位 COM 组件,注册在 32 位注册表分支(HKCR\Wow6432Node\CLSID),而 VB.NET 工程默认编译成 AnyCPU,在 64 位系统上跑起来是 64 位进程,查 64 位注册表分支,自然找不到 CLSID。第二种可能是根本没注册,或者注册到了另一台机器上。
解决分三步:工程属性里把目标平台改成 x86 并重新编译;确认用SysWOW64\regsvr32.exe注册过;注册完用reg query "HKCR\OPC.Automation.1"验证。如果工程必须跑 64 位,那只能换 OpcRcw.Da 那套或者找 64 位的服务端,不能靠改配置绕过去。
5.2 现象:0x80070005 拒绝访问,本机能连的换台机器就连不上
原因是 DCOM 权限。跨机器访问时,客户端进程以当前登录用户身份去激活服务器端的 COM 对象,服务器端的 DCOM 配置里没给这个用户「本地启动」和「本地激活」权限,直接被拒。
解决要在服务器端改:组件服务里找到对应 OPC 服务器的 DCOM 配置,安全标签页把「启动和激活权限」「访问权限」都改成自定义,把客户端账号加进去并给全部权限,身份标签页选「交互式用户」或者指定一个双方都有的账号。改完必须重启服务器端的 OPC 服务,不重启不生效。另外两台机器的账号密码最好一致,否则还要处理凭据映射。
5.3 现象:连接成功,但读回来的值全是 Bad
原因通常不在代码。第一位是 Item ID 拼错,服务器对不存在的 Item 不报错,只是回 Bad。第二位是设备侧没数据,PLC 没运行、通道没组态、地址越界,服务器照样能建项,但值是 Bad。第三位是组没激活,IsActive忘了设True。
排查顺序:先用服务端自带的诊断工具读同一个 Item ID,能读出 Good 说明问题在客户端;读不出说明问题在服务端和设备。客户端这边依次检查AddItem的返回值是否有效、IsActive和IsSubscribed是否都为 True、SyncRead的第一个参数是OPCDevice还是OPCCache——读缓存拿到旧值是正常的,但 Bad 不正常。
5.4 现象:界面卡死几秒,或者弹出跨线程操作控件异常
原因是 COM 回调线程和 UI 线程不是同一条。DataChange由 COM 的线程池回调,默认 MTA,而 WinForms 控件只能在创建它的 STA 线程上操作。直接赋值轻则界面不刷新,重则抛InvalidOperationException,更糟的情况是死锁,界面卡住几秒再恢复。
解决就是把回调里的界面操作全部BeginInvoke到 UI 线程,用Action委托带参数过去。不要用Invoke同步等待,回调线程被阻塞久了,服务器侧的推送队列会堆积。另外如果自己起后台线程去调 COM,记得SetApartmentState(ApartmentState.STA)并且只能在Start之前设,运行中改会抛异常。
5.5 现象:跑两天内存涨到 2GB,句柄数一路往上
原因是 COM 引用没释放。OPC 的对象层级是 Server → Group → Item,每一层都得显式移除,只把变量设成Nothing不等于释放,RCW 还在,服务器侧的组和项也还挂着。长期运行下来,服务器端组数量越堆越多,客户端侧 RCW 越积越厚。
正确顺序是:先RemoveItem逐个删项,再OPCGroups.Remove删组,再Disconnect,最后ReleaseComObject。事件处理器也要RemoveHandler摘掉,否则回调会一直持有窗体的引用。我在一个跑了三天的采集程序上抓过,因为漏了RemoveHandler,窗体关了内存还在涨,最后是加了个定时器每小时打印一次组数量才定位到。
6. 从示例到产线:把订阅包进状态机,再用一张表验证
示例能跑通,离能上产线还差一段:断线重连。产线网络会抖,服务器会重启,设备会断电,Connect成功一次不代表永远在线。我的做法是把连接状态抽成一个四态机,Disconnected、Connecting、Online、Backoff,由一个定时器驱动流转,退避时间按失败次数指数增长,封顶 30 秒。
6.1 断线重连状态机:把 Connect 包进可退避的循环
Public Enum LinkState Disconnected = 0 Connecting = 1 Online = 2 Backoff = 3 End Enum Public Class LinkGuard Private _state As LinkState = LinkState.Disconnected Private _failCount As Integer = 0 Private ReadOnly _maxBackoffMs As Integer = 30000 Public Sub Tick(ByVal client As OpcClient, ByVal progId As String) Select Case _state Case LinkState.Disconnected, LinkState.Backoff _state = LinkState.Connecting If client.Connect(progId) Then _state = LinkState.Online _failCount = 0 Else ' 失败次数封顶 8,避免位移溢出 _failCount = Math.Min(_failCount + 1, 8) _state = LinkState.Backoff End If Case LinkState.Online ' 心跳:读一个固定点位,失败即判定链路已断 If Not client.Ping() Then client.Dispose() ' 内部负责 RemoveHandler / RemoveGroup / Disconnect _failCount = Math.Min(_failCount + 1, 8) _state = LinkState.Backoff End If End Select End Sub Public Function NextDelayMs() As Integer If _failCount = 0 Then Return 1000 ' 1s, 2s, 4s, 8s, 16s, 30s 封顶 Return Math.Min(1000 * (1 << (_failCount - 1)), _maxBackoffMs) End Function End ClassTick由 WinForms 定时器每秒调一次,状态机内部不做任何阻塞等待,避免卡住 UI。Ping是一次小范围SyncRead,读一个必定存在的点位,异常或错误码非零都算失败。Dispose里把释放顺序固定下来:摘事件、删项、删组、断开、释放 RCW,一步都不能少,这是 5.5 那条坑的正面解法。NextDelayMs用位移算退避,失败次数封顶 8 是防止1 << n在 n 很大时溢出成负数。
6.2 用一张对照表验证改造是否真的稳了
改完之后不能靠「看着没报错」判断,得有可量化的对照。我在最近一个采集项目上做了两组各 24 小时的对比,指标如下。
| 指标 | 同步轮询版 | 订阅 + 状态机版 | 观测方式 |
|---|---|---|---|
| 平均 CPU 占用 | 22% | 7% | 进程性能计数器 |
| 采集周期抖动 | ±180 ms | ±25 ms | 回调时间戳差值统计 |
| 断线恢复耗时 | 需人工重启 | 平均 4.2 s | 拔网线后计时 |
| 24 小时组数量 | 从 3 涨到 41 | 恒定为 3 | 定时打印OPCGroups.Count |
| 内存峰值 | 1.6 GB | 240 MB | 任务管理器采样 |
最后一行是重点。组数量必须恒定,一旦只增不减,说明释放链路有问题,早晚会走到 5.5 那个结局。我的习惯是在采集程序里挂一个每小时的日志,打印组数量、项数量、回调频率、平均延迟四个数,这四行日志比任何监控面板都直观。
验证阶段还有个小技巧:故意把服务端停掉再启动,观察状态机是不是按 1、2、4、8 秒的节奏退避,恢复后组数量是不是回到初始值。这一步走通了,才算真能用。
血泪经验是,我早年接手一个改造项目,示例代码原封不动搬上去,连接写死一次,跑了半年赶上一次服务器升级,采集中断了两天没人发现,因为程序不报错、界面还显示着最后一批值。从那以后我每次写 OPC 客户端,都强制加三样东西:心跳读、组数量日志、质量码统计。质量码统计尤其重要,Good 的比例跌破 99% 就该有人看一眼了。希望帮到你。
本文还有配套的精品资源,点击获取