免驱USB键盘模拟:Windows HID驱动与描述符配置全解析
2026/9/16 20:13:12 网站建设 项目流程

前阵子帮朋友调一个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键盘协议

建议子类选择1(Boot Interface),这个选择在UEFI引导阶段非常有用,否则BIOS/UEFI里可能用不了你的键盘。虽然Windows本身对Boot SubClass没有硬性要求,但加上它兼容性更好。

端点描述符方面,键盘通常用一个中断IN端点来上报按键报告,最大包长全速设备一般为8字节或16字节,轮询间隔设为10ms(0x0A)比较稳妥。很多资料里写1ms,但实际键盘用10ms完全够,还能降低一点USB总线负载。

2.2 HID报告描述符才是灵魂

如果说描述符树决定Windows“怎么枚举你”,那HID报告描述符就决定Windows“怎么理解你的数据”。键盘的报告描述符描述了输入报告的结构,告诉系统:

  • 哪些bit是修饰键位(Ctrl、Shift、Alt、Win);
  • 哪些bit是保留字节;
  • 哪些字节是普通按键数组;
  • 按键码的取值范围是多少。

这里最容易犯的错是把报告描述符写错了,导致Windows虽然枚举成功,但设备管理器中显示的是“HID-compliant device”而不是“HID Keyboard Device”,或者干脆没有输入反应。

标准键盘报告描述符,我直接给一段经过验证的Hex:

const 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,表示设备类由接口描述符单独定义,这是复合设备和标准键盘的推荐做法。如果误设成3(HID),有些Windows版本会直接把它当成一个HID设备而不是键盘,出现识别异常。

字符串描述符可选,但强烈建议至少提供一个产品字符串。没有字符串描述符虽然能工作,但设备管理器里显示“USB Input Device”这种通用名字,后期排查哪个设备是哪个会很痛苦。

3.2 三种常用HID报告描述符

第一种是上面给出的标准键盘报告描述符,适合大多数按键输入场景。第二种是带多媒体控制键的描述符,第三种是带报告ID的描述符。

多媒体键(音量、播放控制)和普通按键不一样,它们的Usage Page是Consumer(0x0C),不是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, 0x01(Report 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是保险值,既不影响速度,也不丢事件。

组合键的模拟要特别注意:先发送修饰键加按键码的组合报告,松开时必须先清按键码,再清修饰键,分两步发送。如果同时清零,部分场景下系统会识别成普通按击而非组合键。模拟Ctrl+C时,正确顺序是:

  1. 发送modifier=0x02(Ctrl),key[0]=0x06(C)的报告;
  2. 延时10ms;
  3. 发送modifier=0x02key全部为0的报告;
  4. 延时10ms;
  5. 发送全零报告。

在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设备工作电流在配置描述符中声明,如果声明为0x32(100mA),但实际电路消耗远超过这个值,在某些供电能力弱的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。比如模拟“Shift+A”后,如果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 Desktop,Usage用Mouse(0x02),报告里放按钮bitmap、X轴位移、Y轴位移、滚轮。按键逻辑和键盘差异很大,鼠标报告是“相对位移”模型,不是状态快照,所以处理方式完全不同。

更复杂的双向HID设备,比如自定义协议的数据传输,需要增加一个中断OUT端点,报告描述符里加Feature报告或Output报告。Windows内置驱动同样支持,但应用层需要用HIDAPI或CreateFile打开设备句柄收发报告。这样就不需要自己写驱动了,唯一需要的是一个用户态小软件配合使用。

如果在你的场景里需要同时模拟键盘和鼠标,可以考虑做复合设备,也就是一个物理设备里有多个接口描述符,一个接口做键盘,一个接口做鼠标。此时设备描述符的bDeviceClass必须设为0,每个接口各自声明自己的类。Windows对这种复合设备的支持很成熟,枚举时会分别加载kbdclass和mouclass。

最后再分享一个调试习惯:每次改完描述符,不要只改芯片内部配置,先拔掉USB线重新枚举一次,再用抓包工具确认枚举结果。很多人习惯用软件复位代替重新插拔,但USB总线状态有时候会残留,造成“改了配置但没生效”的假象。养成“改一次描述符就完整重新插拔一次”的习惯之后,调试效率直线上升。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询