4. USB控制传输:Setup包结构、数据阶段与状态阶段、标准设备请求

控制传输,说白了就是USB通信里的“控制通道”。所有设备枚举、配置、状态查询,都得靠它。我刚开始做USB固件时,觉得控制传输就是发几个命令,后来踩了不少坑才明白——Setup包的结构、数据阶段的流向、状态阶段的握手,任何一个环节出错,主机就不认你这个设备。

这一节,咱们把控制传输拆开揉碎了讲。你跟着我过一遍,以后写控制传输处理代码,心里就有底了。

4.1 控制传输的四个阶段

控制传输分四个阶段,缺一不可:

  1. Setup阶段:主机发8字节的Setup包,告诉设备“我要干什么”。
  2. 数据阶段(可选):主机和设备之间传数据。方向由Setup包里的bmRequestType决定。
  3. 状态阶段:接收方告诉发送方“我收到了”或“我处理完了”。
  4. 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,后来发现状态机更可靠,也更容易调试。

控制传输状态机 空闲 (IDLE) Setup阶段 有数据? 数据阶段 (IN) 数据阶段 (OUT) 状态阶段 虚线路径:无数据阶段的请求(如Set Address)直接跳转到状态阶段

状态机核心逻辑:

  • 空闲状态:等待主机发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,这一步过了,后面的枚举基本就稳了。