深入探討.NET中的鉤子技術

本文將討論在.NET應用程序中全局系統鉤子的使用。爲此,我開發了一個可重用的類庫並創建一個相應的示例程序.

 你可能注意到另外的關於使用系統鉤子的文章。本文與之類似但是有重要的差別。這篇文章將討論在.NET中使用全局系統鉤子,而其它文章僅討論本地系統鉤子。這些思想是類似的,但是實現要求是不同的。

  二、 背景

  如果你對Windows系統鉤子的概念不熟悉,讓我作一下簡短的描述:

  ·一個系統鉤子允許你插入一個回調函數-它攔截某些Windows消息(例如,鼠標相聯繫的消息)。

  ·一個本地系統鉤子是一個系統鉤子-它僅在指定的消息由一個單一線程處理時被調用。

  ·一個全局系統鉤子是一個系統鉤子-它當指定的消息被任何應用程序在整個系統上所處理時被調用。
已有若干好文章來介紹系統鉤子概念。在此,不是爲了重新收集這些介紹性的信息,我只是簡單地請讀者參考下面有關係統鉤子的一些背景資料文章。如果你對系統鉤子概念很熟悉,那麼你能夠從本文中得到你能夠得到的任何東西。

  ·關於MSDN庫中的鉤子知識。

  ·Dino Esposito的《Cutting Edge-Windows Hooks in the .NET Framework》。

  ·Don Kackman的《在C#中應用鉤子》。

  本文中我們要討論的是擴展這個信息來創建一個全局系統鉤子-它能被.NET類所使用。我們將用C#和一個DLL和非託管C++來開發一個類庫-它們一起將完成這個目標。

  三、 使用代碼

  在我們深入開發這個庫之前,讓我們快速看一下我們的目標。在本文中,我們將開發一個類庫-它安裝全局系統鉤子並且暴露這些由鉤子處理的事件,作爲我們的鉤子類的一個.NET事件。爲了說明這個系統鉤子類的用法,我們將在一個用C#編寫的Windows表單應用程序中創建一個鼠標事件鉤子和一個鍵盤事件鉤子。

  這些類庫能用於創建任何類型的系統鉤子,其中有兩個預編譯的鉤子-MouseHook和KeyboardHook。我們也已經包含了這些類的特定版本,分別稱爲MouseHookExt和KeyboardHookExt。根據這些類所設置的模型,你能容易構建系統鉤子-針對Win32 API中任何15種鉤子事件類型中的任何一種。另外,這個完整的類庫中還有一個編譯的HTML幫助文件-它把這些類歸檔化。請確信你看了這個幫助文件-如果你決定在你的應用程序中使用這個庫的話。

  MouseHook類的用法和生命週期相當簡單。首先,我們創建MouseHook類的一個實例。

mouseHook = new MouseHook();//mouseHook是一個成員變量


  接下來,我們把MouseEvent事件綁定到一個類層次的方法上。

mouseHook.MouseEvent+=new MouseHook.MouseEventHandler(mouseHook_MouseEvent);
// ...
private void mouseHook_MouseEvent(MouseEvents mEvent, int x, int y){
 string msg =string.Format("鼠標事件:{0}:({1},{2}).",mEvent.ToString(),x,y);
 AddText(msg);//增加消息到文本框
}


  爲開始收到鼠標事件,簡單地安裝下面的鉤子即可。

mouseHook.InstallHook();


  爲停止接收事件,只需簡單地卸載這個鉤子。

mouseHook.UninstallHook();


  你也可以調用Dispose來卸載這個鉤子。

  在你的應用程序退出時,卸載這個鉤子是很重要的。讓系統鉤子一直安裝着將減慢系統中的所有的應用程序的消息處理。它甚至能夠使一個或多個進程變得很不穩定。因此,請確保在你使用完鉤子時一定要移去你的系統鉤子。我們確定在我們的示例應用程序會移去該系統鉤子-通過在Form的Dispose方法中添加一個Dispose調用。

