4. USB控制传输:Setup包结构、数据阶段与状态阶段、标准设备请求
控制传输,说白了就是USB通信里的“控制通道”。所有设备枚举、配置、状态查询,都得靠它。我刚开始做USB固件时,觉得控制传输就是发几个命令,后来踩了不少坑才明白——Setup包的结构、数据阶段的流向、状态阶段的握手,任何一个环节出错,主机就不认你这个设备。
这一节,咱们把控制传输拆开揉碎了讲。你跟着我过一遍,以后写控制传输处理代码,心里就有底了。
4.1 控制传输的四个阶段
控制传输分四个阶段,缺一不可:
- Setup阶段:主机发8字节的Setup包,告诉设备“我要干什么”。
- 数据阶段(可选):主机和设备之间传数据。方向由Setup包里的bmRequestType决定。
- 状态阶段:接收方告诉发送方“我收到了”或“我处理完了”。
- Ping阶段(仅USB 2.0高速):高速模式下,主机先发PING探一下设备能不能收数据。这个咱们先放一放,后面讲高速传输时再细说。
你想想看,这就像你去窗口办事:你先递单子(Setup),窗口人员处理完给你回执(数据),你确认收到(状态)。流程清晰,一步都不能乱。
核心要点:控制传输的每个阶段,USB主机控制器都会检查超时和错误。任何一个阶段失败,整个传输就重试或中止。所以固件里处理控制传输,必须严格按状态机来。
4.2 Setup包结构:8字节定乾坤
Setup包是控制传输的“命令头”,固定8字节。我当年第一次看USB规范时,觉得这8字节太简单了。后来调试一个HID设备,死活枚举不过,最后发现是bmRequestType的bit6写反了——方向搞错,主机直接STALL。
这8字节的结构如下:
| 偏移 | 字段 | 长度 | 说明 |
|---|---|---|---|
| 0 | bmRequestType | 1字节 | 请求方向、类型、接收方 |
| 1 | bRequest | 1字节 | 请求码(如GET_DESCRIPTOR=0x06) |
| 2-3 | wValue | 2字节 | 请求参数(如描述符类型和索引) |
| 4-5 | wIndex | 2字节 | 接口或端点号 |
| 6-7 | wLength | 2字节 | 数据阶段期望传输的字节数 |
bmRequestType的位定义:
- bit7:方向。0=主机到设备(OUT),1=设备到主机(IN)。
- bit6-5:类型。0=标准,1=类,2=厂商,3=保留。
- bit4-0:接收方。0=设备,1=接口,2=端点,3=其他。
我的习惯:写固件时,我会把bmRequestType的解析写成一个宏或内联函数。比如:#define REQ_DIR_IN(req) ((req)->bmRequestType & 0x80)。这样代码可读性高,不容易搞错方向。
4.3 数据阶段与状态阶段
数据阶段不是必须的。有些请求(比如Set Address)不需要数据阶段,Setup包发完直接进状态阶段。
数据阶段的规则:
- 数据包大小不能超过端点描述符里定义的wMaxPacketSize。控制传输默认用端点0,全速模式下最大64字节,低速模式8字节。
- 如果数据超过一个包,就分多个事务传输。每个事务都是一个IN或OUT令牌包+数据包+握手包。
- 数据阶段结束时,主机发一个短包(长度小于wMaxPacketSize)或零长度包,表示数据结束。
状态阶段:
- 方向与数据阶段相反。如果数据阶段是IN(设备发数据给主机),状态阶段就是OUT(主机发零长度包给设备)。
- 设备收到状态阶段的OUT包后,必须返回ACK。如果设备处理失败,返回STALL。
- 如果数据阶段是OUT(主机发数据给设备),状态阶段就是IN(设备发零长度包给主机)。主机收到后返回ACK。
嗯,这里要注意:状态阶段的握手包,是接收方发的。 我见过不少新手固件工程师,在状态阶段搞反了方向,结果设备一直挂死在那个状态。
我曾经踩过的坑:有一次调试一个复合设备,控制传输状态阶段设备返回了NAK而不是ACK。主机一直重试,最后超时断开。查了半天,发现是固件里状态机处理状态阶段时,忘记清一个标志位,导致设备认为还没准备好。所以,状态阶段的处理一定要干净利落,该ACK就ACK,别犹豫。
4.4 标准设备请求
USB规范定义了11个标准设备请求。设备必须支持这些请求,否则主机无法完成枚举。咱们挑几个最常用的详细说说。
| bRequest | 请求名 | 说明 |
|---|---|---|
| 0x00 | Get Status | 获取设备、接口或端点的状态 |
| 0x01 | Clear Feature | 清除某个特性(如端点STALL) |
| 0x03 | Set Feature | 设置某个特性 |
| 0x05 | Set Address | 分配设备地址 |
| 0x06 | Get Descriptor | 获取描述符 |
| 0x07 | Set Descriptor | 设置描述符(很少用) |
| 0x08 | Get Configuration | 获取当前配置值 |
| 0x09 | Set Configuration | 设置配置 |
| 0x0A | Get Interface | 获取接口的备用设置 |
| 0x0B | Set Interface | 设置接口的备用设置 |
| 0x0C | Synch Frame | 同步帧(用于同步传输) |
4.4.1 Get Descriptor(获取描述符)
这是枚举过程中最频繁的请求。主机通过这个请求,获取设备的各种描述符——设备描述符、配置描述符、字符串描述符等等。
Setup包参数:
- bmRequestType:0x80(设备到主机,标准,设备)
- bRequest:0x06
- wValue高字节:描述符类型(1=设备,2=配置,3=字符串...)
- wValue低字节:描述符索引(字符串描述符用)
- wIndex:语言ID(字符串描述符用)
- wLength:期望读取的字节数
主机第一次请求设备描述符时,wLength通常只填8字节。为什么?因为主机想先看看设备描述符的前8字节,确认端点0的最大包大小。然后主机再用正确的包大小重新请求完整的设备描述符。
避坑指南:设备固件必须能处理wLength小于实际描述符长度的情况。比如设备描述符有18字节,但主机只请求8字节。设备只能返回8字节,不能多给。我见过一个设备,主机请求8字节,它硬是返回了18字节,结果主机解析出错,枚举失败。
4.4.2 Set Address(设置地址)
这个请求没有数据阶段。主机发完Setup包后,直接进入状态阶段。设备必须在状态阶段完成后,才能使用新地址。
Setup包参数:
- bmRequestType:0x00(主机到设备,标准,设备)
- bRequest:0x05
- wValue低字节:新地址(1-127)
- wLength:0x0000
时序要求:
- 设备收到Setup包后,必须在50ms内完成状态阶段。
- 状态阶段完成后,设备有2ms的“地址生效时间”。这2ms内,设备必须用新地址响应主机。
我个人习惯在固件里用一个状态变量记录当前地址。收到Set Address请求后,不立即切换地址,而是等状态阶段完成后再切换。这样能避免时序问题。
4.4.3 Set Configuration(设置配置)
主机枚举完设备描述符和配置描述符后,会发这个请求来激活某个配置。
Setup包参数:
- bmRequestType:0x00
- bRequest:0x09
- wValue低字节:配置值(对应配置描述符里的bConfigurationValue)
- wLength:0x0000
设备收到这个请求后,需要根据配置值初始化相应的端点、分配缓冲区、启动相关功能。如果配置值为0,表示设备进入未配置状态。
我的建议:在固件里,把Set Configuration的处理和具体的硬件初始化解耦。比如用一个函数指针数组,每个配置对应一个初始化函数。这样代码结构清晰,也方便以后扩展。
4.5 控制传输状态机
写固件处理控制传输,最好用状态机。我早期用一堆if-else,后来发现状态机更可靠,也更容易调试。
状态机核心逻辑:
- 空闲状态:等待主机发Setup包。收到后解析bmRequestType,确定方向和数据长度。
- Setup阶段:保存Setup包内容,准备数据或直接进入状态阶段。
- 数据阶段:根据方向,准备发送数据或接收数据。每次事务完成后检查是否还有数据要传。
- 状态阶段:发送或接收零长度包,然后返回空闲状态。
注意:状态机里一定要处理异常情况。比如主机发了STALL,或者超时没收到握手包。我一般会加一个超时计数器,超过一定时间没进展就复位状态机,重新等待Setup包。
4.6 实战:端点0的处理框架
最后,给一个端点0处理框架的伪代码。你可以在自己的固件里参考这个结构。
// 端点0状态机
enum ep0_state {
EP0_IDLE,
EP0_SETUP,
EP0_DATA_IN,
EP0_DATA_OUT,
EP0_STATUS_IN,
EP0_STATUS_OUT,
EP0_STALL
};
void ep0_handler(struct usb_device *dev) {
switch (dev->ep0_state) {
case EP0_IDLE:
// 等待Setup包到达
if (dev->ep0_setup_received) {
parse_setup_packet(&dev->setup);
dev->ep0_state = EP0_SETUP;
}
break;
case EP0_SETUP:
// 解析请求并准备响应
handle_standard_request(dev);
// 根据请求类型,切换到数据阶段或状态阶段
if (dev->setup.wLength > 0) {
dev->ep0_state = (dev->setup.bmRequestType & 0x80) ?
EP0_DATA_IN : EP0_DATA_OUT;
} else {
dev->ep0_state = EP0_STATUS_IN;
}
break;
case EP0_DATA_IN:
// 发送数据到主机
if (send_data(dev) == DONE) {
dev->ep0_state = EP0_STATUS_OUT;
}
break;
case EP0_DATA_OUT:
// 从主机接收数据
if (receive_data(dev) == DONE) {
dev->ep0_state = EP0_STATUS_IN;
}
break;
case EP0_STATUS_IN:
// 发送零长度包
send_zlp(dev);
dev->ep0_state = EP0_IDLE;
break;
case EP0_STATUS_OUT:
// 等待主机发零长度包
if (receive_zlp(dev) == DONE) {
dev->ep0_state = EP0_IDLE;
}
break;
case EP0_STALL:
// 发送STALL,然后复位
send_stall(dev);
dev->ep0_state = EP0_IDLE;
break;
}
}
这个框架虽然简单,但涵盖了控制传输的核心流程。你可以在handle_standard_request函数里,根据bRequest分发到不同的处理函数。每个处理函数负责填充数据缓冲区或执行相应操作。
我的经验:调试控制传输时,用USB分析仪抓包是最直接的方法。看Setup包、数据包、握手包的时序,一眼就能发现问题。如果没有分析仪,也可以用软件抓包工具,比如Wireshark配合USBPcap。我调试时习惯先看设备能不能正确响应Get Descriptor,这一步过了,后面的枚举基本就稳了。