☰
HID Usage Tables 详解:从编号表到即插即用的底层逻辑
2026/11/3 13:32:27 网站建设 项目流程

简介:《USB HID Usage Tables 1.3》是USB Implementers' Forum于2022年1月发布的HID使用表规范文档,面向USB设备固件工程师、驱动开发者与系统集成人员,用于解决键盘、鼠标、游戏控制器、触摸屏等HID设备在跨平台识别与互操作上的标准依据问题。资源包共1个PDF文件,约4.09MB,内容涵盖Usage ID分类、Usage Page上下文定义、Report Descriptor结构、设备类行为要求及协议传输方式,并附有Microsoft、Intel、Google、Apple、Logitech等厂商专家的贡献者列表与完整修订历史。文档还纳入HUT审查请求101至107及显示器控制、街机设备、POS、电源设备等多个衍生规范的用法定义,便于读者追溯版本演进。目前已有560人学习下载,适合需要对照标准定义Usage、编写报告描述符或排查设备兼容性问题的开发者作为案头参考。

1. 键盘插上就能用,靠的不是驱动,是 Usage Tables

一个 USB 键盘插到任意一台机器上,几秒内就能打字,中间没有装驱动这一步。很多人把这归功于操作系统「自带驱动」,但真正让这件事成立的,是一份看起来极其枯燥的编号表:HID Usage Tables。它规定了「0x04 代表 a 键」「0x30 代表 X 轴」「0x09 页里的 0x01 代表按钮 1」这类映射。没有这张表,主机收到的只是一串字节,根本不知道第 3 个 bit 是空格还是某个厂商自定义的玄学功能。搞懂这张表的组织方式,你才能明白为什么改一个 Report Descriptor 里的 Usage 就能让设备从「不识别」变成「即插即用」,也才能在自定义 HID 设备翻车时知道该查哪一页。

2. Usage Page 与 Usage ID:16 位编号为什么要切成两半

HID 描述符里每个用途(Usage)都是一个 16 位值,但规范把它拆成高 8 位的 Usage Page 和低 8 位的 Usage ID。这个切分不是随意定的,它解决的是「命名空间爆炸」问题:键盘按键、鼠标轴、LED 指示灯、消费类多媒体键、游戏手柄按钮,这些东西语义完全不同,如果全塞进一个平坦的编号空间,冲突几乎不可避免。切成页之后,每一页就是一个独立语义域,页内编号可以从小往大排,互不干扰。

2.1 页号是语义域,不是分类标签

常见的页号在规范里是固定的:0x01 是 Generic Desktop,0x07 是 Keyboard/Keypad,0x08 是 LED,0x09 是 Button,0x0C 是 Consumer。注意 0x09 Button 页很特殊,它只有一个 Usage ID 0x01,但通过 Usage Minimum/Maximum 可以展开成「按钮 1 到按钮 N」。这就是为什么一个 16 键手柄不需要 16 个不同的 Usage ID,描述符里写一段范围就够了。我一般会这么理解:Page 决定「这是哪一类东西」,ID 决定「这一类里的第几个」,范围机制则是对 ID 的批量声明。

2.2 0x01 Generic Desktop 页为什么是入口页

几乎所有 HID 设备的顶层集合(Application Collection)都挂在 0x01 页下:鼠标是 0x02,键盘是 0x06,游戏手柄是 0x05,多轴控制器是 0x08。主机枚举时先看顶层集合的 Usage,判断「这是个什么设备」,再往下解析子集合和字段。如果顶层 Usage 写错页,比如把键盘写成 0x07 页的某个键,主机会把它当成一个奇怪的按键设备而不是键盘,启动协议(Boot Protocol)也不会生效。这是很常见的踩坑点:描述符能通过解析,但设备行为完全不对。

2.3 页内 ID 的分配逻辑:从 0x00 到 0xFF 的取舍

以 Keyboard/Keypad 页(0x07)为例,0x00 是 Reserved,0x01 到 0x03 是错误回滚(ErrorRollOver)、POST 失败、错误未定义,0x04 开始才是 a、b、c 这些字母键。把错误状态放在最前面是有意为之:Boot Protocol 的 8 字节键盘报告里,如果同时按下超过 6 个键,硬件会填 ErrorRollOver(0x01),主机据此知道「按键溢出了」,而不是收到一堆乱码。这种「把异常编码进正常编号空间」的做法,让协议层不需要额外的错误通道,代价是占用了几个宝贵的 ID。

