.NET 中的全局系统挂钩






4.91/5 (145投票s)
一个用于在 .NET 中使用*全局*系统挂钩的类库。
引言
本文讨论了在 .NET 应用程序中使用全局系统挂钩。在此过程中,我们还开发了一个可重用的类库。
您可能已经在 Code Project 或其他出版物上看到了关于使用 P/Invoke 进行系统挂钩的其他文章(请参阅下面的背景部分)。本文与那些文章类似,但有一个显著的区别。本文介绍了在 .NET 中使用全局系统挂钩,而其他文章介绍了本地系统挂钩。概念是相似的,但实现要求不同。
背景
如果您不熟悉 Windows 中的系统挂钩概念,让我简要说明一下。
- 系统挂钩允许您插入一个回调函数,该函数会拦截某些 Windows 消息(例如,与鼠标相关的消息)。.
- 本地系统挂钩是指在指定的*消息*仅由单个线程处理时才会被调用的系统挂钩。.
- 全局系统挂钩是指当指定的*消息*由系统中的任何应用程序处理时都会被调用的系统挂钩。.
有几篇关于系统挂钩概念的优秀文章。我将不再在这里重复介绍性信息,而是将读者引荐给那些文章以获取系统挂钩的背景信息。如果您熟悉系统挂钩的概念,那么您应该能够从本文中获得您需要的一切。
- 关于挂钩[^] MSDN 库。
- Cutting Edge - Windows Hooks in the .NET Framework [^] by Dino Esposito。
- Using Hooks from C# [^] by Don Kackman。
本文将介绍如何扩展这些信息,以创建可供 .NET 类使用的全局系统挂钩。我们将用 C# 开发一个类库,并用非托管 C++ 开发一个 DLL,它们共同实现这一目标。
使用代码
在深入开发这个类库之前,让我们快速看一下我们的目标。在本文中,我们将开发一个安装全局系统挂钩的类库,并将挂钩处理的消息作为我们挂钩类的 .NET 事件暴露出来。为了说明系统挂钩类的用法,我们将使用 C# 编写的 Windows Forms 应用程序创建一个鼠标事件挂钩和键盘事件挂钩。
该类库可用于创建任何类型的系统挂钩。有两种预置类:MouseHook
和 KeyboardHook
。我们还包含这些类的专用版本,分别称为 MouseHookExt
和 KeyboardHookExt
。遵循这些类的模型,您可以轻松地为 Win32 API 中所有 15 种挂钩事件类型构建系统挂钩。此外,整个类库都附带一个编译后的 HTML 帮助文件,其中记录了这些类。如果您决定在应用程序中使用此库,请务必查阅该帮助文件。
MouseHook
类的使用和生命周期非常简单。首先,我们创建一个 MouseHook
类的实例。
mouseHook = new MouseHook(); // mouseHook is a member variable
接下来,我们将 MouseEvent
事件连接到一个类级别的方法。
mouseHook.MouseEvent += new MouseHook.MouseEventHandler(mouseHook_MouseEvent);
// ...
private void mouseHook_MouseEvent(MouseEvents mEvent, int x, int y)
{
string msg = string.Format("Mouse event: {0}: ({1},{2}).",
mEvent.ToString(), x, y);
AddText(msg); // Adds the message to the text box.
}
要开始接收鼠标事件,只需安装挂钩。
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# 中的 int
相同。即,int
在 C++ 和 C# 中都是 Int32
。情况并非总是如此。有些编译器将 C++ int
视为 Int16
。我们将在此项目中使用 Visual Studio .NET C++ 编译器,因此不会因编译器差异而担心其他定义。最后,我们通过显式设置枚举值与 C++ 等价的挂钩类型相同的枚举值,定义了 HookTypes
枚举。这些 C++ 定义位于 winuser.h 头文件中。
接下来,我们需要在 C# 中传递 WPARAM
和 LPARAM
值。它们实际上是指向 C++ UINT
和 LONG
值的指针。用 C# 的说法,它们是指向 uint
和 int
的指针。如果您不确定 WPARAM
是什么,您可以右键单击 C++ 代码并选择“转到定义”,然后在其中查找它的定义。这会将您带到 windef.h 中的定义。
// From windef.h: typedef UINT_PTR WPARAM; typedef LONG_PTR LPARAM;
因此,当 WPARAM
和 LPARAM
类型到达 C# 时,我们选择 System.UIntPtr
和 System.IntPtr
作为它们的变量类型。
现在,让我们看看挂钩基类如何使用这些导入的方法将回调函数(委托)传递给 C++,从而允许 C++ 库直接调用您的系统挂钩类实例。首先,在构造函数中,SystemHook
类创建了一个指向私有方法 InternalHookCallback
的委托,该委托匹配 HookProcessedHandler
委托签名。然后,它将此委托及其 HookType
传递给 C++ 库,以使用上面讨论的 SetUserHookCallback
方法注册回调。以下是代码:
public SystemHook(HookTypes type)
{
_type = type;
_processHandler = new HookProcessedHandler(InternalHookCallback);
SetUserHookCallback(_processHandler, _type);
}
InternalHookCallback
的实现非常简单。InternalHookCallback
只是将调用传递给抽象方法 HookCallback
,同时将其包装在捕获所有情况的 try
/catch
块中。这简化了派生类中的实现,并保护 C++ 代码免受未捕获的 .NET 异常的影响。请记住,一旦一切都连接好,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 中对鼠标事件有意义的数据,然后再触发其事件。这使得类的使用者不必担心解释这些数据结构。此类使用我们在 C++ DLL 中定义的导入的 GetMousePosition
方法来转换这些值。有关此内容的更多详细信息,请参阅下面的几段讨论。
在此方法中,我们检查是否有人正在收听该事件。如果没有,则无需继续处理该事件。然后,我们将 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)
通过将整数参数定义为 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 调用之前会切换上下文的类型。过程大致如下:
- 应用程序 X 获得焦点并正在执行。
- 用户按下按键。
- Windows 从 App X 接管执行,并将执行上下文切换到挂钩应用程序。
- Windows 在挂钩应用程序的进程空间中调用具有按键消息参数的挂钩回调。
- Windows 从挂钩应用程序接管执行,并将其切换回 App X。
- Windows 将消息放入 App X 的消息队列中。
- 一段时间后(不多),当 App X 执行时,它会从其消息队列中检索按键消息,并调用其内部的按键(或弹起或按下)处理程序。
- 应用程序 X 继续其生命周期...
CBT 挂钩(窗口创建等)类型的挂钩不切换上下文。对于这些类型的挂钩,过程是:
- 应用程序 X 获得焦点并正在执行。
- 应用程序 X 创建一个窗口。
- Windows 在 App X 的进程空间中调用具有 CBT 事件消息参数的挂钩回调。
- 应用程序 X 继续其生命周期...
这应该能说明为什么某些类型的挂钩与此库的架构兼容,而某些则不兼容。请记住,这是该库试图做的事情。在上述步骤的第 4 步和第 3 步之后分别插入以下步骤:
- Windows 调用挂钩回调。
- 在非托管 DLL 中执行指定的挂钩回调。
- 指定的挂钩回调查找其对应的托管委托以进行调用。
- 以适当的参数执行托管委托。
- 指定的挂钩回调返回,并且该消息的挂钩处理完成。
步骤 3 和 4 对于不切换类型的挂钩注定会失败。步骤 3 会失败,因为对于该应用程序没有设置相应的托管回调。请记住,DLL 使用全局变量来跟踪这些托管委托,并且挂钩 DLL 加载到每个进程空间中。但是值仅在挂钩应用程序的进程空间中设置。对于所有其他进程,这些值都是 null。
Tim Sylvester 在他题为“Other hook types” [^] 的帖子中指出,使用共享内存段[^] 可以解决这个问题。这是真的,但正如 Tim 也指出的那样,这些托管委托的地址对于除挂钩应用程序之外的任何进程都没有意义。这意味着,在回调执行期间,它们是无意义的,并且无法调用。这会带来麻烦。
因此,要为不进行执行切换的挂钩类型使用这些回调,您需要某种形式的进程间通信。
我曾尝试过在非托管 DLL 的挂钩回调中使用进程外 COM 对象进行 IPC。如果您能成功实现这一点,我很想听听。至于我的尝试,它们结果不佳。基本原因是很难正确初始化各个进程及其线程的 COM apartment(CoInitialize(NULL)
等)。这是使用 COM 对象(进程内或进程外)的基本要求。
当然有办法做到这一点,我毫不怀疑。但我还没有尝试过,因为我认为它们的实用性有限。例如,CBT 挂钩允许您在需要时取消窗口创建。想象一下必须发生什么才能实现这一点。
- 挂钩回调的执行开始。
- 在非托管挂钩 DLL 中调用相应的挂钩回调。
- 必须将执行路由回主挂钩应用程序。
- 该应用程序必须决定是否允许创建。
- 必须将调用路由回仍在执行的挂钩回调。
- 非托管挂钩 DLL 中的挂钩回调从主挂钩应用程序接收要执行的操作。
- 非托管挂钩 DLL 中的挂钩回调执行 CBT 挂钩调用的适当操作。
- 挂钩回调的执行完成。
这并非不可能,但并不漂亮。
我希望这能消除有关库中允许和受限制的挂钩类型的一些神秘感。
附加功能
- 库文档:我们为ManagedHooks 类库提供了相当完善的代码文档。在“Documentation”构建配置中编译时,它会通过 Visual Studio .NET 转换为标准的帮助 XML。最后,我们使用NDoc[^] 将其转换为编译后的 HTML 帮助 (CHM)。可以通过单击解决方案中解决方案资源管理器中的 Managed Hooks.chm 文件或查看与本文相关的可下载 ZIP 文件来获得此帮助文件。
- 增强的 IntelliSense:如果您不熟悉 Visual Studio .NET 如何使用编译后的 XML 文件(NDoc 输出之前)来增强引用库的项目 IntelliSense,让我在这里说明一下。如果您决定在应用程序中使用此类库,可以考虑将库的稳定版本复制到一个您将引用它的位置。然后,也将 XML 文档文件(SystemHooks\ManagedHooks\bin\Debug\Kennedy.ManagedHooks.xml)复制到同一位置。当您添加对该库的引用时,Visual Studio .NET 将自动读取该文件并使用它来添加 IntelliSense 文档。这非常有帮助,特别是对于第三方库,如本库。
- 单元测试:我相信,所有库都应该有相关的单元测试。由于我是一家生产 .NET 单元测试软件的公司的一员和软件工程师,这应该不足为奇。因此,您会在解决方案中找到一个名为 ManagedHooksTests 的单元测试项目。要运行单元测试,您需要下载并安装 HarnessIt [^]。此下载是我们商业单元测试软件的免费试用版。
在单元测试中,我特别注意了其中无效的方法参数可能导致 C++ 内存异常的情况。尽管这个库相当简单,但单元测试确实帮助我发现了一些在更微妙情况下的 bug。
- 非托管/托管调试:像这样的混合解决方案(托管代码和非托管代码)的一个棘手之处在于调试。如果您想能够单步跟踪 C++ 代码或在 C++ 代码中设置断点,则必须启用非托管调试。这是 Visual Studio .NET 中的一个项目设置。请注意,您可以在托管层和非托管层之间非常流畅地单步跟踪,但非托管调试会显著减慢调试器中应用程序的加载时间和执行速度。
最后的警告
在提供这个最后的警告时,我将引用 Dr. Seuss 在著名的儿童读物《*Fox in Sox*》中的一段话。
慢慢来。这些类是危险的。
系统挂钩功能强大。而有了强大的力量,就伴随着责任。当系统挂钩出现问题时,它们不仅仅会破坏您的应用程序。它们可能会破坏您系统上运行的所有应用程序。这种情况不太可能发生。尽管如此,在使用系统挂钩时,您需要仔细检查您的代码。
我发现的一个用于开发使用系统挂钩的应用程序的技术是将您最喜欢的开发操作系统和 Visual Studio .NET 安装在Microsoft Virtual PC [^] 中。然后,在虚拟环境中开发您的应用程序。这样,当您的挂钩应用程序出错时,它们只会影响虚拟实例的操作系统,而不是您的真实操作系统。我曾不得不重新启动我的真实操作系统,因为虚拟操作系统因挂钩错误而崩溃,但这要少见得多。
请注意,如果您拥有MSDN 订阅 [^],则可以通过您的订阅免费获得 Virtual PC。
历史
- 2005 年 1 月 1 日:(库版本 1.2.0.10)
- 为键盘挂钩类添加了对 CTRL、ALT、SHIFT
KeyDown
和KeyUp
事件的支持。 - 添加了
KeyboardTracking
类,该类实现键盘挂钩,但跟踪 CTRL、ALT 和 SHIFT 键的状态。有关详细信息,请参阅编译的 HTML 文档。 - 接下来的三个更改来自 Michael Lehenbauer 在他题为Feedback [^] 的帖子中的建议。非常感谢!有关更详细的讨论,请参阅他的帖子。
- 将签名从
LRESULT Method()
更改为static LRESULT CALLBACK Method()
,因为这似乎解决了与某些挂钩类型的一些不兼容问题。 - 将传递给 .NET 挂钩应用程序的
SetWindowsHookEx
方法的HINSTANCE
指针更改为非托管 C++ DLL 的模块HINSTANCE
。 - 将
HookTypes.GetMessage
挂钩类型移至限制部分,因为它似乎不像当前架构所要求的 nor 切换执行上下文。 - 更改了
VirtualKeys
枚举,以便在键盘类中正确检测到 CTRL、ALT 和 SHIFT 键的按下。 - 向挂钩类添加了析构函数/终结器。当它们未正确处置时,它们会在 Trace 系统中发出警告消息。
- 改进了对来自非托管层的意外错误代码的错误报告。
- 更改了示例应用程序中的超链接,使其在默认浏览器中启动,而不是使用直接指向 Internet Explorer 的路径。
- 使用 NDoc 1.3 重新构建了 HTML 帮助。
- 为枚举和多个方法添加了文档,以便在 Documentation 配置中编译时项目不会出现编译器警告。这导致代码的可读性稍差,但我认为能够更轻松地捕获所有警告是值得的。
- 为键盘挂钩类添加了对 CTRL、ALT、SHIFT
- 2004 年 5 月 12 日:(库版本 1.0.0.2)
- 从库中删除了始终导致错误的挂钩类型。有关详细信息,请参阅上面的“限制”部分。
- 添加了关于在 Virtual PC 中开发挂钩应用程序的讨论。有关详细信息,请参阅上面的“最后的警告”部分。
- 2004 年 3 月 5 日:(库版本 1.0.0.0)
- 这是本文的初始发布版本。无更改。