4. USB 描述符体系:设备描述符、配置描述符、接口描述符、端点描述符、字符串描述符
描述符,说白了就是 USB 设备的「身份证」和「说明书」。主机插上你的设备后,第一件事就是通过控制传输把这些描述符读走。读明白了,它才知道你是什么设备、该怎么驱动、数据怎么传。
我刚开始做 USB 时,总觉得描述符就是填几个表格,随便写写就行。结果有一次枚举死活过不去,抓包一看——配置描述符的总长度算错了。嗯,从那以后我再也不敢轻视这些字节了。
4.1 描述符的层级结构
USB 描述符是树形结构,一层套一层。主机从根开始,一层层往下读:
核心层级关系:
- 设备描述符 —— 整棵树的根,描述设备全局信息
- 配置描述符 —— 每个设备可以有多个配置,但同一时刻只能激活一个
- 接口描述符 —— 每个配置下可以有多个接口,接口是功能单元
- 端点描述符 —— 每个接口下可以有多个端点,端点是数据传输的管道
- 字符串描述符 —— 可选,提供人类可读的文本信息
你想想看,主机读描述符的过程,就像拆快递:先看外包装(设备描述符),再打开盒子看里面有几个小包(配置描述符),每个小包里有什么零件(接口描述符),零件上有什么接口(端点描述符)。
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 IN0x02—— 端点 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)),否则编译器可能在结构体里插入填充字节,导致描述符长度不对。