/* 典型键盘报告描述符片段(简化) */ 0x05, 0x01, /* Usage Page (Generic Desktop) */ 0x09, 0x06, /* Usage (Keyboard) —— 顶层集合声明为键盘 */ 0xA1, 0x01, /* Collection (Application) */ 0x05, 0x07, /* Usage Page (Keyboard/Keypad) */ 0x19, 0xE0, /* Usage Minimum (0xE0 左Ctrl) */ 0x29, 0xE7, /* Usage Maximum (0xE7 右GUI) */ 0x15, 0x00, /* Logical Minimum (0) */ 0x25, 0x01, /* Logical Maximum (1) */ 0x75, 0x01, /* Report Size (1 bit) */ 0x95, 0x08, /* Report Count (8) —— 8 个修饰键各占 1 bit */ 0x81, 0x02, /* Input (Data,Var,Abs) */ 0x95, 0x01, /* Report Count (1) */ 0x75, 0x08, /* Report Size (8) */ 0x81, 0x01, /* Input (Const) —— 保留字节 */ 0x95, 0x06, /* Report Count (6) */ 0x75, 0x08, /* Report Size (8) */ 0x15, 0x00, /* Logical Minimum (0) */ 0x25, 0x65, /* Logical Maximum (0x65) */ 0x05, 0x07, /* Usage Page (Keyboard/Keypad) */ 0x19, 0x00, /* Usage Minimum (0) */ 0x29, 0x65, /* Usage Maximum (0x65) */ 0x81, 0x00, /* Input (Data,Array) —— 6 个按键槽位 */ 0xC0 /* End Collection */

这段描述符里有两个关键设计:修饰键(Ctrl/Shift/Alt/GUI)用位图表示,每个键固定 1 bit,所以能同时表达 8 个修饰键的状态;普通按键用数组表示,6 个槽位每个 8 bit,填的是 Usage ID。数组模式下,主机收到的是「按下了哪些键的编号」,而不是「哪个位置被按下」。这就是为什么键盘不需要为每个物理按键分配独立的 bit,104 键键盘的报告仍然只有 8 字节。参数上,Logical Maximum 0x65 对应 Keyboard/Keypad 页里已定义的最大 Usage ID,超出这个范围的值在数组里会被视为无效。

3. Report Descriptor:Usage 表怎么变成主机能解析的二进制结构

Usage Tables 是「字典」,Report Descriptor 是「语法」。字典告诉你 0x04 是 a 键,语法告诉主机「这个设备的第 2 个字节到第 7 个字节是按键数组,每个字节是一个 Usage ID」。两者配合,主机才能把原始报告解析成有语义的输入事件。理解这一层,才能明白为什么改了 Usage 表里的编号,设备行为会跟着变。

3.1 全局项、局部项、主项:描述符的三层状态机

Report Descriptor 的解析是一个状态机。全局项(Global Item)设置后续所有字段共享的参数,比如 Usage Page、Logical Minimum/Maximum、Report Size、Report Count;局部项(Local Item)只作用于下一个主项,比如 Usage、Usage Minimum/Maximum;主项(Main Item)才是真正产生字段的指令,Input、Output、Feature、Collection、End Collection。解析器维护一个全局状态栈,遇到 Push/Pop 可以保存和恢复。这个设计的动机是压缩描述符体积:一个键盘有几十个按键字段,如果每个字段都重复写一遍 Report Size 和 Logical Range,描述符会膨胀好几倍。全局项让公共参数只写一次,局部项让每个字段的 Usage 单独指定。

3.2 Input/Output/Feature 三种报告的方向语义

Input 是设备到主机,Output 是主机到设备,Feature 是双向的、通常用于配置。键盘的按键是 Input,LED 状态是 Output,比如 Caps Lock 灯。这里有个容易混淆的点:LED 页(0x08)的 Usage 出现在 Output 报告里,主机通过 Set_Report 把 LED 位图发给设备,设备再点亮对应的灯。如果开发者把 LED 写成 Input,主机会尝试从设备读 LED 状态,但设备根本不产生这个报告,结果就是灯永远不亮。规范里对每种 Usage 适合放在哪个方向有约定,但解析器不会强制校验,所以这类错误只能靠行为测试发现。

3.3 Usage Minimum/Maximum 与数组、位图的配合

数组(Array)和位图(Bitmap)是两种不同的字段组织方式。数组适合「从一组离散值里选若干个」,比如按键槽位;位图适合「每个 bit 独立表示一个布尔状态」,比如修饰键、LED、按钮。Usage Minimum/Maximum 在两种模式下含义不同:位图模式下,它声明一段连续的 Usage 范围,每个 Usage 对应 1 bit;数组模式下,它声明数组元素的合法取值范围,主机收到某个值后去这个范围里查对应的 Usage。Button 页(0x09)的 0x01 配合 Usage Minimum 1、Maximum 16,就能表达 16 个按钮的位图,这是游戏手柄描述符里最常见的写法。

