第4章:标准设备描述符结构解析——每个字节都不是废话

设备描述符,几乎是所有USB设备与主机建立沟通时的第一张名片。它只有18个字节,但每个字节都在告诉主机“我是谁”“我能干什么”“我得按什么规矩来”。

我接手过不少设备枚举失败的案子,十有八九都是设备描述符里埋了雷。比如bcdUSB写错版本号,或者bMaxPacketSize0搞了个0,主机直接不理人。这章我们就从字节0开始,一个一个掰开看。

整体结构一览

标准设备描述符的长度是固定的:18字节,偏移量0x00到0x11。主机在枚举过程中会先发一个“Get Descriptor(Device)”请求,拿到这18字节后就开始解析。所以,你的固件必须保证这18个字节的数据完全正确,任何一个位错位都会导致枚举失败。

我习惯先把它当一张表格背下来,工作时对照着调,效率高很多。

偏移量字段名长度(字节)功能简述
0bLength1描述符自身长度(固定18)
1bDescriptorType1描述符类型(设备描述符值为1)
2-3bcdUSB2USB规范版本号(BCD格式)
4bDeviceClass1设备类代码(0或大于0xE0有特殊含义)
5bDeviceSubClass1设备子类代码
6bDeviceProtocol1设备协议代码
7bMaxPacketSize01端点0最大包长度
8-9idVendor2厂商ID(VID)
10-11idProduct2产品ID(PID)
12-13bcdDevice2设备版本号(BCD格式)
14iManufacturer1厂商字符串描述符索引
15iProduct1产品字符串描述符索引
16iSerialNumber1序列号字符串描述符索引
17bNumConfigurations1支持的配置描述符数量

重点字段逐个拆解

bLength —— 18是硬性规定

这个字段必须填18。我曾经遇到过一个实习生把这里填成0,主机连描述符都不往下读,直接返回“设备无法识别”。嗯,这个小坑后来成了我们团队代码审查的必检项。

bDescriptorType —— 类型标识就是1

设备描述符的类型值固定为0x01。主机通过这个字段判断当前正在解析的是不是设备描述符。如果固件误填成其他值(比如配置描述符的0x02),主机会直接扔掉这包数据。

bcdUSB —— 版本号不能乱编

这个字段用BCD编码表示USB规范版本。比如0x0200代表USB 2.0,0x0110代表USB 1.1。要注意高字节是主版本号,低字节是次版本号。我调试过一个奇怪的问题:设备声称支持USB 2.0,但实际硬件只能跑全速,主机端检测HS握手失败就反复复位——最后发现是bcdUSB写成了0x0300(USB 3.0)但硬件根本不是。主机很聪明,它不会只信你填的数字,还会靠电信号检测实际速度。但你填错了会误导主机驱动,导致奇怪的兼容性问题。

个人技巧:如果你在做全速设备(12 Mbps)但希望兼容USB 2.0的驱动,bcdUSB可以填0x0200,但bMaxPacketSize0要保持全速的64字节上限。USB-IF官方建议如实填写,别玩花招。

bDeviceClass / bDeviceSubClass / bDeviceProtocol —— 类定义三兄弟

bDeviceClass决定设备整体类别。如果填0,表示每个接口单独定义类(最常见的情况)。如果填0x02(通信设备类),子类和协议就要按CDC ACM规范来。如果填0xEF,表示混合设备(比如既有HID又有Mass Storage)。

我曾经在做一个多功能设备时,不小心把bDeviceClass填成了0x01(音频类),结果主机只加载了音频驱动,大容量存储接口完全没反应。查找两天才发现是这里错了。

  • bDeviceClass = 0:每个接口用自己的类描述符,最灵活
  • bDeviceClass > 0xE0:由USB-IF统一分配,个人项目别乱用
  • bDeviceSubClass和bDeviceProtocol通常与类代码配合使用,详细定义见USB Class Codes文档

bMaxPacketSize0 —— 端点0的缓冲区大小

这个字段表示控制端点0在一次IN/OUT事务中能传输的最大字节数。低速设备只能是8,全速设备可以是8/16/32/64(实际取决于主机控制器和芯片能力),高速设备必须填64。

我踩过的一个坑:在全速设备里填了128,芯片硬件不支持那么大包,主机发请求后设备没响应,枚举卡死。

idVendor / idProduct —— 就是设备的身份证

VID由USB-IF分配,一个厂商一个ID,不能重复。PID由厂商自己分配,用于区分不同产品。这两个字段在驱动匹配中至关重要。Windows和Linux都靠VID+PID来加载对应驱动。如果你只是做内部工具,可以用特殊的测试VID,但正式产品一定要购买官方VID,否则可能和别的设备冲突。

