多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Windows键鼠模拟全解析:从SendInput到驱动级的原理与实战

Windows键鼠模拟全解析:从SendInput到驱动级的原理与实战 做自动化的人迟早会跟键盘鼠标模拟打交道。自动化测试要模拟用户点击RPA 要在业务系统里自动填单辅助流程要帮人完成重复的键鼠操作哪怕你只是想把自己每天都要做一遍的“半小时重复劳动”变成脚本最终都会落到同一个问题上怎么让程序“假装”成一次真实的按键、一次真实的鼠标移动。我早期在这上面走了不少弯路。一开始只知道 keybd_event后来知道 SendInput再后来发现还有 PostMessage 这种能把消息直接丢进目标窗口队列的思路直到把 Windows 输入链路认真梳理一遍才把这几类方式真正串起来。键盘鼠标模拟不是“调一个 API 就完事”的话题而是一套分层体系。不同层级的模拟方法在效果、权限、适用范围和程序可见性上完全不同。搞懂这套层级能帮你少写很多“代码明明没问题但目标程序就是没反应”的冤枉脚本。这篇文章就从原理、实现、选型到踩坑把主流模拟方式完整过一遍。1. 一次按键从物理到系统的旅程模拟的本质在哪里“插队”1.1 物理输入的标准链路要理解模拟输入先要看清楚真实输入是怎么到达应用程序的。按一次物理键盘按键完整链路大概是这样的键盘内部电路检测到按键通过 USB 或 PS/2 接口上报一组 HID 报告里面记录的是这个按键的 hid usage系统会把它解析成硬件扫描码。Windows 的类驱动 kbdclass.sys键盘和 mouclass.sys鼠标接管这些报告按照设备栈向上传递形成规范的内核输入流。内核输入流交给系统的原始输入线程RITRaw Input Thread。RIT 是 Windows 输入系统的中枢它负责把底层硬件事件转换成标准的硬件输入消息。RIT 把消息投递到当前前台线程的消息队列经过消息泵分发最终窗口过程收到 WM_KEYDOWN、WM_KEYUP、WM_CHAR、WM_MOUSEMOVE 等消息应用程序才能响应。另有一条独立通道叫 Raw Input应用可以直接注册 WM_INPUT 接收最原始的输入数据。游戏普遍用它来降低输入延迟绕开常规窗口消息。键盘模拟的“模拟”二字指的就是在这条链路的不同环节人为插入事件。1.2 三层“插队”位置对应三种模拟层次我在实际开发中把模拟方式归成三档第一档是用户态 API 注入代表就是 SendInput。它直接把合成事件塞进 RIT 输入流位置非常靠后和真实输入共享绝大多数路径所以兼容性最好。第二档是消息直达代表是 PostMessage。它不经过 RIT直接把 WM_KEYDOWN 这类消息丢进指定窗口的消息队列。路径最短跳过了一堆“真实性检查”但也正因为跳过太多很多依赖原始输入的程序根本不认。第三档是驱动级甚至硬件级模拟。在 kbdclass 和 RIT 之间挂一个过滤设备或者直接让系统多枚举出一个虚拟键盘设备。对上层来说这就是一个真键盘程序想区分都难。搞清楚你面对的是哪一档就知道该选什么工具。下面逐一展开。2. 用户态模拟的三种实现SendInput、PostMessage 与旧版 API 的差异2.1 SendInput最接近真实输入的用户态方案先给结论Windows 上做键鼠模拟没有特殊理由的话首选 SendInput。微软官方也是这么建议的它同时覆盖键盘、鼠标和硬件输入消息并且是批量提交一次可以发多组事件。SendInput 的核心是 INPUT 数组。我常用 C/C 封装一个最基础的键盘函数#include windows.h void SendKeyboardInput(WORD vk, WORD scan, DWORD flags) { INPUT input {0}; input.type INPUT_KEYBOARD; input.ki.wVk vk; input.ki.wScan scan; input.ki.dwFlags flags; SendInput(1, input, sizeof(INPUT)); }调用时模拟按下一个 A 键然后抬起就是两件事SendKeyboardInput(0x41, 0x1E, 0); // VK_A, 扫描码0x1E, 按下 SendKeyboardInput(0x41, 0x1E, KEYEVENTF_KEYUP); // 松开这里有几个参数必须弄清楚。wVk 是虚拟键码比如 A 是 0x41回车是 0x0D。wScan 是硬件扫描码A 的扫描码是 0x1E。dwFlags 决定这次输入的行为常用的组合有标志含义KEYEVENTF_KEYUP松开按键。如果不带这个标志系统默认是按下KEYEVENTF_SCANCODE表示用扫描码代替虚拟键码。配合 wScan 使用能绕过部分程序对虚拟键码的检查KEYEVENTF_EXTENDEDKEY扩展键标志。方向键、Insert、Delete 这类键必须带否则扫描码对不上KEYEVENTF_UNICODE直接按 Unicode 字符输入wVk 必须设为 0wScan 放字符的 UTF-16 编码鼠标模拟稍微特殊一点尤其是绝对移动。屏幕坐标并不是直接填进 dx/dy 的而是要换算成 0 到 65535 的归一化坐标void MoveMouseAbsolute(int x, int y) { int width GetSystemMetrics(SM_CXSCREEN); int height GetSystemMetrics(SM_CYSCREEN); INPUT input {0}; input.type INPUT_MOUSE; input.mi.dx (LONG)((x * 65535) / (width - 1)); input.mi.dy (LONG)((y * 65535) / (height - 1)); input.mi.dwFlags MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE; SendInput(1, input, sizeof(INPUT)); }为什么要除以 width - 1 而不是 width因为 0 到 width - 1 的像素范围要映射到 0 到 65535 的闭区间。多数代码里直接除以 width 也不会出明显问题但严格写是减一。我现在写工具都按减一来。还有一点值得留意SendInput 的返回值是“成功插入系统输入流的数量”。如果返回 0说明事件被系统拦截了最常见的原因就是权限不足后面排坑章节细说。2.2 PostMessage面向后台窗口的“消息直达”有一种很常见的自动化需求不想抢用户正在用的鼠标键盘只往某个后台窗口里填内容。这时候 SendInput 就不合适了因为 SendInput 永远作用在当前前台窗口没有“指定窗口”这个参数。PostMessage 可以指定目标窗口句柄直接把消息塞进该窗口的消息队列。举个填表的例子void PostKeyToWindow(HWND hwnd, WORD vk, WORD scan, bool keyUp) { LPARAM lParam 0; lParam | 1; // repeat count 1 lParam | (LPARAM)scan 16; // 扫描码放进16-23位 if (keyUp) { lParam | (LPARAM)1 31; // transition state表示松开 } PostMessage(hwnd, keyUp ? WM_KEYUP : WM_KEYDOWN, vk, lParam); }WM_KEYDOWN 和 WM_KEYUP 的 lParam 里信息很密集按位拆开是0-15 位重复计数16-23 位扫描码24 位扩展键标志29 位上下文码30 位前一个按键状态31 位切换状态。严格按位填后台消息才能被目标窗口正常识别。但 PostMessage 有两个先天限制决定了它只能在特定场景用第一个限制是它只对“讲道理”的程序有效。标准 Win32 控件、记事本、浏览器地址栏、很多 MFC/Qt/WPF 程序都是靠窗口消息处理键盘输入的PostMessage 能生效。但游戏、全屏程序、还有那些用 GetAsyncKeyState 或 Raw Input 轮询键盘状态的程序本质上不是“收到一条按键消息”才干活而是每帧去问系统“这个键按了没有”你对它的消息队列发东西它根本不理。第二个限制是PostMessage 发的消息没有经过 RIT缺少系统附加的输入上下文信息。有些控件会校验焦点状态后台窗口不是焦点窗口时就算收到 WM_KEYDOWN也可能直接忽略。这个在后面焦点问题里还要再展开。2.3 keybd_event 与 mouse_event历史遗留的老将老项目里常看到 keybd_event 和 mouse_eventMSDN 上也已经明确提出这俩函数“已被替代”。keybd_event 是 Windows 95 时代的设计它只能发虚拟键码不能很好地表达扫描码、扩展键这些细节。mouse_event 也是类似没有 SendInput 那种统一的 INPUT 结构。我见过很多老代码喜欢用 keybd_event实际处理中你只要把原来的调用替换成 SendInput 对应的 INPUT 结构行为基本一致。它能做的SendInput 都能做SendInput 能做的它有不少做不到。所以新项目不要再用老代码遇到兼容性问题时优先考虑迁移。3. 低级钩子监听、拦截和自动化链路里的“记录仪”3.1 低级钩子的工作机制模拟输入还有一个好搭档钩子。它不是模拟本身但在自动化工具里几乎不可缺席。通过 SetWindowsHookEx 注册 WH_KEYBOARD_LL 或 WH_MOUSE_LL就能在系统输入流经过时先看到事件甚至拦住它。拿监听 F12 热键的键盘钩子举例HHOOK g_hook; LRESULT CALLBACK LowLevelKeyboardProc(int nCode, WPARAM wParam, LPARAM lParam) { if (nCode HC_ACTION) { KBDLLHOOKSTRUCT* info (KBDLLHOOKSTRUCT*)lParam; if (wParam WM_KEYDOWN info-vkCode VK_F12) { // 返回非0 吞掉这个事件 return 1; } } return CallNextHookEx(nullptr, nCode, wParam, lParam); } int main() { g_hook SetWindowsHookEx(WH_KEYBOARD_LL, LowLevelKeyboardProc, GetModuleHandle(nullptr), 0); MSG msg; while (GetMessage(msg, nullptr, 0, 0)) { TranslateMessage(msg); DispatchMessage(msg); } return 0; }低级钩子的一个重要特点是回调运行在安装钩子进程自己的上下文里不需要把 DLL 注入目标程序。这对工具类软件太友好了。使用低级钩子有几条经验第一安装钩子的线程必须有一个消息循环。很多人写完钩子回调却发现不触发多半就是程序跑完 SetWindowsHookEx 就直接退出了。第二回调里不能做耗时操作。Windows 对低级钩子回调的执行时间有隐性超时限制你在回调里 Sleep 或者跑大循环超时后系统会直接跳过你。真要在回调里做复杂逻辑先投递到工作线程。第三想要拦截某个键在回调里返回非 0 就行。监听和拦截二合一这让很多“录制回放”类工具的实现变得非常直接。3.2 录制回放型自动化的组成市面上绝大多数宏录制工具底层结构就是“钩子 SendInput”。录制阶段用钩子监听用户输入记下键码、鼠标坐标、时间戳回放阶段按时间轴调 SendInput 把事件重新注入系统。这种结构的合法性也最广泛自动化测试录脚本、演示工具记录操作、辅助工具帮人做重复动作都是这个套路。我在做类似工具时最低成本的架构是这样一个监听线程负责钩子事件一个工作线程负责把事件排列到一个队列里回放线程从队列按时间差 Sleep 后调用 SendInput。关键点在于回放时绝对不能把 Sleep 放在钩子回调里否则超时概率极大。4. 驱动级模拟与虚拟 HID跨过用户态边界的高门槛方案4.1 用户态模拟的三条边界既然 SendInput 已经这么接近真实输入为什么还会有人去碰驱动级因为用户态方案有三条绕不过去的边界。第一条是注入标志。SendInput 合成的事件系统会带上注入标记钩子回调里的结构体通过 flags 字段可以明确看到。演示一下MSLLHOOKSTRUCT 和 KBDLLHOOKSTRUCT 的 flags 字段如果包含 LLMHF_INJECTED就说明这是一条注入事件。任何程序都能通过钩子或者 GetMessageExtraInfo 察觉到“这不是物理按键”并基于这一点做自己的业务判断。第二条是权限边界。Windows 有用户界面特权隔离普通权限进程向管理员权限窗口发送输入Windows 会直接拦掉SendInput 返回 0PostMessage 也可能静默失败。这在自动化脚本里非常常见后面详细说。第三条是安全桌面和会话隔离。UAC 弹窗出现的时候整个输入动作发生在安全桌面上普通程序的事件根本够不到那里。同理Windows 服务运行在 Session 0也不能直接向用户登录桌面的窗口模拟键鼠。这三条边界恰恰是驱动级方案的动机它工作在更底层可以绕开部分此类限制。4.2 内核输入栈与驱动级模拟的实现思路驱动级模拟通常发生在内核设备栈。键盘类驱动 kbdclass.sys 前面可以挂一类过滤驱动这类驱动能看到/修改从物理设备上报的输入报告也可以自己构造输入报告往上层递。从系统角度看底层真的“来了一个按键”注入标志自然不存在任何用户态 API 都察觉不出问题。还有另一种做法是虚拟 HID 设备。在系统里挂一个假的键盘、鼠标设备让操作系统以为多插了一个外设。这类设备的输入流从底层就是真实输入应用层不可能区分。最常见的落地产品就是各种可编程键盘、脚踏开关、工业控制按钮盒原理都是一样的。但要提醒一句驱动级方案的门槛和风险都很高。x64 架构强制要求内核驱动签名驱动证书和 WHQL 认证流程不便宜Win11 默认开启基于虚拟化的安全VBS/HVCI很多老的第三方输入类驱动直接失效。驱动写不好就是蓝屏调试一次代价极大。我的原则永远是应用层能解决的不碰驱动驱动只留给真正的硬件外设产品。4.3 硬件级“模拟”的特殊位置如果只是做自动化还有一种另类选择物理层模拟。比如用单片机模拟 USB HID 键盘系统看到的就是一个真实键盘。它不需要写 Windows 驱动没有签名问题兼容性最好但需要额外硬件。这类方案在自动化测试工装、医疗设备输入模拟、工厂产线数据录入场景里很常见。做软件自动化的人平时用不上但只要目标环境对输入来源检查异常严格硬件方案往往是最干净的兜底。5. 跨平台键鼠模拟Java Robot、pyautogui 与 pynput 的选型逻辑5.1 跨平台自动化的底层差异Windows 有 SendInputLinux 和 macOS 自然也有对应机制。Linux X11 下最常用的是 XTest 扩展通过 XTestFakeKeyEvent、XTestFakeButtonEvent 合成输入事件macOS 下则是 CGEventPost把事件投进 Quartz 事件流。这里有个隐藏差异值得注意Wayland 环境下因为合成器安全策略普通程序全局模拟键鼠基本是被禁止的你只能用 wtype 这类依赖合成器协议的专用工具。所以“跨平台”键鼠模拟最成熟的目标永远是 X11 或 Windows/macOS 桌面Wayland 一直是个例外。5.2 三套典型方案对比我不太推荐所有人都从 C 开始写实际项目里很多需求用脚本工具就能解决。Java Robot、pyautogui、pynput 是我平时用最多的三套。方案底层依赖能否监听全局输入截图找图适用场景Java Robot各平台原生输入 API不能不能Java 自动化测试、Selenium 辅助操作pyautoguiWindows SendInput / X11 XTest / macOS CGEvent不能能RPA、桌面填表脚本、PPT 自动操作pynput各平台输入 API 事件监听能不能键盘监听、录制回放、热键工具Java Robot 最大的价值是它属于标准库不用装任何第三方依赖。在 Java 技术栈里遇到原生弹窗、文件上传对话框这类 Selenium 搞不定的场景用 Robot 模拟一通 Tab、回车、输入路径是很成熟的方案。缺点很明显不能监听只能单向模拟。pyautogui 我自己用得不算多但它的上手成本低到几乎不需要看文档。pyautogui.click(100, 200) 就是单击pyautogui.write(hello) 就是打字。它还自带截屏定位find_on_screen 能找到按钮图片再返回坐标。这个库有一个贴心设计叫 fail-safe默认开启脚本失控时把鼠标甩到屏幕左上角程序会立刻抛异常退出。我建议任何时候都不要关掉它。pynput 是我个人最偏爱的一套因为它把“监听”和“控制”拆成了两个清晰的模块。Listener 负责全局监听支持返回 False 自动停止Controller 负责发送事件。做“录制回放”类工具时pynput 几乎是 Python 里的最快路径。5.3 一个 Python 示例pynput 控制组合键from pynput.keyboard import Controller, Key kb Controller() def press_hotkey(*keys): for key in keys: kb.press(key) for key in reversed(keys): kb.release(key) # 模拟 Ctrl Shift A press_hotkey(Key.ctrl, Key.shift, a)keyboard 库的按键 key 参数既可以是 Key 枚举里的特殊键也可以是普通字符。注意释放顺序要和按下顺序相反否则某些快捷键会在释放中间态被触发。6. 手写一个键鼠模拟工具代码骨架与关键参数6.1 C 通过 SendInput 完成鼠标绝对移动与点击把前面提到的代码整合起来就是一个可以直接用的 C 鼠标小工具#include windows.h void MoveMouseAbsolute(int x, int y) { int width GetSystemMetrics(SM_CXSCREEN); int height GetSystemMetrics(SM_CYSCREEN); INPUT input {0}; input.type INPUT_MOUSE; input.mi.dx (LONG)((x * 65535) / (width - 1)); input.mi.dy (LONG)((y * 65535) / (height - 1)); input.mi.dwFlags MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE; SendInput(1, input, sizeof(INPUT)); } void ClickAt(int x, int y, bool right false) { MoveMouseAbsolute(x, y); INPUT inputs[2] {0}; inputs[0].type INPUT_MOUSE; inputs[0].mi.dwFlags right ? MOUSEEVENTF_RIGHTDOWN : MOUSEEVENTF_LEFTDOWN; inputs[1].type INPUT_MOUSE; inputs[1].mi.dwFlags right ? MOUSEEVENTF_RIGHTUP : MOUSEEVENTF_LEFTUP; SendInput(2, inputs, sizeof(INPUT)); }这样封装以后ClickAt(960, 540) 就能在当前屏中心完成一次干净利落的单击。多屏环境下要注意坐标基准SendInput 的绝对坐标默认以主显示器为基准扩展屏的坐标可能是负值或者超出主屏范围换算时要加上对应的虚拟屏幕偏移量。6.2 Python 通过 pynput 模拟更复杂的输入行为下面是一个带随机节奏的字符串输入函数。真实人手按键间隔不是完全固定的在需要自然感的自动化里小范围随机间隔很有用from pynput.keyboard import Controller import time, random kb Controller() def type_letter(letter: str): kb.press(letter) time.sleep(random.uniform(0.03, 0.08)) kb.release(letter) time.sleep(random.uniform(0.01, 0.05)) for ch in hello world: type_letter(ch)需要说明的是pynput 的 Controller.type 对纯英文字符最稳。中文字符和特殊符号在不同系统上表现差异很大我通常的做法是写入剪贴板再模拟 CtrlV这是自动化填表里最通用的中文录入方案。6.3 虚拟键码、扫描码与标志位的使用笔记这部分是手写模拟代码最容易出错的地方。我列一个高频按键速查表按键虚拟键码 VK扫描码扩展键标志A0x410x1E无Enter0x0D0x1C无Shift0x100x2A无方向键 Left0x250x4B需要Space0x200x39无很多程序出了“识别不到模拟按键”的问题不是 SendInput 没发出去而是标志位组合不对。比如方向键扫描码 0x4B 是扩展扫描码必须在 dwFlags 里加上 KEYEVENTF_EXTENDEDKEY否则系统理解成小键盘的某个键。另一个容易踩的细节是 KEYEVENTF_SCANCODE。有些游戏和工具软件读取的是硬件扫描码而不是虚拟键码你在 wVk 里填 0x41它如果只认 scancode 就完全无反应。正确做法是 wVk 传 0wScan 传 0x1Eflags 加 KEYEVENTF_SCANCODE。这也是第三方输入库在兼容性上反复打磨的地方。7. 实测排坑记录权限、输入法、焦点与节奏问题7.1 管理员权限与 UIPI 拦截这个坑我踩得最深。写过一个小工具给同事用在他的机器上模拟输入完全没反应代码逻辑换到我的管理员账户里一跑就正常。排查到最后是 UIPI 在作怪同事运行的是普通权限进程目标软件是以管理员身份启动的系统禁止低权限进程向高权限窗口注入输入。排错方法很简单看 SendInput 的返回值。如果连续调用返回 0优先怀疑权限问题。解决方案也直接给程序清单加上 requireAdministrator让自动化的执行进程本身就是管理员权限。requestedExecutionLevel levelrequireAdministrator uiAccessfalse /但这里有个连锁反应要注意一个管理员权限的自动化进程会反过来把 UIPI 限制施加到普通程序上。如果脚本同时要操作多个不同权限的窗口最佳实践是按权限拆成两个进程各自负责各自的窗口避免互相踩脚。服务进程还有一个单独的坑在 Session 0 或者锁屏状态下SendInput 模拟不了用户桌面的键鼠。服务程序想干这个事要么用计划任务“只在用户登录时运行”的方式要么走桌面交互的专门机制不能用 Service 裸奔。7.2 输入法干扰模拟键盘的经典翻车现场脚本通过 SendInput 模拟按下一个普通字母键但在中文输入法状态下这个键被输入法吞掉最终窗口里多了一个候选拼音或者什么都没有。这是因为输入法在 RIT 之上还有一个专门的 IME 链路键盘事件会被输入法组件先过滤一遍。所以做键盘自动化时最好明确自己需要的是“按键”还是“字符”。如果目标是输入字符最稳的办法是用 KEYEVENTF_UNICODE直接把 Unicode 字符注入系统void SendUnicodeChar(wchar_t ch) { INPUT input {0}; input.type INPUT_KEYBOARD; input.ki.wVk 0; // 必须为0 input.ki.wScan ch; // Unicode字符 input.ki.dwFlags KEYEVENTF_UNICODE; SendInput(1, input, sizeof(INPUT)); input.ki.dwFlags KEYEVENTF_UNICODE | KEYEVENTF_KEYUP; SendInput(1, input, sizeof(INPUT)); }如果是模拟快捷键比如 Win R需要保证输入法处于英文模式。我通常的做法就是在执行组合键之前先模拟切换到英文键盘布局或者干脆用 KEYEVENTF_UNICODE 输入组合键里需要的字符部分避免经过 IME。7.3 焦点与后台自动化SendInput 的事件永远投给当前前台窗口没有“指定目标窗口”的概念。所以很多自动化工具的第一步都是把目标窗口拉到前台。SetForegroundWindow 理论上可以但 Windows 对前台切换限制很多目标窗口被最小化、另一个进程正在占用前台、系统设置了前台锁定都可能导致它失败。一个比较通用的暴力解法是用 AttachThreadInput 把两个进程的输入线程挂到一起再做前台切换void ForceForeground(HWND hwnd) { DWORD processId; GetWindowThreadProcessId(hwnd, processId); DWORD currentThreadId GetCurrentThreadId(); AttachThreadInput(processId, currentThreadId, TRUE); SetForegroundWindow(hwnd); ShowWindow(hwnd, SW_RESTORE); AttachThreadInput(processId, currentThreadId, FALSE); }如果真的不希望打扰前台那就别用 SendInput回到 PostMessage 的后台消息方案。我自己的判断标准是目标程序本身就是标准窗口控件、会响应 WM_KEYDOWN后台方案完全够用目标程序是全屏游戏或者自绘界面那基本只有 SendInput 可用也不要再指望后台静默。7.4 节奏与时序的一个经验最后一个坑来自时序。很多人写模拟脚本习惯把按下和抬起连在一起间隔几乎为零。这在普通编辑框里没问题但有些程序对按键 down/up 之间的间隔很敏感尤其是带长按判断的界面。我写轮询型脚本的经验是按下后至少 Sleep 30 到 50 毫秒再抬起宁可慢一点也不要快出问题。多步操作之间也要留“人味”。真实手速再快的操作者也不可能每 10 毫秒完成一步。不是说模拟必须伪装成真人而是很多程序的状态刷新本身有开销事件发得太快会被丢处理最后脚本自己把自己搞挂了。我一般在每两个操作之间固定留 100 到 200 毫秒涉及界面刷新的操作再翻倍。这几类问题我基本都踩过一遍。串起来看键盘鼠标模拟不是比谁用的 API 更底层而是先搞清楚目标程序通过什么路径接收输入再选择对应的层级去模拟。选对了层级工具写起来事半功倍。
返回列表