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 枚举流程实战总结
好了,咱们把整个枚举流程串起来看看:
- 复位:主机检测到设备插入,发送复位信号。设备地址恢复为0。
- 获取设备描述符:主机发请求GetDescriptor(Device),拿到18字节的设备描述符。注意,第一次只读前8字节,确定bMaxPacketSize0后再读完整。
- 设置地址:主机发SetAddress,给设备分配一个唯一地址。设备应答后切换到新地址。
- 再次获取设备描述符:用新地址重新读取完整的设备描述符。
- 获取配置描述符:主机发GetDescriptor(Configuration),先读前9字节得到wTotalLength,再读完整配置描述符集合。
- 解析配置描述符集合:主机遍历配置描述符后面的接口描述符和端点描述符,建立完整的设备拓扑。
- 获取字符串描述符:如果主机需要显示厂商名、产品名等信息,会请求对应的字符串描述符。
- 选择配置:主机发SetConfiguration,设备进入工作状态。
这个过程看起来简单,但每个步骤都可能出问题。我建议你在调试时用USB分析仪抓包,看看主机发了什么、设备回了什么。很多时候,问题就出在描述符的某个字节上。
嗯,枚举是USB通信的基石。描述符写对了,后面的数据传输就顺了。写错了,设备连门都进不去。下一章咱们聊聊实际移植时怎么在代码里组织这些描述符,以及怎么用工具验证它们是否正确。