4. USB 描述符体系:设备描述符、配置描述符、接口描述符、端点描述符、字符串描述符

描述符,说白了就是 USB 设备的「身份证」和「说明书」。主机插上你的设备后,第一件事就是通过控制传输把这些描述符读走。读明白了,它才知道你是什么设备、该怎么驱动、数据怎么传。

我刚开始做 USB 时,总觉得描述符就是填几个表格,随便写写就行。结果有一次枚举死活过不去,抓包一看——配置描述符的总长度算错了。嗯,从那以后我再也不敢轻视这些字节了。

4.1 描述符的层级结构

USB 描述符是树形结构,一层套一层。主机从根开始,一层层往下读:

核心层级关系:

  1. 设备描述符 —— 整棵树的根,描述设备全局信息
  2. 配置描述符 —— 每个设备可以有多个配置,但同一时刻只能激活一个
  3. 接口描述符 —— 每个配置下可以有多个接口,接口是功能单元
  4. 端点描述符 —— 每个接口下可以有多个端点,端点是数据传输的管道
  5. 字符串描述符 —— 可选,提供人类可读的文本信息

你想想看,主机读描述符的过程,就像拆快递:先看外包装(设备描述符),再打开盒子看里面有几个小包(配置描述符),每个小包里有什么零件(接口描述符),零件上有什么接口(端点描述符)。

USB 描述符层级结构 设备描述符 配置描述符 #1 配置描述符 #2 接口描述符 #0 接口描述符 #1 端点 IN (0x81) 端点 OUT (0x01) 端点 IN (0x82) 字符串描述符 💡 关键点: • 设备描述符是根,每个设备有且仅有 1 个 • 配置描述符可以多个,但同一时刻只能激活 1 个 • 接口描述符是功能单元,复合设备有多个接口 • 端点描述符是数据传输的管道,端点 0 是默认控制管道

4.2 设备描述符

设备描述符是主机读到的第一个描述符。主机通过默认控制管道(端点 0)发送 GET_DESCRIPTOR 请求,设备返回 18 个字节。

