跨進程消息通知)
如果你做過 Windows 桌面端開發(fā)大概率會遇到這類需求托盤程序點了“退出”要讓主程序自動暫停任務管理端程序點了“刷新”要通知正在后臺運行的 worker工具 A 跑完了要告訴工具 B 該干活了。很多人第一反應是上 Socket、管道、共享內存其實這類“發(fā)一個信號對方接收到之后執(zhí)行一個動作”的場景Windows 消息機制里就有一個非常合適的老 API——RegisterWindowMessage。這篇文章會從它的使用場景、完整實現(xiàn)、參數(shù)陷阱和排查經(jīng)驗展開希望能幫你把這套進程通信思路真正用起來。1. 為什么進程通信會繞不開這個消息注冊函數(shù)1.1 Windows消息機制里藏著“全球唯一編號”要理解 RegisterWindowMessage先得理解 Windows 的消息模型。Windows 里的每個窗口都綁定了一個窗口過程WndProc系統(tǒng)會把各種事件封裝成“消息”投遞給窗口過程。消息本身是一個整數(shù)編號例如鼠標左鍵按下是 0x0201窗口關閉是 0x0010。窗口過程拿到編號后進入一個 switch 分支知道“哦這消息代表什么動作”。這個編號設計得相當經(jīng)濟學系統(tǒng)保留了一系列數(shù)值給內置消息用從 0 到 WM_APP 之前的部分基本都有約定含義從 WM_USER0x0400開始到 0x7FFF是窗口類自己定義的私有消息從 WM_APP0x8000開始到 0xBFFF是應用程序自定義的消息。問題就出現(xiàn)在這里。兩個不同的進程完全可以不約而同地把 0x8001 當成“刷新數(shù)據(jù)”消息但對方進程的窗口過程拿到 0x8001 時根本不知道它代表什么。它只是一個數(shù)字含義只存在定義它的那一邊。RegisterWindowMessage 解決的正是這個問題它通過一個字符串到系統(tǒng)全局原子表中登記換取一個系統(tǒng)范圍內唯一的消息編號。只要兩個進程在通信前都使用同一個字符串去注冊它們拿到的就是同一個消息編號于是各自窗口過程都能識別跨進程通信這條路就通了。1.2 RegisterWindowMessage與WM_USER/WM_APP的邊界很多初學者會混淆這三者的邊界。簡單說WM_USER 和 WM_APP 都不具備“跨進程約定”的能力它們只是給編寫窗口類或應用的人預留了一整段可用的編號空間具體含義需要代碼自己解釋。相比之下RegisterWindowMessage 返回的編號不會落在 WM_USER 或 WM_APP 區(qū)域而是在系統(tǒng)保留給動態(tài)登記的 0xC000 到 0xFFFF 這個范圍里。它由全局原子表管理同一字符串在同一會話內對應同一個編號。這個編號不依賴進程不依賴窗口類只要系統(tǒng)還在運行它就是全局的。我用一個生活化的類比說明WM_APP1 就像小區(qū)里兩個鄰居在自己家窗戶上各自掛了塊牌子上面寫著“有事招招手”但不同家庭看到手勢并不理解。RegisterWindowMessage 則像是去物業(yè)登記了一個統(tǒng)一的門牌號所有住戶都知道這個號碼指向哪個房子溝通才有了公共基礎。1.3 這才是它的戰(zhàn)場輕型控制消息RegisterWindowMessage 適合的是“通知類”和“控制類”消息而不是大數(shù)據(jù)傳輸。典型的用例包括通知目標進程“刷新配置”告訴托盤程序“主窗口已經(jīng)啟動”讓后臺 worker 立刻停止當前輪詢通知另一個進程“某個路徑發(fā)生了變化”這類消息的共同特點是交互雙方通常不關心消息后面掛著多大的數(shù)據(jù)只需要一個信號最多再攜帶一兩個整數(shù)或布爾開關。你用 RegisterWindowMessage 加上 SendMessage/PostMessage幾十行代碼就能實現(xiàn)不引入任何第三方依賴也不需要考慮握手和連接狀態(tài)。如果你需要傳的數(shù)據(jù)量很大比如幾百 KB 的用戶資料、一個日志文件那就不要硬塞給消息參數(shù)后面第三章我會講為什么。2. 用RegisterWindowMessage做一次真實通信完整拆解2.1 通信雙方要先談好“暗號”RegisterWindowMessage 的第一步是通信雙方約定一個別人很難撞車的字符串。我習慣的格式是“項目代號 用途 隨機后綴”例如const wchar_t* kMyMsgName LMyCoolApp_Notify_8B4F2A7E; UINT g_uMyMsg 0; // 在程序初始化階段注冊 g_uMyMsg RegisterWindowMessage(kMyMsgName); if (g_uMyMsg 0) { // 注冊失敗多半是字符串參數(shù)非法或系統(tǒng)資源不足 return 0; }為什么字符串要非常具體因為 RegisterWindowMessage 是全局登記如果你用一個過于通用的字符串比如 Lquit而另一個程序也恰好注冊了同一個字符串那么你的消息會意外發(fā)到對方窗口對方也可能會收到你的消息。消息編號的語義就徹底亂了。注冊的返回值最好保存到一個全局變量里因為窗口過程、發(fā)送消息的函數(shù)都要用到它。不要試圖自己硬編碼一個 0xXXXX 數(shù)值也不要假設每次注冊都會返回一樣的結果——雖然在絕大多數(shù)情況下是穩(wěn)定的但這是系統(tǒng)動態(tài)分配的你的代碼應該依賴變量而不是魔法數(shù)。2.2 定位目標窗口的兩種常用方式消息是發(fā)給窗口的所以通信的另一端必須是一個窗口句柄 HWND。最常用的定位方式有兩種。第一種是 FindWindow用窗口類名或窗口標題來找HWND hwndTarget FindWindow(NULL, LMessageReceiver_Sender1); if (hwndTarget ! NULL) { // 找到目標窗口 }這種方式的優(yōu)點是簡單缺點是窗口標題很容易變化。一旦用戶改了標題或者窗口還沒創(chuàng)建出來FindWindow 就會失敗。如果你的接收方是自己寫的我建議你在創(chuàng)建窗口時用一個固定的 Window Title或者用一個自定義窗口類名然后按類名查找。第二種是 EnumWindows遍歷所有頂層窗口再通過窗口屬性、標題或者窗口創(chuàng)建時的自定義數(shù)據(jù)來匹配BOOL CALLBACK EnumProc(HWND hwnd, LPARAM lParam) { wchar_t title[256] {0}; GetWindowTextW(hwnd, title, 256); if (wcscmp(title, LMessageReceiver_Sender1) 0) { *(HWND*)lParam hwnd; return FALSE; } return TRUE; } HWND found NULL; EnumWindows(EnumProc, (LPARAM)found);EnumWindows 更可靠適合存在多個同名窗口或者標題帶動態(tài)部分的情況。不過要注意EnumWindows 只枚舉頂層窗口如果你的目標窗口是一個子窗口你需要先找到它的頂層宿主再用 FindWindowEx 往下找。2.3 發(fā)送消息SendMessage和PostMessage怎么選找到窗口句柄后接下來就是發(fā)送消息。Windows 提供了兩個最常用的 APISendMessage 和 PostMessage。SendMessage 是同步的。它會一直阻塞直到目標窗口過程處理完這條消息并返回。對于控制類消息我通常更推薦 SendMessage因為你知道對方確實收到了并且如果對方在消息處理里返回了一個值你還能拿到結果。PostMessage 是異步的。它把消息丟進目標窗口所在線程的消息隊列后立刻返回。如果接收方線程繁忙消息可能會在隊列里積壓甚至你沒法確定對方什么時候處理。但它的好處是發(fā)送方不會阻塞適合那些不關心結果的“通知一下”場景。UINT msgId RegisterWindowMessage(kMyMsgName); HWND hwndTarget FindWindow(NULL, LMessageReceiver_Sender1); // 同步發(fā)送wParam1 表示“開始刷新” if (hwndTarget) { SendMessage(hwndTarget, msgId, 1, 0); }注意如果目標進程已經(jīng)停止響應SendMessage 可能掛住你的調用線程。生產環(huán)境里可以考慮用 SendMessageTimeout 加超時避免因為對方卡死而把自己拖垮。2.4 接收方窗口過程里的捕獲邏輯接收方要在自己的窗口過程里判斷這個消息號。因為消息號是全局變量 g_uMyMsg比較起來非常直接LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { if (msg g_uMyMsg) { // wParam 是發(fā)送方傳給我們的開關或參數(shù) if (wParam 1) { // 執(zhí)行刷新 } else if (wParam 0) { // 執(zhí)行暫停 } // 處理完消息后返回 0 表示已消費 return 0; } switch (msg) { case WM_CLOSE: PostQuitMessage(0); return 0; } return DefWindowProc(hwnd, msg, wParam, lParam); }這里有一個容易被忽略的點RegisterWindowMessage 本身并不“教會”目標進程理解這個消息。它只是讓兩個進程拿到了全局唯一的同一編號。如果接收方窗口過程里根本沒有判斷這個編號那么消息就會被 DefWindowProc 當作未知消息丟棄。所以你如果要和一個第三方程序通信必須先確認對方確實注冊了你這個字符串并且實現(xiàn)了對應處理邏輯。RegisterWindowMessage 不是萬能的魔法它只是一把通用的鑰匙對方必須愿意插鎖才能打開。2.5 一份可以直接編譯運行的C示例為了方便你測試我整理了一份最小可運行的接收方程序。它注冊一個窗口類創(chuàng)建窗口然后用 RegisterWindowMessage 拿到的消息號去攔截通信。#include windows.h const wchar_t* kMsgName LMyCoolApp_Notify_8B4F2A7E; UINT g_uMyMsg 0; LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { if (msg g_uMyMsg) { // 收到消息可以在這里做實際業(yè)務調度 return 0; } switch (msg) { case WM_CLOSE: PostQuitMessage(0); return 0; } return DefWindowProc(hwnd, msg, wParam, lParam); } int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, PWSTR, int nCmdShow) { g_uMyMsg RegisterWindowMessage(kMsgName); if (g_uMyMsg 0) return 1; WNDCLASS wc {}; wc.lpfnWndProc WndProc; wc.hInstance hInstance; wc.lpszClassName LMsgReceiverClass; RegisterClass(wc); HWND hwnd CreateWindowEx(0, LMsgReceiverClass, LMessageReceiver_Sender1, WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 320, 200, NULL, NULL, hInstance, NULL); if (!hwnd) return 1; SetWindowText(hwnd, LMessageReceiver_Sender1); ShowWindow(hwnd, nCmdShow); MSG msg {}; while (GetMessage(msg, NULL, 0, 0)) { TranslateMessage(msg); DispatchMessage(msg); } return 0; }發(fā)送方程序的核心代碼更短UINT msgId RegisterWindowMessage(LMyCoolApp_Notify_8B4F2A7E); HWND hwnd FindWindow(NULL, LMessageReceiver_Sender1); if (hwnd) { SendMessage(hwnd, msgId, 1, 0); }把接收方程序跑起來再運行發(fā)送方只要打印或判斷一下你就能看到窗口過程收到消息。整個鏈路核心邏輯代碼不會超過四十行。3. 消息參數(shù)并不是你想的那么簡單3.1 WPARAM和LPARAM在32位/64位下的大小問題很多第一次用的人會把 wParam 和 lParam 當成普通的 int 來處理但它們在 32 位和 64 位程序里的大小不一樣。在 64 位 Windows 上WPARAM 是 UINT_PTR占 8 字節(jié)LPARAM 是 LONG_PTR占 8 字節(jié)。這意味著你傳一個 32 位整數(shù)當然沒問題但如果想把一個指針塞進去就要注意類型轉換要匹配。在實際代碼里我見過最典型的錯誤是int data 42; SendMessage(hwnd, msgId, (WPARAM)data, 0);發(fā)送方把 data 的地址傳給了消息接收方拿到指針后解引用。在同一個進程內這個做法勉強能用但換到跨進程通信接收方拿到的是一個屬于其他進程的虛擬地址直接解引用大概率崩潰或者讀到舊內存數(shù)據(jù)。請記住一條鐵律進程的地址空間是隔離的。消息參數(shù)里傳的指針在目標進程看來只是一個數(shù)字它并不指向任何有意義的數(shù)據(jù)。3.2 萬不得已別傳指針如果你只是想傳一兩個小整數(shù)、枚舉值、BOOL 開關完全可以放進 wParam 或 lParam 里。比如// 發(fā)送方 SendMessage(hwndTarget, g_uMyMsg, 1, 0);這里 1 就是你要傳的整數(shù)數(shù)據(jù)接收方讀 wParam 就能拿到。這種用法安全又簡單因為數(shù)字值在跨進程時依然有效。如果你需要傳字符串或結構體就不要再往消息參數(shù)里塞指針了。Windows 專門提供了一個用于跨進程傳輸數(shù)據(jù)的安全消息 WM_COPYDATA。它的原理是系統(tǒng)在你發(fā)送時把源進程內存里的數(shù)據(jù)復制到目標進程的地址空間讓接收方窗口過程能在自己的進程里安全讀取。3.3 傳輸小塊結構化數(shù)據(jù)WM_COPYDATA的正確姿勢WM_COPYDATA 的消息格式比較簡單你只需要構造一個 COPYDATASTRUCT 結構把數(shù)據(jù)指針掛進去然后用 SendMessage 發(fā)送// 發(fā)送方 struct MyPayload { DWORD type; DWORD length; char content[128]; }; MyPayload payload {}; payload.type 1; payload.length 16; strcpy_s(payload.content, hello from A); COPYDATASTRUCT cds {}; cds.dwData 0; // 自定義標識告訴你這條數(shù)據(jù)屬于哪種請求 cds.cbData sizeof(MyPayload); cds.lpData payload; SendMessage(hwndTarget, WM_COPYDATA, (WPARAM)hwndSender, (LPARAM)cds);接收方在窗口過程中處理 WM_COPYDATAcase WM_COPYDATA: { PCOPYDATASTRUCT pcds (PCOPYDATASTRUCT)lParam; MyPayload* pMyPayload (MyPayload*)pcds-lpData; if (pMyPayload) { // 安全讀取數(shù)據(jù) } return 1; // 返回非零表示接收成功 }注意接收方讀取到的數(shù)據(jù)只在該條消息處理期間有效。如果你想把它保存到自己的全局變量里必須自己復制一份不能只保存指針。WM_COPYDATA 是同步消息它本身也會阻塞發(fā)送方適合數(shù)據(jù)量不大但又超過兩個整數(shù)的情況。4. 排查實錄消息發(fā)出去對方卻沒有任何反應4.1 FindWindow找不到窗口后面的努力全白費最常見的“對方收不到消息”其實根本不是消息沒發(fā)出去而是發(fā)送方連目標窗口都沒找到。遇到這種情況我會先做一個檢查清單目標進程真的啟動了嗎目標窗口真的創(chuàng)建了嗎還是只在初始化階段窗口尚未顯示窗口標題是否因為多語言環(huán)境而不同比如中文系統(tǒng)上標題被改了目標程序是否是以管理員權限運行而你的發(fā)送程序是普通權限不同完整性級別的窗口在部分情況下對 FindWindow 可見性會有差異。如果 FindWindow 返回 NULL先用 Spy 或者 EnumWindows 打印所有窗口標題確認你找的名稱是對的。我遇到過不少次窗口類名注冊成功但標題是動態(tài)拼接的導致發(fā)送方永遠匹配不上。4.2 注冊字符串必須一致但很多人在字符串上翻車第二個高頻問題是通信雙方注冊的字符串不一致。RegisterWindowMessage 的機制決定了只有字符串一模一樣才會返回同一個消息編號。我曾經(jīng)在一次多進程協(xié)作項目里把接收方的消息名寫成 LMyApp_Notify_123 末尾多了一個空格發(fā)送方寫的是 LMyApp_Notify_123結果兩邊各自注冊成功但消息號不同消息就石沉大海。再強調一下字符串設計最好用項目代號加用途加隨機后綴并且代碼里定義成同一個常量不要兩邊手寫。比如在公共頭文件里寫#define MSG_NAME_UNDO LCrossProc_Undo_9F3C2A11 #define MSG_NAME_REDO LCrossProc_Redo_9F3C2A11這樣就避免了一個地方改了另一個地方忘改的尷尬。4.3 UAC權限隔離程序以管理員運行時消息會被墻Windows 從 Vista 開始引入了用戶界面特權隔離UIPI。簡單說高完整性級別的進程不允許低完整性級別的進程隨意往它的窗口發(fā)消息。如果你的目標進程是管理員權限發(fā)送方是普通權限SendMessage 可能直接失敗或者目標窗口沒有任何反應。針對這個情況有兩種處理思路把通信雙方都設置成相同的權限級別運行這是最省事的。如果必須跨權限通信接收方高權限需要顯式放行注冊消息??梢允褂?ChangeWindowMessageFilterEx 允許指定消息通過ChangeWindowMessageFilterEx(hwnd, g_uMyMsg, MSGFLT_ALLOW, NULL); // 如果還用到 WM_COPYDATA也要放行 ChangeWindowMessageFilterEx(hwnd, WM_COPYDATA, MSGFLT_ALLOW, NULL);這個 API 必須由高權限進程主動調用低權限進程無法強行放行自己。放到窗口創(chuàng)建之后直接調用就行。4.4 64位與32位進程互發(fā)消息要注意什么如果發(fā)送方是 32 位程序接收方是 64 位程序RegisterWindowMessage 本身不會出問題因為消息編號是系統(tǒng)全局的和位數(shù)無關。但參數(shù)傳遞就有講究了。32 位進程的 WPARAM/LPARAM 只有 4 字節(jié)64 位進程的是 8 字節(jié)。當你從 32 位進程往 64 位進程發(fā)送 SendMessage 時數(shù)值本身會做符號擴展或零擴展傳一個小整數(shù)沒問題但如果想傳指針那必然失敗。反過來也一樣。64 位進程把指針壓縮到 4 字節(jié)發(fā)出去對方還原不了。我的實踐原則是跨位數(shù)進程通信時只傳整數(shù)和枚舉值任何復雜數(shù)據(jù)一律走 WM_COPYDATA。如果你需要非常高頻地傳大量數(shù)據(jù)不如直接上共享內存或命名管道別在消息參數(shù)里糾結。5. RegisterWindowMessage之外的通信工具什么時候升級5.1 WM_COPYDATAWindows自帶的小型數(shù)據(jù)通道WM_COPYDATA 和 RegisterWindowMessage 是非常好的組合。RegisterWindowMessage 負責定義消息類型WM_COPYDATA 負責攜帶數(shù)據(jù)。它們都在 user32.dll 里不需要額外打包也不依賴網(wǎng)絡協(xié)議棧。但 WM_COPYDATA 有明顯的限制它是同步阻塞的必須等接收方窗口過程處理完才返回。如果接收方處理很慢發(fā)送方就會卡住。它適合傳輸最多幾十到幾百 KB 的數(shù)據(jù)不適合持續(xù)高頻的大流量。還有一個細節(jié)WM_COPYDATA 不允許跨桌面發(fā)送也不允許低權限往高權限直接發(fā)這就又回到了上一節(jié)說的 UIPI 問題。5.2 大數(shù)據(jù)、雙向高頻傳輸共享內存和命名管道更靠譜當你需要傳幾千兆字節(jié)、或者通信頻率高到每秒幾百次時Windows 消息機制就不太合適了。SendMessage 畢竟是“一次一同步”PostMessage 又會塞滿消息隊列。這種場景我會優(yōu)先考慮共享內存加命名事件。用 CreateFileMapping 和 MapViewOfFile 創(chuàng)建一塊雙方都能映射的內存區(qū)域再用命名 Event 做同步握手。它的優(yōu)點是性能高沒有序列化和復制開銷缺點是要自己處理并發(fā)和同步實現(xiàn)復雜度立刻上去了。命名管道則適合“字節(jié)流”模式的通信比如進程 A 給進程 B 發(fā)一整段日志或者做遠程服務調用。它的優(yōu)點是語法像文件流比較容易理解缺點是每次調用都有一定系統(tǒng)開銷不在于極低延遲的場景。5.3 現(xiàn)代框架里的IPC和Win32消息并不是同一回事現(xiàn)在很多桌面項目用了 Electron、Tauri 這類跨平臺框架里面常常提到 IPC。這類框架的 IPC 是框架自己實現(xiàn)的進程間通信通道底層在不同平臺會分別用管道、共享內存甚至網(wǎng)絡 socket。它們和 Windows 原生消息機制不在同一個抽象層級上不能互相替代。如果你在一個 Electron 應用里需要和外部 Win32 程序通信通常還是得找 Windows 層來處理要么用原生模塊要么讓主進程通過 Shell 命令調用你的 Win32 工具要么直接在本機命名管道上做對接。RegisterWindowMessage 這種方式只適用于純 Windows 原生的窗口程序之間跨框架、跨平臺基本不用考慮它。我做選型時的判斷標準很簡單控制信號用 RegisterWindowMessage小數(shù)據(jù)用 WM_COPYDATA大數(shù)據(jù)用共享內存協(xié)議復雜、需要主動推送或來回問答的用命名管道。工具沒有絕對的好壞只有合不合適。最后再分享一個我實際養(yǎng)成的小習慣注冊消息的字符串里永遠帶上一段類似 GUID 的隨機后綴它不會讓用戶看到卻能在真實運行的 Windows 環(huán)境里幫你躲開大量奇葩沖突。自從踩過一次“字符串太通用導致和第三方軟件互相串消息”的坑之后我每次寫這種跨進程通信都會從命名階段就謹慎起來。這套 API 雖然年代久遠但只要是純 Windows 原生環(huán)境它依然是輕量通信里極順手的選擇。