1. STM32 USB OTG Device Stack 中断处理机制深度解析

USB 设备栈(Device Stack)的稳定运行高度依赖于对底层硬件中断事件的精确响应与分层处理。在 STM32F2/F4/F7/L4 系列 MCU 的 USB OTG 外设中,中断并非简单的“来了就处理”,而是一套具有严格时序、明确职责边界和精细状态管理的协同机制。理解这套机制,是开发可靠 USB 设备应用(如 HID 键鼠、CDC 虚拟串口、大容量存储设备)的核心前提。本节将完全脱离视频语境,以嵌入式工程师的视角,系统性地拆解 OTG Device Stack 的中断处理流程,重点剖析 Receive FIFO Non-Empty IN Endpoint OUT Endpoint 这三个与数据流通信直接相关的关键中断分支。

1.1 OTG 全局中断标志寄存器的工程意义

在深入具体中断处理前,必须明确 OTG 模块全局中断标志寄存器( OTG_FS_GINTSTS OTG_HS_GINTSTS )的结构与含义。该寄存器是整个 USB 设备栈的“神经中枢”,其每一位都对应一个特定的硬件事件。准确解读这些标志位,是编写健壮中断服务程序(ISR)的第一步。

中断标志位 名称 工程含义 Device Stack 处理状态
MODEMIS Mode Mismatch OTG 模块当前角色(Host/Device)与配置寄存器期望值不一致。这通常发生在 VBUS 检测异常或初始配置错误时,是系统初始化阶段的重要诊断信号。 ✅ 已有处理函数,用于角色切换或错误上报
SOFF Start of Frame 设备检测到 USB 总线上的帧起始(SOF)令牌。这是 USB 协议的定时基准,所有周期性传输(如 HID 报告)均以此为时间参考。 ✅ 已有处理函数,用于更新内部帧计数器
RXFLVL Receive FIFO Non-Empty 核心中断 :接收 FIFO 中有数据待读取。这是所有 OUT 传输(包括 Setup、Data、Status)数据到达的首要信号。 ✅ 已有处理函数,是数据流入口
GINAKEFF Global IN NAK Effective 应用程序已通过 HAL_PCD_EP_StartXfer() 启用了某个 IN 端点的 NAK,且该 NAK 已被硬件采纳。此标志位主要用于调试和状态确认。 ❌ 未在标准栈中处理
GONAKEFF Global OUT NAK Effective 应用程序已通过 HAL_PCD_EP_StartXfer() 启用了某个 OUT 端点的 NAK,且该 NAK 已被硬件采纳。同上,属调试辅助标志。 ❌ 未在标准栈中处理
ESUSP Early Suspend 设备检测到总线上连续 3ms 无任何活动(无 SOF、无令牌包)。这是进入挂起(Suspend)状态的预信号,允许设备提前进行低功耗准备。 ✅ 已有处理函数,触发低功耗流程
USBSUSP USB Suspend 设备正式进入挂起状态。此时 VBUS 仍存在,但总线无活动,设备应将大部分外设置于低功耗模式。 ✅ 已有处理函数,执行深度睡眠
USBRST USB Reset 设备收到了主机发出的复位(Reset)信号。这是 USB 枚举过程的起点,设备必须清空所有端点状态、重置地址,并重新开始枚举。 ✅ 已有处理函数,执行完整复位流程
ENUMDNE Enumeration Done 设备枚举(Enumeration)已完成,主机已成功为设备分配了地址(Address)。这是设备从“未配置”状态进入“已配置”状态的标志性事件。 ✅ 已有处理函数,触发 USBD_DeviceEventCallback 中的 USBD_EVENT_CONFIGURED 回调
ISOODRP ISO OUT Packet Drop 因接收 FIFO 空间不足,导致一个同步(Isochronous)OUT 数据包被硬件丢弃。这是一个严重错误,表明软件读取 FIFO 的速度跟不上主机发送速度,需立即优化数据处理逻辑。 ✅ 已有处理函数,用于错误统计与恢复
EOPF End of Periodic Frame 当前微帧(Microframe)已结束。此标志位在高速(HS)模式下尤为重要,用于同步微帧级别的操作。 ✅ 已有处理函数,用于微帧计时
IEPINT IN Endpoint Interrupt 核心中断 :一个或多个 IN 端点发生了中断事件(如 Transfer Complete, TXFE)。这是所有 IN 传输(Data、Status)完成的信号。 ✅ 已有处理函数,是数据流出口
IISOIXFR Incomplete Isochronous IN Transfer 当前微帧内,存在一个尚未完成的同步(Isochronous)IN 传输。这通常是由于主机未能及时提供数据或设备未能及时读取导致。 ✅ 已有处理函数,用于同步传输状态管理
OISOIXFR Incomplete Isochronous OUT Transfer 当前微帧内,存在一个尚未完成的同步(Isochronous)OUT 传输。同上,是同步传输的健康指示器。 ✅ 已有处理函数,用于同步传输状态管理
WKUPINT Wakeup Interrupt 设备检测到了主机发出的远程唤醒(Resume)信号。这是从挂起状态恢复的唯一合法途径。 ✅ 已有处理函数,执行唤醒流程

从上表可见,红色字体的中断( IISOIXFR , OISOIXFR )与同步传输强相关;蓝色方框标注的中断( GINAKEFF , GONAKEFF , EOPF )在标准 Device Stack 中并未实现处理逻辑,它们更多服务于高级调试或特定协议栈;而绿色填充的中断( RXFLVL , IEPINT , OEPIINT )则是 Device Stack 的核心处理对象。其中, RXFLVL IEPINT OEPIINT 这三个中断,构成了 USB 设备数据通信的主干道,它们的处理顺序与协作逻辑,正是本文后续要深入剖析的重点。

