3. USB枚举过程:描述符的解析与实战
USB枚举,说白了就是主机和设备“互相认识”的过程。设备插上去,主机怎么知道你是谁?你是什么类型的设备?你需要多少带宽?这些问题,全靠一套标准化的描述符来回答。
我个人习惯把枚举比作“面试”。设备是求职者,主机是面试官。描述符就是你的简历——上面写清楚了你的学历、技能、期望薪资。面试官(主机)一份份看完,觉得合适,就给你分配资源,让你上岗工作。
3.1 描述符的层级结构
USB描述符是典型的树形结构。从设备描述符开始,一层层往下展开。我刚开始学USB时,总觉得这结构有点绕。后来画了一张图,一下子就清楚了。
核心要点:描述符的层级关系是:设备 → 配置 → 接口 → 端点。每个层级都有对应的描述符,且必须按这个顺序请求。
3.2 设备描述符
设备描述符是主机拿到的第一份“简历”。它只有18个字节,但包含了设备最核心的信息。主机通过端点0发送请求GET_DESCRIPTOR(DEVICE),设备就得乖乖把这份数据交出来。
| 偏移 | 字段 | 长度 | 说明 |
|---|---|---|---|
| 0 | bLength | 1 | 描述符长度,固定为18 |
| 1 | bDescriptorType | 1 | 类型码,设备描述符为0x01 |
| 2-3 | bcdUSB | 2 | USB规范版本号,如0x0200表示USB 2.0 |
| 4 | bDeviceClass | 1 | 设备类代码,0x00表示在接口描述符中定义 |
| 5 | bDeviceSubClass | 1 | 子类代码 |
| 6 | bDeviceProtocol | 1 | 协议代码 |
| 7 | bMaxPacketSize0 | 1 | 端点0的最大包长 |
| 8-9 | idVendor | 2 | 厂商ID(VID),由USB-IF分配 |
| 10-11 | idProduct | 2 | 产品ID(PID),厂商自行定义 |
| 12-13 | bcdDevice | 2 | 设备版本号 |
| 14 | iManufacturer | 1 | 厂商字符串描述符的索引 |
| 15 | iProduct | 1 | 产品字符串描述符的索引 |
| 16 | iSerialNumber | 1 | 序列号字符串描述符的索引 |
| 17 | bNumConfigurations | 1 | 支持的配置数量 |
实战经验:我遇到过不少设备,bMaxPacketSize0填错了。USB 2.0全速设备这个值必须是8、16、32或64。有一次客户说设备枚举不稳定,查了半天,发现他把这个值填成了128——全速设备根本不支持。改回64后,问题就解决了。
3.3 配置描述符
拿到设备描述符后,主机接着请求配置描述符。这里有个坑:配置描述符不是单独一个,而是“配置描述符 + 接口描述符 + 端点描述符”的打包数据。主机先请求前4个字节,拿到总长度,再一次性把整个配置数据读回来。
// 配置描述符结构(9字节)
typedef struct {
uint8_t bLength; // 固定为9
uint8_t bDescriptorType; // 0x02
uint16_t wTotalLength; // 整个配置数据的总长度
uint8_t bNumInterfaces; // 该配置下的接口数量
uint8_t bConfigurationValue;// 配置值,用于SetConfiguration
uint8_t iConfiguration; // 配置字符串索引
uint8_t bmAttributes; // 属性:自供电/远程唤醒
uint8_t bMaxPower; // 最大电流,单位2mA
} __attribute__((packed)) usb_config_descriptor_t;
这里有个细节:bMaxPower的单位是2mA。如果填50,表示最大消耗100mA。我见过新手直接填500,以为单位是1mA——结果设备被识别为需要1A电流,主机直接拒绝供电。
注意:配置描述符中的bmAttributes字段,bit6必须为1(表示设备有自供电能力),bit5表示是否支持远程唤醒。如果这两个位都置0,主机可能会认为设备有问题。
3.4 接口描述符
接口描述符定义了设备的功能。一个配置可以有多个接口,比如一个USB摄像头可能有视频接口和音频接口。接口描述符紧跟在配置描述符后面,主机解析时会逐个读取。
| 字段 | 说明 |
|---|---|
| bInterfaceNumber | 接口编号,从0开始 |
| bAlternateSetting | 备用设置,用于切换接口配置 |
| bNumEndpoints | 该接口下的端点数量(不包括端点0) |
| bInterfaceClass | 接口类代码,如HID类为0x03 |
| bInterfaceSubClass | 接口子类 |
| bInterfaceProtocol | 接口协议 |
| iInterface | 接口字符串索引 |
为什么要有备用设置?举个例子:一个音频设备,默认设置下可能只有1个等时端点用于播放。通过切换备用设置,可以启用更多端点,支持更高的采样率。我在做USB音频设备驱动时,就靠这个特性实现了采样率的动态切换。
3.5 端点描述符
端点是USB通信的“管道”。每个端点描述符7个字节,定义了端点的传输类型、方向和最大包长。端点0是默认控制端点,不需要描述符。
// 端点描述符结构(7字节)
typedef struct {
uint8_t bLength; // 固定为7
uint8_t bDescriptorType; // 0x05
uint8_t bEndpointAddress; // bit7:方向(0=OUT,1=IN), bit3-0:端点号
uint8_t bmAttributes; // bit1-0:传输类型(0=控制,1=等时,2=批量,3=中断)
uint16_t wMaxPacketSize; // 最大包长
uint8_t bInterval; // 查询间隔(中断/等时端点用)
} __attribute__((packed)) usb_endpoint_descriptor_t;
关键点:wMaxPacketSize对于高速批量端点,bit12-11表示每微帧的额外事务数。这个位段经常被忽略,导致高速设备在批量传输时性能上不去。我调试过一个U盘驱动,就是因为这个字段没填对,读写速度只有理论值的一半。
3.6 字符串描述符
字符串描述符是可选的。它提供了人类可读的厂商名、产品名和序列号。主机通过索引值来请求对应的字符串。字符串描述符的格式比较特殊:第一个字节是长度,第二个字节是类型码(0x03),后面是Unicode编码的字符串。
// 字符串描述符示例("USB")
// 字节0: 长度 = 8 (2字节头 + 6字节Unicode)
// 字节1: 类型 = 0x03
// 字节2-3: 'U' = 0x0055
// 字节4-5: 'S' = 0x0053
// 字节6-7: 'B' = 0x0042
uint8_t string_desc[] = {
0x08, 0x03,
0x55, 0x00, // U
0x53, 0x00, // S
0x42, 0x00 // B
};
避坑指南:我曾经在字符串描述符上栽过跟头。Windows和Linux对字符串描述符的解析要求不一样。Windows要求字符串描述符必须以0x00结尾(双字节空字符),而Linux则没有这个要求。如果你的设备只在Windows上枚举失败,多半是字符串描述符的格式问题。
3.7 枚举实战:抓包分析
理论说完了,咱们来点实战。用USB分析仪抓一次枚举过程,看看主机和设备是怎么“对话”的。
- 复位:主机检测到设备插入,对端口复位
- 获取设备描述符(前8字节):主机发请求,设备回复前8字节,主机从中知道端点0的最大包长
- 复位(再次):主机根据获取到的包长重新配置端点0
- 获取完整设备描述符:这次拿到全部18字节
- 获取配置描述符:先拿前4字节得到总长度,再拿完整数据
- 设置地址:主机给设备分配唯一地址
- 再次获取配置描述符:地址分配后,主机重新读取配置信息
- 获取字符串描述符:如果有字符串索引,主机逐个请求
- 设置配置:主机选择有效的配置值,设备进入配置状态
嗯,这里要注意:步骤2和步骤4看起来重复,但目的不同。第一次只拿8字节是为了确定端点0的包长,第二次才拿完整数据。这是USB规范规定的标准流程,不能省略。
常见问题:有些设备在步骤2之后没有正确响应步骤4的请求。原因往往是设备在第一次复位后还没准备好。我建议在固件中加一个延时,确保在收到完整设备描述符请求时,设备已经初始化完毕。
3.8 描述符解析的调试技巧
调试描述符问题,我一般用三板斧:
- 用USB分析仪抓包:看主机发了什么,设备回了什么。这是最直接的方法
- 检查描述符的校验和:每个描述符的第一个字节是长度,第二个字节是类型。如果长度和实际数据不匹配,主机直接丢弃
- 对比标准设备的描述符:拿一个同类设备抓包,对比描述符的差异。很多时候问题就出在某个字段的值不对
举个例子,有一次我调试一个HID设备,主机总是枚举失败。抓包一看,设备返回的配置描述符中wTotalLength比实际数据少了2个字节。主机读完配置描述符后,发现数据不完整,直接放弃枚举。改回正确的总长度后,问题就解决了。
描述符解析是USB驱动开发的基本功。你想想看,如果连设备描述符都填不对,后面的驱动开发根本无从谈起。把这些结构体吃透,枚举过程烂熟于心,后面写驱动就顺了。