3. USB枚举过程:设备连接与复位、获取设备描述符、地址分配、配置描述符解析、驱动加载
好,咱们今天聊聊USB枚举。说实话,这是USB协议里最核心、也最容易出问题的一环。我刚开始做USB驱动时,就被枚举过程折腾得够呛——设备插上去没反应,或者识别到一半就挂了,查了半天发现是描述符里一个字节填错了。
枚举,说白了就是主机和设备互相认识的过程。设备刚插上去,主机对它一无所知,连它是什么东西都不知道。那怎么通信呢?嗯,这里有个巧妙的设计——USB协议规定,设备在没被分配地址之前,可以用默认地址0来通信。就像你刚到一个新公司,还没工号,但前台可以先喊你“新来的那位”。
3.1 设备连接与复位
设备插入USB口的那一刻,事情就开始了。主机端的Hub检测到D+或D-线上的电平变化——全速和高速设备会把D+拉高,低速设备则拉高D-。这个电平变化告诉Hub:有设备来了。
Hub把这个事件报告给主机控制器,主机控制器就会让Hub往设备发送一个复位信号。复位信号是什么?就是把D+和D-同时拉低,持续至少10毫秒。我见过不少新手工程师,以为复位时间越长越好,结果把设备搞死了。其实USB规范里写得很清楚:复位信号持续10ms到20ms就够了。
关键点:复位完成后,设备必须处于默认地址0,并且能够响应主机发来的控制传输。这是枚举的起点。
复位结束后,设备就进入默认状态,等待主机来“盘问”它。这时候设备能做的事情很有限——只能响应控制传输,而且端点0是唯一可用的管道。
我的经验:调试枚举问题时,我习惯先用USB分析仪抓一下总线上的波形。如果复位信号之后没有任何事务发生,那多半是设备没正确响应复位。检查一下D+的上拉电阻——很多新手会忘记这个。
3.2 获取设备描述符
主机复位完设备,接下来要干的第一件事就是——获取设备描述符。这是主机对设备的第一次正式“问话”。
主机通过默认地址0,向端点0发送一个控制传输。请求类型是标准设备请求,具体是GET_DESCRIPTOR,描述符类型是设备描述符。注意,主机第一次只请求8个字节,而不是完整的18个字节。为什么?
因为主机还不知道设备的端点0最大包长度是多少。设备描述符的第8个字节就是bMaxPacketSize0,告诉主机端点0一次能传多少字节。主机拿到这个值后,才能正确地进行后续的传输。
// 设备描述符结构(前8字节)
struct usb_device_descriptor {
uint8_t bLength; // 描述符长度,固定18
uint8_t bDescriptorType; // 描述符类型,设备描述符为1
uint16_t bcdUSB; // USB规范版本号,如0x0200表示USB 2.0
uint8_t bDeviceClass; // 设备类代码
uint8_t bDeviceSubClass; // 子类代码
uint8_t bDeviceProtocol; // 协议代码
uint8_t bMaxPacketSize0; // 端点0最大包长度
// 后面还有10个字节,第一次请求时不需要
};
注意:设备描述符里的bMaxPacketSize0必须正确填写。全速设备通常是8、16、32或64字节,高速设备固定为64字节。我曾经遇到一个设备,这里填了0,结果主机直接不认——枚举失败。
主机拿到前8个字节后,就知道了端点0的最大包长度。然后它会重新发送一次GET_DESCRIPTOR请求,这次要完整的18个字节。设备收到后,把完整的设备描述符发回去。
嗯,这里有个细节:主机可能会在第一次请求时就要全部18个字节,但如果设备返回的数据超过了端点0的最大包长度,就会出问题。所以规范里建议分两步走——先要8个字节,再要完整的。我建议你写固件时,两种方式都要支持。
3.3 地址分配
主机拿到了设备描述符,知道了设备的基本信息,接下来就要给设备分配一个唯一的地址。毕竟默认地址0是公共的,不能一直用。
主机发送SET_ADDRESS请求,把分配好的地址告诉设备。这个地址范围是1到127。设备收到后,必须在状态阶段完成后才能切换到新地址。注意,是状态阶段完成后,不是收到请求后立刻切换。
我记得有一次调试,设备在收到SET_ADDRESS请求后立刻切换了地址,结果主机发来的状态阶段确认包它收不到了——因为主机还在用旧地址发,设备已经切到新地址了。这就是典型的时序问题。
正确做法:设备应该在状态阶段完成后,再切换到新地址。具体来说,就是在收到主机发来的空数据包(表示状态阶段完成)之后,才更新内部地址寄存器。
地址分配完成后,主机后续的所有通信都会使用这个新地址。设备也正式从“默认状态”进入“地址状态”。
3.4 配置描述符解析
地址分配好了,主机继续“盘问”设备。这次要拿的是配置描述符。配置描述符比设备描述符复杂得多,因为它包含了一整套描述符集合——配置描述符本身、接口描述符、端点描述符,还可能包含类特殊描述符。
主机先发送GET_DESCRIPTOR请求,要配置描述符。但这次它先要前4个字节,因为配置描述符的第3、4字节(wTotalLength)告诉主机整个描述符集合的总长度。主机拿到这个长度后,再一次性请求全部数据。
// 配置描述符结构
struct usb_config_descriptor {
uint8_t bLength; // 描述符长度,固定9
uint8_t bDescriptorType; // 描述符类型,配置描述符为2
uint16_t wTotalLength; // 整个描述符集合的总长度
uint8_t bNumInterfaces; // 该配置支持的接口数
uint8_t bConfigurationValue; // 配置值,用于选择配置
uint8_t iConfiguration; // 描述该配置的字符串索引
uint8_t bmAttributes; // 配置特性,如自供电、远程唤醒
uint8_t bMaxPower; // 最大功耗,单位2mA
};
解析配置描述符时,我建议你按这个顺序来:
- 先找到配置描述符头(9字节),确认
wTotalLength - 然后遍历整个描述符集合,根据
bDescriptorType区分不同类型的描述符 - 接口描述符(类型4)告诉你有几个端点,每个端点的用途
- 端点描述符(类型5)告诉你端点号、方向、传输类型、最大包大小
避坑指南:我曾经遇到一个设备,它的配置描述符里wTotalLength填错了,比实际描述符集合小了几个字节。主机解析到一半就截断了,结果找不到端点描述符,枚举失败。所以写固件时,这个长度一定要算准。
主机解析完配置描述符后,就知道了设备有哪些接口、每个接口有哪些端点、每个端点支持什么传输类型。这时候主机已经对设备有了完整的了解。
3.5 驱动加载
配置描述符解析完了,主机知道了设备是什么——比如是一个HID键盘、一个大容量存储设备、还是一个自定义设备。接下来,主机就要为它加载合适的驱动程序。
驱动加载的依据是什么?主要是设备描述符里的bDeviceClass、bDeviceSubClass、bDeviceProtocol,以及接口描述符里的bInterfaceClass、bInterfaceSubClass、bInterfaceProtocol。
举个例子:如果接口描述符里bInterfaceClass是3(HID类),主机就会去找HID驱动。如果是8(大容量存储类),就去找Mass Storage驱动。
| 类代码 | 含义 | 常见驱动 |
|---|---|---|
| 0x01 | 音频类 | Audio驱动 |
| 0x02 | 通信类 | CDC驱动 |
| 0x03 | HID类 | HID驱动 |
| 0x08 | 大容量存储类 | Mass Storage驱动 |
| 0xFF | 厂商自定义 | 需要厂商提供驱动 |
驱动加载成功后,主机发送SET_CONFIGURATION请求,选择一个配置。设备收到后,按照配置描述符里的设置,初始化各个端点,准备好收发数据。这时候,设备就进入了“配置状态”,可以正常工作了。
总结一下枚举的完整流程:
- 设备插入,Hub检测到电平变化
- 主机发送复位信号
- 主机获取设备描述符(先8字节,再完整18字节)
- 主机分配唯一地址(SET_ADDRESS)
- 主机获取配置描述符(含接口和端点描述符)
- 主机加载匹配的驱动程序
- 主机发送SET_CONFIGURATION,设备进入工作状态
嗯,整个枚举过程看起来步骤不少,但实际在硬件上跑起来,也就是几十毫秒的事。不过一旦哪个环节出了问题,排查起来就头疼了。我个人建议,做USB设备开发时,手里常备一个USB分析仪,再配合逻辑分析仪抓一下D+和D-的波形,大部分问题都能快速定位。
下一章我们会深入聊聊USB的各种传输类型——控制传输、批量传输、中断传输和同步传输。到时候我会结合具体项目,讲讲每种传输类型的应用场景和注意事项。