
STM32 HAL库的QSPI驱动平时用起来挺顺手但真遇到“传输过程中突然要停止”这种场景HAL_QSPI_Abort这个接口就会暴露一个很典型的竞态条件Race Condition。我最早是在一个外接NOR Flash的批量擦写项目里踩到这颗雷后台任务正在用QSPI读数据前端命令过来需要立刻中止并切换为擦写操作结果Abort调用后整个QSPI外设直接“僵死”读状态寄存器永远返回忙后续所有命令全部超时。当时排查了很久最终把矛头指向了HAL库内部状态机在Abort路径上的更新时序问题。这个问题不是偶发的它跟调用时序、中断优先级、是否开启DMA都有关系一旦出现就很难复现但造成的后果往往是系统级故障。如果你正在用STM32F4/F7/H7系列的QSPI外设而且代码里存在“多个任务或中断并发访问QSPI”的可能这篇文章值得认真读一下。我会从竞态产生的底层原因讲起给出可复现的测试方法并分享几种工程上稳妥的解决方案。1. 竞态条件到底是怎么来的1.1 QSPI正常操作与Abort的调用场景先梳理一下调用场景。QSPI外设跟普通SPI最大的区别在于它能同时支持单线/双线/四线模式读速度可以做到很高而且自带FIFO和DMA通道。很多项目里用它外接NOR FlashW25Q128这类或者驱动一些支持Quad SPI接口的小屏。以NOR Flash为例读取一页数据可能只要几微秒但擦除一个扇区可能要几百毫秒这就导致任务调度时必须允许“抢占”。按HAL库的设计一次完整的QSPI传输过程是这样的调用HAL_QSPI_Transmit、HAL_QSPI_Receive或HAL_QSPI_Command启动传输。HAL层把控制字写入CR寄存器使能FIFO以及对应的中断/DMA通道。传输过程中句柄里的State从READY变成BUSY对应的变体还会细分BUSY_TX、BUSY_RX、BUSY_COMMAND。传输结束后由中断入口HAL_QSPI_IRQHandler或DMA回调把State恢复为READY再触发用户回调。Abort这个接口设计出来就是为了在第2步到第4步之间强行终止一次传输。比如你想给Flash发一个RDSR命令但上一次大块读取还没结束此时就必须先调用HAL_QSPI_Abort把总线抢回来。从设计意图上看这是个好东西但实际执行的时候HAL库自己的状态更新路径出现了时间窗口。1.2 竞态产生的底层机制HAL_QSPI_Abort的执行可以拆成两个部分第一步向CR寄存器的ABORT位写1让QSPI外设在硬件层面停止当前传输第二步是等待传输真正停下来然后清理状态、恢复READY状态并触发HAL_QSPI_AbortCpltCallback回调。如果使用的是中断版本HAL_QSPI_Abort_IT第二步的动作会被挪到IRQHandler里执行。问题就出在这里Abort函数读取到State的值和QSPI硬件此刻的真实状态可能是不同步的。举个例子假设最后一次读传输已经把FIFO里的数据全部读完硬件的BUSY位刚刚被清零。此时传输完成中断还在中断控制器里排队还没执行到HAL_QSPI_IRQHandler句柄的State还停留在READY与BUSY之间的中间态。你的代码碰巧在此时调用HAL_QSPI_AbortHAL库会判断State不是READY于是正常执行Abort流程。但由于硬件层面已经静默了Abort等待循环可能会提前退出或者和随后到来的“传输完成中断”在状态判断上打架。两者都对同一个State字段做读改写最终结果就是State停留在BUSY回不到READY。另一种更常见的碰撞出现在DMA模式。QSPI传输完成后DMA会先产生自己的完成中断与此同时QSPI的TC标志也置位。如果你的代码在DMA中断里直接调用HAL_QSPI_Abort而此时HAL库正处在“DMA切换”的中间状态State虽然是BUSY但底层已经有一部分路径进入了“等待DMA完成”的分支。这时候Abort和DMA回调、QSPI中断三路并发修改同一个句柄必然出问题。1.3 为什么中断里调用时格外明显在裸机时代大部分人用轮询方式来调用HAL_QSPI_Abort出问题的概率相对低一些因为在轮询模式下整个调用是同步的谁调用谁等待时序冲突面小。但到了RTOS环境下大家习惯把QSPI的完成事件用信号量或事件标志组来同步比如任务A发送读命令后阻塞在信号量上传输完成中断里释放信号量。这种模式下State的修改只发生在中断上下文而真正调用Abort的可能是另外一个任务。此时只要中断优先级配置稍有不合理就会出现典型的悬挂场景任务B因为某个外部信号要中止当前QSPI操作它调用HAL_QSPI_Abort往ABORT位写了1。而任务A等待的那次传输完成中断恰好被一个更高优先级的中断打断了还没进ISR。等传输完成中断终于进入HAL_QSPI_IRQHandler时它发现自己要等待的状态已经被Abort路径改掉了不知道该走“正常完成”分支还是“Abort完成”分支回调不触发信号量没人释放任务A就永远挂死。如果QSPI中断和DMA中断优先级相同还可能出现两个中断互相等待的递归问题。这跟任务调度里的死锁有点像A等BB等A谁都不往前推进外设状态就卡死在中间。2. 复现与定位不放过每一个现场2.1 稳定复现的三个前置条件竞态问题最麻烦的地方在于复现概率低很多时候你说“这里有bug”别人跑一整天都跑不出来。所以我先讲清楚复现的三个前置条件有足够长的传输时间窗口。传输如果只有几微秒Abort很难插进去。建议让单次QSPI读或写操作至少在50微秒以上比如一次读取256字节以上或者直接操作Flash的页编程。传输完成与Abort在时间上重叠。可以用一个定时器周期性地在传输末尾阶段触发Abort人为制造“刚好完成、又刚被中止”的碰撞。关闭编译器优化或者开优化都测一下。我发现-O2优化下问题更容易出现因为编译器可能调整对volatile变量的读写顺序让State跨语句更新时更容易暴露竞态窗口。满足这三个条件跑个几分钟就能看到异常。如果还是复现不了把中断优先级故意调成相同或者把DMA打开一定会出现。2.2 通过读写循环高频触发竞态复现程序我建议用最朴素的方式写不要加太多业务逻辑方便观察。核心就是一个主循环反复发读请求同时一个定时器中断周期性调用Abort。代码大概是下面这样volatile int abort_trigger_count 0; volatile uint8_t abort_done_flag 0; void HAL_QSPI_AbortCpltCallback(QSPI_HandleTypeDef *hqspi) { abort_done_flag 1; } void HAL_QSPI_ErrorCallback(QSPI_HandleTypeDef *hqspi) { printf(QSPI error: %d, state: %d\n, hqspi-ErrorCode, hqspi-State); } int main(void) { // ... 初始化时钟、QSPI、串口 ... uint8_t rx_buf[1024]; while (1) { abort_done_flag 0; HAL_StatusTypeDef ret HAL_QSPI_Receive(hqspi, rx_buf, 1024, 1000); if (ret ! HAL_OK) { printf(Receive failed: %d, state: %d\n, ret, hqspi.State); } HAL_Delay(1); } } void TIM2_IRQHandler(void) { if (TIM_GET_FLAG(TIM2, TIM_FLAG_UPDATE) ! RESET) { TIM_CLEAR_FLAG(TIM2, TIM_FLAG_UPDATE); if (abort_trigger_count % 5 0) { HAL_QSPI_Abort(hqspi); } } }注意这只是一个复现程序的写法实际工程里不要在中断中直接调用阻塞版的HAL_QSPI_Abort这会拖长中断响应时间。之所以在这里这么写是为了人为制造“传输完成中断还没处理完Abort就已经进来”的冲突让竞态问题快速暴露。跑起来之后当异常出现时你会看到几个典型现象