1.2 核心数据流中断: Receive FIFO Non-Empty

RXFLVL 中断是 USB 设备数据流的绝对源头。无论主机发送的是 Setup 包、OUT Data 包还是 OUT Status 包,其原始字节流首先被硬件 DMA 或 FIFO 控制器捕获,并存入一个共享的接收 FIFO。 RXFLVL 中断的唯一职责,就是通知软件:“FIFO 里有东西了,快来看!” 它本身不关心数据属于哪个端点、是什么类型,它只负责“搬运工”的第一站。

1.2.1 中断处理流程:从状态寄存器到数据缓冲区

RXFLVL 中断触发时,Device Stack 的 ISR(通常为 DCD_HandleRxNPInterrupt() )会执行以下标准化流程:

  1. 临时禁用中断 :首先通过写入 OTG_FS_GINTMSK 寄存器,暂时屏蔽 RXFLVL 中断。这是为了防止在处理当前 FIFO 内容时,新的数据涌入导致 FIFO 溢出(Overflow),从而丢失数据。这是一种典型的临界区保护策略。
  2. 读取 FIFO 状态寄存器 :紧接着,软件读取 OTG_FS_GRXSTSP (Global Receive Status Pop Register)。 这是最关键的一步,也是初学者最容易混淆的地方。 此寄存器并非 FIFO 的数据内容,而是一个“状态快照”。它包含了本次入队数据包的全部元信息:
    • Packet Status ( PKTSTS ) :标识包的类型。例如 0x02 表示 Setup 包, 0x03 表示普通 OUT Data 包, 0x04 表示 OUT Status 包, 0x06 表示 Setup Transaction 完成(Setup Complete)。
    • Byte Count ( BCNT ) :该数据包的有效字节数。
    • Endpoint Number ( EPNUM ) :该数据包的目标端点号(0-15)。由于所有 OUT 端点共享一个 FIFO,此字段是区分数据归属的唯一依据。
  3. 获取端点句柄 :根据 EPNUM 字段的值,软件索引 Device Stack 内部维护的端点控制结构体( PCD_EPTypeDef )数组,获取指向该端点的句柄。这个句柄中包含了该端点的所有关键信息,其中最重要的是 xfer_buff ——一个指向用户为该端点分配的数据缓冲区的指针。
  4. 按状态执行数据搬运
    • PKTSTS == SETUP :这是一个 Setup 包。软件调用 HAL_PCD_EP_Read() API(其底层实际执行 USB_OTG_ReadPacket() ),将 BCNT (固定为 8 字节)个字节从 FIFO 读取到 pEp->setup 缓冲区(一个专门用于存放 Setup 请求的 8 字节数组)。至此,Setup 请求已被完整解析,等待后续 OUT Endpoint 中断来触发其处理。
    • PKTSTS == OUT_DATA :这是一个普通的 OUT Data 包。软件同样调用 HAL_PCD_EP_Read() ,将 BCNT 个字节从 FIFO 读取到 pEp->xfer_buff 指向的用户缓冲区。此时,数据已经安全地进入了应用程序可访问的内存空间。
    • PKTSTS == SETUP_COMPLETE :这是一个特殊的“伪包”。它标志着上一个 Setup Transaction 已经被硬件完全接收并处理完毕,FIFO 中已无 Setup 数据。此状态本身不携带任何有效载荷,因此无需读取数据,直接退出即可。它的主要作用是作为“开关”,触发后续 OUT Endpoint 中断的处理逻辑。
1.2.2 关键设计哲学:两次读取,职责分离

RXFLVL 中断的设计体现了清晰的职责分离思想。它只做两件事: 读状态 搬数据 。它绝不参与任何业务逻辑判断(如“这个 Setup 请求是什么?”、“这个 OUT 数据包该交给谁?”)。所有的协议解析、状态机跳转、回调函数调用,都留给了后续更上层的中断去完成。这种设计极大地降低了单个 ISR 的复杂度,提高了代码的可维护性和可测试性。

一个常见的实践误区是试图在 RXFLVL 中断中直接解析 Setup 请求。这是危险的,因为 RXFLVL 是一个高频率、低延迟的中断,必须尽可能快地返回。将复杂的 switch-case 解析逻辑放在这里,会显著增加中断延迟,可能导致后续中断(如 OEPIINT )被错过,进而引发通信故障。

1.3 核心数据流中断: OUT Endpoint Interrupt

如果说 RXFLVL 是数据流的“入口闸门”,那么 OEPIINT 就是数据流的“业务处理中心”。 RXFLVL 只负责把数据从 FIFO 搬到内存,而 OEPIINT 则负责告诉软件:“你刚刚搬进来的那些数据,现在该做什么了。”

1.3.1 中断处理流程:从端点识别到回调分发

