3. 标准USB描述符结构解析(Device/Configuration/Interface/Endpoint)
描述符这个东西,说白了就是USB设备的“身份证”。设备插上电脑,主机第一件事就是问:“你是谁?你是什么?你能干什么?”设备就得老老实实把这套描述符交上去。
我记得刚做PD调试那会,有个项目设备枚举老失败。折腾了两天,最后发现是设备描述符的bcdUSB版本号写错了。你说冤不冤?但从那以后,我对描述符的每一个字节都不敢马虎了。
核心要点:USB描述符是一个层级结构。设备在最顶层,往下是配置,再往下是接口,最下层是端点。主机从顶到底逐级读取。
3.1 设备描述符(Device Descriptor)——设备的“户口本”
设备描述符是主机的第一站。它描述了设备的基本信息。长度是固定的,18个字节。
结构如下:
struct usb_device_descriptor {
uint8_t bLength; // 描述符长度 = 18
uint8_t bDescriptorType; // 描述符类型 = 0x01
uint16_t bcdUSB; // USB规范版本号 (BCD码)
uint8_t bDeviceClass; // 设备类
uint8_t bDeviceSubClass; // 设备子类
uint8_t bDeviceProtocol; // 设备协议
uint8_t bMaxPacketSize0; // 端点0的最大包长
uint16_t idVendor; // 厂商ID (VID)
uint16_t idProduct; // 产品ID (PID)
uint16_t bcdDevice; // 设备版本号 (BCD码)
uint8_t iManufacturer; // 厂商字符串索引
uint8_t iProduct; // 产品字符串索引
uint8_t iSerialNumber; // 序列号字符串索引
uint8_t bNumConfigurations; // 支持的配置数
} __attribute__((packed));
这里有几个坑我得提醒你:
- bcdUSB:用BCD码表示,比如0x0200表示USB 2.0,0x0300表示USB 3.0。我曾经见过有人写成0x0020,那就是2.0吗?不是,那是0.20,直接枚举失败。
- bMaxPacketSize0:这个值很关键。对于高速设备,必须是64;全速设备可以是8/16/32/64。我调试时遇到过设备报64,但实际只能发8个字节,结果数据全乱套了。
- idVendor / idProduct:这是设备的“身份证号”。主机靠它加载驱动。调试时可以用自定义的VID/PID,但要注意别跟已有的冲突。
我的习惯:拿到一个新的PD设备,先用USB分析仪抓一把描述符,核对bcdUSB和bMaxPacketSize0。这两个错了,后面全白搭。
3.2 配置描述符(Configuration Descriptor)——设备的“功能清单”
设备描述符告诉主机你是谁,配置描述符则告诉主机你能干什么。每个设备可以有一个或多个配置,但同一时间只能激活一个。
配置描述符的结构:
struct usb_configuration_descriptor {
uint8_t bLength; // 描述符长度 = 9
uint8_t bDescriptorType; // 描述符类型 = 0x02
uint16_t wTotalLength; // 配置描述符集合的总长度
uint8_t bNumInterfaces; // 此配置下的接口数量
uint8_t bConfigurationValue; // 配置值,用于SetConfiguration
uint8_t iConfiguration; // 配置字符串索引
uint8_t bmAttributes; // 配置属性
uint8_t bMaxPower; // 最大电流 (2mA为单位)
} __attribute__((packed));
这里重点看两个字段:
- wTotalLength:别小看它。这是配置描述符本身加上所有接口描述符、端点描述符等附属描述符的总长度。我见过有人只算了配置描述符的长度,没算子描述符,结果主机只读了一部分,剩下的数据全丢了。
- bMaxPower:单位是2mA,比如写0x32就表示100mA。对于PD设备,这个值往往不准确,因为PD协商后的功率远大于USB的默认供电。但主机枚举阶段还是靠这个值来决定是否供电,所以别乱填。
注意:配置描述符的bConfigurationValue是从1开始的。有些工程师习惯从0开始,结果SetConfiguration(1)选不中配置,主机一脸懵。
3.3 接口描述符(Interface Descriptor)——设备的“功能模块”
一个配置可以有多个接口。比如一个USB声卡,可能有音频控制接口、音频流接口、MIDI接口。每个接口都是一个独立的功能单元。
接口描述符结构:
struct usb_interface_descriptor {
uint8_t bLength; // 描述符长度 = 9
uint8_t bDescriptorType; // 描述符类型 = 0x04
uint8_t bInterfaceNumber; // 接口编号 (从0开始)
uint8_t bAlternateSetting; // 备用设置编号
uint8_t bNumEndpoints; // 除端点0外的端点数量
uint8_t bInterfaceClass; // 接口类
uint8_t bInterfaceSubClass; // 接口子类
uint8_t bInterfaceProtocol; // 接口协议
uint8_t iInterface; // 接口字符串索引
} __attribute__((packed));
你想想看,为什么要有bAlternateSetting?举个例子,一个视频设备可以有两种分辨率。通过切换备用设置,主机可以要求设备切换到不同的端点配置。我在调试一个UVC摄像头时就靠这个实现了分辨率切换,效果立竿见影。
3.4 端点描述符(Endpoint Descriptor)——数据的“管道”
端点就是数据的出入口。所有的USB通信都通过端点进行,除了控制传输用的端点0。端点描述符告诉主机这个端点的传输类型、方向、最大包长和轮询间隔。
端点描述符结构:
struct usb_endpoint_descriptor {
uint8_t bLength; // 描述符长度 = 7 (高速ISO为9)
uint8_t bDescriptorType; // 描述符类型 = 0x05
uint8_t bEndpointAddress; // 端点地址 (bit7:方向, bit3-0:编号)
uint8_t bmAttributes; // 传输类型 (bit1-0)
uint16_t wMaxPacketSize; // 最大包长
uint8_t bInterval; // 轮询间隔
// 如果是高速ISO端点,还有下面这两个
// uint8_t bRefresh;
// uint8_t bSynchAddress;
} __attribute__((packed));
这里有几个关键点:
- bEndpointAddress:bit7表示方向——0是OUT(主机到设备),1是IN(设备到主机)。我曾经见过有人把OUT写成了0x81,方向搞反了,主机发数据设备收不到,反过来也一样。
- bmAttributes:低两位表示传输类型,0=控制,1=等时,2=批量,3=中断。别搞混了。
- wMaxPacketSize:对于高速批量端点,这个值通常是512。但要注意,高速中断端点和等时端点可以有更大的包长。
避坑指南:调试PD时,端点的wMaxPacketSize一定要和实际的FIFO大小匹配。我曾经调一个高速批量端点,挂起之后恢复了,但FIFO里的数据被截断了,就是因为wMaxPacketSize写的是64,但实际FIFO深度是512。查了好久才找到原因。
3.5 描述符层级关系总结
这四个描述符构成了一个清晰的树形结构。设备描述符在最顶端,下面是配置描述符,配置里面包含接口描述符,接口里面又包含若干个端点描述符。主机枚举时就是这样一层层问下去的。
嗯,这张图应该能帮你理清关系了。从上到下,设备描述符指向配置,配置指向接口,接口指向端点。主机枚举时就是沿着这条路径一路问下去。
3.6 实际调试中的常见问题
最后,我总结几个在PD调试中常遇到的描述符问题:
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 设备无法枚举 | bcdUSB写错 / VID/PID全0 / bMaxPacketSize0不对 | 检查设备描述符前8个字节 |
| 主机只读到一个配置 | wTotalLength计算错误 / 描述符顺序乱 | 核对配置描述符集合的完整性 |
| 某个功能无法使用 | 接口的bInterfaceClass/SubClass/Protocol写错 | 对比标准类的定义,比如HID、UVC、UAC |
| 数据传输乱码或丢包 | 端点的wMaxPacketSize与实际不匹配 / bDirection相反 | 抓包确认端点的配置是否与驱动一致 |
其实这些问题的根因往往都很简单——描述符里某个字节填错了。但就是因为简单,反而不容易被注意到。我的建议是:每次调试,先把描述符完整抓出来,对着标准一个一个字段核对。别嫌麻烦,这一步能省你后面好几个小时的排查时间。