protected override void Dispose(bool disposing) {
 if (disposing) {
  if (mouseHook != null) {
   mouseHook.Dispose();
   mouseHook = null;
  }
  // ...
 }
}


  使用該類庫的情況就是如此。該類庫中有兩個系統鉤子類並且相當容易擴充。

  四、 構建庫

  這個庫共有兩個主要組件。第一部分是一個C#類庫-你可以直接使用於你的應用程序中。該類庫,反過來,在內部使用一個非託管的C++ DLL來直接管理系統鉤子。我們將首先討論開發該C++部分。接下來,我們將討論怎麼在C#中使用這個庫來構建一個通用的鉤子類。就象我們討論C++/C#交互一樣,我們將特別注意C++方法和數據類型是怎樣映射到.NET方法和數據類型的。

  你可能想知道爲什麼我們需要兩個庫,特別是一個非託管的C++ DLL。你還可能注意到在本文的背景一節中提到的兩篇參考文章,其中並沒有使用任何非託管的代碼。爲此,我的回答是,"對!這正是我寫這篇文章的原因"。當你思考系統鉤子是怎樣實際地實現它們的功能時,我們需要非託管的代碼是十分重要的。爲了使一個全局的系統鉤子能夠工作,Windows把你的DLL插入到每個正在運行的進程的進程空間中。既然大多數進程不是.NET進程,所以,它們不能直接執行.NET裝配集。我們需要一種非託管的代碼代理-Windows可以把它插入到所有將要被鉤住的進程中。

  首先是提供一種機制來把一個.NET代理傳遞到我們的C++庫。這樣,我們用C++語言定義下列函數(SetUserHookCallback)和函數指針(HookProc)。

int SetUserHookCallback(HookProc userProc, UINT hookID)
typedef void (CALLBACK *HookProc)(int code, WPARAM w, LPARAM l)


  SetUserHookCallback的第二個參數是鉤子類型-這個函數指針將使用它。現在,我們必須用C#來定義相應的方法和代理以使用這段代碼。下面是我們怎樣把它映射到C#。

private static extern SetCallBackResults
SetUserHookCallback(HookProcessedHandler hookCallback, HookTypes hookType)
protected delegate void HookProcessedHandler(int code, UIntPtr wparam, IntPtr lparam)
public enum HookTypes {
 JournalRecord = 0,
 JournalPlayback = 1,
 // ...
 KeyboardLL = 13,
 MouseLL = 14
};


  首先,我們使用DllImport屬性導入SetUserHookCallback函數,作爲我們的抽象基鉤子類SystemHook的一個靜態的外部的方法。爲此,我們必須映射一些外部數據類型。首先,我們必須創建一個代理作爲我們的函數指針。這是通過定義上面的HookProcessHandler來實現的。我們需要一個函數,它的C++簽名爲(int,WPARAM,LPARAM)。在Visual Studio .NET C++編譯器中,int與C#中是一樣的。也就是說,在C++與C#中int就是Int32。事情並不總是這樣。一些編譯器把C++ int作爲Int16對待。我們堅持使用Visual Studio .NET C++編譯器來實現這個工程,因此,我們不必擔心編譯器差別所帶來的另外的定義。

  接下來,我們需要用C#傳遞WPARAM和LPARAM值。這些確實是指針,它們分別指向C++的UINT和LONG值。用C#來說,它們是指向uint和int的指針。如果你還不確定什麼是WPARAM,你可以通過在C++代碼中單擊右鍵來查詢它,並且選擇"Go to definition"。這將會引導你到在windef.h中的定義。

//從windef.h:
typedef UINT_PTR WPARAM;
typedef LONG_PTR LPARAM;


  因此,我們選擇System.UIntPtr和System.IntPtr作爲我們的變量類型-它們分別相應於WPARAM和LPARAM類型,當它們使用在C#中時。
現在,讓我們看一下鉤子基類是怎樣使用這些導入的方法來傳遞一個回叫函數(代理)到C++中-它允許C++庫直接調用你的系統鉤子類的實例。首先,在構造器中,SystemHook類創建一個到私有方法InternalHookCallback的代理-它匹配HookProcessedHandler代理簽名。然後,它把這個代理和它的HookType傳遞到C++庫以使用SetUserHookCallback方法來註冊該回叫函數,如上面所討論的。下面是其代碼實現:

public SystemHook(HookTypes type){
 _type = type;
 _processHandler = new HookProcessedHandler(InternalHookCallback);
 SetUserHookCallback(_processHandler, _type);
}


  InternalHookCallback的實現相當簡單。InternalHookCallback在用一個catch-all try/catch塊包裝它的同時僅傳遞到抽象方法HookCallback的調用。這將簡化在派生類中的實現並且保護C++代碼。記住,一旦一切都準備妥當,這個C++鉤子就會直接調用這個方法。

[MethodImpl(MethodImplOptions.NoInlining)]
private void InternalHookCallback(int code, UIntPtr wparam, IntPtr lparam){
try { HookCallback(code, wparam, lparam); }
catch {}
}


  我們已增加了一個方法實現屬性-它告訴編譯器不要內聯這個方法。這不是可選的。至少,在我添加try/catch之前是需要的。看起來,由於某些原因,編譯器在試圖內聯這個方法-這將給包裝它的代理帶來各種麻煩。然後,C++層將回叫,而該應用程序將會崩潰。

  現在,讓我們看一下一個派生類是怎樣用一個特定的HookType來接收和處理鉤子事件。下面是虛擬的MouseHook類的HookCallback方法實現:

protected override void HookCallback(int code, UIntPtr wparam, IntPtr lparam){
 if (MouseEvent == null) { return; }
  int x = 0, y = 0;
  MouseEvents mEvent = (MouseEvents)wparam.ToUInt32();
  switch(mEvent) {
   case MouseEvents.LeftButtonDown:
    GetMousePosition(wparam, lparam, ref x, ref y);
    break;
   // ...
  }
 MouseEvent(mEvent, new Point(x, y));
}


  首先,注意這個類定義一個事件MouseEvent-該類在收到一個鉤子事件時激發這個事件。這個類在激發它的事件之前,把數據從WPARAM和LPARAM類型轉換成.NET中有意義的鼠標事件數據。這樣可以使得類的消費者免於擔心解釋這些數據結構。這個類使用導入的GetMousePosition函數-我們在C++ DLL中定義的用來轉換這些值。爲此,請看下面幾段的討論。

  在這個方法中,我們檢查是否有人在聽這一個事件。如果沒有,不必繼續處理這一事件。然後,我們把WPARAM轉換成一個MouseEvents枚舉類型。我們已小心地構造了MouseEvents枚舉來準確匹配它們在C++中相應的常數。這允許我們簡單地把指針的值轉換成枚舉類型。但是要注意,這種轉換即使在WPARAM的值不匹配一個枚舉值的情況下也會成功。mEvent的值將僅是未定義的(不是null,只是不在枚舉值範圍之內)。爲此,請詳細分析System.Enum.IsDefined方法。

  接下來,在確定我們收到的事件類型後,該類激活這個事件,並且通知消費者鼠標事件的類型及在該事件過程中鼠標的位置。

  最後注意,有關轉換WPARAM和LPARAM值:對於每個類型的事件,這些變量的值和意思是不同的。因此,在每一種鉤子類型中,我們必須區別地解釋這些值。我選擇用C++實現這種轉換,而不是儘量用C#來模仿複雜的C++結構和指針。例如,前面的類就使用了一個叫作GetMousePosition的C++函數。下面是C++ DLL中的這個方法:

bool GetMousePosition(WPARAM wparam, LPARAM lparam, int & x, int & y) {
 MOUSEHOOKSTRUCT * pMouseStruct = (MOUSEHOOKSTRUCT *)lparam;
 x = pMouseStruct->pt.x;
 y = pMouseStruct->pt.y;
 return true;
}


  不是儘量映射MOUSEHOOKSTRUCT結構指針到C#,我們簡單地暫時把它回傳到C++層以提取我們需要的值。注意,因爲我們需要從這個調用中返回一些值,我們把我們的整數作爲參考變量傳遞。這直接映射到C#中的int*。但是,我們可以重載這個行爲,通過選擇正確的簽名來導入這個方法。

private static extern bool InternalGetMousePosition(UIntPtr wparam,IntPtr lparam, ref int x, ref int y)


  通過把integer參數定義爲ref int,我們得到通過C++參照傳遞給我們的值。如果我們想要的話,我們還可以使用out int。

 

  五、 限制

  一些鉤子類型並不適合實現全局鉤子。我當前正在考慮解決辦法-它將允許使用受限制的鉤子類型。到目前爲止,不要把這些類型添加回該庫中,因爲它們將導致應用程序的失敗(經常是系統範圍的災難性失敗)。下一節將集中討論這些限制背後的原因和解決辦法。