OEPIINT 中断的触发源是 OTG_FS_DAINT (Device All IN/OUT Endpoint Interrupt)寄存器。由于多个 OUT 端点可能同时产生中断,其处理流程比 RXFLVL 更加复杂:

  1. 识别中断源端点 :首先,软件读取 OTG_FS_DAINT 寄存器,通过其 OEPINT 位域(Out Endpoint Interrupt)确定是哪一个具体的 OUT 端点( EP0 , EP1 , …)产生了中断。
  2. 读取端点中断状态 :接着,软件读取该端点对应的中断状态寄存器 OTG_FS_DOEPINT (Device OUT Endpoint Interrupt)。该寄存器的每一位代表一种具体的中断事件。
  3. 事件驱动的分支处理 :根据 DOEPINT 中被置位的标志位,执行不同的处理分支:
    • XFERCOMPL (Transfer Complete) :表示一个 OUT Transaction(数据包)已成功完成。这是最常见的事件。此时,Device Stack 会调用其核心回调函数 USBD_LL_DataOutStage() 。该函数是整个设备栈的“大脑”,它会根据当前端点的状态(由 USBD_HandleTypeDef 结构体中的 ep0_state 等字段维护)来决定下一步动作:
      • 若为 EP0 (端点零)且处于 USBD_EP0_DATA_OUT 状态,则意味着这是一个控制传输的数据阶段(Data OUT Stage)的结束。 USBD_LL_DataOutStage() 会调用用户定义的 class_cb->DataOut() 回调(如果已注册),或者直接进入下一个状态(如准备 Status 阶段)。
      • 若为非零端点( EP1 , EP2 , …),则意味着一个应用数据包已成功接收。此时, USBD_LL_DataOutStage() 会调用用户定义的 class_cb->DataOut() 回调,将数据处理权完全交还给应用程序。
    • SETUP :表示一个 Setup Transaction 已成功完成。这是控制传输的起点。 USBD_LL_SetupStage() 函数会被调用。该函数的核心任务是:
      • pDev->dev_prop->setup 缓冲区中读取刚才在 RXFLVL 中断里读取的 8 字节 Setup 请求。
      • 解析 bRequest , bmRequestType , wIndex 等字段,判断请求的目标(Device、Configuration、Interface、Endpoint)和类型(Standard, Class, Vendor)。
      • 对于标准请求(如 SET_ADDRESS , GET_DESCRIPTOR ),Device Stack 会自行处理(如设置新地址、从 Flash 中读取描述符)。
      • 对于类特定(Class-specific)或厂商特定(Vendor-specific)请求,Device Stack 会调用用户定义的 class_cb->Setup() 回调,将解析后的请求结构体 pSetup 作为参数传递过去,由用户代码完成最终的业务逻辑。
1.3.2 为什么 RXFLVL OEPIINT 必须协同?

一个至关重要的工程事实是: OEPIINT 中断永远不会在 RXFLVL 中断之前发生。 主机发送一个 OUT 包的完整过程是:主机发出 OUT Token -> 设备硬件接收并存入 FIFO -> 触发 RXFLVL 中断 -> 软件读取 FIFO 并将数据放入内存 -> 主机收到 ACK -> 设备硬件确认 Transaction 完成 -> 触发 OEPIINT 中断。

这意味着,在 OEPIINT XFERCOMPL 分支中,你永远可以放心地认为, RXFLVL 中断已经将数据安全地搬运到了你的缓冲区里。你在 OEPIINT 中所做的,仅仅是“确认收货”和“安排下一步工作”,而不是“收货”。这种严格的时序依赖,是 USB 协议栈稳定性的基石。在 HID Demo 中, class_cb->DataOut() 被定义为 NULL ,正是因为 HID 设备(鼠标/键盘)是单向的,它只向主机发送数据,不需要从主机接收任何应用数据,因此 OEPIINT XFERCOMPL 事件对于非零端点而言,就是一个“无事发生”的空操作。

1.4 核心数据流中断: IN Endpoint Interrupt

IEPINT 中断是 USB 设备数据流的“出口闸门”,它标志着设备向主机发送数据的成功。与 RXFLVL 类似,它也分为两个层面:硬件层面的 TXFIFO Empty (发送 FIFO 空)事件,以及协议层面的 XFERCOMPL (传输完成)事件。

1.4.1 中断处理流程:从 FIFO 填充到状态机推进

IEPINT 的处理流程与 OEPIINT 高度对称:

  1. 识别中断源端点 :读取 OTG_FS_DAINT 寄存器的 IEPINT 位域,确定是哪个 IN 端点。
  2. 读取端点中断状态 :读取该端点的 OTG_FS_DIEPINT (Device IN Endpoint Interrupt)寄存器。
  3. 事件驱动的分支处理
    • XFERCOMPL (Transfer Complete) :表示一个 IN Transaction(数据包)已成功被主机取走。这是数据发送成功的确认信号。 USBD_LL_DataInStage() 函数被调用。其逻辑与 DataOutStage() 类似:
      • 若为 EP0 且处于 USBD_EP0_DATA_IN 状态,则处理控制传输的数据阶段(Data IN Stage)的结束。若数据未发完,会继续调用 USBD_LL_Transmit() 发送剩余数据;若已发完,则进入 USBD_EP0_STATUS_IN 状态,准备发送 Status 阶段的 ACK。
      • 若为非零端点,则意味着一个应用数据包已成功发送。此时, USBD_LL_DataInStage() 会调用用户定义的 class_cb->DataIn() 回调,通知应用程序“数据已发出”,可以准备下一批数据了。
    • TXFE (Transmit FIFO Empty) :表示该 IN 端点对应的发送 FIFO 已经为空,可以向其中写入新的数据了。这是启动数据发送的关键信号。 USBD_LL_WriteEmptyTxFifo() 函数被调用。该函数会检查该端点的 xfer_len (待发送总长度)和 xfer_count (已发送长度),计算出本次需要写入 FIFO 的字节数 len 。然后,它会循环调用 HAL_PCD_EP_Write() (其底层为 USB_OTG_WritePacket() ),将数据从用户缓冲区 pEp->xfer_buff 按最大包长( pEp->maxpacket )分片写入 FIFO。例如,若要发送 100 字节,最大包长为 64,则第一次写入 64 字节;当主机取走这 64 字节后,再次触发 TXFE ,再写入剩余的 36 字节。这种“边写边发”的流水线模式,是保证高吞吐量的关键。
