3. USB描述符与枚举过程:设备描述符、配置描述符、接口描述符、端点描述符、字符串描述符,以及枚举流程分析

好,咱们今天聊点硬核的——USB描述符和枚举过程。

说实话,我刚入行那会儿,觉得枚举就是个黑盒子。插上设备,电脑“叮咚”一声,完事。直到有一次,我调试一个高速U盘,死活枚举不过去,抓包一看,描述符长度写错了。嗯,从那以后,我再也不敢小看这堆结构体了。

3.1 描述符家族:五兄弟各司其职

USB描述符,说白了就是设备的“身份证”和“说明书”。主机不认识你的设备,全靠这堆描述符来了解你。它们是一层套一层的树形结构,我习惯叫它“俄罗斯套娃”。

3.4.1 设备描述符(Device Descriptor)

这是最顶层的描述符,每个USB设备只能有一个。它告诉主机:你是谁家的孩子(VID/PID),用的是什么版本的USB协议,需要多少个配置。

关键字段:

  • bLength:固定18字节,别写错了,我见过有人写成17,结果枚举直接挂掉。
  • bcdUSB:USB规范版本号,比如0x0200表示USB 2.0,0x0300表示USB 3.0。
  • bDeviceClass/bDeviceSubClass/bDeviceProtocol:设备大类、子类、协议。如果设为0,表示在接口描述符里定义。
  • idVendor/idProduct:厂商ID和产品ID,这个得去USB-IF申请,别乱用。
  • bNumConfigurations:配置数量,通常为1。
// 设备描述符结构体(USB 2.0)
typedef struct {
    uint8_t  bLength;            // 18
    uint8_t  bDescriptorType;    // 0x01
    uint16_t bcdUSB;             // 0x0200
    uint8_t  bDeviceClass;
    uint8_t  bDeviceSubClass;
    uint8_t  bDeviceProtocol;
    uint8_t  bMaxPacketSize0;    // 端点0最大包长
    uint16_t idVendor;
    uint16_t idProduct;
    uint16_t bcdDevice;
    uint8_t  iManufacturer;      // 厂商字符串索引
    uint8_t  iProduct;           // 产品字符串索引
    uint8_t  iSerialNumber;      // 序列号字符串索引
    uint8_t  bNumConfigurations;
} usb_device_descriptor_t;

避坑指南:我曾经遇到一个设备,bMaxPacketSize0填了64,但实际硬件只支持8字节。结果主机发64字节的请求,设备直接不响应。记住,这个值必须和硬件能力匹配。

3.4.2 配置描述符(Configuration Descriptor)

一个设备可以有多个配置,但同一时间只能激活一个。配置描述符描述了设备的功耗、供电方式,以及包含多少个接口。

我个人习惯把配置描述符看作“电源方案说明书”。比如,总线供电还是自供电?最大电流多少?这些都在这里定义。

字段 说明 我的经验
bConfigurationValue 配置编号,主机用这个值来选择配置 从1开始编号,别用0
bmAttributes 供电属性,bit6=1表示自供电,bit5=1支持远程唤醒 总线供电设备记得把bit7置1
bMaxPower 最大电流,单位2mA USB 2.0最大500mA,填250(500/2)

3.4.3 接口描述符(Interface Descriptor)

接口描述符是配置描述符的孩子。一个配置可以有多个接口,比如一个音频设备可能有音频控制接口和音频数据接口。

你想想看,接口描述符其实就是“功能模块说明书”。它告诉主机:我这个接口是干啥的,用哪个驱动来伺候我。

关键点:

  • bInterfaceNumber:接口编号,从0开始。
  • bAlternateSetting:备用设置,用于切换接口参数。比如视频设备切换分辨率。
  • bNumEndpoints:接口使用的端点数量(不包括端点0)。
  • bInterfaceClass/bInterfaceSubClass/bInterfaceProtocol:接口类、子类、协议。比如HID类、大容量存储类。

3.4.4 端点描述符(Endpoint Descriptor)

端点,是USB通信的终点。每个接口可以有多个端点,但端点0是控制端点,不在这里描述。

端点描述符定义了数据传输的“管道参数”:传输方向、传输类型、最大包长、轮询间隔。

// 端点描述符结构体
typedef struct {
    uint8_t  bLength;            // 7
    uint8_t  bDescriptorType;    // 0x05
    uint8_t  bEndpointAddress;   // bit7: 方向(0=OUT,1=IN), bit3-0: 端点号
    uint8_t  bmAttributes;       // 传输类型: 0=控制,1=等时,2=批量,3=中断
    uint16_t wMaxPacketSize;     // 最大包长
    uint8_t  bInterval;          // 轮询间隔(ms)
} usb_endpoint_descriptor_t;

注意:高速模式下,wMaxPacketSize有特殊编码。比如批量端点最大包长512字节,但等时端点可以到1024字节。我踩过坑——给高速等时端点填了512,结果带宽不够用,视频卡顿。