偏移 字段 大小 说明
0 bLength 1 描述符长度,固定 0x12 (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 的最大包大小,FS 下通常为 64
8-9 idVendor 2 厂商 ID(VID),由 USB-IF 分配
10-11 idProduct 2 产品 ID(PID),厂商自行定义
12-13 bcdDevice 2 设备版本号
14 iManufacturer 1 厂商字符串描述符索引,0 表示无
15 iProduct 1 产品字符串描述符索引
16 iSerialNumber 1 序列号字符串描述符索引
17 bNumConfigurations 1 支持的配置描述符数量

我个人的习惯:设备描述符里的 bMaxPacketSize0 经常被忽略。FS 设备这个值必须是 8、16、32、64 之一。我见过有人填了 128,结果主机直接不认。还有 bcdUSB,如果你设备只支持 USB 2.0,就别填 0x0300,否则主机可能用 USB 3.0 的时序跟你通信,那就乱套了。

4.3 配置描述符

配置描述符是描述符树里最复杂的一层。它本身只有 9 个字节,但它后面紧跟着接口描述符和端点描述符。主机读配置描述符时,是一次性把整个配置树(配置 + 接口 + 端点)全部读走的。

偏移 字段 大小 说明
0 bLength 1 固定 0x09 (9)
1 bDescriptorType 1 配置描述符类型,0x02
2-3 wTotalLength 2 整个配置树的总长度(配置 + 接口 + 端点)
4 bNumInterfaces 1 该配置下的接口数量
5 bConfigurationValue 1 配置编号,用于 SET_CONFIGURATION 请求
6 iConfiguration 1 配置字符串描述符索引
7 bmAttributes 1 配置特性:D7=1 必须为 1,D6=1 自供电,D5=1 支持远程唤醒
8 bMaxPower 1 最大电流,单位 2mA,值 50 表示 100mA

我曾经踩过的坑:wTotalLength 这个字段,我见过太多人算错了。你算的时候,要把配置描述符自己(9 字节)加上所有接口描述符(每个 9 字节)加上所有端点描述符(每个 7 字节)全部加起来。少算一个端点,主机读到的数据就不完整,枚举直接失败。我的建议是:写代码时用宏自动计算,别手算。

4.4 接口描述符

接口描述符定义了一个功能单元。比如一个 U 盘可能有 1 个接口(Bulk Only Transport),一个复合设备可能有多个接口(键盘接口 + 鼠标接口 + 音频接口)。

偏移 字段 大小 说明
0 bLength 1 固定 0x09 (9)
1 bDescriptorType 1 接口描述符类型,0x04
2 bInterfaceNumber 1 接口编号,从 0 开始
3 bAlternateSetting 1 备用设置编号,用于切换接口配置
4 bNumEndpoints 1 该接口下的端点数量(不包括端点 0)
5 bInterfaceClass 1 接口类代码,如 HID 类为 0x03
6 bInterfaceSubClass 1 接口子类代码
7 bInterfaceProtocol 1 接口协议代码
8 iInterface 1 接口字符串描述符索引

这里有个关键点:bAlternateSetting。你想想看,同一个接口可以有多个「备用设置」,每个备用设置下端点配置不同。主机通过 SET_INTERFACE 请求切换。我做过一个音频设备,就是用备用设置来切换采样率——备用设置 0 是 48kHz,备用设置 1 是 96kHz,端点大小也跟着变。

4.5 端点描述符

端点是数据传输的管道。除了默认的端点 0(控制传输),其他端点都需要在端点描述符中定义。

偏移 字段 大小 说明
0 bLength 1 固定 0x07 (7)
1 bDescriptorType 1 端点描述符类型,0x05
2 bEndpointAddress 1 端点地址:D7=0 表示 OUT,D7=1 表示 IN;D3-D0 为端点号
3 bmAttributes 1 传输类型:0=控制,1=等时,2=批量,3=中断
4-5 wMaxPacketSize 2 最大包大小,FS 批量通常为 64,HS 批量通常为 512
6 bInterval 1 轮询间隔,中断端点用,单位 ms(FS)或 125μs 帧(HS)

端点地址的编码规则:

  • 0x81 —— 端点 1 IN(主机读取数据)
  • 0x01 —— 端点 1 OUT(主机发送数据)
  • 0x82 —— 端点 2 IN
  • 0x02 —— 端点 2 OUT

注意:同一个端点号可以同时有 IN 和 OUT 两个方向,它们是独立的端点描述符。

4.6 字符串描述符

字符串描述符是可选的,但实际产品中几乎都会用。它提供人类可读的文本:厂商名、产品名、序列号。

字符串描述符的格式有点特殊:

  • 索引 0:返回一个特殊的描述符,包含设备支持的语言 ID 列表(如 0x0409 表示美式英语)
  • 索引 > 0:返回实际的 Unicode 字符串

字符串使用 UTF-16LE 编码,每个字符占 2 字节。比如字符串 "USB" 在描述符里就是:

// 字符串描述符示例:"USB"
// 索引 0 的语言 ID 描述符
0x04,       // bLength = 4
0x03,       // bDescriptorType = 0x03 (字符串描述符)
0x09, 0x04, // wLANGID = 0x0409 (美式英语)

// 索引 1 的实际字符串描述符
0x08,       // bLength = 8
0x03,       // bDescriptorType = 0x03
'U', 0x00,  // Unicode 字符 'U'
'S', 0x00,  // Unicode 字符 'S'
'B', 0x00   // Unicode 字符 'B'

我建议:字符串描述符的长度必须是偶数,因为每个 Unicode 字符占 2 字节。另外,有些主机对字符串描述符的读取有 bug——它可能只读前几个字节。所以字符串不要太长,厂商名控制在 20 个字符以内比较安全。

4.7 描述符的常见故障与定位

枚举失败,十有八九是描述符出了问题。我总结了几种最常见的场景:

故障现象 可能原因 排查方法
主机不认设备,枚举卡在第一步 设备描述符的 bMaxPacketSize0 错误 抓包确认主机是否收到设备描述符,检查返回值
枚举到配置描述符时失败 wTotalLength 计算错误 手动计算所有描述符长度之和,与固件值对比
驱动加载成功但数据传输失败 端点描述符的 wMaxPacketSize 与实际硬件不匹配 检查端点 FIFO 大小是否与描述符一致
字符串显示乱码 UTF-16LE 编码错误或语言 ID 不支持 确认语言 ID 在主机支持列表中,检查字节序
复合设备只有一个接口被识别 接口描述符的 bInterfaceNumber 重复或跳号 检查接口编号是否连续且不重复

我曾经遇到过一个特别隐蔽的 bug:配置描述符里的 bmAttributes 字段,D7 必须为 1。我有个项目不小心把这个位写成了 0,结果主机在 Windows 上能识别,但在 macOS 上死活不认。查了两天才发现是这个位的问题。所以,不要假设所有主机行为一致,严格按规范来。

4.8 描述符的代码实现要点

实际固件中,描述符通常定义为一个字节数组。我习惯用结构体加宏的方式,既清晰又不容易出错:

// 设备描述符结构体
typedef struct __attribute__((packed)) {
    uint8_t  bLength;
    uint8_t  bDescriptorType;
    uint16_t bcdUSB;
    uint8_t  bDeviceClass;
    uint8_t  bDeviceSubClass;
    uint8_t  bDeviceProtocol;
    uint8_t  bMaxPacketSize0;
    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;

// 实际定义
const usb_device_descriptor_t device_descriptor = {
    .bLength            = sizeof(usb_device_descriptor_t),
    .bDescriptorType    = 0x01,
    .bcdUSB             = 0x0200,  // USB 2.0
    .bDeviceClass       = 0x00,    // 在接口中定义
    .bDeviceSubClass    = 0x00,
    .bDeviceProtocol    = 0x00,
    .bMaxPacketSize0    = 64,
    .idVendor           = 0x1234,
    .idProduct          = 0x5678,
    .bcdDevice          = 0x0100,
    .iManufacturer      = 1,       // 字符串索引 1
    .iProduct           = 2,       // 字符串索引 2
    .iSerialNumber      = 3,       // 字符串索引 3
    .bNumConfigurations = 1
};

用结构体的好处是:编译器会自动计算长度,你不需要手动数每个字段的偏移。但要注意加上 __attribute__((packed)),否则编译器可能在结构体里插入填充字节,导致描述符长度不对。