1.4.2 TXFE XFERCOMPL 的协同:HID Demo 的启示

在 HID Demo 中, class_cb->DataIn() 的实现非常精妙,它揭示了 TXFE XFERCOMPL 协同工作的本质。Demo 使用了一个 SysTick 定时器,每隔一段时间就调用 USBD_HID_SendReport() 来准备一个鼠标报告。 USBD_HID_SendReport() 并不会立即将数据写入 FIFO,而是仅设置好 EP0 xfer_buff xfer_len ,并使能 TXFE 中断。真正的数据写入操作,是在 TXFE 中断的 USBD_LL_WriteEmptyTxFifo() 中完成的。

那么, DataIn() 回调在这里的作用是什么?它被定义为 USBD_HID_DataIn() ,其核心操作是 HAL_PCD_EP_Flush() ,即手动清空发送 FIFO。这看起来很奇怪,因为 FIFO 在 XFERCOMPL 后本应是空的。但这里有一个隐藏的时序问题:当 TXFE 中断被触发, WriteEmptyTxFifo() 开始向 FIFO 写入数据时,如果此时 XFERCOMPL 中断恰好到来(意味着主机刚刚取走了上一批数据),那么 DataIn() 回调就会被执行。 DataIn() 的清空 FIFO 操作,是为了确保在 WriteEmptyTxFifo() 下一次被调用前,FIFO 是干净的,从而避免新旧数据混杂。这是一种针对特定应用场景(定时报告)的、经过实践检验的稳健性增强措施。

2. Device Stack 的回调函数架构:用户代码的接入点

Device Stack 的强大之处在于其分层清晰的抽象。它将 USB 协议的复杂性封装在底层(DCD 层),而将用户关注的应用逻辑,通过一套标准化的回调函数(Callback)接口暴露出来。这套架构使得开发者无需修改底层栈代码,即可快速构建出功能各异的 USB 设备。

2.1 三层回调函数体系

Device Stack 的回调函数体系并非扁平化,而是分为三个层次,各自承担不同的职责:

2.1.1 USBD_UsrDevEventCallback :设备生命周期事件

这是最顶层的回调,定义在 usbd_usr.h/c 中,用于响应设备物理层面的重大事件。它不涉及任何数据包,只关乎设备的整体状态。

回调函数 触发时机 工程用途
USBD_USR_Init() USBD_Init() 被调用时 初始化用户自定义的 USB 相关外设,如 LED、按键等。
USBD_USR_DeviceReset() USBRST 中断发生后 复位后执行清理工作,如关闭 LED、重置用户状态机。
USBD_USR_DeviceConfigured() ENUMDNE 中断发生,设备进入 CONFIGURED 状态后 设备已准备好,可以开始正常工作。在此处开启 LED、启动数据采集任务等。
USBD_USR_DeviceSuspended() USBSUSP 中断发生后 进入低功耗模式前的最后准备,如保存关键状态、关闭非必要外设。
USBD_USR_DeviceResumed() WKUPINT 中断发生后 从挂起状态恢复,重新初始化外设、恢复数据流。

这些回调是用户与 USB 设备物理世界交互的桥梁。例如,在 USBD_USR_DeviceConfigured() 中点亮一个绿色 LED,是嵌入式开发中最直观的“设备已就绪”指示。

2.1.2 USBD_UsrClassCallback :类特定与应用数据事件

这是最核心、最常用的回调层,定义在 usbd_class.h/c 中(如 usbd_hid.h/c , usbd_cdc.h/c )。它直接处理 USB 协议中的数据流和类特定命令。

回调函数 触发时机 工程用途
Setup() OEPIINT SETUP 事件触发 USBD_LL_SetupStage() 处理非标准请求 。当 USBD_LL_SetupStage() 解析出一个 bRequestType CLASS VENDOR 的请求时,调用此函数。例如,HID 类的 SET_PROTOCOL GET_IDLE 命令都在此处实现。
DataIn() IEPINT XFERCOMPL 事件触发 USBD_LL_DataInStage() 通知 IN 传输完成 。当一个 IN 端点(尤其是非零端点)的数据包被主机成功取走后,调用此函数。用户可在此处准备下一批数据,或更新状态。
DataOut() OEPIINT XFERCOMPL 事件触发 USBD_LL_DataOutStage() 通知 OUT 传输完成 。当一个 OUT 端点(尤其是非零端点)的数据包被设备成功接收后,调用此函数。用户可在此处解析接收到的数据,并执行相应操作。
EP0_RxReady() OEPIINT XFERCOMPL 事件,且端点为 EP0 ,状态为 USBD_EP0_STATUS_OUT 在 Status 阶段前执行特殊操作 。这是一个非常精细的钩子,用于在控制传输的 OUT 方向 Status 阶段(即主机发送最后一个 ACK)之前,执行一些类特定的清理或准备操作。
EP0_TxSent() IEPINT XFERCOMPL 事件,且端点为 EP0 ,状态为 USBD_EP0_STATUS_IN 在 Status 阶段后执行特殊操作 。与 EP0_RxReady() 对应,用于在 IN 方向 Status 阶段(即主机接收最后一个 ACK)之后执行操作。

Setup() 是类实现的“心脏”,它决定了你的设备是否能正确响应主机的各类查询和配置命令。 DataIn() DataOut() 则是应用数据流的“阀门”,它们的实现质量直接决定了设备的实时性和吞吐量。

2.1.3 USBD_LL_Callbacks :底层硬件抽象层

