STM32 USB OTG Device中断处理机制深度解析
USB设备栈是嵌入式系统实现即插即用通信的核心基础,其稳定性高度依赖于对硬件中断的精准响应与分层调度。在STM32系列MCU中,USB OTG外设通过全局中断寄存器(GINTSTS)统一管理数十类事件,其中RXFLVL(接收FIFO非空)、IEPINT(IN端点中断)和OEPIINT(OUT端点中断)构成数据流处理的三大支柱——前者负责原始字节搬运,后两者分别驱动IN/OUT传输的状态推进与业务回
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() )会执行以下标准化流程:
- 临时禁用中断 :首先通过写入
OTG_FS_GINTMSK寄存器,暂时屏蔽RXFLVL中断。这是为了防止在处理当前 FIFO 内容时,新的数据涌入导致 FIFO 溢出(Overflow),从而丢失数据。这是一种典型的临界区保护策略。 - 读取 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,此字段是区分数据归属的唯一依据。
- Packet Status (
- 获取端点句柄 :根据
EPNUM字段的值,软件索引 Device Stack 内部维护的端点控制结构体(PCD_EPTypeDef)数组,获取指向该端点的句柄。这个句柄中包含了该端点的所有关键信息,其中最重要的是xfer_buff——一个指向用户为该端点分配的数据缓冲区的指针。 - 按状态执行数据搬运 :
-
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 更加复杂:
- 识别中断源端点 :首先,软件读取
OTG_FS_DAINT寄存器,通过其OEPINT位域(Out Endpoint Interrupt)确定是哪一个具体的 OUT 端点(EP0,EP1, …)产生了中断。 - 读取端点中断状态 :接着,软件读取该端点对应的中断状态寄存器
OTG_FS_DOEPINT(Device OUT Endpoint Interrupt)。该寄存器的每一位代表一种具体的中断事件。 - 事件驱动的分支处理 :根据
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 高度对称:
- 识别中断源端点 :读取
OTG_FS_DAINT寄存器的IEPINT位域,确定是哪个 IN 端点。 - 读取端点中断状态 :读取该端点的
OTG_FS_DIEPINT(Device IN Endpoint Interrupt)寄存器。 - 事件驱动的分支处理 :
-
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 初始化时被显式注册。整个过程遵循一个清晰的模板:
- 定义用户回调结构体 :在你的应用文件(如
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, }; - 在主应用中创建句柄 :在
main.c或app.c中,声明一个USBD_HandleTypeDef类型的全局句柄。c USBD_HandleTypeDef hUsbDeviceFS; -
调用初始化 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 总线上进行常规数据通信的资格。其完整的事务序列如下:
- Setup 阶段(Transaction #116) :
- 主机发出一个
SET_CONFIGURATIONSetup 包(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状态。
- 主机发出一个
- 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 的协同工作。
- Setup 阶段(Transaction #14) :
- 主机发出
GET_DESCRIPTORSetup 包(bmRequestType=0x80,bRequest=0x06,wValue=0x0100)。 RXFLVL中断触发,Setup 包被读入setup缓冲区。
- 主机发出
- 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)。
- 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 的底层配置容不得半点马虎,每一个地址、每一个大小,都必须与芯片手册的描述严丝合缝。
openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。
更多推荐



所有评论(0)