ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

免驱USB键盘模拟:Windows HID驱动与描述符配置全解析

免驱USB键盘模拟:Windows HID驱动与描述符配置全解析 前阵子帮朋友调一个USB读卡器改造成模拟键盘输入的小项目过程中找到一个非常省事的路径不写任何驱动直接用Windows内置的HID驱动让设备在系统里枚举成标准USB键盘业务逻辑全部放在固件里处理。这套方案对很多“把设备变成键盘”的场景都适用比如读卡器模拟刷卡输入、脚踏开关、宏键盘、自拍按钮、扫码枪之类的。但越是看着简单的方案坑越藏得深。USB描述符、HID报告描述符、端点和接口配置任何一个字段不对Windows就会给你回敬一个“未知设备”或者“设备无法启动”的大礼包。这篇文章把从原理到实操的完整路径梳理一遍重点说清楚Windows是怎么用内置驱动把我设备认成键盘的以及在调试过程中遇到的各种诡异情况。文章内容适合正在做USB HID设备开发的工程师也适合准备用STM32、CH552、杰理AC6328A2这类带USB Device控制器的芯片做键盘模拟产品的朋友。整篇不聊理论废话直接讲怎么配置、怎么测、怎么排查。1. 项目概述与选型思路1.1 这个项目解决什么问题所谓键盘模拟就是把USB设备伪装成标准键盘插到Windows电脑上之后系统会把它当作键盘处理设备发过来的按键报告会直接变成系统级按键事件。这个能力在很多产品里都有需求RFID/NFC读卡器模拟键盘输入刷卡后直接把卡号“打”到光标所在位置脚踏开关模拟快捷键做医疗、工业场景的辅助输入自定义宏键盘一键触发组合键自拍器、签到机、扫码设备等需要免驱输入的外设。这类项目最大的诉求是零安装、零维护。用户把设备插上就能用不需要装任何第三方驱动更不需要签驱动证书、过WHQL认证。Windows内置的HID类驱动天然支持这一点只要设备在枚举时正确宣告自己是键盘剩下的就交给系统。1.2 为什么可以不写驱动Windows HID类驱动机制很多人一听到“USB设备开发”就以为要写WDF/WDM内核驱动其实在HID键盘鼠标这类标准设备上完全没必要。Windows系统自带了一套完整HID驱动栈底层是hidusb.sys负责USB总线上的HID传输中间是hidclass.sys负责HID协议解析上层是kbdclass.sys把HID输入报告转换成键盘输入事件。你的设备只需要做一件事在USB枚举阶段正确上报描述符让系统识别这是一个“HID键盘”。之后Windows会自动把驱动栈匹配上来你的设备就变成了系统原生键盘。这里有个关键点设备需要的不是“驱动支持”而是“描述符正确”。Windows判断一个设备是不是键盘不看你主控是什么芯片只看USB接口描述符里的类代码以及HID报告描述符里的Usage Page和Usage。1.3 什么场景适合、什么场景要绕开这套方案适合纯输入类的键盘模拟也就是设备单向给电脑发按键报告。如果你需要设备既能模拟键盘又能接收电脑下发的数据比如做双向HID通信、固件升级、配置读写那就要在报告描述符里增加Feature报告或Output报告同时还需要编写一个用户态应用来访问HID设备Windows的内置驱动也能支持这种用法但复杂度会明显上升。如果你的目标是模拟出“媒体键”之外的特殊功能键比如屏幕亮度、蓝牙配对等笔记本专用键那不一定有标准Usage可用这时候要么找厂商扩展Usage要么就别用纯键盘方案。另外如果需要模拟的是“键盘上的某个厂商自定义键”Windows系统层面往往不认也最好提前确认清楚。2. 核心原理Windows如何把你的设备当键盘2.1 USB枚举流程与描述符树USB设备插入后主机控制器会发起总线枚举通过控制传输请求设备的各种描述符。设备必须按层次关系一次上报设备描述符、配置描述符、接口描述符、HID描述符、端点描述符。描述符的层级关系可以理解为一份设备“自我介绍书”设备描述符说明设备遵守的USB版本、厂商ID、产品ID配置描述符说明这个设备有几个配置、供电方式、最大电流接口描述符说明这个配置下有几个接口每个接口是什么类设备HID描述符说明接口实现了HID规范以及报告描述符有多长端点描述符说明数据走哪个端点、传输类型、最大包长、轮询间隔。Windows在做枚举时会先读取设备描述符然后读取配置描述符。值得注意的一个细节是标准请求GET_DESCRIPTOR(Configuration)返回的是整个配置描述符集合也就是“配置接口HID端点”连着一起返回而不是分开单独读取除了HID报告描述符是单独读取的。所以固件里必须把这些描述符按顺序拼好一个字节都不能乱。在接口描述符中有三个字段决定Windows把设备归到哪一类字段标准键盘取值作用bInterfaceClass0x03HID设备类bInterfaceSubClass0x01Boot Interface支持BIOS级键盘bInterfaceProtocol0x01键盘协议建议子类选择1Boot Interface这个选择在UEFI引导阶段非常有用否则BIOS/UEFI里可能用不了你的键盘。虽然Windows本身对Boot SubClass没有硬性要求但加上它兼容性更好。端点描述符方面键盘通常用一个中断IN端点来上报按键报告最大包长全速设备一般为8字节或16字节轮询间隔设为10ms0x0A比较稳妥。很多资料里写1ms但实际键盘用10ms完全够还能降低一点USB总线负载。2.2 HID报告描述符才是灵魂如果说描述符树决定Windows“怎么枚举你”那HID报告描述符就决定Windows“怎么理解你的数据”。键盘的报告描述符描述了输入报告的结构告诉系统哪些bit是修饰键位Ctrl、Shift、Alt、Win哪些bit是保留字节哪些字节是普通按键数组按键码的取值范围是多少。这里最容易犯的错是把报告描述符写错了导致Windows虽然枚举成功但设备管理器中显示的是“HID-compliant device”而不是“HID Keyboard Device”或者干脆没有输入反应。标准键盘报告描述符我直接给一段经过验证的Hexconst uint8_t keyboard_report_desc[] { 0x05, 0x01, // Usage Page (Generic Desktop) 0x09, 0x06, // Usage (Keyboard) 0xA1, 0x01, // Collection (Application) 0x05, 0x07, // Usage Page (Key Codes) 0x19, 0xE0, // Usage Minimum (224) 0x29, 0xE7, // Usage Maximum (231) 0x15, 0x00, // Logical Minimum (0) 0x25, 0x01, // Logical Maximum (1) 0x75, 0x01, // Report Size (1) 0x95, 0x08, // Report Count (8) 0x81, 0x02, // Input (Data, Variable, Absolute) 0x95, 0x01, // Report Count (1) 0x75, 0x08, // Report Size (8) 0x81, 0x01, // Input (Constant) 0x95, 0x05, // Report Count (5) 0x75, 0x01, // Report Size (1) 0x05, 0x08, // Usage Page (LEDs) 0x19, 0x01, // Usage Minimum (Num Lock) 0x29, 0x05, // Usage Maximum (Kana) 0x91, 0x02, // Output (Data, Variable, Absolute) 0x95, 0x01, // Report Count (1) 0x75, 0x03, // Report Size (3) 0x91, 0x01, // Output (Constant) 0x95, 0x06, // Report Count (6) 0x75, 0x08, // Report Size (8) 0x15, 0x00, // Logical Minimum (0) 0x25, 0x65, // Logical Maximum (101) 0x05, 0x07, // Usage Page (Key Codes) 0x19, 0x00, // Usage Minimum (0) 0x29, 0x65, // Usage Maximum (101) 0x81, 0x00, // Input (Data, Array) 0xC0 // End Collection };这段描述符定义了一个标准的8字节键盘输入报告第0字节是8个修饰键的bitmap第1字节是保留字节第2到第7字节是6个按键码数组。Output部分对应NumLock、CapsLock、ScrollLock等LED状态Windows默认也会读。2.3 按键事件模型与6键无冲理解Windows键盘事件模型也很重要。HID键盘报告不是“事件流”而是“状态快照”。也就是说你告诉系统“现在哪些键处于按下状态”而不是“我刚刚按了A键”。这个模型有两个直接影响第一按键按下后如果一直不松开系统会基于这个状态自动产生连续按键事件重复速率由Windows控制。所以固件不需要反复发送同一个报告。第二标准的6键无冲报告最多同时报告6个普通键加8个修饰键。如果同时按下的键超过6个多出来的键会被忽略或者按报告描述符里Usage Array的处理规则覆盖。游戏键盘如果标榜全键无冲一般需要特殊报告描述符来扩展按键数量但普通的免驱键盘模拟6键足够。手动发按键的标准流程是发送一条带目标键值的报告保持一小段时间再发送一条全部清零的报告表示松开。这两个报告之间建议间隔10ms左右确保Windows能识别到一次完整的“按下-释放”事件。3. 实操配置一个最小可用USB键盘描述符3.1 描述符的字节级配置在写固件时建议把描述符定义成字节数组这样便于对照USB规范检查和用抓包工具比对。以下是基于全速USB设备的一套最小键盘描述符适用于STM32、CH552等常见MCU。设备描述符const uint8_t device_desc[] { 0x12, // bLength: 18字节 0x01, // bDescriptorType: Device 0x00, 0x02, // bcdUSB: 2.00 0x00, // bDeviceClass: 每个接口单独定义 0x00, // bDeviceSubClass 0x00, // bDeviceProtocol 0x40, // bMaxPacketSize0: 64 0x88, 0x66, // idVendor: 测试用厂商ID量产需申请 0x10, 0x88, // idProduct: 产品ID 0x00, 0x01, // bcdDevice: 1.00 0x01, // iManufacturer 0x02, // iProduct 0x03, // iSerialNumber 0x01 // bNumConfigurations };配置描述符集合const uint8_t config_desc[] { // Configuration Descriptor 0x09, 0x02, 0x22, 0x00, 0x01, 0x01, 0x00, 0x80, 0x32, // Interface Descriptor 0x09, 0x04, 0x00, 0x00, 0x01, 0x03, 0x01, 0x01, 0x00, // HID Descriptor 0x09, 0x21, 0x10, 0x01, 0x00, 0x01, 0x22, 0x41, 0x00, // Endpoint Descriptor 0x07, 0x05, 0x81, 0x03, 0x08, 0x00, 0x0A };这里有个特别容易出问题的点配置描述符里有一个wTotalLength字段也就是整体描述符集合的总长度必须和上面所有描述符实际字节数一致。上面这套是0x0022也就是34字节9字节配置描述符9字节接口描述符9字节HID描述符7字节端点描述符。如果这个长度算错Windows会在枚举阶段直接放弃表现为设备管理器中不断刷新“未知设备”。Device Descriptor中的bDeviceClass设为0表示设备类由接口描述符单独定义这是复合设备和标准键盘的推荐做法。如果误设成3HID有些Windows版本会直接把它当成一个HID设备而不是键盘出现识别异常。字符串描述符可选但强烈建议至少提供一个产品字符串。没有字符串描述符虽然能工作但设备管理器里显示“USB Input Device”这种通用名字后期排查哪个设备是哪个会很痛苦。3.2 三种常用HID报告描述符第一种是上面给出的标准键盘报告描述符适合大多数按键输入场景。第二种是带多媒体控制键的描述符第三种是带报告ID的描述符。多媒体键音量、播放控制和普通按键不一样它们的Usage Page是Consumer0x0C不是Generic Desktop。如果直接把音量键的Usage编码放到键盘报告描述符的Key Codes Page里Windows不会报错但按键毫无反应因为系统根本不认为那是有效键。多媒体键报告描述符可以这样写const uint8_t consumer_report_desc[] { 0x05, 0x0C, // Usage Page (Consumer) 0x09, 0x01, // Usage (Consumer Control) 0xA1, 0x01, // Collection (Application) 0x15, 0x00, // Logical Minimum (0) 0x25, 0x01, // Logical Maximum (1) 0x75, 0x01, // Report Size (1) 0x95, 0x06, // Report Count (6) 0x09, 0xE9, // Usage (Volume Increment) 0x09, 0xEA, // Usage (Volume Decrement) 0x09, 0xE2, // Usage (Mute) 0x09, 0xB5, // Usage (Next Track) 0x09, 0xB6, // Usage (Previous Track) 0x09, 0xCD, // Usage (Play/Pause) 0x81, 0x02, // Input (Data, Variable, Absolute) 0x75, 0x01, // Report Size (1) 0x95, 0x02, // Report Count (2) 0x81, 0x01, // Input (Constant) 0xC0 // End Collection };这个描述符定义了一个1字节的消费类输入报告低6位分别对应6个媒体键高2位填充常数。每次发送时把对应位置1就是按下清0就是释放。第三种是带报告ID的描述符通常用于复合HID设备比如同一个接口里同时上报键盘和媒体键。此时需要给每个输入报告一个ID区分。带报告ID的键盘报告描述符只需在最前面加0x85, 0x01Report ID 1相应的输入报告第一个字节也变成报告ID。用报告ID时有一个坑Windows kbdclass对带报告ID的键盘支持没问题但很多老款BIOS和部分行业软件不识别所以纯键盘设备建议不带报告ID。3.3 固件主循环与事件时序键盘模拟的固件逻辑不复杂核心就是一个轮询循环读取按键状态填充报告发送到中断IN端点。下面是一份典型的主循环伪代码typedef struct { uint8_t modifier; // 修饰键 Ctrl/Shift/Alt/Win uint8_t reserved; // 保留字节恒为0 uint8_t key[6]; // 普通按键码数组 } keyboard_report_t; keyboard_report_t report; void send_report(void) { usb_hid_send_int_in((uint8_t *)report, sizeof(report)); } void key_press(uint8_t keycode, uint8_t modifier) { // 填修饰键位 report.modifier | modifier; // 找一个空位填按键码 for (int i 0; i 6; i) { if (report.key[i] 0) { report.key[i] keycode; break; } } send_report(); delay_ms(10); } void key_release(uint8_t keycode) { // 清修饰键优先级低的处理逻辑这里不展开 for (int i 0; i 6; i) { if (report.key[i] keycode) { report.key[i] 0; break; } } send_report(); delay_ms(10); }按键按下的处理里delay_ms(10)是一个安全缓冲。实测发现如果按下和释放两包报告间隔太短小于2ms时Windows有时会漏事件表现为按键偶尔没有反应。10ms是保险值既不影响速度也不丢事件。组合键的模拟要特别注意先发送修饰键加按键码的组合报告松开时必须先清按键码再清修饰键分两步发送。如果同时清零部分场景下系统会识别成普通按击而非组合键。模拟CtrlC时正确顺序是发送modifier0x02Ctrlkey[0]0x06C的报告延时10ms发送modifier0x02key全部为0的报告延时10ms发送全零报告。在USB挂起和恢复的处理上也有讲究。如果设备支持远程唤醒需要在配置描述符里设置bmAttributes的bit5并在固件里实现挂起中断和恢复逻辑。如果不需要远程唤醒就老老实实不设置Windows电源管理反而更稳定。4. 常见问题与排查技巧实录4.1 设备管理器报未知设备或枚举失败这个是出现频率最高的问题。一旦描述符有错误Windows在枚举时会识别失败设备管理器出现黄色感叹号。排除硬件问题供电、D/D-接线、时钟后首选用USB抓包工具看枚举过程。推荐的免费工具组合是Wireshark加USBPcap。装好后在Wireshark里选择USBPcap接口插入设备就能看到完整的枚举流程。重点抓GET_DESCRIPTOR请求和响应。比如请求GET_DESCRIPTOR(Device)时如果设备没有响应说明枚举在最开始就挂了问题出在设备描述符如果响应了但长度不对Windows会继续尝试其他地址最后放弃。看到一个常见现象设备管理器中显示“无法识别的USB设备”但用抓包工具能看到设备有response返回的数据却是乱的。这种情况多数是因为USB控制传输的Data Toggle没有同步。比如设备复位后Data Toggle没有重置到DATA0导致Windows协议层校验失败。另一类“枚举失败”实际是电源问题。USB设备工作电流在配置描述符中声明如果声明为0x32100mA但实际电路消耗远超过这个值在某些供电能力弱的USB Hub上会出现枚举成功但一传数据就断连的情况。量产时建议按实际功耗填写不要无脑填500mA。4.2 枚举成功但系统没把它当键盘这种问题最让人抓狂设备管理器里能看到“HID-compliant device”但没有“HID Keyboard Device”输入也完全无效。产生这个现象的原因基本集中在报告描述符上。最核心的坑是报告描述符第一行的Usage必须正确指向键盘。有朋友把Usage Page (Generic Desktop)写成了Usage Page (Generic Desktop Controls)对应的错误值或者把Usage (Keyboard)的0x06写成了0x00导致Windows虽然枚举成功却不知道这个设备是什么扔进了一个泛化的HID设备类别kbdclass不会加载。排查时可以打开设备管理器查看设备属性里的“硬件ID”。如果看到HID_DEVICE_UP:000D_U:0001这种硬件ID说明系统确实识别到它是Generic Desktop下的某个设备。对于标准键盘硬件ID应该是HID_DEVICE_UP:0001_U:0006。用这个信息反推报告描述符里的Usage编码是否写对了。还有一种情况是接口描述符的bInterfaceProtocol设成了2鼠标但报告描述符描述的是键盘Windows会优先遵循接口描述符的协议值于是整个HID数据流被交给鼠标驱动解析自然没有键盘反应。4.3 按键卡住、重复触发卡键问题基本上都是固件发送状态机的问题。键盘的“状态快照”特性意味着如果某次按下后没有正确发送全零报告Windows会认为这个键一直被按住。常见原因有只有按下逻辑没有松开逻辑报告数组没有清零就复用延迟处理中丢失了关键报告。快速连点的场景特别容易暴露这类问题。比如模拟读卡器快速输入一长串数字时如果每发送一个键值后没有及时清空按键数组就会打出11111111而不是12345678。解决方案是每个按键事件之间强制发送一条空报告。另一个坑是修饰键卡住。常见场景是发送组合键后只清了普通按键码忘了清修饰键bit。比如模拟“ShiftA”后如果report.modifier没有清零后面所有字母都变成大写或者Shift影响的其他键。排查思路很简单专门打印或调试修饰键字节确认每次事件结束时它恢复为0。4.4 多媒体键/音量键不生效如果你用了消费者Control Page的媒体键但音量纹丝不动先检查两点。第一报告描述符的Usage Page是否真的是0x0C。很多代码里沿用键盘描述符把Usage Page漏改结果媒体键被当成键盘按键发送Windows在键盘驱动层直接忽略这些非法键值。第二媒体键的事件模型和普通键不一样。普通键按下后需要发送按下报告和释放报告媒体键同样需要释放事件但很多固件只发了一次按下就完了。音量键一直不弹回会导致音量连续递减而不是调整一格。调试时可以先在记事本里测试普通键确认键盘基础功能正常再测试媒体键。媒体键还有一个容易被忽略的点音量键的Usage值是0xE9音量加和0xEA音量减不是字母键的扫描码。有些人在写媒体键描述符时直接拿键盘扫描码表和媒体键Usage对照数值完全对不上自然无效。4.5 用USB抓包快速定位描述符问题USB调试里效率最高的手段就是抓包。Windows平台最常用的是Wireshark USBPcap抓包后通过USB URB过滤器看枚举流程。我常用的过滤器是usb.idVendor 0x6666替换成自己的VID可以精准显示该设备的所有URB。枚举阶段重点看这几个请求GET_DESCRIPTOR Request DEVICE确认设备描述符返回长度是18字节bcdUSB、idVendor/idProduct字段和预期一致GET_DESCRIPTOR Request CONFIG确认wTotalLength和实际描述符总长一致GET_DESCRIPTOR Request HID REPORT这一步要看返回的字节流是否和你定义的报告描述符一致SET_CONFIGURATION确认设备正常进入配置状态。搭配Bus Hound做包级别分析也可以不过上手门槛比Wireshark高一些。免费工具里还有一个叫“HID报告描述符分析工具v1.7”的小工具虽然界面比较朴素但对报告描述符的解码很直观适合把Hex复制进去检查Usage和Report Size是否匹配。5. 踩坑心得与后续扩展5.1 值得提前记住的几个坑做了几个项目之后整理了一套自己的检查清单每次改完固件都按这个顺序过一遍第一描述符长度。所有描述符的bLength字段必须和实际字节数一致尤其配置描述符的wTotalLength里面任何一个错误都会导致枚举失败。每次改完描述符先用抓包工具确认一遍字节流不要凭肉眼硬找。第二报告描述符的Usage设置。键盘用Generic Desktop Page Keyboard Usage媒体键用Consumer Page,这两组东西完全独立混用就是白调一晚上的节奏。第三中断端点的轮询间隔。全速设备设置10ms后Windows kbdclass驱动完全正常工作。不要迷信1ms反而可能在某些Hub上因为带宽占用过多出现优先级问题。第四VID/PID不要随手用。调试阶段随便用一个VID没问题但量产产品必须申请自己的VID。有些项目直接用买来的开发板的VID/PID发到客户手上和别的产品冲突很尴尬。5.2 从键盘模拟到更复杂的HID设备键盘模拟只是HID设备开发的入口。接口描述符不变报告描述符改一下同样的硬件就能变鼠标、变游戏手柄、变自定义数据通道。鼠标HID的描述符核心是Usage Page也用Generic DesktopUsage用Mouse0x02报告里放按钮bitmap、X轴位移、Y轴位移、滚轮。按键逻辑和键盘差异很大鼠标报告是“相对位移”模型不是状态快照所以处理方式完全不同。更复杂的双向HID设备比如自定义协议的数据传输需要增加一个中断OUT端点报告描述符里加Feature报告或Output报告。Windows内置驱动同样支持但应用层需要用HIDAPI或CreateFile打开设备句柄收发报告。这样就不需要自己写驱动了唯一需要的是一个用户态小软件配合使用。如果在你的场景里需要同时模拟键盘和鼠标可以考虑做复合设备也就是一个物理设备里有多个接口描述符一个接口做键盘一个接口做鼠标。此时设备描述符的bDeviceClass必须设为0每个接口各自声明自己的类。Windows对这种复合设备的支持很成熟枚举时会分别加载kbdclass和mouclass。最后再分享一个调试习惯每次改完描述符不要只改芯片内部配置先拔掉USB线重新枚举一次再用抓包工具确认枚举结果。很多人习惯用软件复位代替重新插拔但USB总线状态有时候会残留造成“改了配置但没生效”的假象。养成“改一次描述符就完整重新插拔一次”的习惯之后调试效率直线上升。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进