4. 设备描述符结构解析
设备描述符,是 USB 设备给主机递交的第一张"身份证"。
主机一旦检测到设备插入,第一步就是发一个 Get_Descriptor(Device) 请求。设备得把这个 18 字节的数据包原样送回去。如果出错,主机连你的设备叫什么名字都不知道,更别提后续的 PD 协商了。
我处理过太多案例,设备描述符里 VID/PID 写错或者 bMaxPacketSize0 填了个不合法的值,导致整个枚举流程卡在第一步。主机不停复位、重试,最后干脆给个 "Unknown Device"。
4.1 标准结构回顾
USB 2.0 规范里,设备描述符的长度固定为 18 字节。它的布局是这样的:
| 偏移 | 字段 | 长度 | 说明 | 常见错误 |
|---|---|---|---|---|
| 0 | bLength | 1 | 描述符总长度,固定 0x12 (18) | 写错成其他值 |
| 1 | bDescriptorType | 1 | 描述符类型,设备描述符为 0x01 | 填成 0x02 或 0x00 |
| 2 | bcdUSB | 2 | USB 版本号,如 0x0200 | 字节序写反,变成 0x0002 |
| 4 | bDeviceClass | 1 | 设备类代码,0x00 表示从接口描述 | 0xFF 被当做 vendor-specific |
| 5 | bDeviceSubClass | 1 | 子类 | 与主类不匹配 |
| 6 | bDeviceProtocol | 1 | 协议 | 同上 |
| 7 | bMaxPacketSize0 | 1 | 端点 0 的最大包长,必须是 8/16/32/64 | 填了 27 这种非法值 |
| 8 | idVendor | 2 | 厂商 ID,USB-IF 分配 | 字节序写反 |
| 10 | idProduct | 2 | 产品 ID | 全 0 或全 F |
| 12 | bcdDevice | 2 | 设备版本号 | 不注意大小端 |
| 14 | iManufacturer | 1 | 厂商字符串索引 | 索引指向空或不存在 |
| 15 | iProduct | 1 | 产品字符串索引 | 同上 |
| 16 | iSerialNumber | 1 | 序列号字符串索引 | 即使没有序列号也要写 0 |
| 17 | bNumConfigurations | 1 | 配置描述符数量,通常 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 描述符树的根节点。下面这张图展示了我认为最清晰的层次:
从这个图你能直观看到:设备描述符在最顶层,下面挂配置描述符。每个配置里可以有多个接口,每个接口里又有多个端点。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 实战排查流程
当你怀疑设备描述符有问题时,按以下步骤来:
- 抓 USB 总线数据——用 USBlyzer 或 Saleae 抓主机发出的 SETUP 包和设备返回的 DATA 包。
- 检查首字节——设备回复的第一个字节必须是 0x12,第二个字节必须是 0x01。
- 校验字节序——重点关注 bcdUSB、idVendor、idProduct 这些 2 字节字段。
- 核对 bMaxPacketSize0——根据设备速度判定合法值。
- 检查字符串索引——iManufacturer、iProduct、iSerialNumber 如果非 0,必须对应有效的字符串描述符。
如果以上都正常,但主机仍然无法完成枚举,你可能需要检查配置描述符——那是下一节的内容了。
我常用的一个技巧:在代码里注入一段调试 Log,在响应 Get_Descriptor 前把 18 字节逐字节打印出来,然后跟规范里期望的值做比较。很多肉眼看不出的字节序错误,一打印就现形。
设备描述符就像一个陌生人的自我介绍。名字(VID/PID)、版本(bcdUSB)、能力(bMaxPacketSize0)都写对了,别人才愿意跟你进一步沟通。一旦这里出错,后面的一切都免谈。