4. 设备描述符结构解析

设备描述符,是 USB 设备给主机递交的第一张"身份证"。

主机一旦检测到设备插入,第一步就是发一个 Get_Descriptor(Device) 请求。设备得把这个 18 字节的数据包原样送回去。如果出错,主机连你的设备叫什么名字都不知道,更别提后续的 PD 协商了。

我处理过太多案例,设备描述符里 VID/PID 写错或者 bMaxPacketSize0 填了个不合法的值,导致整个枚举流程卡在第一步。主机不停复位、重试,最后干脆给个 "Unknown Device"。

4.1 标准结构回顾

USB 2.0 规范里,设备描述符的长度固定为 18 字节。它的布局是这样的:

偏移字段长度说明常见错误
0bLength1描述符总长度,固定 0x12 (18)写错成其他值
1bDescriptorType1描述符类型,设备描述符为 0x01填成 0x02 或 0x00
2bcdUSB2USB 版本号,如 0x0200字节序写反,变成 0x0002
4bDeviceClass1设备类代码,0x00 表示从接口描述0xFF 被当做 vendor-specific
5bDeviceSubClass1子类与主类不匹配
6bDeviceProtocol1协议同上
7bMaxPacketSize01端点 0 的最大包长,必须是 8/16/32/64填了 27 这种非法值
8idVendor2厂商 ID,USB-IF 分配字节序写反
10idProduct2产品 ID全 0 或全 F
12bcdDevice2设备版本号不注意大小端
14iManufacturer1厂商字符串索引索引指向空或不存在
15iProduct1产品字符串索引同上
16iSerialNumber1序列号字符串索引即使没有序列号也要写 0
17bNumConfigurations1配置描述符数量,通常 1写 0 导致系统找不到配置

说白了,主机拿到这 18 字节后,首先检查 bLength 和 bDescriptorType,不对就直接放弃。所以这两个字段不能出错。

4.2 小端序陷阱

USB 协议里多字节字段都用小端序(little-endian)。我见过很多开发者在代码里直接写:

// 错误示范:直接按人类阅读顺序赋值
const uint8_t device_desc[] = {
    0x12,        // bLength
    0x01,        // bDescriptorType
    0x00, 0x02,  // bcdUSB = 2.0 —— 这里写反了!
    // ...
};

正确写法应该是 0x00, 0x02 还是 0x02, 0x00?

低字节在前,所以 USB 2.0 的 bcdUSB 应该写 0x00, 0x02。注意,这里是先放低字节 0x00,再放高字节 0x02,组合起来是 0x0200。别搞反了。

同样,idVendor 和 idProduct 也需要低字节在前。咱们常用的 USB analyzer 抓包工具会按小端解释,但有些开发板上的 printf 输出可能是大端,容易看懵。

我个人的习惯是:在代码里直接用结构体+packed 属性,让编译器处理字节序。比如:

typedef struct __attribute__((packed)) {
    uint8_t  bLength;
    uint8_t  bDescriptorType;
    uint16_t bcdUSB;
    // ...
} usb_device_descriptor_t;

这样直接赋值 .bcdUSB = 0x0200 就行,编译器会自动转成小端。

4.3 bMaxPacketSize0 该填多少?

这个字段决定了端点 0 一次能传多少字节。根据 USB 2.0 规范,高速设备必须是 64,全速设备可以是 8/16/32/64,低速设备只能是 8。

我遇到过一个案子:某全速设备把 bMaxPacketSize0 填成了 0x40,也就是 64。按理说全速设备用 64 是没问题的,可主机一直收不到设备描述符。用逻辑分析仪一看,设备发了 64 字节,主机只期望 8 字节?仔细查,原来设备第一次返回时还没枚举完全,用的是默认 8 字节的控制传输。实际上,主机在 Get_Descriptor(Device) 时要求前 8 字节,设备必须先发 8 字节,然后主机根据 bMaxPacketSize0 再发一次获取剩余字节。如果第一次就发 64,长度对不上,就会出错。

注意:设备描述符的前 8 字节必须能在一次控制传输的 DATA 阶段发完。所以 bMaxPacketSize0 即使填 64,第一次响应也只能返回 8 字节(实际返回 8 字节后,主机根据 bMaxPacketSize0 判断后续需要重新请求完整描述符)。很多芯片的 USB IP 核会自动处理这个分包,但如果你是自己用 MCU 裸写 USB,就得手动处理。

4.4 描述符层级关系

设备描述符是 USB 描述符树的根节点。下面这张图展示了我认为最清晰的层次:

USB 描述符层级结构 设备描述符 配置描述符 接口描述符 端点描述符(IN) 接口描述符 (备用) 端点描述符 (IN/OUT) 一个设备描述符对应一个物理设备;配置描述符可以有多份(但通常只有 1 份); 接口描述符代表功能单元,端点描述符定义数据传输通道。

从这个图你能直观看到:设备描述符在最顶层,下面挂配置描述符。每个配置里可以有多个接口,每个接口里又有多个端点。PD 协商本质上发生在设备描述符被正确读取之后——主机知道你是谁(VID/PID),才能决定用哪种 PD 策略。

4.5 设备描述符错误实例

我曾经接手过一个量产项目,PC 端始终报错 "USB Device Not Recognized"。用 USB 分析仪抓包,发现主机连续发了三次 Get_Descriptor(Device),设备都回复了完全相同的数据。

仔细看回复内容:bDescriptorType 字段竟然是 0x02!0x02 是配置描述符的类型码,不是设备描述符。结果主机收到后判断描述符类型非法,直接复位总线。三次失败后,系统放弃枚举。

原因很蠢:代码里宏定义写错了,把 DEVICE_DESC_TYPE 和 CONFIG_DESC_TYPE 搞混了。修复后恢复正常。

所以排查设备描述符错误时,第一步就是验证 bDescriptorType 是否为 0x01。很多问题出在这个看似简单的字段上。

4.6 实战排查流程

当你怀疑设备描述符有问题时,按以下步骤来:

  1. 抓 USB 总线数据——用 USBlyzer 或 Saleae 抓主机发出的 SETUP 包和设备返回的 DATA 包。
  2. 检查首字节——设备回复的第一个字节必须是 0x12,第二个字节必须是 0x01。
  3. 校验字节序——重点关注 bcdUSB、idVendor、idProduct 这些 2 字节字段。
  4. 核对 bMaxPacketSize0——根据设备速度判定合法值。
  5. 检查字符串索引——iManufacturer、iProduct、iSerialNumber 如果非 0,必须对应有效的字符串描述符。

如果以上都正常,但主机仍然无法完成枚举,你可能需要检查配置描述符——那是下一节的内容了。

我常用的一个技巧:在代码里注入一段调试 Log,在响应 Get_Descriptor 前把 18 字节逐字节打印出来,然后跟规范里期望的值做比较。很多肉眼看不出的字节序错误,一打印就现形。

设备描述符就像一个陌生人的自我介绍。名字(VID/PID)、版本(bcdUSB)、能力(bMaxPacketSize0)都写对了,别人才愿意跟你进一步沟通。一旦这里出错,后面的一切都免谈。