当前位置: 首页 > news >正文

.NET C# 使用 SetWindowsHookEx 监听鼠标或键盘消息以及此方法的坑

一般来说,大家在需要监听全局消息的时候会考虑 SetWindowsHookEx 这个 API。或者需要处理一些非自己编写的窗口的消息循环的时候,也会考虑使用它。

如果要知道如何使用这个 API,你可以在网上搜到大量这样的文章/博客/教程/文档,然而大多不会提及使用此 API 时遇到的一些坑。阅读本文,你当然也可以知道应该如何使用这个 API,但同时也能了解如何正确使用以避免一些奇怪的问题。


@TOC

基本使用

简单一点,先贴出一部分可以工作起来的代码,你直接可以放到你的项目当中运行测试:

public partial class MainWindow : Window
{
    private readonly HookProc _mouseHook;
    private IntPtr _hMouseHook;

    public MainWindow()
    {
        InitializeComponent();
        _mouseHook = OnMouseHook;
        Loaded += OnLoaded;
    }

    private void OnLoaded(object sender, RoutedEventArgs e)
    {
        var hModule = GetModuleHandle(null);
        // 你可能会在网上搜索到下面注释掉的这种代码,但实际上已经过时了。
        //   下面代码在 .NET Core 3.x 以上可正常工作,在 .NET Framework 4.0 以下可正常工作。
        //   如果不满足此条件,你也可能可以正常工作,详情请阅读本文后续内容。
        // var hModule = Marshal.GetHINSTANCE(Assembly.GetExecutingAssembly().GetModules()[0]);

        _hMouseHook = SetWindowsHookEx(
            HookType.WH_MOUSE_LL,
            mouseHook,
            hModule,
            0);
        if (_hMouseHook == IntPtr.Zero)
        {
            int errorCode = Marshal.GetLastWin32Error();
            throw new Win32Exception(errorCode);
        }
    }

    private IntPtr OnMouseHook(int nCode, IntPtr wParam, IntPtr lParam)
    {
        // 在这里,你可以处理全局鼠标消息。
        return CallNextHookEx(new IntPtr(0), nCode, wParam, lParam);
    }
}

本文讨论使用 .NET/C# 来完成 SetWindowsHookEx 的调用,所以自然少不了 P/Invoke(平台调用)。因此你必须将以下代码也添加到你的代码仓库中:

[DllImport("kernel32.dll")]
public static extern IntPtr GetModuleHandle(string lpModuleName);

[DllImport("kernel32", SetLastError = true)]
static extern IntPtr LoadLibrary(string lpFileName);

[DllImport("user32.dll")]
static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);

[DllImport("user32.dll", SetLastError = true)]
static extern IntPtr SetWindowsHookEx(HookType hookType, HookProc lpfn, IntPtr hMod, uint dwThreadId);

[DllImport("user32.dll")]
static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam);

private delegate IntPtr HookProc(int nCode, IntPtr wParam, IntPtr lParam);

public enum HookType : int
{
    WH_JOURNALRECORD = 0,
    WH_JOURNALPLAYBACK = 1,
    WH_KEYBOARD = 2,
    WH_GETMESSAGE = 3,
    WH_CALLWNDPROC = 4,
    WH_CBT = 5,
    WH_SYSMSGFILTER = 6,
    WH_MOUSE = 7,
    WH_HARDWARE = 8,
    WH_DEBUG = 9,
    WH_SHELL = 10,
    WH_FOREGROUNDIDLE = 11,
    WH_CALLWNDPROCRET = 12,
    WH_KEYBOARD_LL = 13,
    WH_MOUSE_LL = 14
}

SetWindowsHookEx

SetWindowsHookEx 的签名如下:

