HID设备规范精讲:从协议到枚举
HID设备,说白了就是人机交互设备——键盘、鼠标、游戏手柄、触摸屏都归它管。USB HID协议的好处是通用,你不用为每种输入设备单独写驱动。今天我就把这套规范的底细给你讲透。
HID类协议概述
HID类协议是USB设备类协议里最常用的一类。它定义了一套标准的数据交换方式,让主机不用知道设备具体是什么,就能读懂你的按键、移动、摇杆数据。
我个人习惯把HID协议拆成三层理解:
- 协议层:定义数据怎么传(中断传输、报告协议)
- 描述符层:定义数据长什么样(报告描述符)
- 应用层:定义数据怎么用(鼠标、键盘等用途)
嗯,这里要注意:HID设备不一定非得是USB的。蓝牙HID、I2C HID也都有,但USB HID是最成熟的。我在项目中遇到过客户拿蓝牙HID方案来问USB问题,结果发现是报告描述符写错了——这个坑后面会细说。
报告描述符:HID设备的灵魂
报告描述符(Report Descriptor)是HID设备最核心的东西。它告诉主机:你的数据包里每个bit代表什么。没有它,主机就是个瞎子。
描述符的语法结构
报告描述符由一系列Item组成。每个Item包含一个字节的头部和若干字节的数据。头部的高位决定Item类型,低位决定数据长度。
| Item类型 | Tag值 | 作用 |
|---|---|---|
| Main | 0x80-0xFF | 定义输入、输出、功能、集合 |
| Global | 0x00-0x7F | 设置全局状态(用途页、逻辑最小值等) |
| Local | 0x00-0x7F | 设置局部状态(用途、物理最小值等) |
你想想看,这其实就是一个状态机。Global项设置全局参数,Local项设置局部参数,Main项用这些参数生成一个数据字段。我刚开始学的时候,被这个嵌套关系绕晕过。
一个简单的鼠标报告描述符
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x02, // Usage (Mouse)
0xA1, 0x01, // Collection (Application)
0x09, 0x01, // Usage (Pointer)
0xA1, 0x00, // Collection (Physical)
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)
0x95, 0x03, // Report Count (3)
0x75, 0x01, // Report Size (1)
0x81, 0x02, // Input (Data,Var,Abs)
0x95, 0x01, // Report Count (1)
0x75, 0x05, // Report Size (5)
0x81, 0x01, // Input (Const,Var,Abs)
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x30, // Usage (X)
0x09, 0x31, // Usage (Y)
0x15, 0x81, // Logical Minimum (-127)
0x25, 0x7F, // Logical Maximum (127)
0x75, 0x08, // Report Size (8)
0x95, 0x02, // Report Count (2)
0x81, 0x06, // Input (Data,Var,Rel)
0xC0, // End Collection
0xC0 // End Collection
这段描述符定义了一个标准三键鼠标:3个bit的按键,5个bit的填充,2个字节的X/Y相对位移。总共3个字节的报告长度。
关键点:Report Count和Report Size的乘积决定了这个字段占多少bit。所有字段的bit数加起来必须是8的倍数,否则要补填充位。
解析报告描述符的步骤
我建议你按这个流程来解析:
- 读第一个字节,判断Item类型和长度
- 根据类型决定是Main、Global还是Local
- Global项会改变后续Main项的行为
- Local项只影响紧跟着的Main项
- 遇到Main项时,用当前Global和Local状态生成一个数据字段
- 遇到Collection时,递归解析内部内容
调试技巧:用USBlyzer或Wireshark抓包时,可以直接看到解析后的报告描述符树形结构。我经常拿它跟自己的解析结果对比,找差异。
HID设备枚举流程
枚举过程就是主机认识你的过程。HID设备在标准USB枚举基础上,多了几个特殊步骤。
枚举的完整流程
- 获取设备描述符:主机知道这是个USB设备
- 获取配置描述符:主机知道设备有哪些配置
- 获取接口描述符:主机发现接口类是HID(0x03)
- 获取HID描述符:主机知道报告描述符的长度和版本
- 获取报告描述符:主机解析你的数据格式
- 获取端点描述符:主机知道用哪个端点通信
我曾经在一个项目里踩过坑:HID描述符里的报告描述符长度写错了,导致主机只读了前半段描述符,后半段被截断了。鼠标能识别但按键全乱套。查了两天才找到原因。
接口描述符
接口描述符告诉主机:这个接口是HID类的。关键字段如下:
| 字段 | 值 | 说明 |
|---|---|---|
| bInterfaceClass | 0x03 | HID类 |
| bInterfaceSubClass | 0x00 / 0x01 | 0x00=无启动协议,0x01=支持启动协议 |
| bInterfaceProtocol | 0x00-0x02 | 0x00=无,0x01=键盘,0x02=鼠标 |
注意:如果你做的是键盘或鼠标,建议把SubClass设为0x01,Protocol设为对应的值。这样BIOS下也能用,否则进系统前可能没反应。
HID描述符
HID描述符是HID设备特有的。它紧跟在接口描述符后面,结构如下:
typedef struct {
uint8_t bDescriptorType; // 0x21
uint16_t bcdHID; // HID版本号,通常是0x0111
uint8_t bCountryCode; // 国家代码,键盘布局用
uint8_t bNumDescriptors; // 后续描述符数量,通常是1
uint8_t bDescriptorType2; // 0x22(报告描述符)
uint16_t wDescriptorLength; // 报告描述符长度
} HID_DESCRIPTOR;
嗯,这里有个细节:bcdHID字段是BCD码格式。0x0111表示版本1.11,不是1.11。我见过有人写成0x0101,结果主机不认。
端点描述符
HID设备通常使用中断端点来传输数据。端点描述符定义了这个端点的属性:
| 字段 | 典型值 | 说明 |
|---|---|---|
| bEndpointAddress | 0x81 | IN端点1 |
| bmAttributes | 0x03 | 中断传输 |
| wMaxPacketSize | 0x0040 | 64字节(全速) |
| bInterval | 0x0A | 10ms轮询间隔 |
你想想看,bInterval这个值很关键。全速设备单位是ms,高速设备单位是125μs的倍数。鼠标一般设1-10ms,键盘设10-20ms就够了。设太短浪费带宽,设太长手感延迟。
知识结构总览
下面这张图把HID设备规范的核心脉络串起来了:
避坑指南
做HID设备这么多年,我总结了几条血泪教训:
- 报告描述符长度必须准确:HID描述符里的wDescriptorLength一定要跟实际长度一致。我曾经少算了2个字节,主机解析到一半就停了。
- 中断端点间隔别设太短:全速设备bInterval最小1ms,但设1ms意味着每秒1000次中断。USB总线会扛不住的。
- 启动协议要谨慎:如果你设了支持启动协议,BIOS下会用简化版的报告格式。你的固件要能同时处理两种格式。
- 报告ID不是必须的:只有一个报告时不需要报告ID。加了反而多一个字节的开销。
调试建议:用USB分析仪抓包看枚举过程。重点关注主机请求报告描述符时,设备返回的数据是否完整。我习惯在固件里加一个调试串口,把每次枚举的请求和响应都打印出来。