理解描述符的层级结构
USB 描述符这个东西,说白了就是设备拿来向主机“自我介绍”的一堆数据结构。主机插上设备后,会像警察查户口一样,一级一级地往下问:你是谁?你有什么功能?每个接口用什么协议?端点怎么传数据?这些问题,全靠描述符来回答。
我个人习惯把描述符比作一本“设备说明书”,第一章是目录(设备描述符),然后是各章节(配置描述符、接口描述符、端点描述符),最后还有附录(HID 报告描述符、字符串描述符等等)。主机读这本说明书的顺序是固定的,不能跳着读。我早年做 USB 复合设备时,就因为描述符层级顺序写错,导致主机只认到一个接口,折腾了两天才发现是设备描述符里配置数量写少了……
设备描述符 —— 说明书首页
设备描述符是主机问的第一个问题,也就是请求索引为 0 的描述符。它只有 18 个字节,但信息密度极高。你可以把它看成设备的“身份证”:
- bLength:整个描述符长度,固定 18。
- bDescriptorType:类型码,设备描述符是 0x01。
- bcdUSB:USB 规范版本号,比如 0x0200 表示 USB 2.0。
- bDeviceClass / bDeviceSubClass / bDeviceProtocol:设备大类、子类、协议。如果这三个都是 0,说明功能在接口描述符里定义——也就是复合设备。
- bMaxPacketSize0:端点 0 的最大包长。这个值如果是 64,那么后续控制传输就能一次发 64 字节。
- idVendor / idProduct / bcdDevice:厂商 ID、产品 ID、设备版本号。主机靠这个装驱动。
- iManufacturer / iProduct / iSerialNumber:字符串描述符索引,没有就写 0。
- bNumConfigurations:配置描述符的数量,通常是 1。如果写 2,主机就要问两次配置描述符。
嗯,这里要注意:bMaxPacketSize0 在 USB 2.0 下必须是 8、16、32、64 中的一个。我遇到过有人写 128,结果主机直接复位设备……
配置描述符 —— 设备的功能模块
读完设备描述符后,主机接着请求配置描述符(索引 0)。配置描述符本身只有 9 个字节,但它后面跟着“配置描述符集合”——也就是接口描述符、端点描述符以及可能的其他描述符。主机先读前 9 字节,根据 wTotalLength 知道整个集合大小,然后一次性读完整包。
配置描述符的关键字段:
| 字段 | 意义 |
|---|---|
| bLength | 9 字节 |
| bDescriptorType | 0x02 |
| wTotalLength | 整个配置描述符集合的总字节数 |
| bNumInterfaces | 该配置下有多少个接口 |
| bConfigurationValue | 选择该配置用的数值(Set Configuration 时用到) |
| iConfiguration | 字符串描述符索引 |
| bmAttributes | 供电模式(自供电/总线供电) |
| bMaxPower | 最大电流,每单位 2mA |
bNumInterfaces 等于实际接口数,否则主机只会枚举到第一个接口。
接口描述符 —— 设备的功能角色
接口描述符是描述符层级的第三层。一个配置下可以有多个接口,每个接口代表一个“功能角色”。比如设备描述符说“我有 1 个配置”,配置描述符说“我有 2 个接口”,那主机就会连续读 2 个接口描述符。
接口描述符结构:
bLength: 9
bDescriptorType: 0x04
bInterfaceNumber: 0 // 接口编号,从0开始
bAlternateSetting: 0 // 备用设置,用于切换不同带宽
bNumEndpoints: 2 // 该接口下端点个数(不包括端点0)
bInterfaceClass: 3 // HID 类
bInterfaceSubClass: 1 // 启动接口
bInterfaceProtocol: 1 // 键盘
iInterface: 0 // 字符串索引
注意 bAlternateSetting 这个字段。我司曾有个摄像头项目,用备用设置切换 480p 和 720p 流。结果备用设置里的端点描述符写错了带宽,导致视频卡顿。
端点描述符 —— 数据的真实通道
每个接口下的端点描述符描述了一个具体的传输管道。主机读接口描述符时,会根据 bNumEndpoints 知道接下来有几个端点描述符要读。端点 0 是默认控制端点,不在描述符里列出。
端点描述符字段:
- bLength:7 字节(等时端点 9 字节,多了同步属性)。
- bDescriptorType:0x05。
- bEndpointAddress:低 4 位是端点号,高位 DIR 位表示方向(1=IN,0=OUT)。
- bmAttributes:传输类型 0=控制,1=等时,2=批量,3=中断。
- wMaxPacketSize:最大包大小,高速批量端点可达 512。
- bInterval:轮询间隔,中断/等时用。
wMaxPacketSize 写的 64,但实际上 HID 报告长度只有 8 字节。主机认为每次都能收 64 字节,导致缓冲区错位。正确的做法是 wMaxPacketSize 设成 8,跟报告长度一致。
其他描述符 —— 附属信息
除了上述四类核心描述符,还有字符串描述符、HID 报告描述符、接口关联描述符(IAD)等。它们不是独立的层级,而是依附在配置描述符集合中。比如字符串描述符通过索引被设备描述符和配置描述符引用,主机在需要时才单独请求。
这里我提一句 IAD(Interface Association Descriptor)。做复合设备时,需要把两个接口绑定成一个功能(比如 CDC 虚拟串口需要 2 个接口:控制接口和数据接口)。IAD 告诉主机:“这些接口是同一个设备功能的。” 如果没有 IAD,早期系统可能会把它们当成两个独立设备。
整体层级关系图
下面的 SVG 图展示了主机枚举时读取描述符的层次顺序。你可以从设备描述符开始,沿着箭头往下走:
从这张图可以清晰看到:主机不会一下子把所有描述符读完,而是分层请求。每层描述符都包含下一层存在多少个子元素的计数。要是你在设备描述符里写 bNumConfigurations = 2,主机就会重复第二层和第三层的读取过程。
描述符的读取顺序总结
这里有个容易被忽视的点:配置描述符集合中,接口描述符和端点描述符是连续存放的。举个例子,配置描述符后紧接着第一个接口描述符,然后是它的端点描述符,再是第二个接口描述符,以此类推。你不能把所有的接口描述符写在一起,再统一写端点描述符——那样主机解析时会完全错乱。
我曾经调试过一个 3D 打印机的固件,因为把端点描述符放到了所有接口描述符之后,结果主机枚举时以为第二个接口下没有端点,传输一直超时。老实按照规范顺序重排后,问题就解决了。
最后提一嘴,描述符的 bDescriptorType 字段也很关键。主机通过类型码知道当前读的是什么。如果你在配置描述符集合里混入错误类型码,主机可能会中断枚举或回退。常用的类型码:
| 类型码 | 描述符名称 |
|---|---|
| 0x01 | 设备描述符 |
| 0x02 | 配置描述符 |
| 0x04 | 接口描述符 |
| 0x05 | 端点描述符 |
| 0x03 | 字符串描述符 |
| 0x21 | HID 描述符(位于配置集合中的第一个) |
理解了这个层级结构,后续分析具体设备的描述符时就会非常清晰。主机眼里没有“设备”,只有一张张描述符表格。你把这些表格写对,设备就能正常工作;写错了,轻则驱动装不上,重则设备完全不识别。