一 介绍
本文将讨论在NET应用程序中全局系统钩子的使用为此我开发了一个可重用的类库并创建一个相应的示例程序(见下图)
你可能注意到另外的关于使用系统钩子的文章本文与之类似但是有重要的差别这篇文章将讨论在NET中使用全局系统钩子而其它文章仅讨论本地系统钩子这些思想是类似的但是实现要求是不同的
二 背景
如果你对Windows系统钩子的概念不熟悉让我作一下简短的描述
·一个系统钩子允许你插入一个回调函数它拦截某些Windows消息(例如鼠标相联系的消息)
·一个本地系统钩子是一个系统钩子它仅在指定的消息由一个单一线程处理时被调用
·一个全局系统钩子是一个系统钩子它当指定的消息被任何应用程序在整个系统上所处理时被调用
已有若干好文章来介绍系统钩子概念在此不是为了重新收集这些介绍性的信息我只是简单地请读者参考下面有关系统钩子的一些背景资料文章如果你对系统钩子概念很熟悉那么你能够从本文中得到你能够得到的任何东西
·关于MSDN库中的钩子知识
·Dino Esposito的《Cutting EdgeWindows Hooks in the NET Framework》
·Don Kackman的《在C#中应用钩子》
本文中我们要讨论的是扩展这个信息来创建一个全局系统钩子它能被NET类所使用我们将用C#和一个DLL和非托管C++来开发一个类库它们一起将完成这个目标
三 使用代码
在我们深入开发这个库之前让我们快速看一下我们的目标在本文中我们将开发一个类库它安装全局系统钩子并且暴露这些由钩子处理的事件作为我们的钩子类的一个NET事件为了说明这个系统钩子类的用法我们将在一个用C#编写的Windows表单应用程序中创建一个鼠标事件钩子和一个键盘事件钩子
这些类库能用于创建任何类型的系统钩子其中有两个预编译的钩子MouseHook和KeyboardHook我们也已经包含了这些类的特定版本分别称为MouseHookExt和KeyboardHookExt根据这些类所设置的模型你能容易构建系统钩子针对Win API中任何种钩子事件类型中的任何一种另外这个完整的类库中还有一个编译的HTML帮助文件它把这些类归档化请确信你看了这个帮助文件如果你决定在你的应用程序中使用这个库的话
MouseHook类的用法和生命周期相当简单首先我们创建MouseHook类的一个实例
mouseHook = new MouseHook();//mouseHook是一个成员变量
接下来我们把MouseEvent事件绑定到一个类层次的方法上
mouseHookMouseEvent+=new MouseHookMouseEventHandler(mouseHook_MouseEvent);
//
private void mouseHook_MouseEvent(MouseEvents mEvent int x int y){
string msg =stringFormat(鼠标事件:{}:({}{})mEventToString()xy);
AddText(msg);//增加消息到文本框
}
为开始收到鼠标事件简单地安装下面的钩子即可
mouseHookInstallHook();
为停止接收事件只需简单地卸载这个钩子
mouseHookUninstallHook();
你也可以调用Dispose来卸载这个钩子
在你的应用程序退出时卸载这个钩子是很重要的让系统钩子一直安装着将减慢系统中的所有的应用程序的消息处理它甚至能够使一个或多个进程变得很不稳定因此请确保在你使用完钩子时一定要移去你的系统钩子我们确定在我们的示例应用程序会移去该系统钩子通过在Form的Dispose方法中添加一个Dispose调用
protected override void Dispose(bool disposing) {
if (disposing) {
if (mouseHook != null) {
mouseHookDispose();
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 =
JournalPlayback =
//
KeyboardLL =
MouseLL =
};
首先我们使用DllImport属性导入SetUserHookCallback函数作为我们的抽象基钩子类SystemHook的一个静态的外部的方法为此我们必须映射一些外部数据类型首先我们必须创建一个代理作为我们的函数指针这是通过定义上面的HookProcessHandler来实现的我们需要一个函数它的C++签名为(intWPARAMLPARAM)在Visual Studio NET C++编译器中int与C#中是一样的也就是说在C++与C#中int就是Int事情并不总是这样一些编译器把C++ int作为Int对待我们坚持使用Visual Studio NET C++编译器来实现这个工程因此我们不必担心编译器差别所带来的另外的定义
接下来我们需要用C#传递WPARAM和LPARAM值这些确实是指针它们分别指向C++的UINT和LONG值用C#来说它们是指向uint和int的指针如果你还不确定什么是WPARAM你可以通过在C++代码中单击右键来查询它并且选择Go to definition这将会引导你到在windefh中的定义
//从windefh:
typedef UINT_PTR WPARAM;
typedef LONG_PTR LPARAM;
因此我们选择SystemUIntPtr和SystemIntPtr作为我们的变量类型它们分别相应于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在用一个catchall try/catch块包装它的同时仅传递到抽象方法HookCallback的调用这将简化在派生类中的实现并且保护C++代码记住一旦一切都准备妥当这个C++钩子就会直接调用这个方法
[MethodImpl(MethodImplOptionsNoInlining)]
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 = y = ;
MouseEvents mEvent = (MouseEvents)wparamToUInt();
switch(mEvent) {
case MouseEventsLeftButtonDown:
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只是不在枚举值范围之内)为此请详细分析SystemEnumIsDefined方法
接下来在确定我们收到的事件类型后该类激活这个事件并且通知消费者鼠标事件的类型及在该事件过程中鼠标的位置
最后注意有关转换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>ptx;
y = pMouseStruct>pty;
return true;
}
不是尽量映射MOUSEHOOKSTRUCT结构指针到C#我们简单地暂时把它回传到C++层以提取我们需要的值注意因为我们需要从这个调用中返回一些值我们把我们的整数作为参考变量传递这直接映射到C#中的int*但是我们可以重载这个行为通过选择正确的签名来导入这个方法
private static extern bool InternalGetMousePosition(UIntPtr wparamIntPtr lparam ref int x ref int y)
通过把integer参数定义为ref int我们得到通过C++参照传递给我们的值如果我们想要的话我们还可以使用out int
五 限制
一些钩子类型并不适合实现全局钩子我当前正在考虑解决办法它将允许使用受限制的钩子类型到目前为止不要把这些类型添加回该库中因为它们将导致应用程序的失败(经常是系统范围的灾难性失败)下一节将集中讨论这些限制背后的原因和解决办法
HookTypesCallWindowProcedure
HookTypesCallWindowProret
HookTypesComputerBasedTraining
HookTypesDebug
HookTypesForegroundIdle
HookTypesJournalRecord
HookTypesJournalPlayback
HookTypesGetMessage
HookTypesSystemMessageFilter
六 两种类型的钩子
在本节中我将尽量解释为什么一些钩子类型被限制在一定的范畴内而另外一些则不受限制如果我使用有点偏差术语的话请原谅我我还没有找到任何有关这部分题目的文档因此我编造了我自己的词汇另外如果你认为我根本就不对请告诉我好了
当Windows调用传递到SetWindowsHookEx()的回调函数时它们会因不同类型的钩子而被区别调用基本上有两种情况切换执行上下文的钩子和不切换执行上下文的钩子用另一种方式说也就是在放钩子的应用程序进程空间执行钩子回调函数的情况和在被钩住的应用程序进程空间执行钩子回调函数的情况
钩子类型例如鼠标和键盘钩子都是在被Windows调用之前切换上下文的整个过程大致如下
应用程序X拥有焦点并执行
用户按下一个键
Windows从应用程序X接管上下文并把执行上下文切换到放钩子的应用程序
Windows用放钩子的应用程序进程空间中的键消息参数调用钩子回调函数
Windows从放钩子的应用程序接管上下文并把执行上下文切换回应用程序X
Windows把消息放进应用程序X的消息排队
稍微一会儿之后当应用程序X执行时它从自己的消息排队中取出消息并且调用它的内部按键(或松开或按下)处理器
应用程序X继续执行
例如CBT钩子(window创建等等)的钩子类型并不切换上下文对于这些类型的钩子过程大致如下
应用程序X拥有焦点并执行
应用程序X创建一个窗口
Windows用在应用程序X进程空间中的CBT事件消息参数调用钩子回调函数
应用程序X继续执行
这应该说明了为什么某种类型的钩子能够用这个库结构工作而一些却不能记住这正是该库要做的在上面第步和第步之后分别插入下列步骤
Windows调用钩子回调函数
目标回调函数在非托管的DLL中执行
目标回调函数查找它的相应托管的调用代理
托管代理被以适当的参数执行
目标回调函数返回并执行相应于指定消息的钩子处理
第三步和第四步因非切换钩子类型而注定失败第三步将失败因为相应的托管回调函数不会为该应用程序而设置记住这个DLL使用全局变量来跟蹤这些托管代理并且该钩子DLL被加载到每一个进程空间但是这个值仅在放钩子的应用程序进程空间中设置对于另外其它情况它们全部为null
Tim Sylvester在他的《Other hook types》一文中指出使用一个共享内存区段将会解决这个问题这是真实的但是也如Tim所指出的那些托管代理地址对于除了放钩子的应用程序之外的任何进程是无意义的这意味着它们是无意义的并且不能在回调函数的执行过程中调用那样会有麻烦的
因此为了把这些回调函数使用于不执行上下文切换的钩子类型你需要某种进程间的通讯
我已经试验过这种思想使用非托管的DLL钩子回调函数中的进程外COM对象进行IPC如果你能使这种方法工作我将很高兴了解到这点至于我的尝试结果并不理想基本原因是很难针对各种进程和它们的线程(CoInitialize(NULL))而正确地初始化COM单元这是一个在你可以使用COM对象之前的基本要求
我不怀疑一定有办法来解决这个问题但是我还没有试用过它们因为我认为它们仅有有限的用处例如CBT钩子可以让你取消一个窗口创建如果你希望的话可以想像为使这能够工作将会发生什么
钩子回调函数开始执行
调用非托管的钩子DLL中的相应的钩子回调函数
执行必须被路由回到主钩子应用程序
该应用程序必须决定是否允许这一创建
调用必须被路由回仍旧在运行中的钩子回调函数
在非托管的钩子DLL中的钩子回调函数从主钩子应用程序接收到要采取的行动
在非托管的钩子DLL中的钩子回调函数针对CBT钩子调用采取适当的行动
完成钩子回调函数的执行
这不是不可能的但是不算好的我希望这会消除在该库中的围绕被允许的和受限制的钩子类型所带来的神秘
七 其它
·库文档我们已经包含了有关ManagedHooks类库的比较完整的代码文档当以Documentation构建配置进行编译时这被经由Visual StudioNET转换成标准帮助XML最后我们已使用NDoc来把它转换成编译的HTML帮助(CHM)你可以看这个帮助文件只需简单地在该方案的解决方案资源管理器中点击Hookschm文件或通过查找与该文相关的可下载的ZIP文件
·增强的智能感知如果你不熟悉Visual StudioNET怎样使用编译的XML文件(preNDoc output)来为参考库的工程增强智能感知那么让我简单地介绍一下如果你决定在你的应用程序中使用这个类库你可以考虑复制该库的一个稳定构建版本到你想参考它的位置同时还要把XML文档文件 (SystemHooks\ManagedHooks\bin\Debug\KennedyManagedHooksxml)复制到相同的位置当你添加一个参考到该库时Visual StudioNET将自动地读该文件并使用它来添加智能感知文档这是很有用的特别是对于象这样的第三方库
·单元测试我相信所有的库都应有与之相应的单元测试既然我是一家公司(主要负责针对NET环境软件的单元测试)的合伙人和软件工程师任何人不会对此感到惊讶因而你将会在名为ManagedHooksTests的解决方案中找到一个单元测试工程为了运行该单元测试你需要下载和安装HarnessIt这个下载是我们的商业单元测试软件的一个自由的试用版本在该单元测试中我对这给予了特殊的注意在此处方法的无效参数可能导致C++内存异常的发生尽管这个库是相当简单的但该单元测试确实能够帮助我在一些更为微妙的情况下发现一些错误
·非托管的/托管的调试有关混合解决方案(例如本文的托管的和非托管的代码)最为技巧的地方之一是调试问题如果你想单步调试该C++代码或在C++代码中设置断点你必须启动非托管的调试这是一个Visual StudioNET中的工程设置注意你可以非常顺利地单步调试托管的和非托管的层但是在调试过程中非托管的调试确实严重地减慢应用程序的装载时间和执行速度
八 最后警告
很明显系统钩子相当有力量然而使用这种力量应该是有责任性的在系统钩子出了问题时它们不仅仅垮掉你的应用程序它们可以垮掉在你的当前系统中运行的每个应用程序但是到这种程度的可能性一般是很小的尽管如此在使用系统钩子时你还是需要再三检查你的代码
我发现了一项可以用来开发应用程序的有用的技术它使用系统钩子来在微软的虚拟PC上安装你的喜爱的开发操作系统的一个拷贝和Visual StudioNET然后你就可以在此虚拟的环境中开发你的应用程序用这种方式当你的钩子应用程序出现错误时它们将仅退出你的操作系统的虚拟实例而不是你的真正的操作系统我已经不得不重启动我的真正的OS在这个虚拟OS由于一个钩子错误崩溃时但是这并不经常
注意如果你在网上订阅了一个MSDN那么在你整个订阅过程中你可以自由使用虚拟PC