HHOOK SetWindowsHookExA(
  int       idHook,
  HOOKPROC  lpfn,
  HINSTANCE hmod,
  DWORD     dwThreadId
);
  • 当方法执行成功时,返回值是钩子处理函数的句柄,用于在钩子的消息处理中调用 CallNextHookEx 方法。当方法执行失败时,这里返回 0
  • idHood 参数表示需要处理的消息类型(我们前面定义成了枚举类型 HookType
  • lpfn 是自己定义的钩子的消息处理方法(对应我们前面定义的委托)
  • hmod 是模块的句柄,在本机代码中,对应 dll 的句柄(可在 dll 的入口函数中获取);而我们是托管代码
  • dwThreadId 是线程 Id,传入 0 则为全局所有线程,否则传入特定的线程 Id

需要注意的坑

模块句柄传什么?

本文一开始被注释掉的代码中,我使用 Marshal 直接从托管程序集中获取了模块句柄。

这里需要说明,托管程序集不能注入到其他进程,因此也不可以挂接钩子。但有例外,WH_KEYBOARD_LL 或者 WH_MOUSE_LL 这两个是不需要注入 dll 的,因此可以挂接钩子。

对于 WH_KEYBOARD_LLWH_MOUSE_LLSetWindowsHookEx 方法里面根本没有使用这个模块做什么真正的事情,它只是验证一下一个模块而已。只要存在于你的进程中。

所以,传入其他的模块都是可以的:

var hModule = LoadLibrary("user32.dll");

传入口模块也是可以的:

var hModule = Marshal.GetHINSTANCE(Assembly.GetEntryAssembly().GetModules()[0]);
var hModule = GetModuleHandle(null);

这也是一开始我在 P/Invoke 的方法里面预留了 LoadLibraryGetModuleHandle 方法的原因。

通过调试也能发现这两个的入口模块是相同的:

入口模块句柄

至于为什么可以用 user32.dll。嗯,反正我们创建窗口监听消息都已经大量调用 user32.dll 的 API 了,这 dll 肯定已经加入到我们的进程中了,所以我们把这个传入到参数中是可以通过验证的。

错误 126:找不到指定的模块。

The specified module could not be found.

如果你只是拿代码做做 demo 可能一切顺利,但放到实际项目里面就挂得一塌糊涂:

找不到指定的模块

这也是我在一开始的 P/Invoke 里面加上了 SetLassError 的重要原因,因为这 API 容易挂。

检查的错误码是 126(0x0000007E)。

然而我的 dll 是存在的呀!

让我们再来看我一开始预留的注释:

// 下面代码在 .NET Core 3.x 以上可正常工作,在 .NET Framework 4.0 以下可正常工作。
// 如果不满足此条件,你也可能可以正常工作,详情请阅读本文后续内容。
var hModule = Marshal.GetHINSTANCE(Assembly.GetExecutingAssembly().GetModules()[0]);

是的,你遇到这样的异常,多半意味着你落入 .NET Framework 4.x 版本的运行时了。

.NET Framework 4.0 相比于之前的 CLR 发生了很大的更改,不再假装 JIT 代码存在一非托管模块中,因此 Marshal.GetHINSTANCE 将不再起作用。

对于低级钩子来说,SetWindowsHookEx 需要一个有效的模块句柄进行检查,但实际上此 API 执行时根本没有使用这个模块。所以更推荐使用前一小节中提供的 LoadLibrary 函数来获取模块句柄,而不是获取当前托管模块的句柄。

解决方法,两/三个:

  1. 方法一:使用 LoadLibrary("user32.dll") 获取模块句柄代替 Marshal.GetHINSTANCE
  2. 方法二:将获取句柄的模块改为入口程序集(exe),即 Assembly.GetEntryAssembly()
  3. 方法三:升级成纯 .NET Core 程序

错误 1428:没有模块句柄无法设置非本机的挂接。

Cannot set nonlocal hook without a module handle.

对于前面说的 126 错误,你可能从 Assembly.GetExecutingAssembly 改成 Assembly.GetEntryAssembly() 之后会出现此异常。

解决方法:

  • 使用 LoadLibrary("user32.dll") 获取模块句柄代替 Marshal.GetHINSTANCE

错误 1429:此挂接程序只可整体设置。

This hook procedure can only be set globally.

估计找到这里的方式可能是搜索,因为这段中文读起来真的是太晦涩了。不过我把英文贴到上一行了,相信你差不多就知道是怎么回事了。

因为你给 SetWindowsHookEx 方法中传入的 HookType 参数指定了低级类型(Low Level,HookType 枚举后面带了 LL 后缀的),这时只能全局设置钩子。意味着你的第四个参数必须传入 0

如何只处理特定窗口的消息?

消息循环属于“线程”,而不是属于某个窗口或者进程。在 CreateWindowEx 创建窗口时传入的消息处理函数会仅处理特定窗口的消息,然而当通过钩子的方式来处理消息的话,无法精确定位到某个特定的窗口,只能针对消息循环所在的线程。因此,要处理特定窗口的消息,只能先拿到此窗口所在的线程。

前面的 P/Invoke 中我也预留了获取窗口所在线程的方法。因此,可以直接使用以下调用来获取 hWnd 句柄窗口所在的线程。

var threadId = GetWindowThreadProcessId(hWnd, out _);

本来在 SetWindowsHookEx 最后一个参数传入 0 表示全局钩子的,那么现在传入 threadId 即仅监听此线程的消息。

另外,如果只是打算处理单个窗口的消息,而不是这个线程里的所有消息,那么建议使用子类化的方式来实现。详情可阅读我的另一篇博客:

  • 通过子类化窗口(SubClass)来为现有的某个窗口添加新的窗口处理程序(或者叫钩子,Hook) - walterlv

为什么会导致其他进程闪退?

你可能会发现,明明按照本文所述的方法挂接了钩子,但一运行起来后,其他程序(被挂接的程序)出现了闪退现象。

接下来说明:

HookType 的所有种类中,只有 WH_MOUSE_LLWH_KEYBOARD_LL 是不需要注入到目标进程的,其他都必须将 dll 注入到目标进程才可以完成挂接。然而 .NET 程序集无法被注入到其他进程;随便用一个其他 dll 时,里面没有被挂接的函数地址,在注入后就会导致目标进程崩溃。

所以:

  1. 如果需要挂接的进程就在本进程内(最后参数指定的线程是本进程内的线程),那么所有种类都可以挂接;
  2. 如果需要全局挂接,或者要挂接别的进程,那么 .NET 程序只能使用 WH_MOUSE_LLWH_KEYBOARD_LL 两种挂接类型;

如果就是要挂接其他类型的钩子怎么办?办法总还是有的:

  1. 可以考虑做非托管 dll,专门用来挂接;
  2. 可以考虑使用 SetWinEventHook,这个是不用注入到目标进程的;
  3. 可以考虑使用 System.Windows.Automation 抓取一部分有限的信息。

参考资料

  • SetWindowsHookExA function (winuser.h) - Win32 apps - Microsoft Docs
  • Processing Global Mouse and Keyboard Hooks in C# - CodeProject
  • c# - SetWindowsHookEx fails with error 126 - Stack Overflow
  • winapi - How to pass window handle to wndproc? - Stack Overflow
  • .net - Example of hooking a window? - Stack Overflow
  • .net - SetWindowHookEx fails at runtime in C# application - Stack Overflow
  • winapi - Is there a way to know when another hwnd has closed? - Stack Overflow

我的博客会首发于 https://blog.walterlv.com/,而 CSDN 会从其中精选发布,但是一旦发布了就很少更新。

如果在博客看到有任何不懂的内容,欢迎交流。我搭建了 dotnet 职业技术学院 欢迎大家加入。

知识共享许可协议

本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。欢迎转载、使用、重新发布,但务必保留文章署名吕毅(包含链接:https://walterlv.blog.csdn.net/),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请与我联系。

相关文章:

  • .NET WebClient 类下载部分文件会错误?可能是解压缩的锅
  • Unity3D 入门:为 Unity 的 C# 项目添加 dll 引用或安装 NuGet 包
  • .NET 的静态构造函数是否线程安全?答案是肯定的!
  • 如何在终端和 PowerShell 中将一个命令自动重复执行多次
  • WPF:无法对元素“XXX”设置 Name 特性值“YYY”。“XXX”在元素“ZZZ”的范围内,在另一范围内定义它时,已注册了名称。
  • 一点点从坑里爬出来:如何正确打开 WPF 里的 Popup?
  • Windows Linux 系统中获取端口被哪个应用程序占用
  • 设置用户无需密码自动登录到 Windows 系统
  • 最简单的代码,让 WPF 支持响应式布局
  • 当无边框窗口被子窗口遮挡导致难以调节窗口大小时,可通过处理 NCHITTEST 消息重新支持调节窗口大小
  • 如何给 GitHub Pages 配置多个域名?
  • 通过子类化窗口(SubClass)来为现有的某个窗口添加新的窗口处理程序(或者叫钩子,Hook)
  • .NET Framework 和 .NET Core 在默认情况下垃圾回收(GC)机制的不同(局部变量部分)
  • .NET Windows:删除文件夹后立即判断,有可能依然存在
  • .NET 将混合了多个不同平台(Windows Mac Linux)的文件 目录的路径格式化成同一个平台下的路径
  • [iOS]Core Data浅析一 -- 启用Core Data
  • canvas实际项目操作,包含:线条,圆形,扇形,图片绘制,图片圆角遮罩,矩形,弧形文字...
  • Facebook AccountKit 接入的坑点
  • JAVA_NIO系列——Channel和Buffer详解
  • Netty+SpringBoot+FastDFS+Html5实现聊天App(六)
  • Objective-C 中关联引用的概念
  • PHP那些事儿
  • Promise面试题,控制异步流程
  • 回顾 Swift 多平台移植进度 #2
  • 通过几道题目学习二叉搜索树
  • 无服务器化是企业 IT 架构的未来吗?
  • 自动记录MySQL慢查询快照脚本
  • 移动端高清、多屏适配方案
  • ​ ​Redis(五)主从复制:主从模式介绍、配置、拓扑(一主一从结构、一主多从结构、树形主从结构)、原理(复制过程、​​​​​​​数据同步psync)、总结
  • #QT(一种朴素的计算器实现方法)
  • #QT项目实战(天气预报)
  • #控制台大学课堂点名问题_课堂随机点名
  • (1/2) 为了理解 UWP 的启动流程,我从零开始创建了一个 UWP 程序
  • (4)STL算法之比较
  • (react踩过的坑)Antd Select(设置了labelInValue)在FormItem中initialValue的问题
  • (一) springboot详细介绍
  • (已解决)报错:Could not load the Qt platform plugin “xcb“
  • (译)2019年前端性能优化清单 — 下篇
  • (源码版)2024美国大学生数学建模E题财产保险的可持续模型详解思路+具体代码季节性时序预测SARIMA天气预测建模
  • (转)JAVA中的堆栈
  • .net core 6 集成和使用 mongodb
  • .net on S60 ---- Net60 1.1发布 支持VS2008以及新的特性
  • .NET 将多个程序集合并成单一程序集的 4+3 种方法
  • .NET/C# 使用 ConditionalWeakTable 附加字段(CLR 版本的附加属性,也可用用来当作弱引用字典 WeakDictionary)
  • .NET开发人员必知的八个网站
  • ?
  • [20160902]rm -rf的惨案.txt
  • [ABP实战开源项目]---ABP实时服务-通知系统.发布模式
  • [AIGC] Java 和 Kotlin 的区别
  • [BUUCTF NewStarCTF 2023 公开赛道] week4 crypto/pwn
  • [c#基础]DataTable的Select方法
  • [c语言]小课堂 day2
  • [Hadoop in China 2011] Hadoop之上 中国移动“大云”系统解析
  • [Invalid postback or callback argument]昨晚调试程序时出现的问题,MARK一下
  • [JDBC-1] JDBC Base Template