本篇將講解如何編寫一個OPC客戶端程序測試我們在前文《基於第三方開源庫的OPC服務器開發指南(2)——LightOPC的編譯及部署》一篇建立的服務器。本指南的目的是熟悉OPC服務器的開發流程,所以客戶端部分我就不做過多描述,只是簡單講解幾個關鍵技術細節及其實現函數,完整工程源碼請從如下地址獲取:
https://github.com/Neo-T/OPCDASrvBasedOnLightOPC
OPC客戶端的編寫流程與涉及的技術細節跟本指南第一篇博文給出的DCOM客戶端本質上沒有什么不同,同樣是模擬登錄遠程機器、獲取操作接口然后調用就可以了。相較於普通的DCOM客戶端,OPC客戶端還需要枚舉並讀取遠程機器上已經注冊的OPC服務器的CLSID,我們需要根據這個CLSID來指定要使用遠程機器上哪一個OPC服務器提供的遠程操作接口。首先是如何枚舉遠程機器上所有已注冊OPC服務器以及如何讀取指定服務器的CLSID,有兩種方法實現這個操作:
1、訪問遠程機器上的注冊表,直接讀取指定OPC服務器的CLSID;
2、使用OPC組織提供的OPCEnum服務,枚舉所有已注冊OPC服務並讀取指定服務器的CLSID;
第一種方法不需要下載OPC組織提供的——目前看個人開發者已經無法通過OPC基金會網站免費獲得的——“opc core components”支持包,只需要目標機器開通“RemoteRegistry”服務,同時我們擁有訪問注冊表的權限即可。可以說這種方式是最對我胃口的,不受別人限制。該方法的實現函數如下:
1 //* 獲取指定名稱的OPC服務器的CLSID 2 static INT __GetRemoteOPCSrvCLSIDByRegistry(CHAR *pszIPAddr, CHAR *pszUserName, CHAR *pszPassword, CHAR *pszOPCSrvProgID, CHAR *pszOPCSrvCLSID) 3 { 4 INT nRtnVal = 0; 5 6 //* 登錄遠程計算機 7 HANDLE hToken; 8 if (!LogonUser(pszUserName, pszIPAddr, pszPassword, LOGON32_LOGON_NEW_CREDENTIALS, LOGON32_PROVIDER_DEFAULT, &hToken)) 9 { 10 printf("無法連接IP地址為%s的計算機,錯誤碼:%d", pszIPAddr, GetLastError()); 11 return -1; 12 } 13 14 //* 模擬當前登錄的用戶 15 ImpersonateLoggedOnUser(hToken); 16 { 17 do { 18 CHAR szKey[MAX_PATH + 1]; 19 DWORD dwLen = MAX_PATH; 20 DWORD dwIdx = 0; 21 CHAR szCLSID[100]; 22 LONG lSize; 23 HKEY hKey = HKEY_CLASSES_ROOT; 24 DWORD dwRtnVal = RegConnectRegistry(pszIPAddr, HKEY_CLASSES_ROOT, &hKey); 25 if (dwRtnVal != ERROR_SUCCESS) 26 { 27 printf("無法連接IP地址為%s的計算機,錯誤碼:%d", pszIPAddr, dwRtnVal); 28 nRtnVal = -2; 29 break; 30 } 31 32 printf("成功連接IP地址為%s的計算機,開始枚舉該計算機系統上的注冊表...\r\n", pszIPAddr); 33 34 //* 讀取指定鍵值 35 if (RegEnumKey(hKey, dwIdx, szKey, dwLen) == ERROR_SUCCESS) 36 { 37 HKEY hSubKey; 38 39 //* 打開指定名稱的OPC服務器所在的鍵,在這里就是"OPC.LightOPC-exe" 40 sprintf(szKey, pszOPCSrvProgID); 41 42 //* 打開指定鍵值並取值 43 if (RegOpenKey(hKey, szKey, &hSubKey) == ERROR_SUCCESS) 44 { 45 memset(szCLSID, 0, sizeof(szCLSID)); 46 lSize = sizeof(szCLSID) - 1; 47 if (RegQueryValue(hSubKey, "CLSID", szCLSID, &lSize) == ERROR_SUCCESS) 48 { 49 if (RegQueryValue(hSubKey, "OPC", NULL, NULL) == ERROR_SUCCESS) 50 { 51 sprintf(pszOPCSrvCLSID, "%s", szCLSID); 52 printf("『%s』服務已找到:%s\r\n", pszOPCSrvProgID, pszOPCSrvCLSID); 53 } 54 else 55 { 56 printf("查詢OPC鍵失敗,錯誤碼:%d\r\n", GetLastError()); 57 nRtnVal = -6; 58 } 59 } 60 else 61 { 62 printf("查詢CLSID鍵失敗,錯誤碼:%d\r\n", GetLastError()); 63 nRtnVal = -5; 64 } 65 66 RegCloseKey(hSubKey); 67 } 68 else 69 { 70 printf("RegOpenKey()函數執行失敗,錯誤碼:%d\r\n", GetLastError()); 71 nRtnVal = -4; 72 } 73 } 74 else 75 { 76 printf("RegEnumKey()函數執行失敗,錯誤碼:%d\r\n", GetLastError()); 77 nRtnVal = -3; 78 } 79 80 } while (FALSE); 81 } 82 RevertToSelf(); //* 結束模擬 83 84 return nRtnVal; 85 }
這個函數對注冊表的操作沒什么可說的,使用的是標准API,重點是如何獲取遠程注冊表的訪問權限。在這里我們依然使用了模擬用戶登錄技術,利用遠程機器為我們分配的某個具有注冊表訪問權限的用戶,通過調用LogonUser()函數獲取該用戶成功登錄后的訪問令牌,通過這個令牌獲取對注冊表的訪問權限,這才是這個函數的得以正常執行的關鍵。我們可以在main()函數中輸入如下代碼測試一下這個函數:
1 int main(int argc, CHAR* argv[]) 2 { 3 CHAR szCLSID[100]; 4 5 __GetRemoteOPCSrvCLSIDByRegistry(argv[1], argv[2], argv[3], argv[4], szCLSID); 6 7 return 0; 8 }
打開控制台輸入測試指令,順利的話我們會如願得到LightOPC樣例服務器的CLSID:
控制台輸入的參數依次為:遠程機器的IP地址、登錄賬戶、密碼以及樣例服務器的ProgID。
需要注意的一點是,如果你的程序不能正常訪問遠程機器,請按順序確定以下內容無誤:
1、確保lanmanserver,也就是名稱為“Server”的服務已經啟動,如果你的遠程機器是“Server 2008 R2”,且在服務管理器中沒找到“Server”服務,請在我提供的源碼工程“opc_core_components”目錄下找到“lanmanServer.reg”文件,直接導入你的原機器即可解決“Server”服務不存在的問題;
2、確保Workstation服務啟動;
3、確保RemoteRegistry服務啟動;
4、確保Remote Procedure Call (RPC)服務啟動;
5、確保TCP/IP NetBIOS Helper服務啟動;
6、確保網卡屬性中Microsoft網絡的文件和打印機共享服務已勾選;
使用第二種方法利用OPCEnum服務獲取CLSID的實現函數如下:
1 static INT __GetRemoteOPCSrvCLSIDByOPCEnum(CHAR *pszIPAddr, CHAR *pszUserName, CHAR *pszPassword, CHAR *pszOPCSrvProgID, CHAR *pszOPCSrvCLSID) 2 { 3 HRESULT hr; 4 INT nRtnVal = 0; 5 6 hr = CoInitializeEx(NULL, COINIT_MULTITHREADED); 7 if (!SUCCEEDED(hr)) 8 { 9 printf("CoInitializeEx()初始化失敗:0x%08X\r\n", hr); 10 return -1; 11 } 12 13 do { 14 COSERVERINFO stCoServerInfo; 15 COAUTHINFO stCoAuthInfo; 16 COAUTHIDENTITY stCoAuthID; 17 INT nSize = strlen(pszIPAddr) * sizeof(WCHAR); 18 memset(&stCoServerInfo, 0, sizeof(stCoServerInfo)); 19 stCoServerInfo.pwszName = (WCHAR *)CoTaskMemAlloc(nSize * sizeof(WCHAR)); 20 if (!stCoServerInfo.pwszName) 21 { 22 printf("CoTaskMemAlloc()函數執行失敗!\r\n"); 23 nRtnVal = -2; 24 break; 25 } 26 27 ZeroMemory(&stCoAuthID, sizeof(COAUTHIDENTITY)); 28 stCoAuthID.User = reinterpret_cast<USHORT *>(pszUserName); 29 stCoAuthID.UserLength = strlen(pszUserName); 30 stCoAuthID.Domain = reinterpret_cast<USHORT *>(""); 31 stCoAuthID.DomainLength = 0; 32 stCoAuthID.Password = reinterpret_cast<USHORT *>(pszPassword); 33 stCoAuthID.PasswordLength = strlen(pszPassword); 34 stCoAuthID.Flags = SEC_WINNT_AUTH_IDENTITY_ANSI; 35 36 ZeroMemory(&stCoAuthInfo, sizeof(COAUTHINFO)); 37 stCoAuthInfo.dwAuthnSvc = RPC_C_AUTHN_WINNT; 38 stCoAuthInfo.dwAuthzSvc = RPC_C_AUTHZ_NONE; 39 stCoAuthInfo.pwszServerPrincName = NULL; 40 stCoAuthInfo.dwAuthnLevel = RPC_C_AUTHN_LEVEL_CONNECT; 41 stCoAuthInfo.dwImpersonationLevel = RPC_C_IMP_LEVEL_IMPERSONATE; //* 必須是模擬登陸 42 stCoAuthInfo.pAuthIdentityData = &stCoAuthID; 43 stCoAuthInfo.dwCapabilities = EOAC_NONE; 44 45 mbstowcs(stCoServerInfo.pwszName, pszIPAddr, nSize); 46 stCoServerInfo.pAuthInfo = &stCoAuthInfo; 47 stCoServerInfo.dwReserved1 = 0; 48 stCoServerInfo.dwReserved2 = 0; 49 50 MULTI_QI stMultiQI; 51 ZeroMemory(&stMultiQI, sizeof(stMultiQI)); 52 stMultiQI.pIID = &IID_IOPCServerList; //* 參見opccomn_i.c 53 stMultiQI.pItf = NULL; 54 55 //* 初始化安全結構,模擬登錄遠程機器 56 hr = CoInitializeSecurity(NULL, -1, NULL, NULL, RPC_C_AUTHN_LEVEL_CONNECT, RPC_C_IMP_LEVEL_IMPERSONATE, NULL, EOAC_NONE, NULL); 57 if (!(SUCCEEDED(hr) || RPC_E_TOO_LATE == hr)) 58 { 59 printf("CoInitializeSecurity()函數執行失敗,錯誤碼:0x%08X\r\n", hr); 60 nRtnVal = -3; 61 break; 62 } 63 64 hr = CoCreateInstanceEx(CLSID_OpcServerList, 65 NULL, 66 CLSCTX_REMOTE_SERVER, //* 顯式的指定要連接遠程機器 67 &stCoServerInfo, 68 sizeof(stMultiQI) / sizeof(MULTI_QI), 69 &stMultiQI); 70 71 //* 無論成功與否,先釋放剛才申請的內存 72 CoTaskMemFree(stCoServerInfo.pwszName); 73 74 //* 如果CoCreateInstanceEx()執行失敗 75 if (FAILED(hr)) 76 { 77 printf("CoCreateInstanceEx()函數執行失敗,錯誤碼:0x%08X %s %s\r\n", hr, pszIPAddr, pszUserName); 78 nRtnVal = -4; 79 break; 80 } 81 82 //* 如果沒有獲取到DCOM組件的查詢接 83 if (FAILED(stMultiQI.hr)) 84 { 85 printf("獲取組件的查詢接口失敗,錯誤碼:0x%08X\r\n", stMultiQI.hr); 86 nRtnVal = -5; 87 break; 88 } 89 90 //* 讀取所有已注冊的OPC服務器 91 CComPtr<IOPCServerList> pobjOPCSrvList = (IOPCServerList *)stMultiQI.pItf; 92 IEnumGUID *pobjEnumGUID = NULL; 93 CLSID stCLSID; 94 DWORD dwCeltFetchedNum; 95 LPOLESTR wszProgID, wszUserType; 96 CLSID stCatID = CATID_OPCDAServer20; 97 hr = pobjOPCSrvList->EnumClassesOfCategories(1, &stCatID, 1, &stCatID, &pobjEnumGUID); 98 if (FAILED(hr)) 99 { 100 printf("EnumClassesOfCategories()函數執行失敗,錯誤碼:0x%08X\r\n", hr); 101 nRtnVal = -6; 102 break; 103 } 104 105 //* 開始枚舉服務器並獲取指定ProgID的CLSID 106 while (SUCCEEDED(pobjEnumGUID->Next(1, &stCLSID, &dwCeltFetchedNum))) 107 { 108 if (!dwCeltFetchedNum) 109 break; 110 hr = pobjOPCSrvList->GetClassDetails(stCLSID, &wszProgID, &wszUserType); 111 if (FAILED(hr)) 112 { 113 printf("GetClassDetails()函數執行失敗,錯誤碼:0x%08X\r\n", hr); 114 nRtnVal = -7; 115 break; 116 } 117 118 CHAR szProgID[100]; 119 CString cstrProgID = wszProgID; 120 sprintf(szProgID, "%s", cstrProgID); 121 122 if(!strcmp(pszOPCSrvProgID, szProgID)) 123 { 124 BSTR wszCLSID; 125 StringFromCLSID(stCLSID, &wszCLSID); 126 CString cstrCLSID = wszCLSID; 127 128 sprintf(pszOPCSrvCLSID, "%s", cstrCLSID); 129 printf("『%s』服務已找到:%s\r\n", pszOPCSrvProgID, pszOPCSrvCLSID); 130 131 //* 釋放占用的內存 132 CoTaskMemFree(wszProgID); 133 CoTaskMemFree(wszUserType); 134 135 break; 136 } 137 138 //* 釋放占用的內存 139 CoTaskMemFree(wszProgID); 140 CoTaskMemFree(wszUserType); 141 } 142 } while (FALSE); 143 144 145 CoUninitialize(); 146 147 return nRtnVal; 148 }
這個函數處理流程與DCOM客戶端基本相同,不多說了,重點是如何使用這個函數?首先我們必須下載“opc core components”支持包,64位的機器下載x64版本,32位的機器下載x86版本,可問題是在哪里下載呢?前面我不止一次說過,OPC基金會關閉了普通用戶的下載通道,我們在基金會網站是找不到下載地址的。像CSDN、pudn之類的同樣銅臭味十足的GoPi網站提供下載,可是要積分啊,我記得CSDN上有個64位版本支持包的下載鏈接竟然喪心病狂的要44積分,太WuChi了。還是要感謝github,感謝無私奉獻的大神們,請去這個地址下載兩個版本的支持包,順便給該資源的主人點個星:
https://github.com/jmbeach/chocolatey-OpcClassicCoreComponents/tree/master/tools
下載下來后,遠程機器和本地都要安裝,遠程機器安裝完就不用管它了。本地安裝完畢后,需要在安裝路徑下找到OPCEnum.h和OpcEnum_i.c兩個文件將其添加到客戶端工程中,同時把OPCEnum.h文件#include進來,否則會編譯失敗。然后修改main()函數:
1 int main(int argc, CHAR* argv[]) 2 { 3 CHAR szCLSID[100]; 4 5 __GetRemoteOPCSrvCLSIDByOPCEnum(argv[1], argv[2], argv[3], argv[4], szCLSID); 6 //__GetRemoteOPCSrvCLSIDByRegistry(argv[1], argv[2], argv[3], argv[4], szCLSID); 7 8 return 0; 9 }
控制台輸入指令測試該函數,結果如下:
拋開與OPC業務有關的邏輯不說,以上兩個函數針對遠程OPC服務器的訪問提供了一種更安全的訪問方法,而不像相當一部分公開資料所描述的那樣把服務器權限降低到任何人都可以訪問的令人發指的地步。
OPC客戶端的主業務邏輯可以參見源碼的main()函數,以此按圖索驥理解整個處理流程:
1 int main(int argc, CHAR* argv[]) 2 { 3 CHAR szCLSID[100]; 4 5 if (argc != 5) 6 { 7 printf("Usage:%s opcserver_ip username password OpcProgID\r\n", argv[0]); 8 return -1; 9 } 10 11 //* 設定該程序捕獲控制台CTRL+C輸入,以使程序能夠正常退出 12 SetConsoleCtrlHandler((PHANDLER_ROUTINE)ConsoleHandler, TRUE); 13 14 //__GetRemoteOPCSrvCLSIDByOPCEnum(argv[1], argv[2], argv[3], argv[4], szCLSID); 15 if (__GetRemoteOPCSrvCLSIDByRegistry(argv[1], argv[2], argv[3], argv[4], szCLSID)) 16 { 17 printf("__GetRemoteOPCSrvCLSIDByRegistry()函數執行失敗,進程退出!\r\n"); 18 return -2; 19 } 20 21 //* 連接成功則進行后續操作 22 if (!__ConnOPCServer(argv[1], argv[2], argv[3], szCLSID)) 23 { 24 do { 25 if (__AddDefaultGroup()) 26 { 27 printf("__AddDefaultGroup()函數執行失敗,進程退出!\r\n"); 28 break; 29 } 30 31 //* 手動添加要操作的變量 32 //* ======================================================= 33 ST_OPC_ITEM staItem[2]; 34 sprintf(staItem[0].szItemName, "lulu"); 35 staItem[0].vtDataType = VT_I2; 36 if (__AddItemToLocalMgmtIf(staItem[0].szItemName, staItem[0].vtDataType, &staItem[0].ohItemSrv)) //* exe_samp工程之sample.cpp文件第573行“lulu”變量為VT_I2類型 37 { 38 printf("__AddItemToLocalMgmtIf()函數添加變量[lulu]執行失敗,進程退出!\r\n"); 39 break; 40 } 41 42 sprintf(staItem[1].szItemName, "zuzu"); 43 staItem[1].vtDataType = VT_R8; 44 if (__AddItemToLocalMgmtIf(staItem[1].szItemName, staItem[1].vtDataType, &staItem[1].ohItemSrv)) //* exe_samp工程之sample.cpp文件第564行“zuzu”變量為VT_R8類型 45 { 46 printf("__AddItemToLocalMgmtIf()函數添加變量[zuzu]執行失敗,進程退出!\r\n"); 47 break; 48 } 49 //* ======================================================= 50 51 //* 隱藏控制台光標 52 ShowConsoleCursor(FALSE); 53 54 //* 讀取控制台當前光標位置,以便循環讀取時固定輸出位置,而不是整屏滾動輸出 55 SHORT x, y; 56 GetConsoleCursorPosition(&x, &y); 57 58 time_t tPrevWriteTime = time(NULL); 59 ULONG ulWriteVal = 2009; 60 61 blIsRunning = TRUE; 62 while (blIsRunning) 63 { 64 //* 每次讀取均設定在控制台同一輸出位置 65 SetConsoleCursorPosition(x, y); 66 67 if (__ReadItem(staItem, sizeof(staItem) / sizeof(ST_OPC_ITEM))) 68 break; 69 70 Sleep(100); 71 72 if (time(NULL) - tPrevWriteTime > 1) 73 { 74 if (__WriteItem(&staItem[0], ulWriteVal++)) 75 break; 76 77 tPrevWriteTime = time(NULL); 78 } 79 } 80 81 //* 恢復控制台光標 82 ShowConsoleCursor(TRUE); 83 84 } while (FALSE); 85 86 //* 斷開連接 87 __DisconnectOPCServer(); 88 } 89 else 90 { 91 printf("__ConnOPCServer()函數執行失敗,進程退出!\r\n"); 92 return -3; 93 } 94 95 return 0; 96 } 97
整個流程很簡單:獲取CLSID,利用這個CLSID連接服務器,連接成功后調用__AddDefaultGroup()函數添加一個缺省組,添加成功則就獲得了對OPC服務器指定變量進行管理、讀、寫等操作的具體操作接口,接着通過__AddItemToLocalMgmtIf()函數把我們要操作的樣例服務器提供的兩個變量添加到OPC的本地變量管理接口,然后就可以進入主循環進行讀取操作了。主循環以100毫秒間隔讀取“zuzu”變量的值,以1秒間隔寫“lulu”變量的值。其實OPC樣例服務器提供了多個變量,具體列表參見“sample.cpp”文件的第278-282行:
1 …… …… 2 3 /* Our data tags: */ 4 /* zero is resierved for an invalid RealTag */ 5 #define TI_zuzu (1) 6 #define TI_lulu (2) 7 #define TI_bandwidth (3) 8 #define TI_array (4) 9 #define TI_enum (5) 10 #define TI_quiet (6) 11 #define TI_quality (7) 12 #define TI_string (8) 13 #define TI_MAX (8) 14 15 static loTagId ti[TI_MAX + 1]; /* their IDs */ 16 static const char *tn[TI_MAX + 1] = /* their names */ 17 { "--not--used--", "zuzu", "lulu", "bandwidth", "array", "enum-localizable", 18 "quiet", "quality", "string" }; 19 static loTagValue tv[TI_MAX + 1]; /* their values */ 20 21 …… ……
這些變量的數據類型參見driver_init()函數:
1 int driver_init(int lflags) 2 { 3 …… …… 4 5 /* We needn't to VariantClear() for simple datatypes like numbers */ 6 V_R8(&var) = 214.1; /* initial value. Will be used to check types conersions */ 7 V_VT(&var) = VT_R8; //* 變量“zuzu”的數據類型 8 ecode = loAddRealTag_a(my_service, /* actual service context */ 9 &ti[TI_zuzu], /* returned TagId */ 10 (loRealTag)TI_zuzu, /* != 0 driver's key */ 11 tn[TI_zuzu], /* tag name */ 12 0, /* loTF_ Flags */ 13 OPC_READABLE | OPC_WRITEABLE, &var, 12., 1200.); 14 UL_TRACE((LOGID, "%!e loAddRealTag_a(zuzu) = %u ", ecode, ti[TI_zuzu])); 15 16 V_I2(&var) = 1000; 17 V_VT(&var) = VT_I2; //* 變量“lulu”的數據類型 18 ecode = loAddRealTag(my_service, /* actual service context */ 19 &ti[TI_lulu], /* returned TagId */ 20 (loRealTag) TI_lulu, /* != 0 driver's key */ 21 tn[TI_lulu], /* tag name */ 22 0, /* loTF_ Flags */ 23 OPC_READABLE | OPC_WRITEABLE, &var, 0, 0); 24 UL_TRACE((LOGID, "%!e loAddRealTag(lulu) = %u ", ecode, ti[TI_lulu])); 25 26 27 …… …… 28 }
進行客戶端測試之前我們還需要對“sample.cpp”文件的代碼做些調整,否則無法測試寫操作。共有兩個地方需要調整,其一,“simulate()”函數找到如下幾句:
1 void simulate(unsigned pause) 2 { 3 …… …… 4 5 double zuzu = 6 (V_R8(&tv[TI_zuzu].tvValue) += 1./3.); /* main simulation */ 7 V_VT(&tv[TI_zuzu].tvValue) = VT_R8; 8 tv[TI_zuzu].tvState.tsTime = ft; 9 10 V_I2(&tv[TI_lulu].tvValue) = (short)zuzu; 11 V_VT(&tv[TI_lulu].tvValue) = VT_I2; 12 tv[TI_lulu].tvState.tsTime = ft; 13 14 V_I2(&tv[TI_enum].tvValue) = (short)((ft.dwLowDateTime >> 22) % 7); 15 V_VT(&tv[TI_enum].tvValue) = VT_I2; 16 tv[TI_enum].tvState.tsTime = ft; 17 18 …… …… 19 }
紅色語句全部注釋掉,不讓OPC服務器更新“lulu”變量。然后是“WriteTags()”函數,增加如下幾句:
1 int WriteTags(const loCaller *ca, 2 unsigned count, loTagPair taglist[], 3 VARIANT values[], HRESULT error[], HRESULT *master, LCID lcid) 4 { 5 …… …… 6 7 case TI_lulu: 8 hr = VariantChangeType(&tv[TI_lulu].tvValue, &values[ii], 0, VT_I2); 9 if (S_OK == hr) 10 { 11 lo_statperiod(V_I2(&tv[TI_lulu].tvValue)); /* VERY OPTIONAL, really */ 12 13 FILETIME ft;
14 GetSystemTimeAsFileTime(&ft); /* awoke */
15 tv[TI_lulu].tvState.tsTime = ft;
16 } 17 18 …… …… 19 }
紅色部分為要增加的語句,這幾條語句的作用是當客戶端寫入新的“lulu”變量值時同步更新該變量的時間戳。重新編譯修改后的樣例服務器,並覆蓋遠程機器上的舊文件,然后我們就可以啟動客戶端看看效果了:
最后,需要注意的一點是,OPC服務器所在的機器必須已經登錄,否則OPC客戶端是無法連接的,會報0x8000401A錯誤。這一點與普通的DCOM不同,普通的DCOM客戶端無須DCOM組件所在的服務器登錄即可正常使用。
至此,OPC系統的完整開發流程梳理完畢。本指南的最后一篇——《基於第三方開源庫的OPC服務器開發指南(4)——后記:與另一個開源庫opc workshop庫相關的問題》將推薦另一個更加簡單的開源庫opc workshop。