3. 设备描述符深度解析:这些字段你真的懂了吗?
设备描述符,是USB设备给主机递上的第一张名片。主机一上来就会读它,读对了,后面才能愉快通信。我当年刚接触USB协议时,觉得这玩意儿就是18个字节,没什么大不了的。直到有一次,我花了一整天调一块开发板的枚举问题——最后发现,就只是bMaxPacketSize0填错了。嗯,从那以后,我再也不敢小看这些字段了。
3.1 设备描述符的整体骨架
先给你看看它长什么样。标准设备描述符固定 18字节,字段一个接一个,顺序不能乱。
| 字段 | 字节数 | 含义 | 约束与常见异常 |
|---|---|---|---|
| bLength | 1 | 描述符总长度 | 必须为0x12(18)。很多新手填错成0x11或0x13,枚举立刻失败 |
| bDescriptorType | 1 | 描述符类型 | 设备描述符固定为0x01。填0x02就成了配置描述符,主机翻脸 |
| bcdUSB | 2 | USB规范版本 | USB 3.0为0x0300。注意是大端格式,USB 2.0是0x0200,别混了 |
| bDeviceClass | 1 | 设备类 | 0x00表示每个接口自行定义。0xFF是厂商自定义。不要乱填标准类代码而不实现相应协议 |
| bDeviceSubClass | 1 | 子类 | 配合bDeviceClass使用。当Class=0时,SubClass必须为0 |
| bDeviceProtocol | 1 | 协议 | 同样依存于Class/SubClass。比如HID键盘需要0x01 |
| bMaxPacketSize0 | 1 | 端点0最大包长 | USB 3.0要求为0x09(9字节)。这是硬约束,填大了主机不会协商 |
| idVendor | 2 | 厂商ID | 由USB-IF分配。0x0000或0xFFFF是保留值,不能用于产品 |
| idProduct | 2 | 产品ID | 厂商自行分配。同一vendor下不要重复 |
| bcdDevice | 2 | 设备版本号 | BCD格式,比如0x0100=1.0。主机用此区分硬件版本 |
| iManufacturer | 1 | 厂商字符串索引 | 0表示无字符串描述符。如果非0,必须在字符串描述符表里存在,否则枚举卡死 |
| iProduct | 1 | 产品字符串索引 | 同上。我曾经遇到过索引填了1,但字符串描述符里根本没写,结果Windows蓝屏 |
| iSerialNumber | 1 | 序列号字符串索引 | 0=无序列号。多个同型号设备串号一样时,主机可能会混淆 |
| bNumConfigurations | 1 | 配置描述符数量 | 至少为1。填0的话主机以为你是个空壳子,立刻断开 |
核心口诀:18字节、0x01、0x0300、端点0填9。 这四样错了,枚举基本凉凉。
3.2 挨个字段说清楚
bLength 和 bDescriptorType
这两个字段是描述符的「身份证」。bLength告诉主机这个描述符有多大。设备描述符必须等于0x12,少一个字节都不行。我个人习惯是在代码里直接用sizeof(usb_device_descriptor_t),但前提是你的结构体没加多余的填充字节。
bDescriptorType固定为0x01。你可能会问:为什么不是0x00?因为0x00在USB规范里是保留的,给主机看的类型标识。我曾经接手过一个项目,同事把bDescriptorType写成了0x02,结果主机把它当成配置描述符来解析,后面所有数据都乱套了。你想想看,主机拿到18个字节,按配置描述符的结构去读,读取bLength时发现是0x12(18),但配置描述符的bLength最小是9,bDescriptorType又是0x02……逻辑全乱了。最后抓包才发现。
bcdUSB
小端还是大端?bcdUSB用的是 大端序。USB 3.0应该是0x0300(高位03,低位00)。如果你填0x0003,主机就认为你是USB 0.3版本——不存在的东西,直接忽略你的SuperSpeed能力。我记得有一次调试一个USB 3.0摄像头,死活跑不上5Gbps,最后发现是工程师把字节序搞反了。改过来,速度立刻上去。
小技巧:在代码里直接用宏定义,比如 #define USB_BCD_VERSION 0x0300,避免手写错误。
bDeviceClass / bDeviceSubClass / bDeviceProtocol
这三个字段一起决定设备是什么类型。常见值:
- 0x00:每个接口自己定义类。这是最灵活的方式,适合复合设备。
- 0x02:通信设备类(CDC)。子类0x02对应ACM,协议0x01。
- 0xEF:综合设备类(IAD)。子类0x02,协议0x01。
- 0xFF:厂商自定义。这时候主机需要你提供专门的驱动。
注意一点:如果bDeviceClass不是0x00也不是0xFF,主机就会用这个类来加载通用驱动。但你实际接口可能又不匹配,导致驱动加载失败。我建议你一般情况下用0x00,让每个接口描述符来声明自己的类,这样最清晰。
bMaxPacketSize0
端点0的最大包大小。对于USB 3.0来说,这个值必须为 9(字节)。为什么是9?因为USB 3.0的物理层支持512字节的数据包,但控制端点0在SuperSpeed下最大包长固定为9字节。这是协议规定的,你不能改。我曾经看到有人写成64,想着兼容USB 2.0。结果主机看到bcdUSB=0x0300,bMaxPacketSize0=64,直接报错——因为SuperSpeed控制器不认64。所以记住:USB 3.0下,端点0永远是9字节。
供应商/产品相关字段
idVendor和idProduct是USB设备的「身份证号」。idVendor由USB-IF分配,没有的话可以用开发阶段的0xFFFE、0xFFFF等,但正式产品必须申请。idProduct自己定,但不要和同厂商的其他产品冲突。
bcdDevice用来区分硬件版本。比如第一版写0x0100,第二版写0x0200。主机驱动可以根据这个版本号加载不同的固件或配置。
iManufacturer、iProduct、iSerialNumber是字符串描述符的索引。如果填0,表示没有对应的字符串。但如果你填了一个非0值,却在字符串描述符表里找不到该索引,主机就会进入死循环或超时。我建议你:如果不打算提供字符串,就老老实实填0;如果填了,一定要在字符串描述符中写对应内容。
注意:多个USB设备共用相同的idVendor、idProduct和iSerialNumber时,主机可能把它们当成同一个设备,导致驱动冲突。所以序列号一定要唯一,或者干脆空着让主机自动生成。
bNumConfigurations
设备至少要有1个配置描述符。如果填0,主机认为你没有任何可用的配置,直接放弃设备。但也不能填多——你只能填实际存在的配置数,否则主机去读第2个配置时你会返回错误。我遇到过一个人,写了1个配置,但bNumConfigurations填了2,结果主机第二次请求配置描述符时,他的固件没处理,导致枚举失败。
3.3 一段真实的描述符数据(十六进制)
光说不练假把式。我给你贴一段标准USB 3.0 U盘设备描述符的原始数据:
Offset Bytes Field
0x00 0x12 bLength → 18
0x01 0x01 bDescriptorType → 设备描述符
0x02 0x00 bcdUSB (低字节)
0x03 0x03 bcdUSB (高字节) → 0x0300 (USB 3.0)
0x04 0x00 bDeviceClass → 0 (接口定义)
0x05 0x00 bDeviceSubClass → 0
0x06 0x00 bDeviceProtocol → 0
0x07 0x09 bMaxPacketSize0 → 9
0x08 0x78 idVendor (低) → 0x0781 (Sandisk)
0x09 0x81 idVendor (高)
0x0A 0x55 idProduct (低) → 0x5555 (示例)
0x0B 0x55 idProduct (高)
0x0C 0x10 bcdDevice (低) → 0x0110 (1.10版本)
0x0D 0x01 bcdDevice (高)
0x0E 0x01 iManufacturer → 1 (字符串索引)
0x0F 0x02 iProduct → 2
0x10 0x00 iSerialNumber → 0 (无序列号)
0x11 0x01 bNumConfigurations → 1
注意看:bLength=0x12,bDescriptorType=0x01,bcdUSB=0x0300(大端),bMaxPacketSize0=0x09,全对。如果你用逻辑分析仪抓到的数据与此不符,基本可以判定描述符有问题。
3.4 设备描述符的层级与依赖关系
下面这张SVG图展示了设备描述符在USB描述符树中的位置,以及它如何关联其他描述符。
从这张图可以看到,设备描述符是根节点,它决定了端点0的包长、配置数量、字符串索引,以及设备的类。后续的配置描述符、接口描述符、端点描述符都依赖这个根节点的正确性。如果bNumConfigurations填错了,整个配置树都建立不起来。所以,我认为调试USB枚举时,第一件事就是抓设备描述符,确认这几个核心字段。
重要提醒:很多工程师在修改固件时只改配置描述符,不改设备描述符里的bNumConfigurations。如果你增加了一个配置,务必同步更新这个字段,否则主机只会请求原来数量的配置,新配置永远不会被枚举出来。我曾经踩过这个坑,浪费了整整一个下午抓逻辑分析仪才发现。
3.5 常见异常与修复思路
- bLength ≠ 0x12: 主机收到后直接返回STALL。修复:检查描述符结构体打包,确保没有填充字节。建议使用
#pragma pack(1)或__attribute__((packed))。 - bDescriptorType ≠ 0x01: 主机按错误类型解析。修复:写一个宏,在枚举时断言描述符类型。我个人的习惯是在固件初始化时做一次自检,打印所有描述符头两个字节。
- bcdUSB 字节序搞反: 如果是小端存储bcdUSB,USB 3.0会变成0x0003。修复:统一用大端常量
0x0300赋值,不要手动填字节。 - bMaxPacketSize0 ≠ 9: 在SuperSpeed下枚举失败,但可能降级到USB 2.0。修复:判断bcdUSB==0x0300时,强制写入9;兼容模式可写64但不要同时宣称USB 3.0。
- 字符串索引越界: 主机请求字符串描述符时,你的固件返回空或超时。修复:在字符串描述符数组中增加边界检查,或者干脆把不需要的字符串索引设为0。
- bNumConfigurations 与实际不符: 主机多要或少要一个配置。修复:运行时动态统计实际配置数量,再填入这个字段。
说实话,设备描述符虽然只有18个字节,但它决定了整个USB通信的起点。我每次做新硬件时,都会花10分钟仔细核对这18个字节,然后才继续写后面的描述符。省下的调试时间,比这10分钟多多了。
我的一个小工具:写一个Python脚本,把描述符二进制数据扔进去,自动校验bLength、bDescriptorType、bcdUSB、bMaxPacketSize0这四个关键字段,不符合预期就抛异常。用这个脚本,我给团队省了无数枚举排查时间。你也可以写一个类似的。