HookTypes.CallWindowProcedure
HookTypes.CallWindowProret
HookTypes.ComputerBasedTraining
HookTypes.Debug
HookTypes.ForegroundIdle
HookTypes.JournalRecord
HookTypes.JournalPlayback
HookTypes.GetMessage
HookTypes.SystemMessageFilter


  六、 兩種類型的鉤子

  在本節中,我將盡量解釋爲什麼一些鉤子類型被限制在一定的範疇內而另外一些則不受限制。如果我使用有點偏差術語的話,請原諒我。我還沒有找到任何有關這部分題目的文檔,因此,我編造了我自己的詞彙。另外,如果你認爲我根本就不對,請告訴我好了。

  當Windows調用傳遞到SetWindowsHookEx()的回調函數時它們會因不同類型的鉤子而被區別調用。基本上有兩種情況:切換執行上下文的鉤子和不切換執行上下文的鉤子。用另一種方式說,也就是,在放鉤子的應用程序進程空間執行鉤子回調函數的情況和在被鉤住的應用程序進程空間執行鉤子回調函數的情況。

  鉤子類型例如鼠標和鍵盤鉤子都是在被Windows調用之前切換上下文的。整個過程大致如下:

  1. 應用程序X擁有焦點並執行。

  2. 用戶按下一個鍵。

  3. Windows從應用程序X接管上下文並把執行上下文切換到放鉤子的應用程序。

  4. Windows用放鉤子的應用程序進程空間中的鍵消息參數調用鉤子回調函數。

  5. Windows從放鉤子的應用程序接管上下文並把執行上下文切換回應用程序X。

  6. Windows把消息放進應用程序X的消息排隊。

  7. 稍微一會兒之後,當應用程序X執行時,它從自己的消息排隊中取出消息並且調用它的內部按鍵(或鬆開或按下)處理器。

  8. 應用程序X繼續執行...

  例如CBT鉤子(window創建,等等。)的鉤子類型並不切換上下文。對於這些類型的鉤子,過程大致如下:

  1. 應用程序X擁有焦點並執行。

  2. 應用程序X創建一個窗口。

  3. Windows用在應用程序X進程空間中的CBT事件消息參數調用鉤子回調函數。

  4. 應用程序X繼續執行...

  這應該說明了爲什麼某種類型的鉤子能夠用這個庫結構工作而一些卻不能。記住,這正是該庫要做的。在上面第4步和第3步之後,分別插入下列步驟:

  1. Windows調用鉤子回調函數。

  2. 目標回調函數在非託管的DLL中執行。

  3. 目標回調函數查找它的相應託管的調用代理。

  4. 託管代理被以適當的參數執行。

  5. 目標回調函數返回並執行相應於指定消息的鉤子處理。

  第三步和第四步因非切換鉤子類型而註定失敗。第三步將失敗,因爲相應的託管回調函數不會爲該應用程序而設置。記住,這個DLL使用全局變量來跟蹤這些託管代理並且該鉤子DLL被加載到每一個進程空間。但是這個值僅在放鉤子的應用程序進程空間中設置。對於另外其它情況,它們全部爲null。

  Tim Sylvester在他的《Other hook types》一文中指出,使用一個共享內存區段將會解決這個問題。這是真實的,但是也如Tim所指出的,那些託管代理地址對於除了放鉤子的應用程序之外的任何進程是無意義的。這意味着,它們是無意義的並且不能在回調函數的執行過程中調用。那樣會有麻煩的。

  因此,爲了把這些回調函數使用於不執行上下文切換的鉤子類型,你需要某種進程間的通訊。

  我已經試驗過這種思想-使用非託管的DLL鉤子回調函數中的進程外COM對象進行IPC。如果你能使這種方法工作,我將很高興瞭解到這點。至於我的嘗試,結果並不理想。基本原因是很難針對各種進程和它們的線程(CoInitialize(NULL))而正確地初始化COM單元。這是一個在你可以使用COM對象之前的基本要求。

  我不懷疑,一定有辦法來解決這個問題。但是我還沒有試用過它們,因爲我認爲它們僅有有限的用處。例如,CBT鉤子可以讓你取消一個窗口創建,如果你希望的話。可以想像,爲使這能夠工作將會發生什麼。

  1. 鉤子回調函數開始執行。

  2. 調用非託管的鉤子DLL中的相應的鉤子回調函數。

  3. 執行必須被路由回到主鉤子應用程序。

  4. 該應用程序必須決定是否允許這一創建。

  5. 調用必須被路由回仍舊在運行中的鉤子回調函數。

  6. 在非託管的鉤子DLL中的鉤子回調函數從主鉤子應用程序接收到要採取的行動。

  7. 在非託管的鉤子DLL中的鉤子回調函數針對CBT鉤子調用採取適當的行動。

  8. 完成鉤子回調函數的執行。

  這不是不可能的,但是不算好的。我希望這會消除在該庫中的圍繞被允許的和受限制的鉤子類型所帶來的神祕。

  七、 其它

  ·庫文檔:我們已經包含了有關ManagedHooks類庫的比較完整的代碼文檔。當以"Documentation"構建配置進行編譯時,這被經由Visual Studio.NET轉換成標準幫助XML。最後,我們已使用NDoc來把它轉換成編譯的HTML幫助(CHM)。你可以看這個幫助文件,只需簡單地在該方案的解決方案資源管理器中點擊Hooks.chm文件或通過查找與該文相關的可下載的ZIP文件。

  ·增強的智能感知:如果你不熟悉Visual Studio.NET怎樣使用編譯的XML文件(pre-NDoc output)來爲參考庫的工程增強智能感知,那麼讓我簡單地介紹一下。如果你決定在你的應用程序中使用這個類庫,你可以考慮複製該庫的一個穩定構建版本到你想參考它的位置。同時,還要把XML文檔文件 (SystemHooks/ManagedHooks/bin/Debug/Kennedy.ManagedHooks.xml)複製到相同的位置。當你添加一個參考到該庫時,Visual Studio.NET將自動地讀該文件並使用它來添加智能感知文檔。這是很有用的,特別是對於象這樣的第三方庫。

  ·單元測試:我相信,所有的庫都應有與之相應的單元測試。既然我是一家公司(主要負責針對.NET環境軟件的單元測試)的合夥人和軟件工程師,任何人不會對此感到驚訝。因而,你將會在名爲ManagedHooksTests的解決方案中找到一個單元測試工程。爲了運行該單元測試,你需要下載和安裝HarnessIt-這個下載是我們的商業單元測試軟件的一個自由的試用版本。在該單元測試中,我對這給予了特殊的注意-在此處,方法的無效參數可能導致C++內存異常的發生。儘管這個庫是相當簡單的,但該單元測試確實能夠幫助我在一些更爲微妙的情況下發現一些錯誤。

  ·非託管的/託管的調試:有關混合解決方案(例如,本文的託管的和非託管的代碼)最爲技巧的地方之一是調試問題。如果你想單步調試該C++代碼或在C++代碼中設置斷點,你必須啓動非託管的調試。這是一個Visual Studio.NET中的工程設置。注意,你可以非常順利地單步調試託管的和非託管的層,但是,在調試過程中,非託管的調試確實嚴重地減慢應用程序的裝載時間和執行速度。

  八、 最後警告

  很明顯,系統鉤子相當有力量;然而,使用這種力量應該是有責任性的。在系統鉤子出了問題時,它們不僅僅垮掉你的應用程序。它們可以垮掉在你的當前系統中運行的每個應用程序。但是到這種程度的可能性一般是很小的。儘管如此,在使用系統鉤子時,你還是需要再三檢查你的代碼。

  我發現了一項可以用來開發應用程序的有用的技術-它使用系統鉤子來在微軟的虛擬PC上安裝你的喜愛的開發操作系統的一個拷貝和Visual Studio.NET。然後,你就可以在此虛擬的環境中開發你的應用程序。用這種方式,當你的鉤子應用程序出現錯誤時,它們將僅退出你的操作系統的虛擬實例而不是你的真正的操作系統。我已經不得不重啓動我的真正的OS-在這個虛擬OS由於一個鉤子錯誤崩潰時,但是這並不經常。

  注意,如果你在網上訂閱了一個MSDN,那麼在你整個訂閱過程中你可以自由使用虛擬PC。

 
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章