易语言驱动级键鼠模拟:原理、实现与踩坑全解析
有段时间没聊驱动层的东西了。圈子里朋友问我最多的问题就是易语言能不能做“真实系统级”的键鼠模拟而不是调那些一眼被识破的普通API。坦白讲应用层模拟和驱动级模拟差的不是一点点而是“权限层级”和“可信度”两个维度的问题。这篇文章我想把手上的这套“易语言键鼠硬件驱动模块”拆开来讲讲清楚驱动级鼠标移动和键盘模拟的原理、工程实现、以及实操中容易踩的坑不吹不黑希望能帮到做自动化测试、远程运维工具、以及硬件级输入模拟场景的朋友。这套模块由三部分组成易语言写的上层控制程序、底层内核驱动、以及两者之间的IOCTL通道。它能做到鼠标的绝对移动、相对移动、滚轮、按键按下与抬起、键盘组合键的完整模拟且不依赖窗口焦点也不会被常见的输入钩子拦截。简单说它走的是硬件输入栈的最底层从根儿上解决了普通模拟方式“层级不够”的问题。至于大家关心的游戏辅助场景我最后会专门说使用边界先说技术本身。1. 项目画像为什么“驱动级”键鼠模拟成了刚需1.1 应用层模拟的天花板在哪几乎每个写过自动化脚本的朋友都经历过这种场景SendInput调用出去了鼠标也确实动了但某些程序就是不识别或者识别以后动作断断续续、按键丢失。这不是你的代码写错了而是你站在了“应用层”这层地板上。Windows的输入系统分为好几层普通APImouse_event、keybd_event、SendInput实际上是调用win32k的输入管理模块处于系统调用层面。但凡目标程序有全局钩子、自绘窗口、输入重定向、反作弊级别的保护应用层输入就很容易被识别成“外部注入”从而被丢弃或者限速。我实际测过一个场景用一个走应用层接口的脚本去执行“按下A键、稍等50毫秒、松开”的循环在普通文本编辑器里毫无问题。但把它放到游戏引擎里就会出现按键时灵时不灵的情况。原因就是目标程序安装了底层的键盘过滤设备对这种“输入流”做了校验。它检查的是鼠标/键盘上报的轨迹是否具备“硬件随机性”与“时序一致性”而API模拟的输入往往太规整了——你几百毫秒一次、间隔精确到个位数一看就不是物理手指按出来的节奏。1.2 驱动层面的模拟为什么更接近“真实软件输入”这套模块的核心并不是在应用层“注入一个鼠标事件”而是在内核态加载一个过滤驱动把自己的设备对象挂到鼠标键盘类驱动的上层。当物理鼠标移动、物理按键按下时Windows输入系统会从这个设备对象读数据而当我们需要模拟输入时也是往这个设备对象挂一个模拟的输入数据包。也就是说在系统看来你模拟出来的动作和物理设备的动作都是从同一路径进入输入处理器的。目标程序无论怎么钩都只能看到一个“真实鼠标”在移动而不是一个“第三方程序”在发指令。打个比方应用层API像是在大楼门口递了一张快递单门卫看见你是个陌生人随时可以拒收驱动级模拟则是把快递员换成了电梯维修工走的是设备维护通道门卫根本不知道你在送货。这个电梯维修工就是驱动过滤设备。1.3 这套模块真正适合做什么场景每次说“驱动级模拟”总有人第一时间联想到游戏外挂。我得先把话说在前面如果你的目标是做外挂、绕过反作弊系统那这篇文章不教你任何对抗手段我下面讲的所有内容都基于三个正当场景一是自动化测试。比如UI自动化框架需要模拟极端操作高频点击、连续按键、鼠标拖拽轨迹应用层API会被测试对象识别并拒绝驱动级输入能保证测试用例稳定复现。二是远程桌面与远控工具。国际知名远控软件在Windows上就有使用kernel-mode驱动来模拟输入的先例目的就是让自己在会话切换、UAC提权、锁屏状态下都能正常工作。三是个人效率工具/无障碍辅助。比如给上肢运动不便的用户做一个“头部控制鼠标”的辅助系统键盘模拟实现“呼吸吹气代替按键”这类场景对输入的真度要求很高驱动级几乎是唯一方案。至于游戏场景我只补一句很多游戏外设电竞键盘、宏鼠标在硬件层面做的是同样的事——设备内部先录制动作再通过USB协议上报给电脑。硬件层面的宏和驱动层面的模拟本质是同一件事。但具体到某款游戏允不允许请务必看它的用户协议别把技术讨论变成违规行为的帮凶。2. 技术原理深度拆解从APP到驱动的完整输入链路2.1 Windows输入栈的完整链路要理解这套模块的设计必须先建立起Windows输入系统的“栈”概念。从上到下大致是层级组件职能用户层应用程序调用API或接收消息系统层user32 / win32k封装调用进入内核驱动层KBDCLASS / MOUCLASS键盘/鼠标类驱动对接PNP设备总线层HID / USB / PS2物理传输中断与轮询外设产生的数据流沿着“物理设备 - 总线驱动 - 类驱动(键盘/鼠标) - 输入管理(wink32k) - 应用程序”这条路往上走。类驱动是最后一个能看到“原始数据”的地方。我们的过滤驱动就附着在KBDCLASS键盘类驱动和MOUCLASS鼠标类驱动的外部往下连接物理设备往上仍然对接win32k输入管理。它不替代原有驱动只做“中间商”。这个位置选得好是因为它既能看到所有物理输入又能主动注入数据而且是系统层的标准扩展点不需要动用特殊的内核API。2.2 模块的整体架构设计一个完整的驱动级键鼠模块我推荐分成四层UI控制层易语言窗口负责显示状态、接收命令、加载功能开关。业务逻辑层易语言/其他高级语言定义动作序列、逻辑循环、条件判断。服务封装层C写的服务控制程序负责启动驱动、创建服务、传递IOCTL。内核驱动层C/C写的内核驱动真正的模拟执行者。很多人误以为“易语言直接写驱动”其实不是。易语言不能编译内核驱动内核驱动一般用C/C写易语言负责出“上层外壳”。上层先通过服务管理接口SCManager把驱动加载成内核服务然后用DeviceIoControl打开设备句柄下发填好的结构体包含鼠标坐标、按键码、动作类型。驱动收到结构体后组装成标准的输入数据包往自己的设备对象上提交。上一层的“业务逻辑”和“内核机制”在这个点完成解耦。这样划分的理由很简单易语言做界面和逻辑很方便中文语言特性让脚本逻辑清晰内核部分用C/C则能保证内存安全与运行效率。把易语言放在内核层是很危险的想法一个指针错误直接导致蓝屏别这样搞。2.3 全Windows版本兼容意味着什么标题里强调“全Windows版本兼容”并不是一句营销口号它背后代表三件麻烦事第一Windows 7、Windows 10、Windows 11对驱动签名的要求不同。Windows 7的64位系统只要求驱动有合法的交叉证书Windows 10/11则强制要求微软签名进入“测试模式”是个临时办法重启后恢复开启Secure Boot又会导致加载失败。所以“兼容”的第一步是编译多套驱动版本x86/x64/ARM64并写好对应的签名策略。第二不同系统版本的类驱动内部结构差异较大。KBDCLASS和MOUCLASS的过滤设备对象在Win7和Win10中的初始化流程有变化IoAttachDeviceToDeviceStack的参数几乎一样但不同版本对IOCTL的处理路径可能不同需要在代码里通过RtlGetVersion判断系统版本走不同的初始化分支。第三输入数据包的结构定义在不同版本中保持稳定但偏移量在部分版本中会有差异。鼠标的MOUSE_INPUT_DATA和键盘的KEYBOARD_INPUT_DATA是公开的结构相对固定但驱动在解析时会依赖一个关键字段——UnitId设备单元号多鼠标/多键盘环境下必须正确设置否则注入目标会被系统自认为“某个不存在的设备”。3. 从零到一搭建一套可运行的驱动级键鼠方案3.1 构建环境你实际需要用到的工具链我在搭建过程中选择了“Visual Studio Windows Driver KitWDK”的组合。易语言这边不需要特殊模块只要能调用外部DLL并在窗口中嵌入一个按钮回调就行。整体工具链如下Visual Studio 2019/2022社区版即可Windows 10/11 SDKWindows 10/11 WDK易语言独立版支持DLL调用一个虚拟机建议备用驱动程序崩溃时不至于折腾主系统WDK装完后VS会自动出现“Driver项目模板”选择“Kernel Mode Driver: Empty”。编译驱动时必须把目标平台设为对应架构。调试用的机器建议以“测试模式”启动命令是bcdedit /set testsigning on重启后生效。特别提醒bcdedit /set testsigning on和“禁用驱动程序强制签名”是两码事前者是系统开启测试签名模式后者是在启动菜单里按F8选择。建议在虚拟机上调试否则一个未果循环会浪费时间。3.2 驱动部分的核心代码骨架伪代码级内核驱动的主要工作是加载时创建设备对象、符号链接附接到键盘鼠标类驱动卸载时清理设备对象、Detach在处理IRP_MJ_DEVICE_CONTROL请求时解析来自应用层的命令组装输入数据包并上报。下面是一段极简化驱动加载逻辑用来理解流程不是源码级实现// 驱动入口 NTSTATUS DriverEntry(DRIVER_OBJECT *DriverObject, PUNICODE_STRING RegistryPath) { DriverObject-DriverUnload DriverUnload; DriverObject-MajorFunction[IRP_MJ_CREATE] DispatchCreate; DriverObject-MajorFunction[IRP_MJ_CLOSE] DispatchClose; DriverObject-MajorFunction[IRP_MJ_DEVICE_CONTROL] DispatchControl; // 创建设备对象 IoCreateDevice(DriverObject, 0, deviceName, FILE_DEVICE_UNKNOWN, 0, FALSE, deviceObject); // 创建符号链接上层可以通过 \\.\MyInputDevice 打开 IoCreateSymbolicLink(symLinkName, deviceName); return STATUS_SUCCESS; }驱动还需要找到键盘和鼠标的类驱动对象并把自己附加上去。这一步常被放在一个工作线程中延迟执行因为系统启动阶段键盘/鼠标驱动不一定已经初始化结束。延迟附接一般用IoGetDeviceObjectPointer获取设备对象指针再用IoAttachDeviceToDeviceStack挂接// 附接到键盘 status IoGetDeviceObjectPointer(keyboardDeviceName, FILE_READ_ATTRIBUTES, fileObject, kbDeviceObject); if (NT_SUCCESS(status)) { filterDevice-kbLowerDevice IoAttachDeviceToDeviceStack(filterDevice-deviceObject, kbDeviceObject); }关键点在于IoAttachDeviceToDeviceStack之后过滤设备对象成为栈顶后续所有键盘输入都会进入过滤驱动的分发例程。改分发例程里什么不做、原样传递系统不受影响我们只有当IOCTL命令到来时才主动向栈顶注入数据包。3.3 应用层与驱动之间的IOCTL通道设计应用层和驱动之间的数据交互我推荐用IOCTL控制码加自定义结构体而不是用ReadFile/WriteFile直接传裸字节。IOCTL的好处是语义清晰每种操作对应一个控制码驱动内部直接switch处理不用在数据头加协议标记。IOCTL控制码的计算公式标准宏#define IOCTL_MOUSE_MOVE CTL_CODE(FILE_DEVICE_UNKNOWN, 0x1000, METHOD_BUFFERED, FILE_WRITE_ACCESS) #define IOCTL_KBD_PRESS CTL_CODE(FILE_DEVICE_UNKNOWN, 0x1001, METHOD_BUFFERED, FILE_WRITE_ACCESS) #define IOCTL_KBD_RELEASE CTL_CODE(FILE_DEVICE_UNKNOWN, 0x1002, METHOD_BUFFERED, FILE_WRITE_ACCESS)CTL_CODE接收四个参数设备类型、命令序号、缓冲区访问方式、权限。命令序号从0x1000往后排给自己留足扩展空间。METHOD_BUFFERED是指系统会自动分配一块缓冲区把应用层输入缓冲复制过来内核在缓冲区中处理完再复制回去。这个方法别嫌它慢驱动控制命令的调用频率远没到性能瓶颈稳定才是第一位。在易语言里调用DeviceIoControl的方式如下以鼠标移动为例.DLL命令 SendIoctl, 整数型, kernel32.dll, DeviceIoControl .参数 hDevice, 整数型 .参数 ioControlCode, 整数型 .参数 inBuffer, 字节集 .参数 inSize, 整数型 .参数 outBuffer, 字节集 .参数 outSize, 整数型 .参数 bytesReturned, 整数型, 传址 .参数 overlapped, 整数型然后hDev CreateFile (\\\\.\\MyInputDevice, 位或 ( #GENERIC_READ, #GENERIC_WRITE ), 0, 0, 0, 0, 0) 如果 (hDev -1) 则提示打开失败 inBuf 到字节集 ({00, 00} 或构造一个“相对移动”的结构) SendIoctl (hDev, 4096 0, inBuf, 取字节集长度 (inBuf), 0, 0, bytesReturned, 0)易语言里的4096就是CTL_CODE展开后的十进制值你也可以在C里计算好直接写死但建议写个宏在Python里生成免得算错。3.4 服务创建与驱动加载管理驱动加载需要以管理员权限执行。我封装了一个“驱动安装器”核心逻辑是调用OpenSCManager创建服务然后StartService启动。如果服务已存在则先删除旧服务再重新创建。务必在安装前检查驱动文件是否存在于System32目录下驱动路径不支持相对路径。// 创建服务示例 SC_HANDLE serviceManager OpenSCManagerW(NULL, NULL, SC_MANAGER_CREATE_SERVICE); CreateServiceW(serviceManager, LInputDriver, LInputDriver, SERVICE_KERNEL_DRIVER, SERVICE_DEMAND_START, SERVICE_ERROR_NORMAL, L\\SystemRoot\\System32\\drivers\\InputDriver.sys, NULL, NULL, NULL, NULL, NULL);驱动是“按需启动”还是“开机启动”取决于你的使用频率。如果只是测试SERVICE_DEMAND_START按需启动更安全卸载时直接DeleteService就行。如果做长期后台工具可以考虑SERVICE_AUTO_START但随之而来的问题是安全软件开机扫描时会更敏感。4. 鼠标移动与键盘模拟的工程细节4.1 鼠标相对位移、绝对坐标与指针速度模拟鼠标移动有两个维度相对位移dxdy和绝对坐标绝对像素坐标。应用层的API对两者的支持都有限制而驱动数据包MOUSE_INPUT_DATA里有一个Flags字段用来标记是相对移动还是绝对移动。相对移动的关键问题是“鼠标加速”干扰。Windows默认开启了“提高指针精确度”鼠标加速度功能如果你在驱动层上报((dx10, dy0))系统会依据加速方程把它映射到屏幕上更远的距离。要保证“模拟的就是实际移动距离”最直接的办法是在驱动层把上报的数据的ButtonFlags中带上MOUSE_MOVE_ABSOLUTE但要小心绝对坐标单位不是像素而是“虚拟桌面坐标”的0~65535映射。另一种方式是在系统控制面板临时关闭指针加速但这对服务化工具不合适会影响用户正常鼠标手感。我自己的实现里默认做“相对移动按比例换算”的模式由上层接收用户期望的像素位移换算成相对坐标之后传入驱动。驱动本身不关心加速度因为加速度是在更上层计算的只要应用层做一次“加速度逆变换”就能做到视觉上的“位移精确”。这个逆变换公式不需要驱动处理放到易语言里算就行。实时性方面鼠标移动建议每个数据包只带一次位移然后按50~120Hz频率投递。一次性把dx1000塞进一个包的做法在大部分系统上会触发指针跳到屏幕边缘体验非常奇怪。高频小步移动模拟物理手感和目标程序行为都更好。4.2 键盘键码映射与扩展键键盘模拟的核心结构体是KEYBOARD_INPUT_DATA字段包括MakeCode扫描码、Flags按键状态、Reserved等。需要注意Windows驱动层识别的不是大家熟悉的虚拟键码VK_A而是USB HID Usage ID对应关系。应用层习惯用VK_XX驱动层习惯用扫描码中间需要一张映射表。实际操作中我最常遇到两个坑第一个是扩展键。比如方向键、小键盘键、右Ctrl、右Alt它们的扫描码是双字节0xE0开头如果只按单字节上报按键会错乱。实现时要特别注意KEYBOARD_INPUT_DATA的MakeCode长度标志扩展键需要扩展标志位。第二个是组合键。组合键的按下顺序如果不对目标程序会判读成两个独立按键比如CtrlC必须先报Ctrl按下、等它生效后再报C按下顺序反了等于先C后Ctrl系统一样不会识别为复制操作。组合键的时序主要由上层逻辑控制。我在易语言里做了个“动作队列”把按键事件拆成“按下[[Ctrl]] 等待10毫秒 按下[[C]] 等待5毫秒 松开[[C]] 等待5毫秒 松开[[Ctrl]]”这样的最小粒度动作然后循环发IOCTL。这个队列用简单的线程锁保护避免脚本多处同时写队列导致时序错乱。4.3 性能稳定与缓冲保护驱动级模拟的性能瓶颈不在内核而在应用层发的“频次”。IOCTL调用一次大概百微秒级不算慢但如果像写日志一样每秒发几万个IOCTL用户态和内核态切换的成本就上来了。实际建议是鼠标轨迹平滑移动由驱动内部维护一个发送队列应用层只把目标点发过来驱动自己拆小步。键盘抚摸单个键高频按下应用层直接循环发IOCTL没问题但要控制每次间隔至少1毫秒以上否则总觉得是USB上报时序颠簸。驱动内部队列做好环形Buffer长度一般64或128就够满了就直接丢新包别阻塞。因为输入数据的弱实时性丢一两帧影响很小阻塞反而会把问题放大。稳定性上很多人第一次卸载完驱动再加载时系统提示“服务拒绝访问”底层原因是句柄没释放。应用层所有打开的CreateFile句柄必须先关掉再对服务发停止请求否则驱动卸载会因为设备对象上仍有引用而返回STATUS_DEVICE_IN_USE。5. 踩坑实录我在调试驱动级键鼠模块时遇到的5个问题5.1 未签名驱动无法加载最常见Windows 10/11的64位系统上驱动程序必须微软签名才能加载。开发阶段最实用的解决方案是开启“测试签名模式”然后用signtool sign给驱动打一个测试签名开发者证书或自签证书都可以。测试签名模式的命令参考bcdedit /set loadoptions DISABLE_INTEGRITY_CHECKS bcdedit /set TESTSIGNING ON这里有个醍醐灌顶的坑这句命令需要在管理员CMD里执行并且执行后必须重启但Secure Boot必须关闭否则“测试签名模式”是灰色的。我花了很长时间才发现是UEFI设置里Secure Boot卡着而不是命令没生效。所以排查签名问题先确认Secure Boot状态。5.2 服务创建了但启动失败错误码31或577服务创建成功但StartService返回错误31连接到系统上的设备没有起作用或577Windows无法验证此文件的数字签名。错误31同时可能表示驱动初始化出错了。这时最有效的排查工具是“事件查看器”里的“System”日志驱动内部用DbgPrint输出的信息会出现在Windbg或内核调试器的输出里。还有一个非常隐蔽的原因驱动文件路径不对。开发机用x64驱动但工程配置生成了x86的sys文件放到System32下加载会失败。建议安装器在安装前检查GetBinaryType确认架构匹配。5.3 蓝牙鼠标/键盘模拟后无效果有用户反馈驱动装上后应用层发鼠标移动指令USB鼠标正常动蓝牙鼠标纹丝不动。原因是蓝牙HID设备走的是Microsoft Bluetooth LE HID Proxy驱动不是MOUCLASS的初始化流程中同一类对象附接点不同。解决方向有两个一是给驱动增加枚举逻辑找到蓝牙HID设备为其创建一个独立的过滤设备对象二是蓝牙设备在多鼠标环境下UnitId与USB设备不同需要根据Device对象名称判断到底是哪个设备然后才把模拟包投给具体设备。做轻度使用的话建议优先插一个USB有线鼠标来验证功能蓝牙作为扩展优化项否则调试时间会翻倍。5.4 安全软件把驱动当风险项隔离驱动的行为太像“远程控制木马”了——启动未签名驱动、加载过滤设备、模拟全局按键这三件事叠在一起几乎每个安全软件都会报警。规避方式有两种第一签上EV级别的代码签名证书风险评分会降很多第二在文档中把驱动的调用发到自动化测试/无障碍工具的白名单方向向安全厂商提交误报信任申请。不要试图通过改壳或者白利用躲避安全软件这种技术内容本身就变味了。5.5 高频移动时指针抖动/撕裂调试时发现连续移动鼠标2000毫秒后指针“抽搐”一下。这个问题的根源不是驱动上报速度过快而是驱动内部没有保持“上一次坐标”作为起点偏移导致下一帧指令被系统解释成“从相对位置继续/从绝对位置覆盖”两种模式之间的切换。解决方式很简单应用层计算轨迹时每一次相对移动的dx/dy都要针对当前时刻的屏幕指针位置做一次坐标差值计算并且所有IOCTL请求轴同步发送不能让“X移动一帧、Y移动一帧”交替发出否则视觉上就是Z字形抖动。6. 使用边界与长期维护经验6.1 合规性能做什么不建议做什么驱动级键鼠模拟在实际生产中用途很广但并不是所有被称为“模拟”的需求都应该用这种方式解决。适合用驱动级方案的场景有自动化测试框架需要跨会话控制远程运维工具需要在锁屏/不同用户会话间工作无障碍输入设备需要把外部信号转成键鼠事件以及个人效率脚本窗口自动化中目标程序能识别全局钩子却拦截应用层输入的情况。不适合的场景我也列一下任何避开用户知情权的监控/记录类输入设备行为驱动级模拟绝不是用来做键盘记录器的部分游戏如果用户协议明确禁止外设宏那你用驱动实现本质等于破坏规则风险和后果自行承担。开发者在交付模块时最好把这个边界写到项目文档里避免使用者拿去做不该做的事。6.2 维护驱动的长期建议内核驱动最大的特点就是“不更新就落后”。Windows版本迭代、类驱动内部变化、安全策略收紧都会让一个一年前还能跑的驱动在新系统上出现问题。我维护这套模块有三条经验第一版本信息写到驱动文件资源里每次编译都自增版本号便于对应用户“哪个版本驱动跑在哪一版系统”的反馈定位。第二做一个独立的“诊断模式”。驱动启动后用固定路径\??\C:\debug\inputdriver.log记录初始化参数、附接设备名称、收到的IOCTL序列。出现异常时这个日志能直接告诉你到底是“没加载上”还是“附接失败”还是“命令没匹配”。第三保留旧版本的驱动签名与证书备份。签名证书过期后新驱动无法签名旧版本虽然能用但系统会要求启动时按F8。不要等到证书过期才想着备份周期性的证书过期提醒要在日历里加好提醒。6.3 一个小技巧多设备输入区分有时候你需要模拟的是“第二个鼠标”或“第三个键盘”。Windows对多个HID设备会分配不同的UnitId驱动层如果盲目把数据包往父设备对象上扔系统可能把它分配到所有同类型设备共享的输入队列导致动作重复或冲突。我的做法是在驱动初始化时枚举设备栈上的设备名把\Device\PointerDevice0、\Device\KeyboardClass0这类结尾的编号解析出来在IOCTL结构体里加一个“设备索引”字段。应用层默认传0xFFFF表示“所有设备”特殊场景传具体索引做单设备输出。这个功能初看没什么实际在自动化测试“多鼠标并发点击”的场景里非常关键否则并发测试脚本根本写不出来。最后再说回“全Windows版本兼容”。这件事没有银弹只有老老实实地在Win7 x86、Win7 x64、Win10 x64、Win11 x64各准备一台测试环境虚拟机都行每次编译完整跑一遍自测用例。我已经养成习惯把测试用例固化成批处理脚本一键部署到多台虚拟机里执行跑完自动收集日志。省下来的精力值得多写几百行基础设施代码。