4、USB HID协议详解:HID描述符结构、报告描述符、HID通信机制、HID类特定请求
大家好,我是老张。做USB系统这行十几年了,HID协议是我最早接触的协议之一。说实话,HID看起来简单,但坑不少。今天咱们就把HID协议彻底聊透。
4.1 HID描述符结构
HID描述符,说白了就是告诉主机「我这个设备是个啥样的HID设备」。它不像普通描述符那样简单,它是有层次的。
先看标准结构:
struct hid_descriptor {
uint8_t bLength; // 描述符长度,通常是9
uint8_t bDescriptorType; // 0x21,HID描述符类型
uint16_t bcdHID; // HID版本号,比如1.11
uint8_t bCountryCode; // 国家代码,0表示不指定
uint8_t bNumDescriptors; // 下级描述符数量,至少1个
uint8_t bDescriptorType; // 下级描述符类型,通常是0x22(报告描述符)
uint16_t wDescriptorLength;// 报告描述符的长度
} __attribute__((packed));
嗯,这里要注意:bNumDescriptors 字段很多人会忽略。我见过一个项目,工程师只填了1个报告描述符,但设备其实还支持物理描述符。结果主机枚举时只拿到了报告描述符,物理描述符丢了,导致某些特殊应用无法正常工作。
核心要点:HID描述符是「描述符的描述符」。它不直接描述数据,而是告诉主机去哪里找真正的数据描述(报告描述符)。
4.2 报告描述符(Report Descriptor)
报告描述符才是HID协议的精髓。它用一套「微型语言」来描述数据格式。你想想看,鼠标、键盘、游戏手柄,数据格式完全不同,但都靠这套语言来描述。
我习惯把报告描述符理解成「数据模板」。主机拿到这个模板,就知道怎么解析设备发来的数据。
看个最简单的鼠标报告描述符:
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 (1)
0x29, 0x03, // Usage Maximum (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键鼠标:3个按键(每个1位),5位填充,然后是X和Y轴移动数据(各8位,相对值)。
我的经验:写报告描述符时,一定要先画数据布局图。把每个字节的每一位都标清楚,再动手写。我曾经因为Report Count和Report Size搞反,导致主机解析数据完全错位,排查了整整两天。
4.3 HID通信机制
HID设备主要用两种传输方式:中断输入和中断输出。为什么用中断传输?因为HID设备通常是人机交互设备,需要低延迟。
4.3.1 中断输入(Interrupt IN)
这是最常用的方式。设备主动向主机报告数据。比如鼠标移动、键盘按键。
主机通过轮询方式读取数据。轮询间隔在端点描述符的 bInterval 字段中指定。对于鼠标,通常是8ms(125Hz);对于键盘,通常是10ms(100Hz)。
我建议:不要为了省带宽把轮询间隔设得太大。用户能明显感觉到延迟。但也不要太小,比如1ms,USB总线会受不了。
4.3.2 中断输出(Interrupt OUT)
主机向设备发送数据。比如键盘的LED灯控制、游戏手柄的力反馈。
这个方向很多人会忽略。我记得有个项目,设备支持LED控制,但工程师只实现了中断输入端点。结果主机发来的LED状态数据设备收不到,键盘灯死活不亮。
避坑指南:我曾经遇到一个设备,中断输出端点描述符的wMaxPacketSize填错了。主机发数据时总是被拒绝。检查了半天才发现是描述符长度不匹配。记住:中断输出端点的最大包长必须和报告描述符中定义的输出报告长度一致。
4.4 HID类特定请求
除了标准的USB请求,HID设备还支持几个类特定请求。这些请求用于获取和设置HID相关的信息。
| 请求名称 | bRequest | 用途 |
|---|---|---|
| Get_Report | 0x01 | 主机获取设备的报告数据 |
| Get_Idle | 0x02 | 获取设备的空闲速率 |
| Get_Protocol | 0x03 | 获取设备当前协议(仅启动设备) |
| Set_Report | 0x09 | 主机向设备发送报告数据 |
| Set_Idle | 0x0A | 设置设备的空闲速率 |
| Set_Protocol | 0x0B | 设置设备协议(仅启动设备) |
这里面最常用的是 Get_Report 和 Set_Report。为什么有了中断传输还要用控制传输来获取报告?因为有些场景下主机需要主动获取数据,而不是等待设备上报。
举个例子:键盘的启动协议。在BIOS阶段,主机可能还不支持中断传输,就用控制传输通过 Get_Report 来获取按键数据。
关键点:Set_Idle 请求很多人不理解。它设置的是设备在没有新数据时,多久向主机发送一次「空报告」。默认是无限长,也就是只有数据变化时才发送。但有些主机要求设备定期发送报告,哪怕数据没变。这时候就需要设置空闲速率。
我遇到过一个问题:一个游戏手柄在Windows上工作正常,但在Linux上却频繁断连。排查后发现是Linux主机要求设备支持 Set_Idle 请求,而我们的固件没有实现这个请求。加上之后问题解决。
4.5 知识体系总览
下面这张图是我自己整理的HID协议知识体系,你看一眼就能明白各个模块之间的关系:
这张图把HID协议的三大核心模块串起来了。你从上往下看,先有设备,再有描述符、通信机制和类请求,最后落到具体应用场景。报告描述符是中间最关键的环节,它决定了数据怎么组织、怎么传输。
好了,HID协议的核心内容就这些。记住:描述符是基础,报告描述符是灵魂,中断传输是手段,类请求是补充。把这四点吃透,HID设备开发就没啥大问题了。
公众号:蓝海资料掘金营,微信deep3321