/* 游戏手柄按钮位图片段 */ 0x05, 0x09, /* Usage Page (Button) */ 0x19, 0x01, /* Usage Minimum (Button 1) */ 0x29, 0x10, /* Usage Maximum (Button 16) */ 0x15, 0x00, /* Logical Minimum (0) */ 0x25, 0x01, /* Logical Maximum (1) */ 0x75, 0x01, /* Report Size (1) */ 0x95, 0x10, /* Report Count (16) —— 16 个按钮各占 1 bit */ 0x81, 0x02, /* Input (Data,Var,Abs) */

这段代码产生 2 字节的按钮位图,第 0 bit 是 Button 1,第 15 bit 是 Button 16。注意 Report Count 是 16,Report Size 是 1,总宽度 16 bit。如果厂商想加第 17 个按钮,不能简单把 Maximum 改成 17,因为 Report Count 也得跟着改,否则解析器会认为只有 16 个按钮,第 17 个 bit 被当成填充。这种「范围、计数、尺寸三者必须一致」的约束,是描述符调试里最常见的翻车来源之一。

4. 从 Boot Protocol 到 Report Protocol:兼容性是怎么被设计进去的

USB HID 有一个其他设备类很少见的特性:Boot Protocol。它规定键盘和鼠标在启动阶段必须能用一套固定的 8 字节(键盘)和 3 字节(鼠标)报告格式工作,不需要主机先解析 Report Descriptor。这个设计的历史动机是 BIOS/UEFI 环境:固件里的 USB 栈非常简陋,没能力跑完整的描述符解析器,但又必须在操作系统加载前支持键盘输入。Boot Protocol 就是那个「最小可用子集」。

4.1 8 字节键盘报告的固定布局

Boot Protocol 键盘报告固定 8 字节:第 0 字节是修饰键位图(左 Ctrl 到右 GUI),第 1 字节保留,第 2 到第 7 字节是 6 个按键槽位,每个槽位填 Keyboard/Keypad 页的 Usage ID。这个布局和前面那段描述符产生的格式完全一致,所以设备只要按这个格式发报告,无论主机解不解析描述符都能用。代价是灵活性:Boot Protocol 下不能有超过 6 个同时按下的普通键,不能有厂商自定义字段,不能有额外的轴。设备通过 Get_Protocol/Set_Protocol 请求在 Boot 和 Report 两种模式间切换,操作系统加载后会切到 Report Protocol,启用完整描述符定义的能力。

4.2 为什么鼠标的 Boot 报告只有 3 字节

鼠标 Boot 报告是 3 字节:按键位图、X 位移、Y 位移,每个位移是 8 位有符号数,范围 -127 到 127。这个范围决定了 Boot Protocol 下鼠标的移动分辨率上限,快速移动时如果单次位移超过 127,会被截断或溢出。Report Protocol 下可以用 16 位甚至更大的 Logical Maximum 来表达更大位移,也可以用多个轴。这个取舍很典型:兼容性优先的路径必须足够简单,简单到固件能硬编码解析;功能完整的路径则依赖描述符,灵活但要求主机有完整协议栈。

4.3 描述符里的 Usage 如何影响协议切换后的行为

切换到 Report Protocol 后,主机按描述符解析报告,此时 Usage 表的准确性直接决定字段语义。比如一个键盘在描述符里把某个按键声明成 Consumer 页的「播放/暂停」,那么在 Report Protocol 下这个键会产生多媒体键事件,而在 Boot Protocol 下它可能根本不存在(因为 Boot 报告只有 6 个槽位且只认 Keyboard/Keypad 页)。同一个物理按键,在两种协议下行为不同,这不是 bug,是规范允许的。开发者如果发现「BIOS 里能用、进系统后某个键失灵」,大概率是描述符里该键的 Usage 页写错,或者 Report Protocol 下的报告长度和 Boot 模式不一致导致主机解析错位。

5. 避坑:Usage Tables 落地时最容易翻车的几个点

5.1 现象:设备枚举成功,但按键全乱