重点提示:枚举过程中,主机第一次请求设备描述符时只要求前8字节(bLength到bMaxPacketSize0),拿到后根据bMaxPacketSize0重新配置端点0的最大包长度,然后再请求完整18字节。所以你的固件必须能正确响应部分读取。

bcdDevice —— 版本迭代的标记

这个字段通常由厂商自定义,比如0x0100代表第一版,0x0200代表第二版。有些驱动的inf文件会校验这个版本号,不要随意修改。

iManufacturer / iProduct / iSerialNumber —— 字符串索引

这些是字符串描述符的索引值。如果为0表示没有对应的字符串。主机读取设备描述符后,如果需要显示厂商名、产品名或序列号,就会再发Get Descriptor(String)请求获取具体字符串。注意序列号索引如果有值,主机可能用它来区分同一VID/PID的不同设备。

bNumConfigurations —— 你有几个配置表

这个字段告诉主机当前设备有多少个配置描述符。一般普通设备只支持一个配置,填1就行。如果需要支持不同电源模式(自供电/总线供电),可以填多个配置,主机通过Set Configuration切换。

我曾经给一个双模式设备(全速/高速切换)做了两个配置,枚举时主机选了不合适的配置导致功能异常,后来发现是配置号搞反了。所以多配置场景下务必核对配置描述符的bConfigurationValue与设备描述符中bNumConfigurations的关系。

注意事项:主机在读取设备描述符时,一次最多读取8字节的请求是不保证后续字节正确的。你的固件必须能处理这种情况(比如用一个状态机记录当前读取的偏移量)。不要假设主机一定会一次读完18字节。

设备描述符读取流程

下面这张图帮你理清主机和固件之间的交互顺序:

设备描述符读取流程 主机 设备 Get Descriptor (Device) 请求前8字节 返回前8字节 包括bMaxPacketSize0 主机重新配置端点0 根据bMaxPacketSize0 再次 Get Descriptor (Device) 请求完整18字节 返回18字节 包含全部字段 主机解析 VID/PID/类代码等 准备加载驱动并枚举配置描述符

看到没?主机不是一次就拿到全部数据,它分两步走。第一步拿到端点的能力(bMaxPacketSize0),第二步才拿完整描述符。这个设计是为了确保在最大包长度未知时,主机也能安全地读取数据。

代码示例:解析设备描述符

以下是我常用的解析函数(C语言风格),把18字节的原始数据填入结构体:

typedef struct {
    uint8_t  bLength;            // 0: 18
    uint8_t  bDescriptorType;    // 1: 0x01
    uint16_t bcdUSB;             // 2-3: BCD格式版本
    uint8_t  bDeviceClass;       // 4
    uint8_t  bDeviceSubClass;    // 5
    uint8_t  bDeviceProtocol;    // 6
    uint8_t  bMaxPacketSize0;    // 7
    uint16_t idVendor;           // 8-9
    uint16_t idProduct;          // 10-11
    uint16_t bcdDevice;          // 12-13
    uint8_t  iManufacturer;      // 14
    uint8_t  iProduct;           // 15
    uint8_t  iSerialNumber;      // 16
    uint8_t  bNumConfigurations; // 17
} device_descriptor_t;

device_descriptor_t* parse_device_desc(uint8_t* raw_data) {
    device_descriptor_t* desc = (device_descriptor_t*)raw_data;
    // 这里可以加校验
    if (desc->bLength != 18) {
        // 报错,长度异常
        return NULL;
    }
    if (desc->bDescriptorType != 0x01) {
        // 类型不对,可能不是设备描述符
        return NULL;
    }
    return desc;
}

实际项目中,我会在parse函数里额外判断bMaxPacketSize0是否在合理范围内(高速必须64,全速≤64等),防止硬件配置错误。这个小习惯救过我好几次,值得你借鉴。

避坑清单

  • bLength不等于18:大部分USB IP核会直接填入固定值,但如果自己手动构建描述符数组,别写错。
  • bcdUSB版本号与硬件速度不符:比如用USB 1.1全速硬件却填0x0200,主机可能会尝试高速握手导致设备复位。
  • bMaxPacketSize0不要超过硬件缓冲区:全速设备最大64,但有些低端芯片只能支持8或16,查手册确认。
  • VID/PID不要写成0或0xFFFF:这两种值是保留的,主机驱动会忽略。
  • 字符串索引不为0但实际没有对应字符串:主机去读字符串描述符时返回失败,可能导致枚举耗时过长甚至失败。

设备描述符是整个USB描述符体系的基石,后续所有描述符(配置、接口、端点)都在它的基础上展开。吃透这18个字节,后面就轻松多了。