这是最底层的回调,通常由 HAL 库(如 stm32f4xx_hal_pcd.c )提供,定义在 usbd_conf.h/c 中。它将 Device Stack 与具体的 MCU 硬件(如 STM32 的 PCD 外设)隔离开来。

回调函数 触发时机 工程用途
USBD_LL_Init() USBD_Init() 的第一步 初始化 PCD 外设,配置时钟、GPIO、NVIC 中断等。
USBD_LL_DeInit() USBD_DeInit() 反初始化 PCD 外设,释放资源。
USBD_LL_PCD_SetupStage() OEPIINT SETUP 事件 将 Setup 请求从硬件 FIFO 搬运到 pDev->dev_prop->setup 缓冲区。
USBD_LL_PCD_DataInStage() IEPINT XFERCOMPL 事件 XFERCOMPL 事件通知给上层 USBD_LL_DataInStage()
USBD_LL_PCD_DataOutStage() OEPIINT XFERCOMPL 事件 XFERCOMPL 事件通知给上层 USBD_LL_DataOutStage()
USBD_LL_PCD_SOFCallback() SOFF 中断 更新内部帧计数器,供需要微秒级精度的应用使用。

对于绝大多数应用开发者,只需关注 USBD_UsrClassCallback 层。 USBD_UsrDevEventCallback 提供了设备级的宏观控制,而 USBD_LL_Callbacks 则是 HAL 库的内部实现细节,通常无需改动。

2.2 回调函数的注册与实例化