原因通常是 Usage Page 和 Usage ID 拼错了。16 位 Usage 值在描述符里是分两条指令写的:先Usage Page (0x07),再Usage (0x04),解析器把它们组合成 0x00070004。如果只写了 Usage 没写 Page,解析器会沿用上一个全局 Page,结果可能是 0x00010004(Generic Desktop 页的 0x04),语义完全变了。解决方法是逐条核对每个 Usage 前面的 Page 指令,尤其是跨页的字段,比如键盘描述符里混了 LED 页的 Output 字段,Page 必须重新声明。

5.2 现象:Report Count 和 Usage 范围不匹配,主机只认前几个

前面提过,位图模式下 Usage Minimum/Maximum 的范围长度必须等于 Report Count。如果声明了 Button 1 到 Button 16,但 Report Count 写成 8,主机只会解析前 8 个按钮,后 8 个 bit 被当成填充或直接忽略。反过来,Report Count 大于范围长度,多出来的位没有对应 Usage,行为未定义。排查时把描述符按「Page → Usage 范围 → Report Size × Count」逐段列出来,算一下总位数和报告长度是否对得上,基本能定位。

5.3 现象:Logical Maximum 设小了,摇杆值被截断

模拟轴(比如 Generic Desktop 页的 X/Y)用 Logical Minimum/Maximum 声明取值范围。如果硬件是 12 位 ADC(0 到 4095),但描述符里 Logical Maximum 写成 255,主机解析时会把超过 255 的值截断或视为越界。更隐蔽的是有符号轴:Logical Minimum -127、Maximum 127 配 8 位字段,和 Minimum -32768、Maximum 32767 配 16 位字段,报告长度不同,主机解析的字节数也不同。解决方法是让 Logical Range 和 Report Size 的位宽严格匹配,12 位数据要么用 16 位字段承载,要么在固件里先缩放到声明范围。

5.4 现象:自定义 Usage 在某个系统上不生效

Usage Tables 里有一批「保留」和「厂商自定义」区间,比如 0xFF00 到 0xFFFF 是 Vendor Defined。用这些 Usage 的设备在规范层面是合法的,但主机操作系统不一定为它们提供通用映射,需要配套的驱动或应用层解析。如果设备依赖某个系统内置的 Usage 映射,换一个系统就可能失灵。排查时先确认该 Usage 是否在目标系统的 HID 映射表里有定义,没有的话就得走自定义报告 + 用户态解析的路线,不能指望即插即用。

5.5 现象:改了 Usage 表版本,旧设备行为变了

Usage Tables 的修订会新增 Usage、修正编号、废弃旧定义。如果固件里硬编码了某个 Usage ID,而新版本规范调整了该 ID 的含义,设备在新主机上可能表现异常。规避方式是尽量使用长期稳定的核心 Usage(比如 Keyboard/Keypad 页的字母键、Generic Desktop 页的鼠标键盘顶层集合),对新增的消费类、传感器类 Usage 保持谨慎,并在固件里留一个可配置的映射层,而不是把 Usage ID 散落在各处。

6. 设计取舍:一张编号表为什么能撑起整个 HID 生态

回头看,HID Usage Tables 最核心的设计决策是「用编号而不是字符串来标识功能」。字符串可读性好,但每个报告都要带一串名字,带宽和解析成本都高;编号紧凑,16 位就能表达页 + ID,报告里只传编号,语义靠主机侧的字典查。这个取舍的代价是字典必须集中维护、版本必须协调,于是有了这份由多家厂商共同修订的规范,以及它长达二十多年的修订历史。另一个取舍是「页 + ID」的两级结构:一级页隔离语义域,二级 ID 在域内编号,既避免了全局冲突,又保留了扩展空间。新增一类设备时开一个新页,不影响已有页的编号稳定性。

验证一个 HID 设备是否符合规范,最直接的方法不是读文档,而是抓描述符反解析:把设备插上,读出 Report Descriptor,按全局项/局部项/主项逐条展开,检查每个字段的 Usage Page、Usage 范围、Report Size、Report Count、Logical Range 是否自洽,再对照 Usage Tables 确认每个 Usage 的语义和方向(Input/Output/Feature)是否匹配。我一般会把这个检查做成脚本,每次改描述符都跑一遍,比在系统里反复插拔试错快得多。血泪经验是:描述符能枚举成功不代表语义正确,主机对未知 Usage 往往静默忽略,问题会拖到用户实际使用时才暴露。

从那以后我每次设计自定义 HID 设备,都强制先把 Usage 分配表列出来,标清每个字段的页、ID、方向、位宽和逻辑范围,再动手写描述符,最后用反解析脚本对一遍。这张表看起来是额外工作,但它把「玄学不识别」变成了可排查的编号问题。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询