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

WDM鼠标驱动源码实战:INF匹配、IRP分发与HID报告描述符解析

简介Windows WDM模型鼠标驱动程序完整源代码面向驱动开发初学者、底层系统开发者和需要定制HID设备的工程师。压缩包内共13个文件以C源文件、头文件、INF安装配置、Visual Studio工程文件及makefile构建脚本为主覆盖WDM驱动开发的关键流程包括设备对象创建与初始化、即插即用与电源管理、IRP请求处理、HID协议解析及数据上报。资源包体仅12KB结构紧凑适合逐模块精读配套的INF文件详细说明了驱动安装与硬件匹配机制可帮助理解Windows如何加载和绑定驱动。目前已有921人浏览学习对照实际代码学习WDM架构远比单纯阅读理论更容易上手同时这份源码也可作为开发自定义鼠标、键盘或其他HID过滤驱动时的基础模板具备较强的工程参考价值。通过源码可以直观看到设备扩展、派遣函数、完成例程等WDM驱动特有概念的落地写法对进一步学习过滤器驱动和总线驱动也有帮助。1. 鼠标驱动源码切入WDM 模型里最容易复现的驱动样本你在设备管理器里看到“未知设备”的黄叹号或者拿到“Windows 无法加载这个设备的驱动程序代码 31”的状态通常不是鼠标硬件坏了而是驱动的 INF 没有匹配上设备实例 ID或是设备对象没有正确挂进设备栈。这套鼠标驱动程序源代码恰好是 WDM 开发里最完整的切片vhidmou.inf 负责安装匹配vmoudev.cpp 实现设备对象创建与 IRP 分发vhidmou.cpp 封装 HID 协议vhidmou.def 导出内核符号外加 sources 和 dsp/dsw 工程文件构成了一个可编译可加载的完整驱动。它不做总线枚举而是扮演“把输入数据搬到系统输入栈”的函数驱动这个角色能把 WDM 的 PnP 机制、IRP 处理、HID 报告描述符三块核心知识串成一条线。对刚入门的开发者这是除 hello world 之外第一个能看到硬件响应的实现对有经验的工程师也能用它校准自己对设备栈和 HID 协作关系的理解。2. 从 vhidmou.inf 拆解 PnP 驱动的安装与匹配机制2.1 INF 在 WDM 加载流程中的位置WDM 驱动和 NT 式驱动在代码层面的差异并不大真正的分水岭在于设备对象的关系总线驱动枚举硬件后创建一个物理设备对象PDO函数驱动通过 AddDevice 回调把自己的设备对象挂到这个 PDO 上。PnP 管理器在中间做两件事一是根据设备实例 ID 找到匹配的 INF二是解析 INF 里的安装指令和服务注册信息决定复制哪个 sys、注册什么服务。vhidmou.inf 就是这份“简历”它决定了整个驱动能否进入系统。INF 的解析发生在设备首次连接或手动更新驱动时。设备管理器把硬件 ID 与 INF 里各个模型节中的条目比对命中的那一条会跳转到对应的安装节执行。下面是我对照这个项目精简后的核心片段[Version] Signature $WINDOWS NT$ Class Mouse ClassGuid {4d36e96f-e325-11ce-bfc1-08002be10318} Provider %PROVIDER% DriverVer 06/20/2024,1.0.0.0 CatalogFile vhidmou.cat [Manufacturer] %MfgName% VHIDMOU,NTx86 [VHIDMOU.NTx86] %VHIDMOU.DeviceDesc% VHIDMOU_DDI, USB\VID_1234PID_5678MI_00 [VHIDMOU_DDI.NT] CopyFiles VHIDMOU_CopyFiles [VHIDMOU_CopyFiles] vhidmou.sys [VHIDMOU_DDI.NT.AddService] DisplayName %VHIDMOU.SvcDesc% ServiceType 1 StartType 3 ErrorControl 1 ServiceBinary %12%\vhidmou.sysSignature $WINDOWS NT$声明了驱动面向 Windows NT 家族Class Mouse与ClassGuid对应系统鼠标类这个 ClassGuid 决定设备最终显示在设备管理器的“鼠标和其他指针设备”分类下。CatalogFile指向驱动签名目录文件在开启了驱动签名强制的 64 位 Windows 10/11 上缺失有效签名文件会导致安装被直接拒绝设备管理器状态往往就是代码 31 或代码 52。硬件匹配发生在[VHIDMOU.NTx86]节USB\VID_1234PID_5678MI_00是目标设备的硬件 IDVHIDMOU_DDI是安装指令节的名字。如果你手上的鼠标 VID/PID 与之不同改成自己的组合即可甚至可以追加REV_0100这样的版本字段来缩小匹配范围。这里的NTx86后缀限定了 x86 架构64 位系统需要另写一个NTamd64条目否则在 64 位机器上 PnP 管理器会直接跳过这个 INF。2.2 安装指令节与 AddService 的语义[VHIDMOU_DDI.NT]节里的CopyFiles VHIDMOU_CopyFiles指定了文件拷贝任务实际的[VHIDMOU_CopyFiles]节把 vhidmou.sys 复制到%12%这个系统目录它对应的物理路径是\SystemRoot\System32\drivers也就是内核驱动默认的存放位置。设备对象每个驱动文件名的最后一部分会被 I/O 管理器用于构造驱动对象名比如 vhidmou.sys 对应\Driver\vhidmou这在后续 WinDbg 调试时要直接用到。[VHIDMOU_DDI.NT.AddService]在服务控制管理器SCM里注册一个内核服务。ServiceType 1表示内核驱动服务StartType 3表示由 PnP 管理器在设备出现时动态启动而不是开机时自启。ErrorControl 1表示加载失败时仅记录错误到事件日志不触发系统重启——对鼠标这类非 boot 关键驱动这是正确选择如果你把它写成0x0之外的严重级别反而会让排错变得更加困难。这里有一个容易踩的坑修改 INF 后必须回到设备管理器执行“更新驱动程序”而不是仅仅把新 INF 复制到C:\Windows\INF目录。PnP 管理器对 INF 有缓存和信任级别判断未签名的 INF 在 64 位系统上即便能被解析匹配阶段也可能被降权。遇到代码 31 时我一般先确认设备实例 ID 与 INF 中的硬件 ID 是否完全一致再看事件查看器里Microsoft-Windows-Kernel-PnP/Configuration日志中是否记录了“未找到驱动程序”或“签名被拒绝”的条目。2.3 INF 关键参数速查INF 字段/节作用排错时关注点Class/ClassGuid决定设备在设备管理器中的分类与设备类型不符时安装过程会弹兼容性警告DriverVer驱动版本供 PnP 比较新旧版本号低于系统缓存中的驱动时不会安装HardwareID设备枚举时用的匹配串注意大小写、REV_xxxx后缀是否一致AddService注册驱动服务StartType3是 PnP 驱动的标准值CopyFiles定义 sys 文件去向%12%是 drivers 目录别误写成%SystemRoot%NTx86/NTamd64限定目标架构架构不匹配时 PnP 管理器直接跳过该 INF现实里很多二次开发者不改代码只把供应商的原版 INF 拷贝过来改几个字段结果设备枚举正常但驱动始终不加载。这类问题多半出在架构限定节缺失或DriverVer低于系统内置驱动。对照这份 vhidmou.inf 的写法可以看到它把架构限定写进了[Manufacturer]节这是一种更符合 WDM 安装规范的表达方式也便于后续扩展 amd64 条目。3. vmoudev.cpp 中的设备对象创建与 IRP 分发骨架3.1 DriverEntry 与驱动对象初始化驱动的入口函数在系统加载驱动时被调用它要做三件事注册卸载例程、填充分发函数表、初始化必要的同步对象。vmoudev.cpp 中对应的实现骨架如下extern C NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) { DriverObject-DriverUnload VhIdmouUnload; DriverObject-MajorFunction[IRP_MJ_CREATE] VhIdmouCreateClose; DriverObject-MajorFunction[IRP_MJ_CLOSE] VhIdmouCreateClose; DriverObject-MajorFunction[IRP_MJ_READ] VhIdmouRead; DriverObject-MajorFunction[IRP_MJ_DEVICE_CONTROL] VhIdmouDeviceControl; DriverObject-MajorFunction[IRP_MJ_PNP] VhIdmouPnp; return STATUS_SUCCESS; }MajorFunction数组是 I/O 管理器分发 IRP 的依据每个下标对应一类 IRP 主功能号处理函数通过注册到数组里被回调。鼠标驱动至少要处理IRP_MJ_CREATE打开句柄、IRP_MJ_CLOSE释放句柄、IRP_MJ_READ读取输入数据和IRP_MJ_PNP即插即用事件。没有填充分发函数的 IRP 主功能号会由 I/O 管理器直接返回STATUS_INVALID_DEVICE_REQUEST所以设计分发表时宁多勿缺。DriverUnload在系统卸载驱动时被调用注意它不等同于设备移除IRP_MN_REMOVE_DEVICE处理逻辑与 unload 例程是两个路径。正式项目里我会把可分配资源的初始化放在 AddDevice 而不是 DriverEntry因为 DriverEntry 返回失败后驱动对象未必会被清理在入口里分配资源却无法在 DriverUnload 之外释放很容易造成内核内存泄漏。3.2 AddDevice 与设备栈挂载PnP 管理器完成硬件匹配后调用驱动的AddDevice这是 WDM 驱动真正创建设备对象的地点。在 vmoudev 里逻辑可以归纳成四步创建设备对象、初始化设备扩展、清除初始化标志、挂到设备栈NTSTATUS VhIdmouAddDevice(PDRIVER_OBJECT DriverObject, PDEVICE_OBJECT PhysicalDeviceObject) { PDEVICE_OBJECT deviceObject NULL; PDEVICE_EXTENSION devExt; NTSTATUS status IoCreateDevice(DriverObject, sizeof(DEVICE_EXTENSION), NULL, FILE_DEVICE_UNKNOWN, 0, FALSE, deviceObject); if (!NT_SUCCESS(status)) { return status; } devExt (PDEVICE_EXTENSION)deviceObject-DeviceExtension; devExt-DeviceObject deviceObject; devExt-PhysicalDeviceObject PhysicalDeviceObject; deviceObject-Flags | DO_BUFFERED_IO; deviceObject-Flags ~DO_DEVICE_INITIALIZING; devExt-LowerDeviceObject IoAttachDeviceToDeviceStack(deviceObject, PhysicalDeviceObject); if (devExt-LowerDeviceObject NULL) { IoDeleteDevice(deviceObject); return STATUS_DEVICE_REMOVED; } return STATUS_SUCCESS; }IoCreateDevice的参数中DeviceType用FILE_DEVICE_UNKNOWN即可鼠标不是磁盘或串口这类有固有类型语义的设备Characteristics传 0 表示没有特殊兼容需求如果写成了FILE_DEVICE_SECURE_OPEN打开设备时就要额外校验 ACL可能导致服务账户打不开句柄。Exclusive参数必须传 FALSE鼠标设备需要被终端服务、屏幕键盘等多个组件同时打开设成独占会让系统输入栈初始化失败。DeviceExtension是驱动私有的数据结构I/O 管理器只负责分配和释放内存不关心内容这里用它保存上下层设备对象指针、暂停状态和电源状态。DO_BUFFERED_IO标志决定 READ 请求以系统缓冲区方式传给驱动。对鼠标这种单次几字节、低频的输入设备这是最优选择比DO_DIRECT_IO少一次内存映射开销也避免用户缓冲区在内核态被直接解引用带来的安全问题。后续的IoAttachDeviceToDeviceStack把驱动新建的设备对象挂到物理设备对象的设备栈上返回值是栈中下一个设备对象的指针。所有驱动不处理的 IRP 都要转发给它忘记保存或错误转发IRP 就会在设备栈里悬空设备管理器会报异常。注意 AddDevice 返回前必须清除DO_DEVICE_INITIALIZING标志否则系统认为设备尚未完成初始化后续所有打开请求都会被拒绝。3.3 IRP 的分发与完成路径以 READ 请求为例。应用层通过ReadFile读鼠标输入时I/O 管理器构造 IRP 并调用驱动注册的处理函数。对这个 HID 鼠标的例子设备驱动的 READ 处理逻辑是准备好一帧数据然后完成 IRPNTSTATUS VhIdmouRead(PDEVICE_OBJECT DeviceObject, PIRP Irp) { PDEVICE_EXTENSION devExt DeviceObject-DeviceExtension; PIO_STACK_LOCATION irpSp IoGetCurrentIrpStackLocation(Irp); ULONG bytesToRead irpSp-Parameters.Read.Length; NTSTATUS status; status VhIdmouGetInputReport(devExt, Irp-AssociatedIrp.SystemBuffer, bytesToRead); Irp-IoStatus.Status status; Irp-IoStatus.Information NT_SUCCESS(status) ? bytesToRead : 0; IoCompleteRequest(Irp, IO_NO_INCREMENT); return status; }IoGetCurrentIrpStackLocation返回当前堆栈单元IRP 每向下一层转发一次就有一个对应的IO_STACK_LOCATION驱动必须基于自己的堆栈单元读取请求参数而不是直接翻别人栈里的数据。Irp-IoStatus.Information对 READ 请求是实际读取的字节数I/O 管理器用这个值更新应用层ReadFile返回的lpNumberOfBytesRead。填 0 会导致上层拿到空包却认为调用成功而这种失败通常是静默的最容易被误判成设备无数据。对 PnP IRP 的处理略有不同。IRP_MJ_PNP有子功能码如IRP_MN_START_DEVICE、IRP_MN_STOP_DEVICE、IRP_MN_REMOVE_DEVICE。WDM 驱动不是每个子功能都要自己处理标准做法是只关心自己需要的子功能未处理的通过IoSkipCurrentIrpStackLocation加IoCallDriver转发给下一层。处理 REMOVE_DEVICE 时先 detach 再删除自己的设备对象顺序写反会在系统日志里看到设备未正确删除的警告。读取请求还要注意挂起语义。鼠标这类设备在硬件没有新数据时常见做法是把 IRP 挂到队列并返回STATUS_PENDING而不是立刻完成。挂起后必须有另一个中断或 DPC 来摘除 IRP 并完成它否则进程会永远阻塞。如果驱动在 IRQL 为 DISPATCH_LEVEL 的 DPC 里调用了IoCompleteRequest需要关注完成时是否会引发页面错误这是鼠标驱动蓝屏的一个隐藏来源。3.4 分发函数职责速查主功能号处理函数职责常见失败表现IRP_MJ_CREATE校验设备状态允许共享打开上层打开设备返回STATUS_ACCESS_DENIEDIRP_MJ_READ取一帧输入报告并完成 IRP返回长度恒为 0应用层报读取失败IRP_MJ_DEVICE_CONTROL处理 HID 属性查询与报告读取状态码 31 之外出现“参数错误”IRP_MJ_PNP透传非核心子功能处理停止/移除热插拔后设备节点残留IRP_MJ_POWER处理系统电源状态转换待机唤醒后设备不可用设计分发表时我建议把真正关心的主功能号注册一遍未填写的保持 NULL。I/O 管理器遇到 NULL 分发函数会返回STATUS_INVALID_DEVICE_REQUEST这在很多老驱动里是找不到 bug 的根源因为新手通常以为“没注册就等于自动完成”。4. HID 鼠标层报告描述符与 vhidmou.cpp 的实现路径4.1 为什么鼠标要走 HID 协议层基于 USB 的鼠标天然是 HID 设备即便这套源码里的鼠标是虚拟设备、不直接挂在 USB 总线上在 Windows 输入体系里它仍然要按 HID 协议与 hidclass.sys 协作。HID 协议的价值在于用一套统一的“报告”描述设备能力而不是为每种硬件发明接口。鼠标、键盘、游戏手柄共用同一套上报框架上层只负责解析报告里的数据位。vhidmou.cpp 这个模块的角色是把底层采集到的位移和按键状态组装成符合 HID 规范的 Input Report 并交给类驱动层。hidmouse.h 中定义的则是这些数据位、缓冲区大小的约定。从源码文件划分能看出设计意图vmoudev 处理设备对象和 IRP 生命周期vhidmou 处理 HID 协议转换如果这个鼠标真的挂在 USB 总线上上层逻辑几乎可以原封不动地复用。4.2 鼠标报告描述符解析报告描述符是 HID 设备向主机描述“数据长什么样”的字节序列。标准三键带滚轮鼠标的报告描述符核心部分可以写成如下形式const UCHAR MouseReportDescriptor[] { 0x05, 0x01, // Usage Page (Generic Desktop) 0x09, 0x02, // Usage (Mouse) 0xA1, 0x01, // Collection (Application) 0x09, 0x01, // Usage (Pointer) 0xA1, 0x00, // Collection (Physical) 0x95, 0x03, // Report Count (3) 0x75, 0x01, // Report Size (1) 0x05, 0x09, // Usage Page (Button) 0x19, 0x01, // Usage Minimum (Button 1) 0x29, 0x03, // Usage Maximum (Button 3) 0x15, 0x00, // Logical Minimum (0) 0x25, 0x01, // Logical Maximum (1) 0x81, 0x02, // Input (Data, Var, Abs) 0x95, 0x01, // Report Count (1) 0x75, 0x05, // Report Size (5) 0x81, 0x03, // Input (Const, Var, Abs) 0x95, 0x03, // Report Count (3) 0x75, 0x08, // Report Size (8) 0x05, 0x01, // Usage Page (Generic Desktop) 0x09, 0x30, // Usage (X) 0x09, 0x31, // Usage (Y) 0x09, 0x38, // Usage (Wheel) 0x15, 0x81, // Logical Minimum (-127) 0x25, 0x7F, // Logical Maximum (127) 0x81, 0x06, // Input (Data, Var, Rel) 0xC0, // End Collection 0xC0 // End Collection };HID short item 的格式是头字节加若干数据字节头字节的低四位是标签类型和数据长度高四位对应全局项、局部项或主项。0x05, 0x01中0x05表示全局项的 Usage Page数据长度 1后跟的0x01是 Generic Desktop 页0x09是局部项 Usage0x02在这个页里代表 Mouse 用途。真正定义报告布局的是0x81这个 Input 主项后面单字节0x02表示Data | Variable | Absolute而滚轮那一行用的是0x06即Data | Variable | Relative。这里有个经常搞混的点按键在逻辑最小值 0、最大值 1 下用 Absolute 模式X/Y 在 -127 到 127 范围内用 Relative 模式。如果把 X/Y 的0x06误写成0x02系统会把位移解释成绝对坐标鼠标指针会直接跳到屏幕某个位置而不是按相对距离移动这是报告描述符写错后最典型的现象。Report Count 和 Report Size 共同决定一帧报告的字节长度。上面描述符里前 3 个 bit 是按键状态5 个 bit 补齐成一个字节后 3 个字节分别是 X、Y、Wheel所以每帧 Input Report 是 4 字节。实际的 vhidmou.cpp 实现就是从设备读回原始数据后填充这 4 个字节bit0 到 bit2 对应左右中键第 2 字节是 X 偏移第 3 字节是 Y 偏移第 4 字节是滚轮增量。4.3 HID 类驱动与 vhidmou.def 导出定义与裸的 WDM 鼠标驱动不同HID 设备在 Windows 里要跟 hidclass.sys 协作。hidclass.sys 负责管理 HID 集合和上层应用通信底层函数驱动需要响应IOCTL_HID_GET_DEVICE_DESCRIPTOR、IOCTL_HID_GET_REPORT_DESCRIPTOR等 IOCTL。vhidmou.def 文件的作用是约束导出符号保证这些处理函数能被 hidclass.sys 通过导出表解析到LIBRARY vhidmou EXPORTS DriverEntry_PRIVATE VhIdmouGetHidDescriptor VhIdmouGetReportDescriptorLIBRARY指定模块名EXPORTS列表声明的符号如果与 sources 里的TARGETNAME不一致链接器不会报错但 Driver Verifier 会在加载时提示符号解析异常。对 WDM 驱动导出表不是给应用层用的而是给 hidclass.sys 这类内核组件动态解析所以导出名要保持稳定改了名字就要同步改上层调用方。vhidmou 项目里保留着.dsp.dsw工程文件这是 Visual C 6.0 时代的格式现代 VS 打开时往往需要转换或者干脆手工建 CMake。sources 文件则是 WDK 的 nmake 编译脚本参数TARGETNAME、TARGETTYPEDRIVER、MSC_WARNING_LEVEL/W4决定了生成 sys 的方式。在较新的 WDK 环境里可以保留 sources 和 makefile在命令行里直接调用build生成二进制比对 VS 动辄附加一堆中间文件要稳定得多。4.4 HID 报告在上层如何被消费报告描述符只是静态声明真正的数据流动是每帧 Input Report 从驱动发往 hidclass.sys。hidclass.sys 收到报告后根据报告描述符里的 Usage 信息把数据映射成 Windows 的输入消息。鼠标的 X 偏移会被换算成屏幕上的像素位移滚轮增量变成WM_MOUSEWHEEL消息里的滚动行数。这个链路决定了驱动侧的数据精度直接影响到系统鼠标速度如果驱动把 X 偏移限制在 ±1即使控制面板里把指针速度调到最高鼠标移动依然很慢。这套源码里 vhidmou 与 vmoudev 的分层还有一个好处vmoudev 可以独立测试设备对象和 IRP 生命周期vhidmou 的数据处理不依赖特定硬件。调试时我可以先给 VhIdmouGetInputReport 返回一组写死的坐标和按键值确认上层能收到消息再反过来调底层采集逻辑把问题隔离到某一层。HID 层组件职责关联文件底层采集读取硬件寄存器或模拟数据源vmoudev.cpp协议封装组装 4 字节 Input Reportvhidmou.cpp类驱动通信响应 HID IOCTL与 hidclass.sys 协作vhidmou.def安装绑定让 PnP 识别并加载驱动vhidmou.inf5. 驱动构建与内核态调试的验证清单5.1 用 WinDbg 断言驱动加载路径拿到这套源码后第一步不是通读代码而是把驱动编出来装到测试机上确认它真的被加载。我习惯先把 sys 和 inf 放到同一个目录右键 INF 执行安装然后立刻查看设备管理器状态。代码 31 基本锁定是 INF 匹配或签名问题代码 10 则可能是设备初始化失败。连接内核调试器后用!drvobj检查驱动对象是否注册成功kd !drvobj \Driver\vhidmou Driver object (ffffaa0f12345678) is for: \Driver\vhidmou DriverEntry: fffff80012340000如果DriverEntry地址全是问号说明驱动根本没有被内核加载此时先回到 INF 和签名检查不要浪费时间去分析 IRP。!devobj输出里要确认设备对象挂到了正确的设备栈上DeviceExtension 中的 LowerDeviceObject 应该是一个非空且类型为 Device 的对象指针。没有内核调试环境时另一个低成本方案是启用 Driver Verifier只勾选 vhidmou.sys 一个驱动并打开“强制 IRQL 检查”和“I/O 验证”。这类小数据量设备很少触发 IRQL 问题但在 DISPATCH_LEVEL 里调用分页函数一定会被验出来比我逐行审查代码快得多。注意 Driver Verifier 一旦启用无法按进程粒度关闭测试完成后要手动删除验证器设置。5.2 关注驱动签名与设备安装日志驱动加载和安装过程会写入Microsoft-Windows-Kernel-PnP/Configuration管理通道。重点看事件 ID 411设备配置和 420设备启动失败其中status字段如果出现0xC0000428说明签名属性校验失败对应的就是未启用测试签名模式时加载自签驱动的典型错误。排查目标使用的命令/工具预期结果驱动是否注册为服务sc query vhidmouSTATE 显示 RUNNING 或 STOPPED 且无错误签名是否放行signtool verify /pa vhidmou.sys输出 Signed With SHA256或提示未签名设备节点是否建立设备管理器“查看→显示隐藏的设备”能看到鼠标节点且无黄色感叹号报告描述符是否可读用户态 HID 客户端读取描述符字节数与源码定义的一致调试的最后一步通常是打开 DbgPrint 输出动几下鼠标看是否有数值变化。如果 Read 请求里的报告长度与描述符算出的 4 字节不符hidclass.sys 会直接丢弃报告表现为设备管理器一切正常、鼠标毫无响应。这类问题靠翻 IRP 处理代码查不出来必须逐字节对比描述符和数据帧头。一个值得保留的检查习惯改动报告描述符后先在用户态把整个描述符读出来与源码里的字节数组做一次逐字节 diff再编译进驱动。这样可以避免每次修改都走一遍“编译、安装、重启、看指针动不动”的循环能把排错时间压缩一个数量级。本文还有配套的精品资源点击获取
分享:

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

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