3. USB枚举过程:描述符的解析与实战

USB枚举,说白了就是主机和设备“互相认识”的过程。设备插上去,主机怎么知道你是谁?你是什么类型的设备?你需要多少带宽?这些问题,全靠一套标准化的描述符来回答。

我个人习惯把枚举比作“面试”。设备是求职者,主机是面试官。描述符就是你的简历——上面写清楚了你的学历、技能、期望薪资。面试官(主机)一份份看完,觉得合适,就给你分配资源,让你上岗工作。

3.1 描述符的层级结构

USB描述符是典型的树形结构。从设备描述符开始,一层层往下展开。我刚开始学USB时,总觉得这结构有点绕。后来画了一张图,一下子就清楚了。

核心要点:描述符的层级关系是:设备 → 配置 → 接口 → 端点。每个层级都有对应的描述符,且必须按这个顺序请求。

USB描述符层级结构 设备描述符 配置描述符 接口描述符 端点描述符 说明 • 每个设备有且仅有1个设备描述符 • 每个配置下可以有多个接口 • 每个接口下可以有多个端点 • 端点0用于控制传输,没有描述符 枚举顺序 1. 获取设备描述符(前8字节) 2. 获取配置描述符(完整) 3. 获取字符串描述符(可选) 端点方向 • IN端点:设备→主机 • OUT端点:主机→设备 • 端点号+方向唯一标识

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分析仪抓一次枚举过程,看看主机和设备是怎么“对话”的。

  1. 复位:主机检测到设备插入,对端口复位
  2. 获取设备描述符(前8字节):主机发请求,设备回复前8字节,主机从中知道端点0的最大包长
  3. 复位(再次):主机根据获取到的包长重新配置端点0
  4. 获取完整设备描述符:这次拿到全部18字节
  5. 获取配置描述符:先拿前4字节得到总长度,再拿完整数据
  6. 设置地址:主机给设备分配唯一地址
  7. 再次获取配置描述符:地址分配后,主机重新读取配置信息
  8. 获取字符串描述符:如果有字符串索引,主机逐个请求
  9. 设置配置:主机选择有效的配置值,设备进入配置状态

嗯,这里要注意:步骤2和步骤4看起来重复,但目的不同。第一次只拿8字节是为了确定端点0的包长,第二次才拿完整数据。这是USB规范规定的标准流程,不能省略。

常见问题:有些设备在步骤2之后没有正确响应步骤4的请求。原因往往是设备在第一次复位后还没准备好。我建议在固件中加一个延时,确保在收到完整设备描述符请求时,设备已经初始化完毕。

3.8 描述符解析的调试技巧

调试描述符问题,我一般用三板斧:

  • 用USB分析仪抓包:看主机发了什么,设备回了什么。这是最直接的方法
  • 检查描述符的校验和:每个描述符的第一个字节是长度,第二个字节是类型。如果长度和实际数据不匹配,主机直接丢弃
  • 对比标准设备的描述符:拿一个同类设备抓包,对比描述符的差异。很多时候问题就出在某个字段的值不对

举个例子,有一次我调试一个HID设备,主机总是枚举失败。抓包一看,设备返回的配置描述符中wTotalLength比实际数据少了2个字节。主机读完配置描述符后,发现数据不完整,直接放弃枚举。改回正确的总长度后,问题就解决了。

描述符解析是USB驱动开发的基本功。你想想看,如果连设备描述符都填不对,后面的驱动开发根本无从谈起。把这些结构体吃透,枚举过程烂熟于心,后面写驱动就顺了。