拓冰建站拓冰建站
首页 / 资讯中心 / 正文

MFC全局钩子实践:跨窗口监听键盘与鼠标事件

简介面向 Windows C 开发者的 MFC 全局钩子示例项目演示如何结合 MFC 对话框程序与 HOOK.DLL通过 SetWindowsHookEx 设置全局键盘/鼠标钩子实时捕获按键码和鼠标位置等输入信息并写入日志文件。资源包含完整 Visual Studio 工程源码涉及低层钩子、DLL 注入、对话框控件更新和文件输出等关键知识点适合学习 Windows 消息机制和钩子技术的初中级开发者参考实践。压缩包共 80 个文件约 66.54MB以 C 源文件、头文件、rc 资源、解决方案及可执行程序、动态库为主同时附有 PDB 调试符号与编译中间文件便于在 Debug 或 Release 模式下直接编译、运行和调试。目前已收录 1397 人学习读者可通过源码和程序对照理解全局钩子的整个工作流程也可借助工程结构快速定位钩子处理函数、MFC 界面交互和文件写逻辑等核心模块节省自行搭建环境的时间。 去年做远程协助工具的时候客户在办公室一台老机器上录数据每个窗口都要手动切过去输入一遍几轮操作下来特别容易漏。后来我一咬牙给工具加了一个 MFC 全局钩子模块把键盘输入和鼠标输入统一收进来监听、记录问题当场解决。那之后我陆续用这套思路做了快捷键启动器、鼠标宏录制、无障碍辅助输入效果都很稳。如果你也正在做 MFC 程序需要一个能跨窗口监听键盘和鼠标事件的全局钩子这篇笔记值得你看完。我会从钩子的分类说起因为这里最容易出岔子然后给出可以直接抄的键盘钩子和鼠标钩子完整实现最后整理一份我自己反复踩坑后沉淀下来的排查清单。整个过程不绕弯子代码基于 VS2013 的 MFC 编写换了 VS2019、VS2022 一样能跑底层 API 这些年没大变过。1. 先别急着写代码钩子家族里藏着两个容易弄混的东西1.1 全局钩子靠 DLL 注入但低级钩子是个例外很多人一听“全局钩子”第一反应就是“那我得写一个 DLL然后注入到所有进程里”。这个说法对传统钩子成立对低级钩子Low-Level Hook不成立而绝大多数现代应用场景用的恰恰是低级钩子。传统钩子WH_KEYBOARD值 2和WH_MOUSE值 7如果要全局生效系统会把你的钩子 DLL 注入到每一个有消息队列的进程中然后在各个进程里分别调用你的回调函数。这样一来调试就变成了一场噩梦你没法直接在 VS 里单步跟踪只能靠 OutputDebugString 输出日志或者额外挂一个调试器到目标进程上。而且 DLL 注入本身有风险目标进程如果正处在加载锁之类的敏感状态注入失败或者崩溃都是常见事。低级钩子则完全不同。WH_KEYBOARD_LL值 13和WH_MOUSE_LL值 14的回调函数是在安装钩子的那个线程上下文里执行的不需要注入任何 DLL。系统把输入事件传递给目标窗口之前先塞进你安装钩子的那个线程队列里由你的消息循环把事件“捎带”出来调用你的钩子回调。这带来的直接好处是你可以用单个 EXE 完成全局输入监听调试体验和普通线程回调几乎一样。1.2 低级钩子和传统钩子的取舍对比项WH_KEYBOARD_LL / WH_MOUSE_LLWH_KEYBOARD / WH_MOUSE全局是否需要 DLL不需要需要回调执行位置安装钩子的进程内被注入的目标进程内调试难度单工程即可调试需要多进程附加调试能监听的范围系统全局输入事件所有被注入进程的输入事件系统开销较小事件只过一道较大每个进程都要注入回调超时限制有严格超时超了会被系统摘钩限制相对宽松典型场景全局热键、输入记录、鼠标宏、辅助工具兼容老系统、部分输入法场景从我个人的实际体验来说除非你明确知道自己需要和某个进程内部的消息机制深度交互否则优先选低级钩子。理由很简单不用 DLL单 EXE 搞定回调在自己的进程里出问题能快速定位卸载钩子时也不用担心 DLL 在别的进程里没有被正确释放的老问题。1.3 低级钩子也有自己的脾气低级钩子回调是“借用”你当前线程的消息循环来执行的所以你的线程必须持续处理消息。如果消息循环卡住钩子回调就不会被触发看起来就是“钩子没生效”。另外回调里绝不能做耗时操作系统对低级钩子回调有硬性超时限制一旦超时系统会静默地把你的钩子摘掉连个报错都不给。这两点在后面排查章节会展开细说。2. 工程搭建的几个细节配置不对钩子装上了也是摆设2.1 一个干净的最小工程长什么样我建议直接用 VS 创建“MFC 对话框应用”工程名随意字符集选择“使用 Unicode 字符集”。为什么是 Unicode因为钩子回调里拿到的虚拟键码、扫描码都是编码无关的主窗口消息最好统一用宽字符省得后期拼接字符串时出现乱码。如果你手头有老工程是多字节字符集也能跑但新项目我强烈建议 Unicode。新建完工程后你会得到一个默认的CMyApp和CMyDlg。钩子的安装时机有几个选择对话框的OnInitDialog、应用程序类的InitInstance或者某个按钮事件。我在实践中最常用的是OnInitDialog里安装WM_DESTROY里卸载因为对话框是主窗口生命周期好控制。2.2 消息循环对低级钩子意味着什么低级钩子依赖消息循环这一点怎么强调都不过分。MFC 程序的CWinApp::Run()内部本来就有GetMessage/TranslateMessage/DispatchMessage循环所以对话框程序天然满足条件。但如果你图省事用控制台程序测试低级钩子就千万记得自己补一个消息循环MSG msg; while (GetMessage(msg, NULL, 0, 0)) { TranslateMessage(msg); DispatchMessage(msg); }否则SetWindowsHookEx返回的句柄看起来没问题但回调永远不触发。新手在这里卡半天的情况我见得太多了——不是钩子没装上是线程根本没有消息泵。2.3 安装与卸载必须成对出现安装钩子用的是SetWindowsHookEx这个函数的四个参数值得你说个清清楚楚idHook钩子类型。键盘用WH_KEYBOARD_LL13鼠标用WH_MOUSE_LL14。lpfn回调函数指针。hMod包含回调函数的模块句柄。低级钩子直接传GetModuleHandle(NULL)即可因为回调就在 EXE 里。dwThreadId为 0 表示关联系统所有线程也就是全局钩子。对应的卸载函数是UnhookWindowsHookEx传安装时拿到的HHOOK句柄。这里有一个隐含规则安装和卸载最好在同一个线程里完成。低级钩子的回调在安装线程上下文执行如果跨线程卸载可能造成回调还没执行完钩子对象已经被释放轻则访问违例重则程序直接崩掉。我自己的做法是写一个钩子管理类安装和卸载都封装到类里用同一个线程调用。比如在OnInitDialog里调用Start()在OnDestroy里调用Stop()这两个方法都发生在主线程符合规则。2.4 让钩子活得久一点生命周期管理实际项目中钩子不是装一次就完了。窗口可能重建对话框可能被关闭再打开如果每次都重新安装而不卸载旧的会产生钩子泄漏。另外多个模块可能共用一个钩子管理对象这时候最好在类里加一个引用计数或者运行状态标志避免Start()被调用两次导致重复安装。简单粗暴的做法是Start()里先调用Stop()清理旧的再安装新的。这样窗口任意重建多少次都没问题。3. 键盘钩子实现从安装到把按键数据送到 UI3.1 键盘回调的骨架代码键盘钩子的核心就两个部分安装钩子、写回调。下面这段代码我反复用了两年稍微改动就能适配不同需求。#pragma once class CGlobalInputHook { public: static CGlobalInputHook* Instance(); // 简单单例 BOOL Start(HWND hNotifyWnd); BOOL Stop(); private: static LRESULT CALLBACK KeyboardProc(int nCode, WPARAM wParam, LPARAM lParam); static LRESULT CALLBACK MouseProc(int nCode, WPARAM wParam, LPARAM lParam); HWND m_hNotifyWnd; // 通知窗口用于转发消息 HHOOK m_hKeyboardHook; HHOOK m_hMouseHook; };安装接口的典型实现如下BOOL CGlobalInputHook::Start(HWND hNotifyWnd) { Stop(); // 防止重复安装 m_hNotifyWnd hNotifyWnd; m_hKeyboardHook SetWindowsHookEx( WH_KEYBOARD_LL, KeyboardProc, GetModuleHandle(NULL), 0); m_hMouseHook SetWindowsHookEx( WH_MOUSE_LL, MouseProc, GetModuleHandle(NULL), 0); return (m_hKeyboardHook m_hMouseHook); } BOOL CGlobalInputHook::Stop() { if (m_hKeyboardHook) { UnhookWindowsHookEx(m_hKeyboardHook); m_hKeyboardHook NULL; } if (m_hMouseHook) { UnhookWindowsHookEx(m_hMouseHook); m_hMouseHook NULL; } return TRUE; }你需要自己保证KeyboardProc和MouseProc是静态函数或者全局函数因为回调函数指针不能指向非静态成员函数。静态函数里访问成员变量没有任何问题钩子管理类本身就是单例访问静态实例指针即可。3.2 KBDLLHOOKSTRUCT 里能拿到什么回调函数的参数lParam指向一个KBDLLHOOKSTRUCT结构体typedef struct tagKBDLLHOOKSTRUCT { DWORD vkCode; // 虚拟键码如 VK_F1、A DWORD scanCode; // 硬件扫描码 DWORD flags; // 状态标志 DWORD time; // 事件时间戳 ULONG_PTR dwExtraInfo; // 附加信息 } KBDLLHOOKSTRUCT;我用得最多的是vkCode用来判断具体按了哪个键。flags也很重要其中LLKHF_ALTDOWN0x20表示 Alt 键是否被按下LLKHF_EXTENDED0x01表示是不是扩展键比如右 Ctrl、小键盘回车LLKHF_INJECTED0x10表示事件是不是由SendInput之类的方法注入的——这个字段在做输入回放时特别有用可以用来区分“真实按键”和“脚本模拟按键”。回调的wParam参数则表示事件类型WM_KEYDOWN普通按下WM_KEYUP普通抬起WM_SYSKEYDOWNAlt 组合键按下WM_SYSKEYUPAlt 组合键抬起我的键盘回调通常是这样的LRESULT CALLBACK CGlobalInputHook::KeyboardProc(int nCode, WPARAM wParam, LPARAM lParam) { if (nCode HC_ACTION) { KBDLLHOOKSTRUCT* pKb (KBDLLHOOKSTRUCT*)lParam; if (wParam WM_KEYDOWN || wParam WM_SYSKEYDOWN) { // 只做轻量处理把数据转发给主窗口 ::PostMessage(m_hNotifyWnd, WM_APP_KEYBOARD, (WPARAM)pKb-vkCode, (LPARAM)pKb-flags); } } return CallNextHookEx(NULL, nCode, wParam, lParam); }注意我传的是结构体的字段值不是结构体指针。低级钩子回调返回后lParam指向的那块内存后续会被系统重新使用所以绝对不要把这个指针缓存下来。跨线程回消息时更是如此。3.3 怎么过滤按键、怎么把数据传回主窗口过滤按键是键盘钩子最常用的场景之一比如想做全局快捷键希望按下某个组合键时不把按键事件继续传给其他窗口。做法很简单在回调里返回一个非零值系统就会认为这个消息已经被处理掉了目标窗口收不到任何按键事件。举个例子屏蔽A键if (pKb-vkCode A) { return 1; // 吞掉这个按键 }但我要提醒一句return 1返回的是整个KeyboardProc的返回值不是单独一个字段。所以过滤逻辑一定要写在CallNextHookEx之前一旦决定吞掉就不该再调用CallNextHookEx。把数据传回主窗口最安全的手段是PostMessage。它是异步的不会在当前线程里等待目标窗口处理完消息也就不会和主窗口产生死锁。如果需要传多个数据可以用WPARAM传键码、LPARAM传状态标志零拷贝零分配性能最好。数据量大了以后可以考虑自定义一个结构体自己管理内存但那是后话初期数值传递完全够用。4. 鼠标钩子实现点击、移动、滚轮全覆盖4.1 鼠标回调的结构体与事件映射鼠标钩子的安装方式和键盘钩子一模一样区别在回调函数和结构体。MSLLHOOKSTRUCT长这样typedef struct tagMSLLHOOKSTRUCT { POINT pt; // 屏幕坐标 DWORD mouseData; // 滚轮增量或 X 按钮值 DWORD flags; // 事件标志 DWORD time; // 时间戳 ULONG_PTR dwExtraInfo; // 附加信息 } MSLLHOOKSTRUCT;鼠标事件通过wParam区分常用的事件有WM_MOUSEMOVE移动WM_LBUTTONDOWN/WM_LBUTTONUP左键按下/抬起WM_RBUTTONDOWN/WM_RBUTTONUP右键按下/抬起WM_MBUTTONDOWN/WM_MBUTTONUP中键按下/抬起WM_MOUSEWHEEL滚轮滚动WM_XBUTTONDOWN/WM_XBUTTONUP侧键按下/抬起回调代码如下LRESULT CALLBACK CGlobalInputHook::MouseProc(int nCode, WPARAM wParam, LPARAM lParam) { if (nCode HC_ACTION) { MSLLHOOKSTRUCT* pMouse (MSLLHOOKSTRUCT*)lParam; switch (wParam) { case WM_LBUTTONDOWN: ::PostMessage(m_hNotifyWnd, WM_APP_MOUSE, (WPARAM)WM_LBUTTONDOWN, MAKELPARAM(pMouse-pt.x, pMouse-pt.y)); break; case WM_MOUSEMOVE: // 移动事件非常频繁可以加个节流 break; } } return CallNextHookEx(NULL, nCode, wParam, lParam); }MAKELPARAM把坐标压缩到LPARAM里主窗口收到后可以用GET_X_LPARAM和GET_Y_LPARAM解出来或者直接LOWORD/HIWORD。这种攒两数到一参的做法很老派但到现在依然好用而且避免了跨线程传结构体的内存管理问题。4.2 从 mouseData 里取滚轮方向和增量滚轮是鼠标钩子里比较容易写错的地方。mouseData是一个 DWORD但滚轮增量只在高 16 位里而且是带符号的。正确做法是把高 16 位取出来强转成 shortshort zDelta (short)HIWORD(pMouse-mouseData); // 正数向上滚负数向下滚 if (zDelta 0) // 向上滚 else // 向下滚很多新人直接拿mouseData当整数判断结果永远进不了分支排查半天才发现是符号问题。顺手提一句mouseData的另一个用途是 X 按钮高 16 位是XBUTTON1或XBUTTON2对应侧键的按下事件这个在办公软件自动化场景里偶尔会用到。4.3 坐标、状态标记和注入检测pt是屏幕坐标不是窗口客户区坐标。如果主窗口需要判断鼠标落在哪个窗口上可以用WindowFromPoint。事件过滤和键盘钩子一样return 1即可吞掉某个鼠标事件比如屏蔽指定区域内的左键点击。flags字段里最有用的是LLMHF_INJECTED0x01表示这个鼠标事件是不是由SendInput等模拟操作注入的。我当初做鼠标宏回放时就用它来区分真实鼠标操作和模拟回放避免回放过程中再次触发录像逻辑形成无限循环。实测很有效建议你也留个心眼。5. 钩子不生效我的排查清单按顺序抄就行5.1 第一查消息循环是不是真的在轮询低级钩子不生效的头号原因不是安装失败而是线程没有消息循环。我的排查顺序如下确认当前线程是不是 UI 线程。MFC 对话框主线程是 UI 线程天然有消息循环。如果在工作线程里安装钩子必须自己写GetMessage循环。如果主窗口进入模态框循环比如DoModal钩子依然能触发因为模态框内部也有消息循环。但如果你的代码在某个while死循环里不出来消息泵停了钩子就彻底失效。最快的验证手段是在回调开头放一句OutputDebugString配合 Sysinternals 的 DebugView 看输出。如果一句都没出来大概率就是消息循环的问题。5.2 第二查位数、权限和杀毒软件这里是三个容易混淆的问题我分开说。位数问题是最容易被忽略的。64 位系统上32 位进程安装的低级钩子只能收到 32 位进程的输入事件钩不到 64 位进程比如很多新版浏览器、资源管理器都是 64 位。反过来也一样。所以如果你的 MFC 程序是 32 位编译而你想监听的目标是 64 位程序结果是听了个寂寞。解决方案要么把主程序编成 64 位要么同时跑一个 32 位和一个 64 位的协作进程。权限问题也很现实。如果目标窗口以管理员权限运行你的普通权限钩子进程收不到对应事件。开发调试遇到这种情况直接把 VS“以管理员身份运行”最省心。发布时可以在 manifest 里声明requireAdministrator效果差不太多。杀毒软件和安全卫士是第三方变量。360、腾讯管家这类软件对全局钩子非常敏感安装钩子时可能弹窗拦截行为分析也可能把钩子识别成键盘记录器。我开发期间的习惯是测试机暂时关闭实时防护把程序加入白名单跑通之后再重新开启逐个确认告警原因。如果你的程序是正经业务工具建议准备一份给安全软件厂商的说明文档签名证书能有效降低误报率。5.3 第三查回调超时与“被系统悄悄卸载”这是我踩得最深的一个坑。Windows 对低级钩子回调有超时限制默认值在较新系统上大约是 1000~3000 毫秒取决于注册表LowLevelHooksTimeout。也就是说如果回调函数里一次执行超过这个阈值系统会认为钩子已经失去响应悄悄把钩子摘掉而且不产生任何错误码。你回头看自己的代码如果回调里写了Sleep、WaitForSingleObject、或者大循环解析数据、或者调用SendMessage等待跨进程响应那都是在雷区里走。我的处理原则是回调里只做数据采集不写日志、不弹框、不做网络请求。回调里一律用PostMessage异步通知主窗口绝不使用SendMessage同步等待。数据量大时先用一个环形缓冲区暂存让主线程定时批量消费。5.4 回调里禁止做的事SendMessage 和 Sleep 是重灾区死锁是低级钩子最容易出现的深坑具体场景是这样的你的钩子回调里调用SendMessage给主窗口发消息而主窗口恰好也在等某个输入事件完成或者主窗口当前正卡在某个模态循环里结果主窗口不处理你的SendMessage你的钩子回调又停在SendMessage里不返回系统超时后直接把钩子摘掉整个功能瘫痪。要避免这个问题很简单跨线程通知一律PostMessage。记住这句话能少踩 80% 的坑。另外一个经验回调里不要调用可能触发输入事件再处理逻辑的函数比如SendInput。这会引起递归轻则性能雪崩重则栈溢出。如果你要在钩子回调里做输入回放一定要用上一节提到的LLMHF_INJECTED/LLKHF_INJECTED标志做递归防护。6. 这套钩子还能往哪走从我项目里沉淀出的几个扩展方向键盘钩子和鼠标钩子整套跑通之后能干的事情比我最初预想的多得多。我这里分享几个自己做过的扩展都是基于同一套钩子管理类改出来的。第一是全局快捷键管家。在键盘回调里维护一个组合键状态表按下CtrlShiftQ之类组合时PostMessage给主窗口发自定义消息主窗口再执行对应动作。这样不管焦点在哪个程序里快捷键都能全局生效。第二是鼠标宏录制回放。键盘回调里记录按键时间戳和键码鼠标回调里记录点击位置、滚轮增量和时间戳回放时用SendInput按顺序执行。回放时靠LLKHF_INJECTED和LLMHF_INJECTED做防递归判断这个我前面提过是最关键的细节。第三是无障碍辅助输入。比如为特定用户过滤误触按键屏蔽短时间内重复点击或者把长按和短按区分出来映射成不同操作。这类需求在做客户定制软件时很常见钩子模块基本不用改改的是事件处理策略。第四是输入统计工具。在回调里统计按键频率、活跃时间段数据可以缓存在本地文件里或者直接通过局域网发送到可视化面板。用途可以很正经比如团队办公效能分析。但这块涉及用户隐私务必在合法授权场景下使用尊重使用者知情权别越界。我现在的工程结构基本固定了一个CGlobalInputHook单例负责钩子生命周期管理回调里只做事件采集和PostMessage所有业务逻辑都放在主窗口的消息处理函数里。加新功能时只改消息处理那一层钩子层几乎不动。如果你想在项目里快速落地强烈建议也采用这个分层思路一上来就把键盘鼠标两个钩子全写完再联调调试成本会高很多。先跑通键盘再叠加鼠标最后再加过滤规则整个流程能压缩在一个小时以内。最后再分享一个小技巧开发阶段把OutputDebugString日志保留在回调入口处出问题时 DebugView 扫一眼就知道回调到底有没有进来、进入了哪个分支。发布版本再把这行注释掉避免无谓的开销。这套做法我用了很多年比什么花哨的调试工具都直接。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门