回调函数不是自动生效的,它们必须在 Device Stack 初始化时被显式注册。整个过程遵循一个清晰的模板:

  1. 定义用户回调结构体 :在你的应用文件(如 usbd_custom_class.c )中,定义一个 USBD_ClassTypeDef 结构体变量。
    c USBD_ClassTypeDef USBD_CUSTOM_CLASS = { .Name = "CUSTOM", .Init = USBD_CUSTOM_Init, .DeInit = USBD_CUSTOM_DeInit, .Requests = USBD_CUSTOM_Requests, .GetHSConfigDescriptor = USBD_CUSTOM_GetHSConfigDescriptor, .GetFSConfigDescriptor = USBD_CUSTOM_GetFSConfigDescriptor, .GetOtherSpeedConfigDescriptor = USBD_CUSTOM_GetOtherSpeedConfigDescriptor, .GetDeviceQualifierDescriptor = USBD_CUSTOM_GetDeviceQualifierDescriptor, .GetUsrStrDescriptor = USBD_CUSTOM_GetUsrStrDescriptor, .USR_DataIn = USBD_CUSTOM_DataIn, // <-- 这是 class_cb->DataIn() .USR_DataOut = USBD_CUSTOM_DataOut, // <-- 这是 class_cb->DataOut() .USR_Setup = USBD_CUSTOM_Setup, // <-- 这是 class_cb->Setup() .USR_EP0_RxReady = USBD_CUSTOM_EP0_RxReady, .USR_EP0_TxSent = USBD_CUSTOM_EP0_TxSent, .USR_SOF = USBD_CUSTOM_SOF, .USR_Reset = USBD_CUSTOM_Reset, .USR_Suspend = USBD_CUSTOM_Suspend, .USR_Resume = USBD_CUSTOM_Resume, .USR_Configured = USBD_CUSTOM_Configured, .USR_Disconnect = USBD_CUSTOM_Disconnect, .USR_Connect = USBD_CUSTOM_Connect, };
  2. 在主应用中创建句柄 :在 main.c app.c 中,声明一个 USBD_HandleTypeDef 类型的全局句柄。
    c USBD_HandleTypeDef hUsbDeviceFS;
  3. 调用初始化 API :在 main() 函数的初始化阶段,调用 USBD_Init() ,并将用户句柄和回调结构体作为参数传入。
    ```c
    / 初始化 USB 设备栈 /
    USBD_Init(&hUsbDeviceFS, &USBD_CUSTOM_CLASS, DEVICE_FS);

    / 注册用户设备事件回调 /
    USBD_RegisterClass(&hUsbDeviceFS, &USBD_CUSTOM_CLASS);

    / 注册用户类回调 /
    USBD_CUSTOM_RegisterClass(&hUsbDeviceFS, &USBD_CUSTOM_CLASS);

    / 启动 USB 设备 /
    USBD_Start(&hUsbDeviceFS);
    ```

通过这一系列步骤,Device Stack 就完成了与用户代码的“握手”,所有中断事件都将按照预定的路径,最终抵达你精心编写的回调函数中。

3. 控制传输的完整生命周期:以 SET_CONFIGURATION GET_DESCRIPTOR 为例

控制传输(Control Transfer)是 USB 协议中最复杂、最重要的传输类型,它承载着设备的枚举、配置和管理。理解其在 Device Stack 中的完整执行流程,是掌握整个栈运行机制的试金石。我们将以两个经典案例—— SET_CONFIGURATION GET_DESCRIPTOR ——来详细拆解。

3.1 SET_CONFIGURATION :设备的“成人礼”

SET_CONFIGURATION 是主机在枚举过程的最后一步发出的命令,它标志着设备从“未配置”状态正式成为“已配置”状态,获得了在 USB 总线上进行常规数据通信的资格。其完整的事务序列如下:

  1. Setup 阶段(Transaction #116)
    • 主机发出一个 SET_CONFIGURATION Setup 包( bmRequestType=0x00 , bRequest=0x09 , wValue=0x0100 )。
    • 设备硬件接收,触发 RXFLVL 中断。
    • DCD_HandleRxNPInterrupt() 执行,读取 GRXSTSP ,识别出 PKTSTS==SETUP BCNT==8
    • 调用 HAL_PCD_EP_Read() ,将 8 字节 Setup 请求读入 pDev->dev_prop->setup
    • Setup 包接收完成,硬件产生 SETUP_COMPLETE 状态。
  2. Status 阶段(Transaction #118)
    • 主机发出一个 IN Token,准备接收一个 0 长度的 Status 包。
    • RXFLVL 中断中读取到 PKTSTS==SETUP_COMPLETE 状态后,Device Stack 知道 Setup 已完成,于是触发 OEPIINT 中断。
    • DCD_HandleOutEPInterrupt() 执行,读取 DOEPINT ,识别出 SETUP 标志位。
    • 调用 USBD_LL_SetupStage() ,解析 setup 缓冲区,确认为 SET_CONFIGURATION
    • USBD_LL_SetupStage() 调用 USBD_LL_Transmit() ,为 EP0 准备一个 0 长度的 IN 包,并使能 TXFE 中断。
    • TXFE 中断被触发, USBD_LL_WriteEmptyTxFifo() 执行,向 FIFO 写入一个空包。
    • 主机通过 IN Token 取走这个空包,完成 Status 阶段。
    • 此次 IN Transaction 的完成,触发 IEPINT 中断。
    • DCD_HandleInEPInterrupt() 执行,读取 DIEPINT ,识别出 XFERCOMPL
    • USBD_LL_DataInStage() 被调用,由于是 EP0 的 Status 阶段,它将设备状态更新为 USBD_STATE_CONFIGURED
    • 最终, USBD_USR_DeviceConfigured() 回调被触发,用户代码可以开始其业务逻辑。

整个过程中, RXFLVL 负责“收”, OEPIINT 负责“判”(解析 Setup), IEPINT 负责“发”(发送 Status),三者环环相扣,缺一不可。

3.2 GET_DESCRIPTOR :设备的“自我介绍”

GET_DESCRIPTOR 是一个带数据阶段的控制传输,它要求设备向主机发送一个描述符(如设备描述符、配置描述符)。其事务序列更为复杂,完美展现了 RXFLVL OEPIINT IEPINT 的协同工作。

  1. Setup 阶段(Transaction #14)
    • 主机发出 GET_DESCRIPTOR Setup 包( bmRequestType=0x80 , bRequest=0x06 , wValue=0x0100 )。
    • RXFLVL 中断触发,Setup 包被读入 setup 缓冲区。
  2. Data 阶段(Transactions #15, #18, #22)
    • 主机发出第一个 IN Token,请求数据。
    • RXFLVL 中断读取到 SETUP_COMPLETE ,触发 OEPIINT
    • USBD_LL_SetupStage() 解析出 GET_DESCRIPTOR ,计算出要发送的设备描述符长度(18 字节)。
    • USBD_LL_SetupStage() 调用 USBD_LL_Transmit() ,为 EP0 设置 xfer_len=18 ,并使能 TXFE
    • TXFE 中断触发, USBD_LL_WriteEmptyTxFifo() 将描述符的前 8 字节( maxpacket=8 )写入 FIFO。
    • 主机取走这 8 字节(#15),触发 IEPINT
    • USBD_LL_DataInStage() 检查 xfer_len > xfer_count ,发现还有 10 字节未发,于是再次调用 USBD_LL_Transmit() ,准备下一批数据。
    • TXFE 再次触发,写入接下来的 8 字节(#18)。
    • 主机取走(#18),再次触发 IEPINT DataInStage() 继续发送剩余的 2 字节(#22)。
  3. Status 阶段(Transaction #23)
    • 主机发出一个 OUT Token,发送一个 0 长度的 Status 包。
    • RXFLVL 中断触发,读取到 PKTSTS==OUT_DATA BCNT==0
    • RXFLVL 中断读取到 PKTSTS==SETUP_COMPLETE ,触发 OEPIINT
    • USBD_LL_DataOutStage() 被调用,由于是 EP0 的 Status 阶段,它将设备状态更新为 USBD_STATE_DEFAULT 或保持 CONFIGURED ,并准备接收下一个 Setup 包。

这个例子清晰地展示了 Device Stack 如何将一个大的数据块,自动地、无缝地分割成符合 USB 协议规范的多个小包进行传输,并通过中断的接力,实现了高效、可靠的数据流管理。

4. 构建你自己的 Device Stack:工程化实践指南

将官方提供的 Device Stack 集成到自己的项目中,并非简单的复制粘贴。它是一个需要理解、裁剪和定制的工程化过程。以下是基于 STM32CubeMX 和 HAL 库的标准实践流程。

4.1 项目结构准备

一个典型的、可维护的 USB 设备项目,其文件结构应遵循清晰的分层原则:

MyProject/
├── Core/
│   ├── Inc/
│   │   ├── main.h
│   │   ├── stm32f4xx_hal_conf.h
│   │   └── usbd_conf.h          // USB 底层配置,如堆大小、中断优先级
│   ├── Src/
│   │   ├── main.c
│   │   ├── stm32f4xx_hal_msp.c // HAL MSP 回调,配置 USB GPIO/时钟/NVIC
│   │   └── usbd_conf.c         // USB 底层初始化,如 PCD 初始化
├── Drivers/
│   ├── BSP/                    // 板级支持包
│   │   ├── Inc/
│   │   │   └── my_board.h      // 自定义板级头文件
│   │   └── Src/
│   │       └── my_board.c      // 板级初始化,如 LED、按键
│   └── STM32F4xx_HAL_Driver/   // HAL 库
├── Middlewares/
│   └── ST/
│       └── USB_Device/         // 官方 USB Device Stack
│           ├── Class/          // 各类实现 (hid, cdc, msc)
│           ├── Core/           // 核心栈 (usbd_core.c, usbd_ctl.c)
│           └── Target/         // 目标平台适配 (usbd_conf.c, usbd_desc.c)
├── User/
│   ├── Inc/
│   │   ├── usbd_custom_class.h // 用户自定义类头文件
│   │   └── app_usbd.h          // 应用层 USB 接口
│   └── Src/
│       ├── usbd_custom_class.c // 用户自定义类实现
│       ├── app_usbd.c          // 应用层 USB 初始化与管理
│       └── main.c              // 主函数,调用 app_usbd_init()

4.2 关键配置文件详解

4.2.1 usbd_conf.h :栈的“宪法”

此文件定义了 Device Stack 运行时的关键参数,必须根据你的 MCU 和应用需求进行精确配置。

/* USB Device Core Configuration */
#define USBD_MAX_NUM_INTERFACES               1U
#define USBD_MAX_NUM_CONFIGURATION            1U
#define USBD_MAX_STR_DESC_SIZ                 512U
#define USBD_DEBUG_LEVEL                      0U // 0=OFF, 1=ERROR, 2=WARNING, 3=ALL

/* USB Device Configuration Descriptor */
#define USBD_DEVICE_DESC_SIZE                 18U
#define USBD_LANGID_STRING_SIZE               4U
#define USBD_MAX_NUM_CONFIGURATION            1U
#define USBD_MAX_NUM_INTERFACE                1U

/* USB Device Class Configuration */
#define USBD_CUSTOM_CLASS_MAX_PACKET_SIZE     64U // 你的自定义类的最大包长
#define USBD_CUSTOM_CLASS_EP0_BUFFER_SIZE      64U // EP0 的缓冲区大小

/* USB Device Heap Configuration */
#define USBD_RAM_SIZE                           0x1000U // USB 专用 RAM 大小 (FS: 1.25KB, HS: 4KB)
#define USBD_RAM_BASE_ADDRESS                 0x20005000U // FS 模式下的 USB RAM 地址

其中, USBD_RAM_SIZE USBD_RAM_BASE_ADDRESS 是最关键的配置。STM32 的 USB 外设拥有独立的 SRAM(称为 USB RAM),所有端点的 FIFO 都必须从此处分配。配置错误会导致 FIFO 分配失败,进而导致 USB 通信完全失效。

4.2.2 usbd_desc.c :设备的“身份证”

此文件定义了设备向主机宣告自身的所有描述符。它由两部分组成:

  • 设备描述符 ( USBD_DeviceDesc ): 位于 usbd_core.c 中,是固定的,通常无需修改。
  • 用户自定义描述符: 位于 usbd_desc.c 中,必须根据你的设备进行定制。
/* 自定义设备描述符 */
__ALIGN_BEGIN uint8_t USBD_DeviceDesc[USB_LEN_DEV_DESC] __ALIGN_END =
{
  0x12,                       /* bLength */
  USB_DESC_TYPE_DEVICE,       /* bDescriptorType */
  0x00, 0x02,                 /* bcdUSB */
  0x00,                       /* bDeviceClass */
  0x00,                       /* bDeviceSubClass */
  0x00,                       /* bDeviceProtocol */
  USBD_MAX_EP0_SIZE,          /* bMaxPacketSize */
  LOBYTE(USBD_VID), HIBYTE(USBD_VID), /* idVendor */
  LOBYTE(USBD_PID), HIBYTE(USBD_PID), /* idProduct */
  0x00, 0x02,                 /* bcdDevice */
  USBD_IDX_MFC_STR,           /* iManufacturer */
  USBD_IDX_PRODUCT_STR,       /* iProduct */
  USBD_IDX_SERIAL_STR,        /* iSerialNumber */
  USBD_MAX_NUM_CONFIGURATION  /* bNumConfigurations */
};

/* 自定义配置描述符 */
__ALIGN_BEGIN uint8_t USBD_Custom_CfgDesc[USBD_CUSTOM_CONFIG_DESC_SIZ] __ALIGN_END =
{
  /* Configuration 1 */
  0x09,                                     /* bLength: Configuation Descriptor size */
  USB_DESC_TYPE_CONFIGURATION,              /* bDescriptorType: Configuration */
  USBD_CUSTOM_CONFIG_DESC_SIZ, 0x00,        /* wTotalLength: Bytes returned */
  0x01,                                     /* bNumInterfaces: 1 interface */
  0x01,                                     /* bConfigurationValue: Configuration value */
  0x00,                                     /* iConfiguration: Index of string descriptor */
  0xC0,                                     /* bmAttributes: Self powered */
  0x32,                                     /* MaxPower 100 mA */

  /* Interface 1 */
  0x09,                                     /* bLength: Interface Descriptor size */
  USB_DESC_TYPE_INTERFACE,                  /* bDescriptorType: Interface */
  0x00,                                     /* bInterfaceNumber: Number of Interface */
  0x00,                                     /* bAlternateSetting: Alternate setting */
  0x02,                                     /* bNumEndpoints: Two endpoints used */
  0x03,                                     /* bInterfaceClass: HID */
  0x01,                                     /* bInterfaceSubClass: Boot Interface Subclass */
  0x01,                                     /* bInterfaceProtocol: Keyboard Protocol */
  0x00,                                     /* iInterface: Index of string descriptor */

  /* HID Class Descriptor */
  0x09,                                     /* bLength: HID Class Descriptor size */
  0x21,                                     /* bDescriptorType: HID */
  0x10, 0x01,                               /* bcdHID: HID Class Spec release number */
  0x00,                                     /* bCountryCode: Hardware target country */
  0x01,                                     /* bNumDescriptors: Number of HID class descriptors to follow */
  0x22,                                     /* bDescriptorType */
  USBD_CUSTOM_HID_REPORT_DESC_SIZE, 0x00,  /* wItemLength: Total length of Report descriptor */

  /* Endpoint 1 (IN) */
  0x07,                                     /* bLength: Endpoint Descriptor size */
  USB_DESC_TYPE_ENDPOINT,                   /* bDescriptorType: Endpoint */
  0x81,                                     /* bEndpointAddress: IN Endpoint 1 */
  0x03,                                     /* bmAttributes: Interrupt transfer type */
  LOBYTE(USBD_CUSTOM_CLASS_EP0_BUFFER_SIZE), HIBYTE(USBD_CUSTOM_CLASS_EP0_BUFFER_SIZE), /* wMaxPacketSize */
  0x0A,                                     /* bInterval: Polling Interval (10 ms) */

  /* Endpoint 2 (OUT) */
  0x07,                                     /* bLength: Endpoint Descriptor size */
  USB_DESC_TYPE_ENDPOINT,                   /* bDescriptorType: Endpoint */
  0x02,                                     /* bEndpointAddress: OUT Endpoint 2 */
  0x03,                                     /* bmAttributes: Interrupt transfer type */
  LOBYTE(USBD_CUSTOM_CLASS_EP0_BUFFER_SIZE), HIBYTE(USBD_CUSTOM_CLASS_EP0_BUFFER_SIZE), /* wMaxPacketSize */
  0x0A                                      /* bInterval: Polling Interval (10 ms) */
};

4.3 初始化与启动: app_usbd.c 的核心逻辑

app_usbd.c 是连接 HAL 库、Device Stack 和用户应用的枢纽。其核心函数 APP_USBD_Init() 的实现如下:

USBD_HandleTypeDef hUsbDeviceFS;

void APP_USBD_Init(void)
{
  /* 1. 初始化 USB Device Handle */
  USBD_Init(&hUsbDeviceFS, &USBD_CUSTOM_CLASS, DEVICE_FS);

  /* 2. 注册设备类 */
  USBD_RegisterClass(&hUsbDeviceFS, &USBD_CUSTOM_CLASS);

  /* 3. 注册用户设备事件回调 */
  USBD_RegisterDeviceCallback(&hUsbDeviceFS, &USBD_Custom_DeviceCallback);

  /* 4. 注册用户类回调 */
  USBD_CUSTOM_RegisterClass(&hUsbDeviceFS, &USBD_CUSTOM_CLASS);

  /* 5. 启动 USB 设备 */
  USBD_Start(&hUsbDeviceFS);
}

/* 用户设备事件回调结构体 */
USBD_UsrDevEventCallback USBD_Custom_DeviceCallback =
{
  .Init = USBD_Custom_Init,
  .DeviceReset = USBD_Custom_Reset,
  .DeviceConfigured = USBD_Custom_Configured,
  .DeviceSuspended = USBD_Custom_Suspended,
  .DeviceResumed = USBD_Custom_Resumed,
  .DeviceConnected = USBD_Custom_Connected,
  .DeviceDisconnected = USBD_Custom_Disconnected,
};

/* 用户类回调结构体 */
USBD_ClassTypeDef USBD_CUSTOM_CLASS = {
  .Name = "CUSTOM",
  .Init = USBD_CUSTOM_Init,
  .DeInit = USBD_CUSTOM_DeInit,
  .Requests = USBD_CUSTOM_Requests,
  .GetHSConfigDescriptor = USBD_CUSTOM_GetHSConfigDescriptor,
  .GetFSConfigDescriptor = USBD_CUSTOM_GetFSConfigDescriptor,
  .GetOtherSpeedConfigDescriptor = USBD_CUSTOM_GetOtherSpeedConfigDescriptor,
  .GetDeviceQualifierDescriptor = USBD_CUSTOM_GetDeviceQualifierDescriptor,
  .GetUsrStrDescriptor = USBD_CUSTOM_GetUsrStrDescriptor,
  .USR_DataIn = USBD_CUSTOM_DataIn,
  .USR_DataOut = USBD_CUSTOM_DataOut,
  .USR_Setup = USBD_CUSTOM_Setup,
  .USR_EP0_RxReady = USBD_CUSTOM_EP0_RxReady,
  .USR_EP0_TxSent = USBD_CUSTOM_EP0_TxSent,
  .USR_SOF = USBD_CUSTOM_SOF,
  .USR_Reset = USBD_CUSTOM_Reset,
  .USR_Suspend = USBD_CUSTOM_Suspend,
  .USR_Resume = USBD_CUSTOM_Resume,
  .USR_Configured = USBD_CUSTOM_Configured,
  .USR_Disconnect = USBD_CUSTOM_Disconnect,
  .USR_Connect = USBD_CUSTOM_Connect,
};

main() 函数中,只需调用 APP_USBD_Init() ,整个 USB 设备栈便宣告启动。此后,所有 USB 通信都将通过中断异步完成,主循环可以专注于其他业务逻辑,如传感器数据采集、算法处理等。

我在实际项目中遇到过一个典型问题:设备在 Windows 上能被识别,但无法枚举成功。抓包分析发现,主机在发送 GET_DESCRIPTOR 后,没有收到任何回复。排查后发现,是 usbd_conf.h 中的 USBD_RAM_BASE_ADDRESS 配置错误,导致 EP0 的 FIFO 分配失败, TXFE 中断永远无法被触发。这个教训深刻地提醒我,USB 的底层配置容不得半点马虎,每一个地址、每一个大小,都必须与芯片手册的描述严丝合缝。

Logo

openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。

更多推荐