简介:OPCClientToolKit 是一套面向 C/C++ 工业自动化开发者的 OPC DA 客户端静态库源码,适合在 Windows 环境下实现本地或远程 OPC 数据访问,也适合希望理解底层通信封装与对象模型的中高级开发者参考,也可用于上位机数据采集联调与协议实现学习。压缩包共 32 个文件,约 33KB,以 14 个 .h 头文件和 10 个 .cpp 源文件为主体,另有 3 个 .c 接口定义、txt 说明文档及 vcxproj/vcproj 工程文件;头文件负责接口与数据结构声明,源文件承载客户端、服务器、组、项、事务、属性、线程池等模块实现,工程文件便于直接组织编译,整体体积较小,便于快速查阅。代码在上游 GitHub 版本基础上整理,针对远程连接不通和调试遗留的异常机制做了清理,对日志依赖也保留可注释后编译的空间。已有 281 人学习,可作为排查远程连接问题、梳理 OPC DA 对象层次与裁剪轻量客户端原型的参考代码。
1. 我在 64 位工程里对着一台老服务器连了一晚上
接手一个老产线的数据采集改造,上位机是十年前的 VB6,服务器端只认 OPC DA。要从 C++ 侧读写这些点位,最省事的做法是拿 OPCClientToolKit 这个 C/C++ 库套一层,它把 IOPCServer、IOPCGroupStateMgt、IOPCSyncIO 这一串 COM 接口封成了几个类,本地连接和远程连接都能走。我第一次装的时候,工程建成了 x64,编译全绿、链接全过,运行起来 CoCreateInstance 直接甩了个 0x80040154 回来,翻注册表翻了半宿才发现是位数和 OPC 代理 DLL 对不上。这套环境本身不复杂,坑几乎全集中在 COM 注册和 DCOM 权限两块,装坏了的表现高度统一:编译没问题,一跑就红。下面按我实际装一遍的顺序写,适合手上已经有一个能用的 OPC DA 服务器、需要在 C++ 工程里把它接进去的人。
2. 环境准备:ATL 组件、字符集与 OPC Core Components 三件套
2.1 编译器和 ATL 组件的选择
这个库不依赖 MFC,但绕不开 ATL。源码里到处是CComPtr、CComVariant、USES_CONVERSION这些东西,atlbase.h和atlconv.h找不到,第一步就编不过。VS 安装器里默认是不勾 ATL 的,得进「使用 C++ 的桌面开发」右侧的「可选」里,把「适用于最新 v14x 生成工具的 C++ ATL」勾上。装完之后在VC\Tools\MSVC\<版本>\atlmfc\include下应该能看到atlbase.h,用 PowerShell 确认一下最省事:
# 找到本机所有 MSVC 工具集下的 atlbase.h,确认 ATL 真的装上了 Get-ChildItem "C:\Program Files (x86)\Microsoft Visual Studio" -Recurse -Filter atlbase.h -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName这条命令只做一件事:在 VS 安装目录里递归找atlbase.h。有输出说明 ATL 在位,没输出就是没勾。注意别用where atlbase.h,头文件不在 PATH 里,找不到是正常的,不代表没装。
版本上不用太纠结,VS2015 之后的工具集都能编,我常用 v142 和 v143。真正要盯的是平台:这个库和绝大多数 OPC DA 服务器一样,是 32 位的。工程建 x64 编出来的东西自己跟自己玩没问题,一旦去CoCreateInstance一个只注册在 32 位注册表视图里的服务器,立刻REGDB_E_CLASSNOTREG。所以从建工程那一刻起就选 Win32,别等出错了再改。
2.2 OPC Core Components 的注册顺序
OPC DA 的跨进程通信靠的是一组代理/存根 DLL,这些东西不在系统里,得单独装。装的是 OPC Foundation 那套运行时,核心就三个文件:opcproxy.dll负责 DA 接口的列集,opccomn_ps.dll是公共接口的代理存根,opcenum.exe提供服务器枚举服务。三个缺一个,症状各不相同:缺 proxy 是连上了立刻断,缺 commn_ps 是枚举能出来但创建不了,缺 opcenum 是枚举列表直接空。
注册的时候最容易翻车的是 regsvr32 的位数。64 位系统上,System32\regsvr32.exe是 64 位的,SysWOW64\regsvr32.exe才是 32 位的。名字是反的,这个反直觉设计坑过不少人。要注册 32 位的 OPC 组件,必须显式调 SysWOW64 下那个:
:: 注册 32 位 OPC 代理/存根,注意用的是 SysWOW64 下的 regsvr32 %SystemRoot%\SysWOW64\regsvr32.exe /s %SystemRoot%\SysWOW64\opcproxy.dll %SystemRoot%\SysWOW64\regsvr32.exe /s %SystemRoot%\SysWOW64\opccomn_ps.dll :: opcenum 是 exe,用 /regserver 而不是 regsvr32 %SystemRoot%\SysWOW64\opcenum.exe /regserver :: 注册完重启枚举服务,否则枚举结果还是旧的 sc stop OpcEnum sc start OpcEnum/s是静默,不弹成功框。第一次装建议去掉/s,看它到底报什么。如果opcproxy.dll报0x80070005,多半是没用管理员权限开命令行;如果报0x8002801c,是文件版本和系统不匹配,换一套运行时重装。sc stop那两行很多人会漏,以为注册完就生效了,实际上 OpcEnum 是常驻服务,不重启它读的还是老的 CLSID 表。
提示:注册完在注册表里核一下,
HKLM\SOFTWARE\Classes\Wow6432Node\CLSID下能搜到 OPC 相关的项,才算真的写进去了。
2.3 工程侧的目录和位数对齐
新建一个 Win32 控制台工程,把库的头文件目录和 lib 目录加进去,配置项不多,但每一项写错都会在链接期才爆出来。我习惯一次性配好,省得来回改:
| 配置项 | 值 | 说明 |
|---|---|---|
| 平台 | Win32 | 和 OPC 服务器的位数保持一致 |
| 字符集 | 使用多字节字符集 | 库内部大量用std::string,Unicode 下_T()会编不过 |
| 附加包含目录 | 库源码根目录及其include子目录 | 两个都要加,头文件分两层 |
| 附加库目录 | 编译产出的.lib所在目录 | 后面第 3 章会产出 |
| 运行库 | 多线程 (/MT) 或多线程调试 (/MTd) | 必须和库的编译选项一致 |
字符集这一项值得单独说。OPC DA 的 COM 接口签名全是LPWSTR,宽字符;而 OPCClientToolKit 对外暴露的参数大量是std::string,内部用USES_CONVERSION加W2A/A2W转换。工程设成 Unicode 之后,_T("Demo.OPC.Sim.1")展开成const wchar_t*,塞进std::string形参就是 C2664,一堆模板报错看得人头大。设成多字节字符集,这类错误直接消失。代价是中文路径和中文点位名要自己转码,如果你的点位 ID 里有中文,老老实实做一次MultiByteToWideChar,别指望宏。
3. 编译 OPCClientToolKit:预处理器宏、运行库和产出物
3.1 源码目录里该编哪个工程
拉下来的源码树一般分三块:核心库、示例程序、以及零散的测试代码。核心库的工程名通常带OpcClientToolKit字样,输出是一个静态库;示例工程依赖核心库,用来演示怎么连;测试代码经常是半成品,缺文件或者引用了不存在的路径,不用管它。判断哪个是核心库有个笨办法但很准:看工程属性里的「配置类型」,是「静态库 (.lib)」的那个就是。
老工程的工程文件是.vcproj格式,用高版本 VS 打开会弹重定向提示。我的建议是先复制一份再让它升级,原文件留着,因为升级过程有时候会把「附加包含目录」里的相对路径改坏,尤其是用了..\..\这种多级回退的。升级完第一件事就是去属性页把这几个路径挨个点开看一眼,路径后面带个黄色感叹号的就是失效了。
如果不想在 IDE 里点,直接用 msbuild 命令行编更干净,也方便以后进 CI:
:: 用 v142 工具集编 32 位 Release 版,/t:Rebuild 强制全量重编 msbuild OpcClientToolKit.sln /t:Rebuild ^ /p:Configuration=Release ^ /p:Platform=Win32 ^ /p:PlatformToolset=v142 ^ /v:minimal/t:Rebuild会先 Clean 再 Build,避免增量编译留下上次的 obj 导致链接到旧符号。/p:Platform=Win32是必须显式写的,解决方案里默认平台经常是Any CPU或者x64,不写就编错位数。/v:minimal只输出警告和错误,日志短,出问题再改成/v:detailed看完整命令行。PlatformToolset按你装的版本填,v140 对应 VS2015,v141 是 2017,v142 是 2019,v143 是 2022。
3.2 预处理器宏和运行库的取舍
库内部要调CoCreateInstance、CoTaskMemFree这些,需要正确的 Windows 版本宏。工程里通常已经定义了_WIN32_WINNT,但如果你的 VS 版本比较新,可能会遇到「CoInitializeEx未声明」或者「RPC_C_AUTHN_LEVEL_PKT_PRIVACY未定义」这种,本质是 SDK 头文件按_WIN32_WINNT裁剪了内容。常见做法是把它抬到0x0601(Windows 7)以上:
// 必须放在所有 windows.h 之前,否则不生效 #define _WIN32_WINNT 0x0601 #define WIN32_LEAN_AND_MEAN #include <windows.h> #include <objbase.h> #include <atlbase.h> #include <atlconv.h>顺序是有讲究的。_WIN32_WINNT一旦被某个头文件间接引入的sdkddkver.h定死,后面再定义就没用了,所以必须顶在最前面。WIN32_LEAN_AND_MEAN排除掉 winsock 那一坨,编译快一点,但如果你后面要自己加网络相关的东西,记得把它去掉,不然winsock2.h和windows.h的包含顺序又要折腾一轮。atlbase.h要在atlconv.h前面,反了会报USES_CONVERSION相关的宏未定义。
运行库必须和库保持一致,这条是硬性的。库编的是/MT,调用方用/MD,链接时会报LNK2005: _malloc 已经在 LIBCMT.lib 中定义,或者更隐蔽的——链接过了,运行到new一个跨模块对象的时候堆崩。查这种问题就看工程属性「C/C++ → 代码生成 → 运行库」,两边字符串完全一样才行。我一般统一用/MT,静态链接 CRT,发布的时候不用带运行时 redist,少一个部署环节就少一个故障点。
3.3 产出物和调用方怎么链
编完之后在Release或者Debug目录下会得到.lib文件。调用方需要三样东西:库的头文件目录、这个.lib、以及库本身依赖的系统库。系统库不用手动加到「附加依赖项」里,用pragma comment写在代码里更不容易漏:
// 放在包含库头文件之后,链接器会自动去找这几个库 #pragma comment(lib, "ole32.lib") // CoInitializeEx / CoCreateInstance #pragma comment(lib, "oleaut32.lib") // VARIANT 相关操作 #pragma comment(lib, "uuid.lib") // CLSID / IID 定义 #pragma comment(lib, "rpcrt4.lib") // 远程连接时的 RPC 运行时 // 然后是 OPCClientToolKit 自己的库 #pragma comment(lib, "OpcClientToolKit.lib")ole32和oleaut32是最容易漏的两个,漏了的表现是LNK2019: 无法解析的外部符号 _CoCreateInstance@20这种,一看符号名就知道缺哪个库。rpcrt4只在远程连接时需要,本地连接不加也能过,但加上没坏处,省得以后加远程功能再回来找。库名要和实际产出的文件名完全一致,大小写不敏感但扩展名要对,有些工程产出的是OpcClientToolKit_d.lib这种带后缀的调试版。
注意:Debug 版和 Release 版的库不能混用。Debug 版里带
_ITERATOR_DEBUG_LEVEL=2,Release 是 0,混着链必报LNK2038检测到不匹配。
4. 连接配置:本地 CoCreateInstance 与远程 COSERVERINFO 的差别
4.1 COM 初始化和拿服务器对象
所有 OPC DA 操作都建立在 COM 之上,第一步永远是初始化。这个库在COPCClient::init()里包了一层CoInitializeEx,但具体用 STA 还是 MTA,各版本不一定一样,我习惯自己先显式调一次,心里有数:
#include "OPCClient.h" int main() { // MTA:OPC 的异步回调在自由线程上跑,MTA 下不用自己泵消息 HRESULT hr = CoInitializeEx(NULL, COINIT_MULTITHREADED); if (FAILED(hr) && hr != RPC_E_CHANGED_MODE) { return 1; // 真的失败了才退出,RPC_E_CHANGED_MODE 说明已经被别人初始化成别的模式 } COPCClient::init(); // 库自己的初始化,重复调 CoInitializeEx 是引用计数,不会出错 COPCServer* server = COPCClient::makeServer("Demo.OPC.Sim.1"); if (server == NULL) { CoUninitialize(); return 1; } // ... 后续连接 }RPC_E_CHANGED_MODE这个返回值要单独放过。它不是说初始化失败,而是说当前线程已经被初始化成另一种 apartment,这时候继续往下走通常没问题,直接return反而把正常的流程掐断了。COPCClient::init()内部再调一次CoInitializeEx是引用计数加一,不会报错,但也意味着退出时CoUninitialize要成对调用,漏了会在调试器里看到一堆泄漏报告。
makeServer传的是 ProgID,不是 CLSID。ProgID 就是那个厂商.产品.版本格式的字符串,比如Demo.OPC.Sim.1。这个字符串必须和服务器注册到注册表里的一模一样,大小写可以不一致,但点和版本号不能少。不确定的话,先用下一节的办法把本机列表枚举出来。
4.2 本地连接:为什么必须走 LOCAL_SERVER
bool ok = server->connect(); if (!ok) { // connect 失败时库内部会保留最后一次 HRESULT,部分版本能通过 getLastError 拿到 return 1; }connect()不带参数就是本地连接。库内部做的事是CLSIDFromProgID拿到 CLSID,再CoCreateInstance(CLSID, NULL, CLSCTX_LOCAL_SERVER, IID_IOPCServer, ...)。关键是那个CLSCTX_LOCAL_SERVER,不能改成CLSCTX_INPROC_SERVER。OPC DA 服务器是以独立 exe 形式跑的,进程外组件,用 INPROC 去找会直接REGDB_E_CLASSNOTREG,因为它在进程内注册表视图里根本不存在。
本地连接成功后,server对象里持有的是IOPCServer指针,后面建组、加项、读写都基于它。这里有个容易忽略的点:connect()返回 true 只代表 COM 对象创建成功,不代表服务器真的在提供数据。有些服务器进程起来了但配置没加载完,这时候建组会失败或者建出来是空的。我一般连上之后立刻调一次枚举或者拿一下服务器状态,确认它是活的再往下走。
4.3 远程连接:COSERVERINFO 里每个字段都不能瞎填
远程连接是这套东西里最容易卡住的地方,因为connect()那个 bool 参数管不了远程,得在makeServer的时候把COSERVERINFO传进去:
COSERVERINFO si; memset(&si, 0, sizeof(si)); si.pwszName = L"HOST-A"; // 远程机器名,不要用 IP,原因见下 COAUTHINFO auth; memset(&auth, 0, sizeof(auth)); auth.dwAuthnSvc = RPC_C_AUTHN_WINNT; // 用 Windows 集成认证 auth.dwAuthzSvc = RPC_C_AUTHZ_NONE; auth.pwszServerPrincName = NULL; auth.dwAuthnLevel = RPC_C_AUTHN_LEVEL_CONNECT; // 连接级认证,够用且兼容性好 auth.dwImpersonationLevel= RPC_C_IMP_LEVEL_IMPERSONATE; // 允许服务端以我的身份访问资源 auth.pAuthIdentityData = NULL; // 用当前登录身份,不显式传账号 auth.dwCapabilities = EOAC_NONE; si.pAuthInfo = &auth; COPCServer* server = COPCClient::makeServer("Demo.OPC.Sim.1", &si); server->connect();pwszName用主机名而不是 IP,这条是血泪经验。用主机名加上域账号,认证走 Kerberos,一路顺畅;填 IP 地址,Kerberos 拿不到 SPN,退化到 NTLM,而 NTLM 在不少默认安全策略下会被拒,表现就是0x80070005。如果非要用 IP,那就得在远程机器的本地安全策略里把「网络访问: 本地账户的共享和安全模型」改成「经典」,并且两边建同名同密码的账号。
dwAuthnLevel填RPC_C_AUTHN_LEVEL_CONNECT是最低可用值,只验证连接建立时的身份,之后的数据包不加密不签名。要更严可以上PKT_INTEGRITY或PKT_PRIVACY,但老服务器不一定支持,会报RPC_E_INVALID_HEADER之类。dwImpersonationLevel必须是IMPERSONATE或更高,IDENTIFY只够看身份,服务端读文件、读注册表的时候会失败。
4.4 建组和加项:更新率和死区怎么填
// 组名随便取,active=true 表示这个组的数据会刷新 // 请求更新率 1000ms,死区 0.0 表示任何变化都上报 COPCGroup* group = server->makeGroup("g1", true, 1000, 0, 0.0); // 第二个参数是 active,不激活的项读出来是 BAD COPCItem* item = group->addItem("Demo.Tag.Level", true); COPCItem::ItemData data; if (item->readSync(data)) { // data.vDataValue 值,VARIANT 类型 // data.wQuality 质量码,0xC0 是 GOOD // data.ftTimeStamp 服务器时间戳 }makeGroup的参数顺序各版本略有差异,我这份是「组名、是否激活、请求更新率(ms)、修订更新率(ms)、死区」。请求更新率是客户端希望服务器多久刷一次,服务器可以打折,实际值要通过IOPCGroupStateMgt去读。死区是模拟量的变化阈值,0.0 表示只要变一点就通知,设大了能减轻负载但会丢细节。revUpdateRate填 0 表示由服务器决定。
addItem的第二个参数是 active。这个参数填 false,项还在组里,但读出来永远是OPC_QUALITY_BAD,不是读不到,是根本没激活。我见过不止一次有人在这里填了 false,然后对着 BAD 质量码查了大半天点位 ID,其实 ID 一点问题没有。
5. 排查:五类连不上的现场和一条自查顺序
5.1 五种故障现象、原因和解决办法
现象一:一运行就返回 0x80040154(REGDB_E_CLASSNOTREG),编译链接全过。原因是类没注册,而类没注册最常见的原因不是没装,是位数错位。32 位进程去 64 位注册表视图找,或者反过来,都会得到这个码。解决办法是先确认自己的 exe 是 32 位(任务管理器里看有没有「32 位」标记),再用SysWOW64\regsvr32.exe重新注册一遍代理 DLL,最后用Get-ItemProperty "HKLM:\SOFTWARE\Classes\Wow6432Node\CLSID\{...}"确认 CLSID 真的在 32 位视图里。
现象二:0x80070005(E_ACCESSDENIED),本地能连,远程连不上。这是 DCOM 权限问题,八成出在远程机器上。去「组件服务 → 计算机 → 我的电脑 → DCOM 配置」,找到服务器的 ProgID 项,右键属性,在「安全」页把「启动和激活权限」「访问权限」都改成自定义,然后把客户端那个账号加进去,给本地启动和本地激活。工作组环境下还要保证两台机器的账号密码完全一致,否则 NTLM 也过不去。
现象三:枚举服务器返回空列表,但服务器明明在跑。先查 OpcEnum 服务状态,sc query OpcEnum,不是 RUNNING 就起一下。服务正常但列表还是空,看防火墙:OPC 枚举走 135 端口,之后动态分配 RPC 端口。防火墙没放行 135,枚举直接超时,表现就是空。临时关掉防火墙验证一下是最快的定位手段,确认是它之后再回来加规则。
现象四:连上了,组也建了,读出来的质量码一直是 0xC0 以外的值(BAD)。质 量码0x00是 BAD,0x40是 UNCERTAIN,0xC0才是 GOOD。读到 BAD 先看项有没有 active,再看点位 ID 对不对。点位 ID 这东西不能猜,必须用服务器自己的客户端或者配置工具导出一份过来。远程服务器上还可能是命名空间没加载完,等几秒再读。
现象五:远程第一次 readSync 卡住不返回,或者超时。两个原因。一是没设COAUTHINFO,用的是默认认证,跨机器时被静默拒绝,表现为卡住直到超时;二是在 STA 线程里做同步读,服务器回调又要回到这个线程,消息泵没转,互相等。前者按 4.3 补上认证信息,后者把线程改成 MTA,或者保证在读的时候有MsgWaitForMultipleObjects在转消息。
5.2 一条从下往上的自查顺序
出问题的时候别一上来就怀疑代码,按这个顺序走,能省掉大量来回:
- 查位数:exe 是 32 位还是 64 位,和服务器对不对得上。
- 查注册:CLSID 在不在对应的注册表视图里,代理 DLL 是不是用对位数的 regsvr32 注册的。
- 查枚举:OpcEnum 服务在不在跑,防火墙 135 通不通。
- 查权限:本地能连远程不能连,一定是 DCOM 配置或账号问题。
- 查 ID:质量码 BAD,先怀疑点位名和 active 标志。
- 才轮到查代码:认证级别、apartment 模型、调用顺序。
前五步都是环境问题,解决起来是确定性的;第六步才是代码问题,往往改一行就好。我把这个顺序贴在显示器边上贴了很久,因为人一旦看到0x80070005就容易往权限上钻,忘了先确认位数。
注意:每次改完 DCOM 配置不用重启机器,但要重新打开「组件服务」控制台,缓存不会自己刷。
6. 验证:一条读写回路把本地和远程都跑一遍
6.1 最小验证程序
装完不算完,得有一条能跑通的读写回路,否则你不知道自己是真装好了还是只编过了。下面这段是我每次装完必跑的最小程序,覆盖初始化、连接、建组、加项、读、写、再读七个动作:
#include <windows.h> #include <iostream> #include "OPCClient.h" int main() { SetConsoleOutputCP(936); // 控制台中文不乱码 CoInitializeEx(NULL, COINIT_MULTITHREADED); COPCClient::init(); COPCServer* server = COPCClient::makeServer("Demo.OPC.Sim.1"); if (!server || !server->connect()) { std::cout << "connect failed" << std::endl; return 1; } COPCGroup* group = server->makeGroup("verify", true, 500, 0, 0.0); COPCItem* item = group->addItem("Demo.Tag.Level", true); COPCItem::ItemData before; item->readSync(before); // 写一个浮点值进去,写之前先读一次,写之后再读一次对比 VARIANT v; VariantInit(&v); v.vt = VT_R4; v.fltVal = 42.5f; item->writeSync(v); COPCItem::ItemData after; item->readSync(after); std::cout << "before quality=0x" << std::hex << before.wQuality << " after quality=0x" << after.wQuality << std::endl; CoUninitialize(); return 0; }判断标准很简单:两次质量码都是0xc0,并且写进去的值能在第二次读里看到,说明从 COM 初始化到数据回读整条链路是通的。只读不写不够,写会走到IOPCSyncIO::Write,这条路径和读不一样,权限问题往往只在写的时候才暴露。
6.2 每次装完都要过的几项
我把验证拆成一张小表,逐项打钩,缺哪项就回去补对应章节:
| 检查项 | 通过标准 | 失败时回看 |
|---|---|---|
| COM 初始化 | 返回 S_OK 或 RPC_E_CHANGED_MODE | 4.1 |
| 枚举服务器 | 能列出至少一个 ProgID | 5.1 现象三 |
| 本地连接 | connect 返回 true | 4.2 |
| 建组加项 | 不返回 NULL | 4.4 |
| 同步读 | 质量码 0xC0 | 5.1 现象四 |
| 同步写 | writeSync 返回 true 且回读一致 | 4.3 / 5.1 现象二 |
| 远程连接 | 同本地,只是 pwszName 换成主机名 | 4.3 |
从那以后我每次装这套东西,都会先把这张表跑完再动业务代码,本地过了再改一个COSERVERINFO去跑远程,两轮都绿了才算收工。远程那轮千万别和业务逻辑混在一起测,混在一起出了错,你分不清是装修没弄好还是代码写错了。希望帮到你。
本文还有配套的精品资源,点击获取