3.4.5 字符串描述符(String Descriptor)

字符串描述符是可选的,但建议加上。它让设备有个“名字”,方便用户识别。

字符串描述符使用Unicode编码(UTF-16LE)。第一个字节是长度,第二个字节是描述符类型(0x03),后面是Unicode字符串。

// 字符串描述符示例(厂商名)
const uint8_t string_desc[] = {
    0x0E,           // bLength = 14字节
    0x03,           // bDescriptorType = 字符串
    'M', 0x00,      // M
    'y', 0x00,      // y
    'C', 0x00,      // C
    'o', 0x00,      // o
    ' ', 0x00,      // 空格
    'I', 0x00,      // I
    'n', 0x00,      // n
    'c', 0x00       // c
};

3.2 枚举流程:主机和设备的一次握手

枚举过程,说白了就是主机和设备“相亲”的过程。主机问一句,设备答一句,直到主机觉得“嗯,我了解你了”,然后分配一个地址,加载驱动。

我记得第一次用逻辑分析仪抓枚举过程,看到那串标准请求,才真正理解什么叫“协议是聊出来的”。

3.2.1 枚举的七个步骤

  1. 设备连接:设备插入,主机检测到D+/D-上的电平变化。
  2. 复位:主机拉低D+/D-至少10ms,设备进入默认状态。
  3. 获取设备描述符:主机发请求获取前8字节(只取前8字节,为了知道端点0最大包长)。
  4. 再次复位:主机根据获取到的bMaxPacketSize0重新配置。
  5. 设置地址:主机分配一个唯一地址(0x01-0x7F)。
  6. 获取完整设备描述符:主机再次获取完整的18字节设备描述符。
  7. 获取配置描述符:主机获取配置描述符及其所有子描述符(接口、端点等)。

为什么主机要分两次获取设备描述符?

因为主机一开始不知道端点0的最大包长。如果设备描述符里写的是64字节,但主机按8字节去读,就会出问题。所以先读前8字节拿到bMaxPacketSize0,再重新配置,最后读完整描述符。

3.2.2 标准请求:主机和设备的对话语言

枚举过程中,主机通过控制传输发送标准请求。每个请求都是14字节的Setup包。

请求 bmRequestType bRequest 说明
GET_DESCRIPTOR 0x80(设备到主机) 0x06 获取描述符
SET_ADDRESS 0x00(主机到设备) 0x05 设置地址
SET_CONFIGURATION 0x00 0x09 选择配置
GET_CONFIGURATION 0x80 0x08 获取当前配置
// Setup包结构
typedef struct {
    uint8_t  bmRequestType;  // 请求方向、类型、接收者
    uint8_t  bRequest;       // 请求码
    uint16_t wValue;         // 请求参数(如描述符类型和索引)
    uint16_t wIndex;         // 接口或端点号
    uint16_t wLength;        // 数据阶段长度
} usb_setup_packet_t;

3.2.3 枚举过程中的常见问题

问题1:描述符长度不匹配

我曾经调试一个设备,配置描述符的wTotalLength写的是50,但实际描述符数据只有40字节。主机读完50字节后,发现数据不对,直接放弃枚举。记住,wTotalLength必须等于所有子描述符长度之和。

问题2:端点0最大包长设置错误

USB 2.0全速设备端点0最大包长可以是8、16、32、64。高速设备固定64。如果你全速设备填了64但硬件只支持8,主机发64字节请求时,设备会NACK。我建议全速设备统一用64,省心。

问题3:字符串描述符索引无效

设备描述符里的iManufacturer、iProduct、iSerialNumber如果非0,必须对应有效的字符串描述符。否则主机请求字符串时,设备返回STALL,枚举失败。我习惯把不用的字符串索引设为0。

3.3 实战:一个完整的枚举抓包分析

咱们来看一个实际案例。我用USB分析仪抓了一个U盘的枚举过程,简化后的时序如下:

  1. 主机发GET_DESCRIPTOR(设备, 前8字节) → 设备返回:12 01 00 02 00 00 00 40
  2. 主机复位设备
  3. 主机发SET_ADDRESS(0x03) → 设备确认
  4. 主机发GET_DESCRIPTOR(设备, 完整18字节) → 设备返回完整描述符
  5. 主机发GET_DESCRIPTOR(配置, 完整) → 设备返回配置+接口+端点描述符
  6. 主机发SET_CONFIGURATION(1) → 设备确认,枚举完成

你想想看,整个过程也就几十毫秒。但任何一个描述符写错,设备就“失联”了。所以,写描述符的时候,我建议你对照USB规范逐字节核对,别偷懒。

嗯,关于描述符和枚举,今天就聊这么多。下一章咱们讲USB传输类型,那才是真正体现USB性能的地方。