using System; using System.Runtime.InteropServices; using System.Windows.Forms; namespaceCSharpHookDemo{ publicclassGlobalKeyboardHook : IDisposable { // 1. 核心 Windows API 映射 #region Windows API 声明 [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelKeyboardProc lpfn, IntPtr hMod, uint dwThreadId); [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool UnhookWindowsHookEx(IntPtr hhk); [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam); [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)] private static extern IntPtr GetModuleHandle(string lpModuleName); #endregion // 2. 常量定义 privateconstint WH_KEYBOARD_LL = 13; privateconstint WM_KEYDOWN = 0x0100; privateconstint WM_KEYUP = 0x0101; // 3. 委托定义与字段保持 (关键!) private delegate IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam); // 【重要】必须将委托实例保存为字段,防止被 GC 回收 privatereadonly LowLevelKeyboardProc _proc; private IntPtr _hookID = IntPtr.Zero; // 4. 对外暴露的事件 publicevent EventHandler<keyeventargs> KeyDown; publicevent EventHandler<keyeventargs> KeyUp; public GlobalKeyboardHook() { _proc = HookCallback; _hookID = SetHook(_proc); if (_hookID == IntPtr.Zero) { thrownew System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error(), "安装钩子失败"); } } private IntPtr SetHook(LowLevelKeyboardProc proc) { using (var curProcess = System.Diagnostics.Process.GetCurrentProcess()) using (var curModule = curProcess.MainModule) { // 第三个参数获取当前模块句柄,第四个参数 0 表示全局钩子 return SetWindowsHookEx(WH_KEYBOARD_LL, proc, GetModuleHandle(curModule.ModuleName), 0); } } private IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam) { if (nCode >= 0) { int vkCode = Marshal.ReadInt32(lParam); Keys key = (Keys)vkCode; if (wParam == (IntPtr)WM_KEYDOWN) { KeyDown?.Invoke(this, new KeyEventArgs(key)); } elseif (wParam == (IntPtr)WM_KEYUP) { KeyUp?.Invoke(this, new KeyEventArgs(key)); } } // 必须传递消息,否则系统其他部分将无法接收键盘输入 return CallNextHookEx(_hookID, nCode, wParam, lParam); } public void Dispose() { if (_hookID != IntPtr.Zero) { UnhookWindowsHookEx(_hookID); _hookID = IntPtr.Zero; } } // 演示入口 public static void Main() { Console.WriteLine("正在启动全局键盘监听... (按 Esc 退出)"); using (var hook = new GlobalKeyboardHook()) { hook.KeyDown += (sender, e) => { if (e.KeyCode == Keys.Escape) return; // 让主循环处理退出 Console.WriteLine($"[按下] 键码: {e.KeyCode}"); }; hook.KeyUp += (sender, e) => { Console.WriteLine($"[释放] 键码: {e.KeyCode}"); }; // 保持程序运行,否则主线程退出会导致 using 块释放钩子 while (Console.ReadKey(true).Key != Keys.Escape) { // 等待用户按 Esc 退出 } } Console.WriteLine("钩子已卸载,程序退出。"); } } }关键技术
1、GC 陷阱(生死攸关)
在 HookCallback 方法被定义为委托传递给非托管代码时,如果该委托对象没有根引用(Root Reference),.NET 的垃圾回收器(GC)会认为它不再被使用并将其回收。
后果:一旦 GC 发生,非托管代码再次尝试回调时,会访问无效的内存地址,直接导致 Access Violation 崩溃。
解法:代码中将 _proc 声明为 private readonly 字段,确保只要 GlobalKeyboardHook 实例存在,委托就不会被回收。
2、消息传递链
CallNextHookEx 是必须调用的。Hook 是一个链表结构,如果你不调用它,消息就会在你的这里"断掉",导致系统或其他软件无法接收到键盘事件(例如用户按了键盘但屏幕上没反应)。
3、权限与兼容性
虽然低级钩子不需要注入,但如果你的程序以普通用户权限运行,而目标程序(如任务管理器)以管理员权限运行,你可能无法拦截到目标程序的消息。
最佳实践:发布时建议请求管理员权限。
三、扩展:鼠标钩子的差异化实现
鼠标钩子的逻辑框架与键盘一致,主要区别在于数据结构的解析。
键盘传递的是整型键码,而鼠标传递的是一个包含坐标、标志位的结构体。
// 鼠标特有常量privateconstint WH_MOUSE_LL = 14; privateconstint WM_LBUTTONDOWN = 0x0201; // 必须定义与 C++ 端内存布局一致的结构体[StructLayout(LayoutKind.Sequential)] privatestruct MSLLHOOKSTRUCT { public POINT pt; // 屏幕坐标 X, Y publicint mouseData; // 滚轮数据或 X 按钮 publicint flags; // 注入标志 publicint time; // 时间戳 public IntPtr dwExtraInfo; } [StructLayout(LayoutKind.Sequential)] privatestruct POINT { publicint X; publicint Y; } // 解析逻辑示例private IntPtr MouseHookCallback(int nCode, IntPtr wParam, IntPtr lParam){ if (nCode >= 0) { // 将指针数据转换为结构体 MSLLHOOKSTRUCT hookStruct = Marshal.PtrToStructure<msllhookstruct>(lParam); if (wParam == (IntPtr)WM_LBUTTONDOWN) { Console.WriteLine($"检测到左键点击!位置:({hookStruct.pt.X}, {hookStruct.pt.Y})"); // 此处可添加逻辑:例如在特定区域点击时拦截返回 1,阻止点击生效 // return (IntPtr)1; } } return CallNextHookEx(_hookID, nCode, wParam, lParam); }四、避坑与实践
在实际工程落地时,请务必关注以下五点:
1、性能红线
钩子回调运行在系统中断上下文中。严禁在回调中进行耗时操作(如数据库读写、网络请求、复杂 UI 渲染)。
正确做法:在回调中仅做标记或快速判断,若需复杂处理,应触发一个异步任务或将消息投递到队列中由其他线程处理。
2、资源泄露防护
务必实现 IDisposable 接口,并在程序退出(包括异常退出)时调用 UnhookWindowsHookEx。
虽然进程结束时 OS 会清理,但在长时间运行的服务或插件中,未卸载的钩子是内存泄露和系统不稳定的元凶。
3、跨平台局限性
这是纯 Windows API 技术。如果你的应用需要部署在 Linux (Ubuntu/CentOS) 或 macOS 上,此方案完全不可用。
跨平台项目需考虑使用操作系统特定的替代方案(如 Linux 的 X11/Wayland 监听或 macOS 的 Quartz Event Taps)。
4、安全软件对抗
由于键盘记录器常利用 Hook 技术,许多杀毒软件(如 360、火绒)和游戏反作弊系统(如 ACE, BattlEye)会对未经签名的 Hook 程序进行静默拦截或直接查杀。
对策:如果是内部工具,需添加白名单;如果是商业软件,需进行代码签名,并明确告知用户权限需求。
5、UI 线程阻塞
如果在 WinForms/WPF 中直接在 Hook 回调里更新 UI,可能会引发跨线程异常或死锁。请使用 SynchronizationContext 或 Control.Invoke 将操作封送回 UI 线程。
总结
C# 中的 Hook 技术是一把双刃剑:用得好,它能赋予应用程序"透视"系统底层的能力,实现全局快捷键、自动化运维等高级功能;用得不好,则会导致程序崩溃、系统卡顿甚至被杀软误报。
核心法则
首选低级钩子 (_LL),避开 DLL 注入的泥潭。
死死守住委托引用,防止 GC 导致的崩溃。
回调函数要轻快,绝不阻塞系统消息流。
用完即卸,保持系统清洁。
掌握了这些原则,大家就能在.NET 的生态下安全地驾驭 Windows 底层消息机制。
关键词
最后
如果你觉得这篇文章对你有帮助,不妨点个赞支持一下!你的支持是我继续分享知识的动力。如果有任何疑问或需要进一步的帮助,欢迎随时留言。也可以加入微信公众号[DotNet技术匠] 社区,与其他热爱技术的同行一起交流心得,共同成长!
作者:小码编匠
出处:gitee.com/smallcore/DotNetCore
声明:网络内容,仅供学习,尊重版权,侵权速删,歉意致谢!
方便大家交流、资源共享和共同成长
纯技术交流群,需要加入的小伙伴请扫码,并备注【加群】
推荐阅读
觉得有收获?不妨分享让更多人受益
关注「DotNet技术匠」,共同提升技术实力
</msllhookstruct></keyeventargs></keyeventargs>
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!