USB 枚举过程详解:从设备插入到配置完成
USB 枚举,说白了就是主机和设备“握手”的过程。设备插上去,主机得知道你是谁、你需要多少功率、用什么协议通信。整个过程分六步走,一步不对,枚举就挂。我在项目里遇到过好几次设备插上没反应,最后发现是上拉电阻没拉对,折腾了大半天。
这一章我们就把这六步掰开揉碎,每一步的时序、数据包格式、常见坑都讲清楚。你想想看,主机端其实什么都不知道——它只看到 D+ 或 D- 线上有个上拉电阻带来的电平变化,然后就开始了下面这套流程。
第一步:设备连接与主机检测
设备插入时,VBUS 先上电。设备内部的上拉电阻(全速设备在 D+ 上,低速设备在 D- 上)会把对应数据线拉到 3.3V 左右。主机通过端口状态寄存器检测到电平变化,就知道有设备插入了。
这里有个细节:主机并不会立即开始枚举,它会先等待一个去抖时间(100ms 左右)。我见过一些山寨 Hub 去抖做得不好,导致主机误判为多次插拔。
连接完成后,主机发一个复位信号(SE0,即 D+ 和 D- 同时拉低 ≥10ms)。设备收到复位后,内部状态机复位到 Default 状态,并准备好响应控制传输。
第二步:获取设备描述符
复位结束后,主机发送第一个控制传输:请求设备描述符。这是枚举过程中最关键的一步,因为它决定了主机能否识别设备。
// 典型的 Get_Descriptor(Device) 请求包格式
bmRequestType = 0x80 // 设备 -> 主机,标准请求
bRequest = 0x06 // GET_DESCRIPTOR
wValue = 0x0100 // 描述符类型(Device) + 索引0
wIndex = 0x0000
wLength = 0x0040 // 最多请求64字节
主机第一次请求时只读 8 字节(设备描述符的前 8 个字段),用来确定端点 0 的最大包长度。如果你在这一步返回的数据不对,主机直接放弃——我遇到过因为描述符中 bcdUSB 写成了 0x0100 导致主机认为设备是 USB 1.0 而拒绝后续传输的坑。
第三步:分配地址
主机拿到设备描述符后,知道端点 0 的最大包长(通常是 8、16、32 或 64)。接着主机发出 Set_Address 请求,给设备分配一个唯一的地址(1~127)。
设备收到该请求后,需要在状态阶段完成后才使用新地址。这一点很重要:很多初学者在 Set_Address 还没完成时就切换地址,导致后续控制传输全部失败。
// Set_Address 请求
bmRequestType = 0x00 // 主机 -> 设备
bRequest = 0x05 // SET_ADDRESS
wValue = 新地址 // 比如 0x0002
wIndex = 0x0000
wLength = 0x0000
我在调试一个自定义 Gadget 时,发现 Set_Address 成功后主机依然发请求到地址 0,查了半天规范才发现:设备必须在状态阶段完成后的下一个微帧才切换地址。我们当时的固件提前切换了,结果主机直接超时重试。
第四步:重新获取全部设备描述符
地址分配完成后,主机再发一次 Get_Descriptor(Device),这次 wLength 设为完整长度(18 字节)。这一步是为了核对完整信息,包括 bMaxPacketSize0 是否与第一次一致。
如果两次返回的 bMaxPacketSize0 不一致,主机直接重置设备——我当年就被这个坑过,因为描述符中写的是 64,但实际端点 0 配置成了 32,主机发现不一致就进入了死循环。
第五步:获取配置描述符
主机接着请求配置描述符(Get_Descriptor(Configuration))。配置描述符是树形结构:一个配置描述符头 + 若干个接口描述符 + 每个接口下的端点描述符。
主机先请求配置描述符头(9 字节),从返回的 wTotalLength 中获知整个配置总长度,然后再请求完整配置。这一步很容易出错:很多人忘记正确计算 wTotalLength,导致主机读到的数据不完整。
// 配置描述符结构示例(部分)
struct usb_config_descriptor {
uint8_t bLength; // 9
uint8_t bDescriptorType; // 0x02
uint16_t wTotalLength; // 整个配置树的总字节数
uint8_t bNumInterfaces; // 接口数量
uint8_t bConfigurationValue; // 配置值
uint8_t iConfiguration; // 字符串描述符索引
uint8_t bmAttributes; // 属性(自供电/远程唤醒等)
uint8_t bMaxPower; // 每2mA为单位,最大电流
};
第六步:选择配置
主机解析完配置描述符后,会选择一个合适的配置,发送 Set_Configuration 请求,参数为 bConfigurationValue。设备收到后,按配置描述符中的要求初始化所有端点,进入 Configured 状态。
这一步执行完毕,USB 枚举才算真正完成。主机接下来就可以进行类相关的通信(比如 HID 报告、海量存储指令等)。
完整枚举流程
为了让你更直观地看清这六步的关系,我画了一张流程图。每一步对应的主机侧状态和设备侧状态都标出来了,你可以对照调试日志来定位问题。
图中的每一步都可能失败,但我个人觉得最容易被忽略的是获取配置描述符这一步——尤其是当你的 Gadget 有多个接口或备用设置时,wTotalLength 计算稍有偏差,主机就会截断数据,导致某些接口永远无法工作。
常见失败点归纳
| 枚举阶段 | 典型失败原因 | 调试手段 |
|---|---|---|
| 设备检测 | 上拉电阻未拉、VBUS 不稳 | 量 D+/D- 电平、检查电源电容 |
| 复位 | SE0 时间不足 | 用逻辑分析仪看复位宽度 |
| 获取设备描述符 | 描述符数据错、超时未响应 | USB 分析仪抓包,核对 bcdUSB 和 bMaxPacketSize0 |
| 分配地址 | 地址切换时机错误、状态阶段未完成 | 检查固件中状态机时序 |
| 获取配置描述符 | wTotalLength 错误、缺少端点描述符 | 用 usb-devices 或 lsusb -v 查看解析结果 |
| 选择配置 | bConfigurationValue 不匹配、端点初始化失败 | 检查端点使能函数返回值 |
好了,USB 枚举的标准流程就是这些。每一步都有明确的电气特性和协议要求,理解透了之后再去看 Linux Gadget 驱动里的 ep0 回调函数,你会觉得豁然开朗。记住:枚举不是玄学,它是可以一步步跟踪和验证的。