1. 现象复盘:一次稳定的USB枚举失败
前阵子在 STM32U585 上调 USB 设备,遇到一个很让人摸不着头脑的问题:功能逻辑没有任何改动,只是把 FIFO 配置里的HAL_PCDEx_SetRxFiFo调用重复了一次,而且是在HAL_PCD_Start之前完成的,枚举就挂了。主机侧表现很典型——设备管理器里永远是“unknown device”,用协议分析仪抓包,能看到主机反复发送 GET_DESCRIPTOR 请求,设备却始终不回复。单步进去,HAL 的 USB 中断状态看起来也是正常的,IN 端点却没有触发完成回调。后来把那次重复调用去掉,设备立刻恢复正常。
这不是运气问题,而是 FIFO RAM 布局在 HAL 库实现里存在一个隐式依赖:所有 TX FIFO 的起始地址,都是基于当时的 RxFIFO 大小来计算的。只要配置顺序不对,EP0_IN 传输就会在枚举阶段暴雷。
这个问题的复现场景很好还原。假设你有一段从旧工程迁移过来的初始化代码:
HAL_PCDEx_SetRxFiFo(&hpcd, 0x180); HAL_PCDEx_SetTxFiFo(&hpcd, 0, 0x40); HAL_PCDEx_SetTxFiFo(&hpcd, 1, 0x80);然后因为某些调试需要,比如想让接收缓冲区更大,你又在后面加了一行:
HAL_PCDEx_SetRxFiFo(&hpcd, 0x200);紧接着调用:
HAL_PCD_Start(&hpcd);表面上看,FIFO 配置在设备启动前全部完成,没有任何异步操作介入,理论上不该出问题。但实际表现为设备无法完成枚举,原因就是第二个HAL_PCDEx_SetRxFiFo把之前已经分配好的 TX FIFO 偏移全部变成了“过期配置”。
我必须强调一个容易误判的点:问题表象是 USB 设备没有枚举成功,但根因并不是时钟、不是中断优先级、不是上拉电阻,而是端点 FIFO RAM 地址产生了冲突。很多人在这种问题面前会去查 DSTATUS、查 USB 中断标志、甚至换 PHY,结果绕了一大圈才回来。本文把这个坑从头到尾拆开,包含原理、排查过程和工程上的防呆办法,供同样在 U5 或其它 STM32 OTG 系列上做 USB 设备开发的同学参考。
1.1 代码里的“重复调用”具体长什么样
先说这次调试的原始工程结构。CubeMX 生成的MX_USB_PCD_Init里已经有一份标准的 FIFO 初始化序列,然后我在新加的一个独立 USB 配置模块里又写了一遍,类似这样:
void MX_USB_PCD_Init(void) { hpcd.Instance = USB_OTG_FS; hpcd.Init.dev_endpoints = 6; hpcd.Init.speed = PCD_SPEED_FULL; hpcd.Init.low_power_enable = DISABLE; hpcd.Init.lpm_enable = DISABLE; hpcd.Init.battery_charging_enable = DISABLE; if (HAL_PCD_Init(&hpcd) != HAL_OK) { Error_Handler(); } HAL_PCDEx_SetRxFiFo(&hpcd, 0x180); HAL_PCDEx_SetTxFiFo(&hpcd, 0, 0x40); HAL_PCDEx_SetTxFiFo(&hpcd, 1, 0x80); } void my_usb_extra_config(void) { // 某些情况下动态调整 FIFO HAL_PCDEx_SetRxFiFo(&hpcd, 0x200); HAL_PCD_Start(&hpcd); }问题就在这里:MX_USB_PCD_Init首先设置了 RX FIFO 为 0x180 个字,并配置了 EP0 的 TX FIFO 和 EP1 的 TX FIFO。随后my_usb_extra_config又把 RX FIFO 改成 0x200 个字,然后立即启动设备。
HAL_PCDEx_SetRxFiFo这个函数本身只修改 GRXFSIZ 寄存器,并不会主动去重新计算和更新已经分配好的 TX FIFO 偏移。所以第二次修改后,设备处于一种“RX FIFO 是新的、TX FIFO 偏移还是旧的”不一致状态。
1.2 表面症状和实际故障点的差距
当设备不枚举时,第一反应通常是查硬件连接。我也一样,花了半天时间检查 D+ 上拉、USB 线缆、供电,确定没问题后才回到代码。通过调试器观察,发现设备的中断已经触发了,主机发来的 Setup 包也被接收了,但是当软件调用HAL_PCD_EP_Transmit准备把设备描述符通过 EP0 IN 发给主机时,传输一直没有完成。
这里有一个很关键的细节:EP0 IN 传输在 HAL 里的完成标志,不是发完就立刻置位的,而是要等主机发送 IN 令牌,硬件把 FIFO 中的数据搬上总线,然后触发 XFRC 中断。如果 FIFO 地址配置有问题,硬件可能无法正确读取数据,或者把数据写到了错误的位置,XFRC 永远不会触发,软件就会一直卡在等待状态。
USB 协议里,主机对 SET_ADDRESS 请求后的第一个状态包非常敏感。设备没有在地址 0 上正确回复 GET_DESCRIPTOR,主机就会认为设备不存在,于是反复发送请求,直到超时放弃。所以表面现象是“设备不枚举”,实际故障点则是“EP0 IN 端点没有完成数据搬运”。
2. FIFO RAM 布局:为什么 RX FIFO 是控制传输的“地基”
要彻底理解这个坑,需要先搞清楚 STM32 U5 的 USB OTG FS 内部那一片 FIFO RAM 是怎么划分的。硬件上,USB OTG 外设内部有一块独立的 RAM,专门用于端点的 FIFO,不占用主存。在设备模式下,这块 RAM 被划分为一个 RX FIFO 和若干个 TX FIFO。
RX FIFO 是所有 OUT 方向事务的公共缓冲区。无论是 EP0 OUT、EP1 OUT 还是 SETUP 包,硬件都会先把数据放到 RX FIFO 里,然后触发对应的中断,由软件把数据读走。TX FIFO 则用于 IN 方向传输,一般情况下每个 IN 端点独立占用一段区域,比如 EP0 的 IN 用 TX0 FIFO,EP1 的 IN 用 TX1 FIFO。
和很多人直觉不同的是,这些 FIFO 的地址并不是硬件自动分配的,而是由软件在初始化时显式写到寄存器里。寄存器命名如下:
| FIFO 角色 | 控制寄存器 | 大小字段 | 起始地址字段 |
|---|---|---|---|
| RX FIFO | GRXFSIZ | RXFD | 固定为 0 |
| TX FIFO 0(EP0 IN) | DIEPTXF0 | TX0FD | 由软件配置 |
| TX FIFO 1(EP1 IN) | DIEPTXF1 | TXFD | 由软件配置 |
| TX FIFO n | DIEPTXFn | TXFD | 由软件配置 |
这里最容易被忽略的是:RX FIFO 永远从 RAM 地址 0 开始,而每一个 TX FIFO 的起始地址,需要软件计算后写进对应的高 16 位字段。计算的基本规则是:某个 TX FIFO 的起始地址 = 之前的 RX FIFO 大小 + 之前所有 TX FIFO 的大小之和。
举个具体例子。假设你设置的 RX FIFO 大小为 0x180 个字,TX0 大小为 0x40 个字,TX1 大小为 0x80 个字,那么布局就是:
- RX FIFO 占 [0x000, 0x180)
- TX0 起始地址 0x180,占 [0x180, 0x1C0)
- TX1 起始地址 0x1C0,占 [0x1C0, 0x240)
寄存器里的配置应该是:
GRXFSIZ = 0x180; DIEPTXF0 = (0x180 << 16) | 0x40; // 起始地址 0x180,大小 0x40 DIEPTXF1 = (0x1C0 << 16) | 0x80; // 起始地址 0x1C0,大小 0x80注意,这里的大小单位是 32 位字,不是字节。如果某段时间你习惯用字节数直接填,数据会偏移得很离谱。
2.1 HAL 是如何计算 TX FIFO 起始地址的
这正是整个问题的核心。很多人以为HAL_PCDEx_SetTxFiFo只是设置了一个“端点 FIFO 大小”,然后硬件会自动分配地址,其实 HAL 内部并没有这么智能。
看 STM32U5 的 HAL 源码,HAL_PCDEx_SetTxFiFo在配置非 0 号 TX FIFO 时,会先读取当前的 GRXFSIZ 寄存器,再加上前面已经配置好的 TX FIFO 大小,最后把算出的偏移写到目标寄存器的起始地址字段。流程示意如下:
offset = GRXFSIZ & USB_OTG_GRXFSIZ_RXFD; // 获取当前 RX FIFO 大小 for (i = 0; i < fifo; i++) { offset += DIEPTXF[i] 的大小字段; // 累加前面各 TX FIFO } DIEPTXF[fifo] = (offset << 16) | size;所以每次配置 TX FIFO 时,它使用的 RX FIFO 大小是“那一刻 GRXFSIZ 寄存器里的值”。如果后面你再修改 GRXFSIZ,HAL 不会回头去更新之前已经算好的 TX FIFO 起始地址。
这就造成了我在第一节里看到的场景:第一次配置时,RX = 0x180,TX0、TX1 都按这个值计算。第二次把 RX 改成 0x200 后,GRXFSIZ 变成 0x200,但 DIEPTXF0 里的起始地址还是 0x180,DIEPTXF1 里的起始地址还是 0x1C0。硬件在传输时按照寄存器里的地址去读写,结果可想而知。
2.2 为什么单独设置 RX FIFO 不像“改个参数”那么简单
FIFO 配置属于互联型寄存器,修改一个寄存器就可能牵动整条地址链。这就像你在一个连续的内存池里用指针分配了几块缓冲区,假如你把第一块缓冲区的大小改大了,后面所有缓冲区的起始地址都必须跟着推后,否则就会出现重叠或空洞。
在 OTG 硬件里,EP0 IN 使用的 TX0 FIFO 和后续 IN 端点使用的 TXn FIFO,都要遵循这条规则。软件如果在配置完 TX FIFO 之后再改 RX FIFO 大小,相当于把地基挖深了,却没有把上面的楼整体平移,整个结构就乱了。
有一种特殊情况值得说明:如果第一次调用HAL_PCDEx_SetRxFiFo后,还没有配置任何 TX FIFO,此时你连续修改 RX FIFO 两次,最终结果不会有问题。因为当前没有 TX FIFO 依赖旧的 RX 值,只要在配置 TX FIFO 之前把 RX 定下来,后续链条是对的。从这一点看,“调用两次”这句话只是一个表象,真正的危险是“在已经配置 TX FIFO 之后再次修改 RX FIFO”。
3. 根因排查:从症状定位到重复调用的完整链路
这一节我想完整还原我当时是怎么一步一步定位到根因的,不是为了重复结论,而是因为类似的 FIFO 问题以后可能会以别的形式出现,排查思路比结论更有价值。
3.1 第一步:确认是不是 EP0 IN 的问题
设备不枚举,先不要急着怀疑整条 USB 链路。我做的第一件事是打开 STM32CubeIDE 的调试器,在USBD_LL_DataInStage这个回调里下了断点,这个函数是 USB 设备库收到 IN 传输完成事件后进入的。如果这个断点从来没有被触发,说明 EP0 IN 传输压根没完成。
实际上,我在第一次运行时就发现:Setup 包进来过,HAL_PCD_SetupStageCallback触发了,但是 IN 完成回调始终进不来。这说明设备已经收到了“读取设备描述符”的请求,也在尝试回复,但回复数据没有成功发出去。到这里,整个问题基本被锁定在 EP0 IN 的硬件数据通路上。
3.2 第二步:检查 FIFO 寄存器,手动复原地址分配
思路明确之后,下一个动作就是看 FIFO 寄存器。在代码停在启动后某个断点时,我读取了几个关键寄存器:
GRXFSIZ = 0x00000200; DIEPTXF0 = 0x01800040; DIEPTXF1 = 0x01C00080;从数值看,GRXFSIZ 是 0x200 个字,DIEPTXF0 的起始地址是 0x180、大小是 0x40,DIEPTXF1 的起始地址是 0x1C0、大小是 0x80。
把地址展开就会发现矛盾:
- 按 GRXFSIZ,RX FIFO 应该占 [0x000, 0x200)
- 按 DIEPTXF0 的起始地址,TX0 应该从 0x180 开始,这已经落入了 RX FIFO 的范围
- 按 DIEPTXF1 的起始地址,TX1 从 0x1C0 开始,同样落在 RX FIFO 范围里
也就是说,TX0 和 TX1 的 FIFO 区域和 RX FIFO 完全重叠了。在这样的布局下,硬件收到 IN 令牌后,可能把要发送的数据写进与 RX FIFO 重叠的区域,而软件接收 Setup 的 RX 路径又可能被这些 IN 数据覆盖。结果是双方都在互相污染,EP0 IN 自然无法正常完成。
其实如果不改大 RX FIFO,而是把 RX FIFO 改小,问题一样存在。因为 TX FIFO 的起始地址虽然不会立即重叠,但地址链已经断开了,后面如果再放新的 TX FIFO,就可能和已有地址冲突,或者白白浪费大量 RAM,导致总 FIFO 空间不够用。
3.3 第三步:找到第二次调用,断点定位调用栈
为了让故障“现形”,我在HAL_PCDEx_SetRxFiFo函数入口设置了一个条件断点,条件就是当前 GRXFSIZ 的值和上一次设置的值不同。这样第一次调用不会停下,第二次修改会立刻触发。
实际运行时,断点停在了我新加的my_usb_extra_config里。调用栈非常清楚:先进入MX_USB_PCD_Init,完成第一次 RX FIFO 设置和 TX FIFO 设置,然后进入另一个文件的新增函数,又一次调用了HAL_PCDEx_SetRxFiFo。
到这里其实已经可以直接下结论了,但我还是做了一次对照实验:把第二次调用删掉,重新编译烧录,设备马上枚举成功。为了进一步证明是“TX FIFO 偏移没有同步更新”导致的问题,我还在第二次调用后又强制重新配置了一遍 TX0 和 TX1,设备也能正常工作。这个实验说明问题确实出在 FIFO 地址链的联动上,而不是某个别的偶然原因。
3.4 EP0_IN 为什么是第一个受害者
很多 USB 应用场景里,EP0 后面还有 EP1、EP2 等批量或中断端点,如果 FIFO 布局出错,为什么最先暴露的是 EP0 IN?
因为控制传输是 USB 枚举的起点。设备上电后,主机做的第一件事就是通过端点 0 发送 SETUP 包获取设备描述符。也就是说,SP 包的目标地址是端点 0,设备回复也走端点 0 IN。如果 EP0 IN 的 FIFO 区域已经和 RX FIFO 重叠,那么在枚举阶段就会立刻失败,后面的 CDC、HID、MSC 这些应用端点甚至还没有机会被涉及。
反过来,假如你在某个应用里根本不依赖 EP0 的正常枚举,而是用 DFU 模式或者特殊 bootloader 跳过枚举,那么这个 FIFO 配置错误可能会潜伏很久,直到某个 IN 端点开始传输数据才暴露。无论如何,EP0 IN 是系统最早、也是最能暴露 FIFO 布局问题的“哨兵”。
4. 分析验证:最小复现实验和边界情况
定位到根因后,我又做了一组小实验,把“什么时候会出错、什么时候不会出错”彻底摸清楚。这样以后遇到类似问题,可以直接根据配置顺序判断风险,不需要每次都从头抓寄存器。
4.1 最小复现代码
我建了一个最小工程,代码控制在 30 行以内,专门复现这个问题:
void test_fifo_reproduce(void) { // 场景 A:先设 RX,再设 TX,再改 RX —— 出问题 HAL_PCDEx_SetRxFiFo(&hpcd, 0x100); HAL_PCDEx_SetTxFiFo(&hpcd, 0, 0x40); HAL_PCDEx_SetTxFiFo(&hpcd, 1, 0x80); HAL_PCDEx_SetRxFiFo(&hpcd, 0x200); // 改了大 RX // 场景 B:先设 RX,再设 RX,再设 TX —— 没问题 // HAL_PCDEx_SetRxFiFo(&hpcd, 0x100); // HAL_PCDEx_SetRxFiFo(&hpcd, 0x200); // HAL_PCDEx_SetTxFiFo(&hpcd, 0, 0x40); // HAL_PCDEx_SetTxFiFo(&hpcd, 1, 0x80); }在实际板子上,场景 A 表现稳定异常,场景 B 表现正常。这个对比实验的结果和 HAL 内部的地址计算逻辑完全吻合:TX FIFO 的偏移只取决于其配置那一刻的 RX FIFO 大小,不取决于最终值。