4. USB HID类协议:从描述符到游戏手柄
好,咱们今天聊聊USB HID。说实话,HID协议是我在嵌入式开发中打交道最多的协议之一。为什么?因为几乎所有的人机交互设备——键盘、鼠标、游戏手柄、甚至一些工业上的操纵杆——都在用HID。
HID的全称是Human Interface Device,人机交互设备。它最大的好处就是:免驱。你插上一个USB游戏手柄,Windows或者Linux立马就能识别,不需要额外装驱动。这背后靠的就是一套标准化的描述符机制。
4.1 HID描述符结构
每个USB设备都有一个设备描述符,HID设备也不例外。但HID设备多了一个特殊的描述符——HID描述符。它告诉主机:我这个设备是HID类的,我的报告描述符在哪里,有多大。
HID描述符的结构是这样的:
| 字段 | 长度 | 说明 |
|---|---|---|
| bLength | 1字节 | 描述符长度,固定为9 |
| bDescriptorType | 1字节 | 类型,HID描述符为0x21 |
| bcdHID | 2字节 | HID协议版本号,通常是1.11 |
| bCountryCode | 1字节 | 国家代码,0表示不指定 |
| bNumDescriptors | 1字节 | 下级描述符数量,通常为1 |
| bDescriptorType | 1字节 | 下级描述符类型,报告描述符为0x22 |
| wDescriptorLength | 2字节 | 报告描述符的总长度 |
嗯,这里要注意:HID描述符是放在接口描述符之后的。标准的USB枚举流程中,主机会先拿到设备描述符,然后配置描述符,接着接口描述符,最后才是HID描述符。
关键点:HID描述符里最重要的字段就是wDescriptorLength。它告诉主机报告描述符有多长。如果这个值写错了,主机解析报告描述符就会出错,设备可能无法正常工作。
4.2 报告描述符(Report Descriptor)
报告描述符是HID设备的核心。它不像普通的描述符那样是固定结构,而是一串可变长度的字节流。主机通过解析这串字节流,才知道你的设备有多少个按键、几个摇杆、每个数据是几位的。
报告描述符由一个个Item组成。每个Item包含一个前缀和若干数据。前缀的高位表示Item类型,低位表示标签。
常见的Item类型有:
- Main Item:定义控件的主要类型,比如Input、Output、Feature、Collection
- Global Item:设置全局状态,比如Usage Page、Logical Minimum、Report Size
- Local Item:设置局部状态,比如Usage、Usage Minimum、Usage Maximum
我举个例子。一个简单的游戏手柄,有8个按键和2个摇杆,它的报告描述符大概长这样:
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x05, // Usage (Game Pad)
0xA1, 0x01, // Collection (Application)
0x05, 0x09, // Usage Page (Button)
0x19, 0x01, // Usage Minimum (Button 1)
0x29, 0x08, // Usage Maximum (Button 8)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
0x75, 0x01, // Report Size (1 bit)
0x95, 0x08, // Report Count (8)
0x81, 0x02, // Input (Data,Var,Abs)
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x30, // Usage (X)
0x09, 0x31, // Usage (Y)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8 bits)
0x95, 0x02, // Report Count (2)
0x81, 0x02, // Input (Data,Var,Abs)
0xC0 // End Collection
这段描述符定义了一个游戏手柄:8个按键(每个占1位),2个摇杆(每个占8位)。主机解析后,就知道每次报告数据是8位+16位=24位,也就是3个字节。
个人经验:写报告描述符时,最容易犯的错误是Report Size和Report Count算错了。我曾经在一个项目中,把摇杆的Report Size写成了16位,但实际数据只给了8位,结果主机读到的数据全是乱的。调试了整整一天才发现是这里的问题。
4.3 HID设备枚举流程
USB HID设备的枚举流程,说白了就是主机和设备之间的一系列握手。我把它拆成几个关键步骤:
- 主机获取设备描述符:主机发请求,设备返回18字节的设备描述符。这里会看到bDeviceClass为0,表示每个接口单独定义类。
- 主机获取配置描述符:主机先读9字节,拿到总长度,再读完整配置描述符。配置描述符里包含了接口描述符和HID描述符。
- 主机解析HID描述符:主机从HID描述符中拿到报告描述符的长度和位置。
- 主机获取报告描述符:主机通过HID类请求(0x01,Get_Report)或者直接读取报告描述符(0x22类型),拿到完整的报告描述符字节流。
- 主机解析报告描述符:主机逐字节解析Item,构建出设备的控件树。
- 主机设置配置:主机发Set Configuration,设备进入配置状态。
- 主机开始接收中断传输:主机通过中断IN端点,周期性地读取设备的报告数据。
为什么会这样设计?因为HID设备的数据是事件驱动的。比如你按下一个按键,设备会主动通过中断端点把报告发给主机。主机不需要轮询,省带宽。
避坑指南:我曾经遇到一个设备,枚举时一切正常,但就是收不到数据。后来发现是中断端点的bInterval字段设得太大了。对于游戏手柄,我建议bInterval设为1ms,这样主机每1ms就会尝试读取一次报告,响应才够快。
4.4 游戏手柄HID报告实例
好了,咱们来看一个真实的游戏手柄报告实例。假设我有一个手柄,它有:
- 12个按键(A、B、X、Y、LB、RB、LT、RT、Start、Back、左摇杆按下、右摇杆按下)
- 2个模拟摇杆(每个X/Y轴,8位精度)
- 1个十字键(4个方向)
那么报告描述符可以这样设计:
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x05, // Usage (Game Pad)
0xA1, 0x01, // Collection (Application)
// 12个按键,每个1位
0x05, 0x09, // Usage Page (Button)
0x19, 0x01, // Usage Minimum (Button 1)
0x29, 0x0C, // Usage Maximum (Button 12)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
0x75, 0x01, // Report Size (1 bit)
0x95, 0x0C, // Report Count (12)
0x81, 0x02, // Input (Data,Var,Abs)
// 填充4位,凑够2字节
0x75, 0x01, // Report Size (1 bit)
0x95, 0x04, // Report Count (4)
0x81, 0x03, // Input (Const,Var,Abs)
// 十字键,4位
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x39, // Usage (Hat Switch)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x07, // Logical Maximum (7)
0x75, 0x04, // Report Size (4 bits)
0x95, 0x01, // Report Count (1)
0x81, 0x42, // Input (Data,Var,Abs,Null)
// 左摇杆 X/Y,各8位
0x09, 0x30, // Usage (X)
0x09, 0x31, // Usage (Y)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8 bits)
0x95, 0x02, // Report Count (2)
0x81, 0x02, // Input (Data,Var,Abs)
// 右摇杆 X/Y,各8位
0x09, 0x32, // Usage (Z)
0x09, 0x35, // Usage (Rz)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8 bits)
0x95, 0x02, // Report Count (2)
0x81, 0x02, // Input (Data,Var,Abs)
0xC0 // End Collection
这个报告描述符定义了一个8字节的报告:
- 字节0-1:12个按键 + 4位填充
- 字节2:十字键(低4位有效)
- 字节3-4:左摇杆X/Y
- 字节5-6:右摇杆X/Y
主机收到这8个字节后,就能解析出所有控件的状态。比如字节0的第0位是1,表示A键被按下;字节3的值是128,表示左摇杆在中间位置。
我的建议:设计报告描述符时,尽量把同类数据放在一起。比如所有按键放一个字节,所有摇杆放连续字节。这样主机解析起来方便,调试时也容易看。我见过一些设计把按键和摇杆混在一起,结果调试时对着十六进制数据看了半天才理清楚。
嗯,关于HID协议,今天就聊这么多。下一章咱们会深入HID报告的数据传输机制,包括中断传输和控制传输的区别,以及如何优化报告传输的实时性。到时候我会分享一些我在实际项目中踩过的坑,希望对你有帮助。