3. USB枚举过程详解:设备描述符、配置描述符、接口描述符、端点描述符、字符串描述符

好,咱们今天聊点硬核的——USB枚举。说白了,枚举就是主机和设备第一次见面时互相认识的过程。主机问:「你是谁?你能干啥?你咋工作的?」设备回答:「我是谁,我能干啥,我咋工作的。」

这个过程,全靠描述符来沟通。描述符就是设备的「身份证」加「说明书」。我刚开始做USB驱动时,总觉得描述符就是一堆枯燥的字节。直到有一次设备死活枚举不过去,我拿着逻辑分析仪一帧一帧地抓数据,才发现——嗯,描述符里一个字节错了,整个设备就废了。

3.1 设备描述符——设备的「身份证」

设备描述符是主机从设备那里拿到的第一个数据包。它只有18个字节,但信息量巨大。主机通过它判断设备类型、厂商、产品,以及后续该发什么请求。

偏移 字段 长度 说明
0 bLength 1 描述符长度,固定0x12(18字节)
1 bDescriptorType 1 描述符类型,设备描述符为0x01
2-3 bcdUSB 2 USB规范版本号,如0x0200表示USB 2.0
4 bDeviceClass 1 设备类代码,0x00表示在接口描述符中定义
5 bDeviceSubClass 1 子类代码
6 bDeviceProtocol 1 协议代码
7 bMaxPacketSize0 1 端点0的最大包大小,USB 2.0通常为64
8-9 idVendor 2 厂商ID,由USB-IF分配
10-11 idProduct 2 产品ID,厂商自行定义
12-13 bcdDevice 2 设备版本号
14 iManufacturer 1 厂商字符串描述符的索引
15 iProduct 1 产品字符串描述符的索引
16 iSerialNumber 1 序列号字符串描述符的索引
17 bNumConfigurations 1 配置描述符的数量,通常为1

关键点:bMaxPacketSize0这个字段特别重要。主机在获取设备描述符之前,默认认为端点0的最大包大小是8字节(USB 1.0)或64字节(USB 2.0高速)。设备描述符返回后,主机才知道真正的包大小。我遇到过设备这里填了8,但实际硬件只支持64,结果主机发64字节数据过来,设备直接丢包——枚举失败。

3.2 配置描述符——设备的「功能清单」

拿到设备描述符后,主机就知道该设备有几个配置。通常只有一个配置,但有些设备(比如支持不同功耗模式的)会有多个。配置描述符描述了设备的功耗、供电方式、以及包含多少个接口。

配置描述符的结构是这样的:

struct usb_config_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。它不只是配置描述符本身的长度,而是整个配置描述符集合的总长度,包括后面跟着的接口描述符、端点描述符,甚至可能的类特殊描述符。我见过有人把这个字段写小了,结果主机只读取了部分数据,后面的接口和端点信息全丢了。

注意:bmAttributes的bit6必须为1(表示总线供电),bit5表示自供电,bit4表示远程唤醒。如果设备是自供电的,但这里写错了,主机可能会以为设备功耗超标而拒绝配置。

3.3 接口描述符——设备的「功能模块」

一个配置可以包含多个接口。每个接口代表一个功能模块。比如一个USB摄像头,可能有视频接口和音频接口。接口描述符告诉主机:这个接口用什么协议、有几个端点、用什么驱动。

接口描述符的结构:

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));

我个人习惯把接口描述符看作「驱动匹配的关键」。主机根据bInterfaceClass、bInterfaceSubClass、bInterfaceProtocol这三个字段来寻找合适的驱动程序。比如HID设备的类代码是0x03,大容量存储设备是0x08。

这里有个细节——bAlternateSetting。它允许同一个接口有多个配置版本。比如一个音频设备,可以有一个默认设置(只有控制端点),和一个备用设置(增加同步端点用于音频流)。主机通过SetInterface来切换。我在做音频设备时就用过这个特性,默认设置只做控制,备用设置才开启数据传输,省电又灵活。

3.4 端点描述符——数据的「出入口」

端点就是设备里实际收发数据的管道。每个接口可以有多个端点,但端点0是控制端点,不算在bNumEndpoints里。端点描述符告诉主机:这个端点是输入还是输出?用什么传输类型?包大小是多少?

