USB HID Usage Tables 1.3 实战指南:从描述符到故障排查
简介:《USB HID Usage Tables 1.3》是USB Implementers' Forum于2022年发布的HID使用表规范文档,面向USB设备开发者、硬件工程师、驱动程序员与系统集成商,用于解决HID设备功能元素标准化定义与跨平台互操作问题。压缩包内为1个PDF文件,约4.09MB,完整收录规范正文、贡献者名单与修订历史,便于离线查阅与团队共享。文档系统定义了Usage ID分类、Usage Page上下文、Report Descriptor结构、设备类行为要求及协议传输方式,并涵盖通用控制、桌面设备、游戏设备、消费设备、模拟设备等类别,同时说明向后兼容策略。1.3版整合了HUT审查请求101至107及多项既有规范中的用法,由Microsoft、Intel、Google、Apple、Logitech等公司专家参与制定,权威性较高。已有560人学习,适合需要对照标准设计报告描述符、排查兼容性问题或构建符合规范HID设备的读者作为案头参考。
1. 拿到一份 200 多页的 HID Usage Tables,先别急着翻键盘那一章
做 USB 外设的同行大概都有过这种体验:设备插上电脑,系统识别了,驱动也装了,但按键映射就是错位,或者自定义的旋钮在某个系统上完全没反应。翻日志、抓包、改描述符,折腾半天,最后发现问题出在一个 Usage ID 上——要么用错了页,要么用了一个厂商私有值,要么把某个 Usage 的类型理解反了。这类问题不靠猜,靠查表。而《USB HID Usage Tables 1.3》就是那张表本身。
这份文档是 USB-IF 在 2022 年 1 月发布的 HID 用法表第 1.3 版,覆盖从 1996 年到 2022 年的历次修订,把键盘、鼠标、游戏手柄、触摸屏、消费电子控制、传感器、电源设备等各类 HID 设备的 Usage Page 和 Usage ID 全部定义清楚。它不是教程,是一份标准参考手册,但对做固件、写驱动、调描述符的人来说,它的价值在于:你写的每一个 Report Descriptor 里的 Usage,都能在这里找到出处和约束。适合谁?做 USB 外设固件的、写 HID 驱动或中间件的、做设备兼容性测试的,以及需要给自定义设备分配合规 Usage 的团队。
2. Usage Page 与 Usage ID 的寻址逻辑:为什么你的描述符总被系统误判
2.1 一个 Usage 是 32 位,高 16 位定页、低 16 位定项
HID 描述符里写Usage (0x09)或Usage (0x00010030),很多人一开始不理解这两种写法有什么区别。关键在于 HID Usage 的编码结构:一个 Usage 是 32 位值,高 16 位是 Usage Page,低 16 位是 Usage ID。当 Page 为 0 时,高 16 位省略,只写低 16 位。所以0x09等价于0x00090009,即 Button Page(0x09)下的 Usage 9;而0x00010030是 Generic Desktop Page(0x01)下的 Usage 0x30,也就是 X 轴。
这个结构决定了你在描述符里写 Usage 时必须心里有数:当前生效的 Usage Page 是什么。描述符里用Usage Page (Generic Desktop)设定页,后续的Usage (X)、Usage (Y)都落在这个页下。一旦页设错,后面所有 Usage 全部跑偏。我见过一个翻车案例:某同事在描述一个自定义旋钮时,前面设了Usage Page (Vendor-Defined 0xFF00),后面却写了Usage (0x30),本意是 X 轴,结果系统把它当成厂商私有页下的 0x30,完全不认。现象是设备能枚举、能出报告,但上位机拿到的轴值永远是 0——因为操作系统按私有页处理,直接忽略了。
常见做法是:在描述符开头集中声明所有用到的 Usage Page,每个 Page 下的 Usage 紧跟其后,不要跨页混写。如果确实需要多个页,用Usage Page切换,切换后立即写该页下的 Usage,形成「页—项」成对出现的节奏。
2.2 Generic Desktop 页是入口,但别把所有东西都往里塞
Generic Desktop Page(0x01)是绝大多数输入设备的起点:鼠标、键盘、摇杆、多轴控制器都从这里取 Application Usage。比如鼠标的顶层 Collection 用Usage (Mouse),即 0x01/0x02;键盘用Usage (Keyboard),0x01/0x06;游戏手柄用Usage (Game Pad),0x01/0x05。这些是 Application Usage,决定操作系统把这个设备归到哪一类。
但问题在于,很多开发者习惯性地把所有自定义控件都挂到 Generic Desktop 下,比如把某个特殊功能键写成Usage (0x00FF)之类的未定义值。这在规范里是不允许的——Generic Desktop 页的 Usage ID 是封闭定义的,未分配的值不应使用。正确做法是:标准功能用标准页,私有功能用 Vendor-Defined Page(0xFF00–0xFFFF),并在文档里注明。1.3 版在 3.2 节「Handling Unknown Usages」里明确说了,接收方遇到未知 Usage 应当忽略而非报错,但这不意味着你可以随便填——忽略意味着你的功能直接失效,用户看到的就是「这个键没反应」。
下面这段描述符片段是一个三轴旋钮的正确写法,注意 Page 与 Usage 的配对关系:
// 三轴旋钮:X/Y/Z 三轴,每轴 8 位有符号,范围 -127~127 0x05, 0x01, // Usage Page (Generic Desktop) 0x09, 0x08, // Usage (Multi-axis Controller) 0xA1, 0x01, // Collection (Application) 0x09, 0x30, // Usage (X) 0x09, 0x31, // Usage (Y) 0x09, 0x32, // Usage (Z) 0x15, 0x81, // Logical Minimum (-127) 0x25, 0x7F, // Logical Maximum (127) 0x75, 0x08, // Report Size (8) 0x95, 0x03, // Report Count (3) 0x81, 0x02, // Input (Data,Var,Abs) 0xC0 // End Collection这段代码的逻辑是:先声明 Generic Desktop 页,顶层 Collection 用 Multi-axis Controller(0x01/0x08),然后在同一个页下依次声明 X、Y、Z 三个 Usage,逻辑范围 -127 到 127,每个字段 8 位,共 3 个字段,输入类型为数据、变量、绝对值。参数上要注意Logical Minimum用 0x81 表示 -127 而不是 -128,是因为 0x80 在部分解析器里会被当作特殊值处理,留一格余量是血泪经验。Report Count (3)对应三个轴,如果实际硬件只有两轴,这里改成 2,否则上位机会多读一个字节,导致后续字段错位。
2.3 Report Descriptor 里的 Usage 只是声明,报告里的数据靠 Usage 对齐
描述符定义的是「有哪些字段、每个字段对应哪个 Usage」,而实际传输的 Input Report 是一串字节。操作系统解析报告时,按描述符里字段的顺序和大小逐段切分,再按 Usage 把每段映射到具体功能。所以描述符里 Usage 的顺序必须和报告里数据的顺序严格一致。这一点在单字段设备上不容易出错,一旦字段多了,尤其是混用不同大小的字段,错位就是家常便饭。
举个典型场景:一个带滚轮的鼠标,报告里依次是按键位图(1 字节)、X 位移(1 字节)、Y 位移(1 字节)、滚轮(1 字节)。描述符里如果先写 X、Y 再写 Button,而报告里 Button 在最前面,系统就会把按键状态当成 X 位移,鼠标指针乱飞。排查这类问题的办法不是盯着描述符看,而是用抓包工具把实际报告字节打出来,对照描述符逐字段核对偏移。1.3 版文档在 3.1 节「HID Usage Table Conventions」里给了 Usage 表的阅读约定,包括 ID 的十六进制表示、类型标注(LC/OOC/MC 等),这些标注在排查时很有用——比如你看到一个 Usage 标着 MC(Momentary Control),就知道它是瞬时控制,不该被当作持续状态来读。
3. 从表里查到落地:键盘、消费控制与厂商私有页的实操路径
3.1 键盘页(0x07)的 Usage ID 与 LED 页(0x08)的联动
Keyboard/Keypad Page 是 0x07,里面定义了标准按键的 Usage ID:0x04 是 A,0x05 是 B,依次到 0x1D 是 Z;0x1E 到 0x27 是数字 1 到 0;修饰键在 0xE0 到 0xE7,分别是左 Ctrl、左 Shift、左 Alt、左 GUI、右 Ctrl、右 Shift、右 Alt、右 GUI。这些值在写键盘固件时是硬编码的,但容易出错的地方在于修饰键和普通键的报告结构不同:标准键盘报告的第一个字节是修饰键位图,第二个字节保留,第三到第八字节是最多 6 个同时按下的普通键 Usage ID。
LED 页(0x08)则是输出方向:Num Lock、Caps Lock、Scroll Lock 等指示灯的 Usage ID 分别是 0x01、0x02、0x03。键盘固件需要处理来自主机的 Output Report,根据 LED 页的 Usage 点亮对应指示灯。这里有个坑:Output Report 的字段顺序同样由描述符决定,如果描述符里 LED 字段的顺序和固件解析的顺序不一致,就会出现「按了 Caps Lock 但灯不亮」或者「灯亮反了」的现象。常见做法是在描述符里把 5 个 LED 位打包成一个字节的位图,固件按位解析,位 0 对应 Num Lock、位 1 对应 Caps Lock,依此类推,和文档里 LED 页的 Usage 顺序保持一致。
下面是一个键盘 LED 输出报告的描述符片段:
// 键盘 LED 输出报告:5 个 LED 位,打包为 1 字节 0x05, 0x08, // Usage Page (LED) 0x09, 0x01, // Usage (Num Lock) 0x09, 0x02, // Usage (Caps Lock) 0x09, 0x03, // Usage (Scroll Lock) 0x09, 0x04, // Usage (Compose) 0x09, 0x05, // Usage (Kana) 0x15, 0x00, // Logical Minimum (0) 0x25, 0x01, // Logical Maximum (1) 0x75, 0x01, // Report Size (1) 0x95, 0x05, // Report Count (5) 0x91, 0x02, // Output (Data,Var,Abs) 0x75, 0x03, // Report Size (3) 补齐到 1 字节 0x95, 0x01, // Report Count (1) 0x91, 0x03, // Output (Const,Var,Abs) 填充位逻辑说明:先切到 LED 页,依次声明 5 个 LED Usage,每个 1 位,共 5 位,输出类型为数据变量绝对值;然后用 3 个常量填充位补齐到 8 位,保证整个输出报告是整字节。参数上,Report Count (5)对应 5 个 LED,如果设备只有 3 个灯,改成 3,填充位相应改成 5 位。填充位必须用Const类型,否则主机会把填充位当成有效数据写入,导致行为异常。
3.2 Consumer Page(0x0C)是多媒体键的归宿,但 ID 选择有讲究
多媒体键盘上的音量加、音量减、静音、播放暂停、下一曲这些键,标准做法是走 Consumer Page(0x0C),而不是塞进 Keyboard 页。Consumer 页的 Usage ID 里,0xE9 是 Volume Increment,0xEA 是 Volume Decrement,0xE2 是 Mute,0xCD 是 Play/Pause,0xB5 是 Scan Next Track。这些 ID 在 1.3 版里有明确表格,查的时候注意区分「One Shot Control」和「Re-Trigger Control」的类型标注——比如 Play/Pause 标的是 OOC(On/Off Control),意味着它是切换型,按一次开、再按一次关,固件里就不该把它当瞬时按键处理。
实际落地时,Consumer 页的键通常和 Keyboard 页的键共存在同一个设备里,形成复合设备。描述符里需要两个 Application Collection,或者一个顶层 Collection 下挂多个 Report ID。用 Report ID 区分是更干净的做法:Keyboard 报告用 Report ID 1,Consumer 报告用 Report ID 2,主机按 ID 分发。这里容易翻车的是 Report ID 的声明位置——Report ID必须写在每个报告的第一个字段之前,且每个报告都要重新声明,漏写会导致主机把两个报告的数据混在一起解析。
3.3 Vendor-Defined Page 不是垃圾桶,分配前先查有没有标准页可用
自定义设备最常犯的错,是一上来就用 Vendor-Defined Page(0xFF00–0xFFFF)把所有控件包进去。这样做能跑,但代价是操作系统完全不认识这些 Usage,所有功能都得靠上位机软件自己解析,换一台机器、换一个系统就可能失效。1.3 版文档覆盖的页已经相当全:Generic Desktop、Simulation Controls、VR Controls、Sport Controls、Game Controls、Generic Device Controls、Keyboard、LED、Button、Ordinal、Telephony、Consumer、Digitizer、Sensor 等。在决定用私有页之前,先按功能逐页翻一遍,大概率能找到现成的 Usage。
比如做的是一个带力度感应的打击垫,很多人第一反应是私有页,但 Sport Controls Page(0x04)里有 Exercise Machines 相关 Usage,Game Controls Page(0x05)里有 3D Game Controller 和 Gamepad 的定义,Digitizer 页里有触摸和笔输入的标准 Usage。只有当功能确实无法归入任何标准页时,才用私有页,并且在文档里把私有 Usage 的语义写清楚,方便后续维护和第三方对接。私有页的 Usage ID 建议从 0x01 开始连续分配,不要跳号,跳号在后期扩展时容易和旧版本冲突。
4. 避坑实录:Usage 用错之后的四类典型故障
4.1 现象:设备枚举成功,但某个轴永远读不到值
原因:描述符里 Usage Page 切换后没有紧跟对应 Usage,或者 Usage 写在了错误的页下。比如在 Generic Desktop 页下写了Usage (0x0201),本意是某个私有功能,但 0x0201 在 Generic Desktop 页里未定义,主机按未知 Usage 忽略处理。
解决:用 HID 描述符解析工具把描述符反解成树状结构,逐节点核对每个 Usage 的完整 32 位值(Page + ID),确认 Page 与 ID 的配对正确。对于自定义功能,改用 Vendor-Defined Page 并显式声明。
4.2 现象:按键映射错位,按 A 出 B
原因:Keyboard 页的 Usage ID 和实际键位对不上,常见于非美式布局的键盘,或者固件里把扫描码直接当 Usage ID 用。扫描码和 HID Usage ID 是两套体系,不能混用。
解决:查 1.3 版 Keyboard/Keypad Page 的表格,按物理键位对应的标准 Usage ID 重新映射。对于布局差异,用 HID LANGID(文档 3.6 节)配合主机端做布局适配,而不是在固件里硬改 Usage。
4.3 现象:多媒体键在某个系统上无效,换一个系统正常
原因:Consumer 页的 Usage ID 用了较新版本才加入的值,旧系统或旧驱动不认识,按未知 Usage 忽略。1.3 版相比早期版本新增了不少 Usage,如果目标环境是旧系统,需要确认该 ID 在目标系统的 HID 表里是否存在。
解决:优先选用 Consumer 页里长期存在的经典 ID(如 0xE9、0xEA、0xE2、0xCD),避免用边缘 ID。如果必须用新 ID,在设备文档里注明最低系统要求,或者提供私有页的备用映射。
4.4 现象:Output Report 写入后设备无反应,但 Input Report 正常
原因:Output Report 的描述符字段类型写成了Input,或者 Report ID 在 Output 报告里漏声明,导致主机把数据发到了错误的报告通道。
解决:检查描述符里 Output 字段的0x91标签(Input 是0x81),确认每个 Output 报告前都有对应的Report ID。用抓包工具确认主机确实发出了 Output 传输,且数据长度与描述符声明的报告长度一致。
5. 把 Usage 表变成可检索的本地数据:一个解析脚本和验证习惯
文档是 PDF,200 多页,查一个 Usage 靠翻页效率太低。我一般会把 Usage 表抽成结构化数据,落到本地做快速检索。思路是用 Python 把 PDF 里的表格文本按页提取,正则匹配「Usage ID、Usage Name、Usage Type」三列,输出成 CSV 或 JSON,再配一个简单的查询脚本。这样在写描述符时,输入一个功能名就能反查 ID,输入一个 ID 就能确认页和类型,比翻 PDF 快一个数量级。
下面是一个提取和查询的骨架脚本,依赖pdfplumber做表格抽取:
import pdfplumber import re import json def extract_usage_table(pdf_path, page_range): """从指定页范围提取 Usage 表,返回 [{page, usage_id, name, type}]""" rows = [] with pdfplumber.open(pdf_path) as pdf: for pno in page_range: page = pdf.pages[pno] tables = page.extract_tables() for table in tables: for row in table: # 典型行:['0x30', 'X', 'DV', 'Axis'] if row and len(row) >= 3: uid = (row[0] or '').strip() if re.match(r'^0x[0-9A-Fa-f]{2,4}$', uid): rows.append({ 'page': pno, 'usage_id': uid, 'name': (row[1] or '').strip(), 'type': (row[2] or '').strip() }) return rows def build_index(rows, out_path): """按 Usage ID 建索引,输出 JSON""" index = {} for r in rows: index.setdefault(r['usage_id'].lower(), []).append(r) with open(out_path, 'w', encoding='utf-8') as f: json.dump(index, f, ensure_ascii=False, indent=2) if __name__ == '__main__': # 页范围按实际 PDF 的页码调整,这里以键盘页为例 data = extract_usage_table('hut13.pdf', range(87, 96)) build_index(data, 'usage_index.json') print(f'extracted {len(data)} rows')逻辑说明:extract_usage_table逐页调用extract_tables,对每一行做正则过滤,只保留第一列是十六进制 ID 的行,避免把表头和说明文字混进来。page_range需要按实际 PDF 的物理页码调整,文档里的章节页码和 PDF 页码通常有偏移,先用一两页试跑,确认抽出来的行数和肉眼看到的一致再扩大范围。build_index以 Usage ID 为键建索引,同一个 ID 在不同页下可能重名,所以值是列表而不是单值。参数上,正则^0x[0-9A-Fa-f]{2,4}$覆盖 2 到 4 位十六进制,因为部分页的 Usage ID 是 16 位(如 0x0001),按实际表格调整位数范围。
抽完数据后,验证习惯比脚本本身更重要。我现在的做法是:每写一个新设备的描述符,先用索引脚本把用到的每个 Usage 反查一遍,确认三件事——Page 对不对、ID 在目标页里是否已定义、Usage Type 是否和实际行为匹配(比如瞬时按键不该标成 OOC)。这三步走完,描述符层面的低级错误基本清零。从那以后我每次改描述符都强制走一遍「反查—核对类型—抓包验证」的流程,宁可多花十分钟,也不想到量产阶段再回头改固件。希望帮到你。
本文还有配套的精品资源,点击获取