struct usb_endpoint_descriptor {
    uint8_t  bLength;             // 固定为7
    uint8_t  bDescriptorType;     // 0x05
    uint8_t  bEndpointAddress;    // 端点地址:bit7方向(1=IN),bit3-0端点号
    uint8_t  bmAttributes;        // 传输类型:0=控制,1=等时,2=批量,3=中断
    uint16_t wMaxPacketSize;      // 最大包大小
    uint8_t  bInterval;           // 轮询间隔(中断/等时端点)
} __attribute__((packed));

关于bEndpointAddress,我刚开始时总搞混方向。记住:IN是设备到主机,OUT是主机到设备。bit7为1表示IN端点。比如0x81就是端点1 IN,0x01就是端点1 OUT。

wMaxPacketSize这个字段在USB 2.0高速模式下有特殊编码。高2位表示每帧的微帧数(1、2或3个微帧),低11位才是实际包大小。我踩过这个坑——直接赋值0x200(512字节)给高速批量端点,结果主机解析出来变成了0x0200,但实际硬件只支持512,导致数据错位。

小技巧:对于中断端点,bInterval的单位在低速/全速模式下是毫秒,在高速模式下是125微秒的倍数。比如全速模式下bInterval=10表示10ms轮询一次,高速模式下bInterval=10表示1.25ms轮询一次。这个差别很容易被忽略。

3.5 字符串描述符——设备的「名字牌」

字符串描述符是可选的。它提供了人类可读的厂商名、产品名、序列号等信息。主机通过索引值(设备描述符中的iManufacturer、iProduct、iSerialNumber)来请求对应的字符串。

字符串描述符的格式比较特殊:

struct usb_string_descriptor {
    uint8_t bLength;             // 长度 = 2 + 字符串长度 * 2
    uint8_t bDescriptorType;     // 0x03
    uint16_t wString[];          // Unicode 16位编码的字符串
} __attribute__((packed));

注意,字符串是UTF-16LE编码的。每个字符占2个字节。比如字符串"ABC"在描述符里就是0x0041, 0x0042, 0x0043。我见过有人直接用ASCII字符串赋值,结果主机读出来全是乱码——因为ASCII是单字节,而主机期望的是双字节Unicode。

另外,字符串描述符索引0是特殊的——它返回的是语言ID列表。主机先请求索引0,拿到支持的语言列表,然后再用指定的语言ID去请求具体的字符串。比如中文的LANGID是0x0804,英语是0x0409。

避坑指南:我曾经在一个量产项目中,所有设备的序列号都写死了同一个字符串。结果两台设备同时插到电脑上,主机以为它们是同一个设备,驱动加载失败。后来我改成用芯片唯一ID生成序列号,问题解决。所以,如果你的设备需要同时连接多个,序列号一定要唯一。

3.6 枚举流程实战总结

好了,咱们把整个枚举流程串起来看看:

  1. 复位:主机检测到设备插入,发送复位信号。设备地址恢复为0。
  2. 获取设备描述符:主机发请求GetDescriptor(Device),拿到18字节的设备描述符。注意,第一次只读前8字节,确定bMaxPacketSize0后再读完整。
  3. 设置地址:主机发SetAddress,给设备分配一个唯一地址。设备应答后切换到新地址。
  4. 再次获取设备描述符:用新地址重新读取完整的设备描述符。
  5. 获取配置描述符:主机发GetDescriptor(Configuration),先读前9字节得到wTotalLength,再读完整配置描述符集合。
  6. 解析配置描述符集合:主机遍历配置描述符后面的接口描述符和端点描述符,建立完整的设备拓扑。
  7. 获取字符串描述符:如果主机需要显示厂商名、产品名等信息,会请求对应的字符串描述符。
  8. 选择配置:主机发SetConfiguration,设备进入工作状态。

这个过程看起来简单,但每个步骤都可能出问题。我建议你在调试时用USB分析仪抓包,看看主机发了什么、设备回了什么。很多时候,问题就出在描述符的某个字节上。

嗯,枚举是USB通信的基石。描述符写对了,后面的数据传输就顺了。写错了,设备连门都进不去。下一章咱们聊聊实际移植时怎么在代码里组织这些描述符,以及怎么用工具验证它们是否正确。