ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

STM32 HAL库SPI DMA发送卡HAL_BUSY?状态机机制与根治方案全解析

STM32 HAL库SPI DMA发送卡HAL_BUSY?状态机机制与根治方案全解析 如果你和我一样用STM32的HAL库做SPI屏幕刷新或者传感器数据读取大概率碰到过这种场景第一帧数据用HAL_SPI_Transmit_DMA发送一切正常第二帧开始就直接返回HAL_BUSY查了一晚上发现hspi1.State这个变量死死卡在HAL_SPI_STATE_BUSY_TX怎么都回不去。更气的是你换个串口用轮询发送又好好的。问题出在哪不在DMA配置也不在GPIO而在于HAL库底层那套SPI状态机机制。这篇文章我会把HAL库SPI状态机的完整运行逻辑、DMA发送的执行链以及导致BUSY状态无法清除的根因全部拆开讲最后给出可以直接落地的修复方案让你少熬几个夜。1. 为什么你的SPI DMA发送会突然返回到HAL_BUSY1.1 先搞清楚卡住的BUSY到底是哪个BUSY很多人在排查的时候会把“SPI卡在BUSY状态”笼统地理解成硬件问题但实际上HAL库里有两个完全不同的“BUSY”概念。一个是SPI外设的硬件状态寄存器里那个BSY位它代表着SPI移位寄存器正在工作数据传输还没有完全结束。这个位是芯片级别的读SPI_SR寄存器就能看到。另一个是HAL库句柄结构体里的State状态机字段。这个字段是一组枚举值包括HAL_SPI_STATE_RESET、HAL_SPI_STATE_READY、HAL_SPI_STATE_BUSY、HAL_SPI_STATE_BUSY_TX、HAL_SPI_STATE_BUSY_RX、HAL_SPI_STATE_BUSY_TX_RX、HAL_SPI_STATE_ERROR等。你调用HAL_SPI_Transmit_DMA之后这个State就会从READY切换成BUSY_TX表示库内部认为“当前句柄正在执行发送操作”。大多数情况下我们说的“DMA发送会卡在BUSY状态”指的就是这个State字段卡在HAL_SPI_STATE_BUSY_TX导致后续调用HAL_SPI_Transmit_DMA时函数因为状态不满足条件而直接返回HAL_BUSY。搞清楚是哪个BUSY后面排查思路才不会乱。1.2 一个典型的卡死现场还原我当初遇到的设备是STM32F407通过SPI1驱动一块TFT屏幕图像数据通过DMA通道发送。CubeMX里配置了SPI1为全双工主机模式开启了DMA TX请求DMA设置为Normal模式传输完成后开启中断。主循环里每100毫秒刷新一帧画面调用一次HAL_SPI_Transmit_DMA。看似没有问题跑起来第一帧正常第二帧就整个屏幕白掉。我在HAL_SPI_Transmit_DMA返回值的地方打断点第二次调用返回的是HAL_BUSY。再往hspi1.State里一看好家伙HAL_SPI_STATE_BUSY_TX停在那里纹丝不动。当时我第一反应是DMA通道配置错了或者中断优先级太低。后来把DMA中断打开、优先级调高依然不行。最后不得不去翻HAL库源码才发现这个BUSY状态和DMA本身的关系没有表面上看起来那么简单。2. 深入HAL库SPI状态机状态定义与流转逻辑2.1 状态机是所有HAL操作的总开关HAL库的每个外设句柄内部都有一个State字段SPI也不例外。它的作用相当于外设当前工作状态的“总开关”所有HAL库函数在执行之前都会先检查这个状态状态不对就拒绝执行。查阅HAL头文件可以看到SPI的状态枚举定义大概是这样的typedef enum { HAL_SPI_STATE_RESET 0x00U, /*! SPI未初始化 */ HAL_SPI_STATE_READY 0x01U, /*! SPI就绪可接收新任务 */ HAL_SPI_STATE_BUSY 0x02U, /*! SPI忙碌存在错误状态 */ HAL_SPI_STATE_BUSY_TX 0x03U, /*! SPI正在发送 */ HAL_SPI_STATE_BUSY_RX 0x04U, /*! SPI正在接收 */ HAL_SPI_STATE_BUSY_TX_RX 0x05U, /*! SPI同时收发 */ HAL_SPI_STATE_ERROR 0x06U, /*! SPI通信错误 */ HAL_SPI_STATE_ABORT 0x07U /*! SPI中止 */ } HAL_SPI_StateTypeDef;这套状态机的设计逻辑并不复杂核心原则就是“同一时间同一句柄只能干一件事”。你想在SPI正在接收数据的时候去启动发送HAL库直接拒绝。你企图在SPI还没初始化完的时候就调DMA发送HAL库也拒绝。这种设计主要是为了保持内部流程的一致性避免多线程或中断场景下出现资源冲突。但问题恰恰出在这里如果某个环节让State从BUSY_TX回不到READY这套保护机制反而变成了一把锁死的门禁。2.2 状态机和句柄锁是两套门禁经常被当成一回事很多人不知道HAL库里除了State状态机还有一个Lock字段。这个Lock是专门用来防止同一个句柄被重入的你可以把它理解成“函数调用级别的互斥锁”。__HAL_LOCK(hspi); __HAL_UNLOCK(hspi);在HAL_SPI_Transmit_DMA执行过程中函数会先尝试对句柄加锁如果加锁失败说明上一次调用还没有完全退出或者正在被中断流程占用直接返回HAL_BUSY。加锁成功后再进入状态机判断、启动DMA、开启SPI最后在函数返回前解锁。于是问题就来了有的人看到HAL_SPI_Transmit_DMA返回HAL_BUSY以为是State状态机卡住实际上可能是Lock锁没释放。这两套机制独立存在任何一个卡住最终表现都是HAL_BUSY。这也是很多人反复查State状态觉得“明明已经READY了为什么还是BUSY”的根本原因。2.3 状态转移完全依赖中断回调驱动再往深一层看HAL库SPI状态机的状态切换并不是主动轮询完成的而是靠中断服务函数里的回调逻辑推动的。DMA发送完成后DMA通道进入中断服务函数HAL内部会调用SPI_DMATransmitCplt这种内部回调来更新State。如果DMA中断没有被使能或者中断优先级配置不合理导致中断一直不能执行那么State就永远停留在BUSY_TX。很多新手在这里踩坑以为DMA发送模式只需要把DMA通道配好就行结果忘了开DMA的中断发送一帧之后整个SPI就再也不能用了。2.4 不同HAL库版本的实现差异也是坑之一还有一点值得注意不同系列的HAL库比如STM32F1的HAL和STM32F4的HAL在内部回调甚至Lock释放时机上存在细微差异。有的版本在调用用户回调函数之前就已经释放了Lock有的版本则是在调用完用户回调之后才释放。这个差异很难从手册里看出来但行为上差别非常大。如果你在用户回调里直接启动下一次DMA发送在Lock先释放的版本里可能没事在Lock后释放的版本里就会频繁返回HAL_BUSY。这也解释了为什么同一段代码在F103上没问题换到F407上就卡死。3. 从HAL_SPI_Transmit_DMA调用到DMA完成中断完整走一遍状态机流转3.1 调用HAL_SPI_Transmit_DMA时内部到底做了什么为了看清问题根源我们以常见的F4系列HAL库源码为参考把HAL_SPI_Transmit_DMA函数的执行流程剥开看。函数开头的判断逻辑大致是if (hspi-State HAL_SPI_STATE_BUSY_TX) { return HAL_BUSY; } if (hspi-State HAL_SPI_STATE_BUSY_RX) { hspi-ErrorCode HAL_SPI_ERROR_BUSY_RX; return HAL_BUSY; } if (hspi-State HAL_SPI_STATE_BUSY_TX_RX) { hspi-ErrorCode HAL_SPI_ERROR_BUSY_TX_RX; return HAL_BUSY; }如果状态检查通过会进行句柄加锁接着把State设置为HAL_SPI_STATE_BUSY_TX然后启动DMA传输使能SPI外设最后解锁。这个流程看起来没什么问题但注意关键的一步State被设置为BUSY_TX之后如果你紧接着又调用了同一个发送函数但上一次的DMA传输还没有完成那么第二次调用必然会卡在状态检查那一关。如果DMA传输永远没有完成或者完成中断一直没有被执行State就永远卡住。完整的状态机流转到这一步就已经中断了这也是“卡在BUSY”最直接的形成链路。3.2 DMA搬运完成不等于SPI数据发送完成还有个容易被忽略的细节。DMA把数据从内存搬运到SPI数据寄存器这个动作完成之后会触发DMA传输完成中断HAL库会认为“本次发送的DMA部分完成了”。但实际上SPI移位寄存器里可能还有最后一个字节正在发送。硬件层面来说SPI的BSY位可能还是1。不同HAL库版本对硬件BSY的处理并不一致。有些内部回调会等待BSY清零有些则直接关闭SPI外设或者恢复State为READY把硬件层的收尾工作交给了下一次发送去处理。这就导致一种非常隐晦的故障State已经回到READY了但你再次调用发送时发现SPI硬件还很忙读SR寄存器BSY位一直为1甚至FLAG被卡住。这种情况虽然不会让HAL_SPI_Transmit_DMA返回HAL_BUSY但在低层操作或硬件片选切换时会表现为SPI通信异常或数据错位很多人误以为又是BUSY状态问题。3.3 DMA完成中断中状态机的恢复逻辑再往回调内部看。DMA传输完成中断触发后会进入HAL内部的DMA回调最终执行类似这样的流程static void SPI_DMATransmitCplt(DMA_HandleTypeDef *hdma) { SPI_HandleTypeDef *hspi (SPI_HandleTypeDef *)hdma-Parent; /* 关闭SPI发送的DMA请求 */ /* 清除溢出标志等异常标志 */ /* 将hspi-State恢复为HAL_SPI_STATE_READY */ /* 调用用户发送完成回调HAL_SPI_TxCpltCallback */ }看到没有State恢复为READY的操作发生在调用用户的HAL_SPI_TxCpltCallback之前。这个设计本意是让用户在回调函数里判断状态时SPI已经处于就绪状态。但如果你在HAL_SPI_TxCpltCallback里再次调用HAL_SPI_Transmit_DMA就会撞上一个很微妙的边界条件State虽然是READY了但函数之前的句柄锁还没有释放。HAL_SPI_Transmit_DMA进入后第一步尝试__HAL_LOCK发现锁还被占用直接返回HAL_BUSY。而你的代码逻辑可能误以为“发送失败”又去重置状态结果越调越乱。这个现象在社区里极其常见几乎每个做SPI DMA显示刷新的工程师都会遇到一次。3.4 哪些操作会把状态机彻底堵死在BUSY_TX把卡死场景总结一下最常见的几种原因DMA中断没有使能或者优先级太低DMA完成回调永远不执行。 在DMA完成回调里直接重入HAL_SPI_Transmit_DMA撞上未释放的句柄锁。 DMA配置成了循环模式Circular传输完成中断的触发时机和HAL库的状态机预期不一致。 SPI发送过程中出现了溢出错误OVR错误中断把State切到了ERROR而代码没有任何错误恢复逻辑。 多个外设共享同一个DMA通道发送没结束就被别的DMA请求抢占。这五种情况里第二种和第一种大约占了故障案例的八成。尤其是第二种几乎每个从轮询发送切到DMA发送的人都会踩一遍。4. 现场复现与调试技巧三板斧定位卡死点4.1 第一板斧调试器直接观察句柄状态卡住之后别急着改代码先用调试器挂上去看变量。观察几个关键位置hspi1.State。如果卡在0x03就是HAL_SPI_STATE_BUSY_TX。 hspi1.Lock。如果Lock等于1说明句柄锁被占用。 hspi1.ErrorCode。如果有错误码说明中间产生了异常事件。 SPI_SR寄存器的BSY、TXE、RXNE位。BSY为1代表硬件还没忙完。这个组合基本能确定问题发生在哪一层。State和Lock都正常但BSY为1说明是硬件层收尾问题。State卡住而Lock为0说明中断没执行或者执行到一半就挂了。Lock为1大概率是重入问题。4.2 第二板斧在HAL_SPI_Transmit_DMA入口打断点在HAL_SPI_Transmit_DMA函数入口处打一个断点每次调用都会停在这里。调用时看hspi1.State、hspi1.Lock再单步执行看它是卡在哪个if判断里。如果卡在StateHAL_SPI_STATE_BUSY_TX的判断说明上一次发送没完成。如果卡在__HAL_LOCK说明句柄锁还没释放。这个定位方法非常直接比我当初盲目查了半个小时的DMA配置高效多了。4.3 第三板斧在DMA中断入口确认中断是否到来如果State一直卡在BUSY_TX但不能确定是DMA中断没触发还是回调卡住可以在DMA中断服务函数入口打一个断点比如DMA1_Stream5_IRQHandler。如果这个断点压根没进去那就是DMA中断配置的问题。如果进去了但State还是没恢复就要往HAL库里层排查。我建议你在做SPI DMA调试时别把DMA中断优先级设置成和SPI中断一样高也别设置成最低。优先使用NVIC里的合理中断优先级分组保证DMA完成中断可以被及时响应。否则就算状态机没卡死数据的时序也会变得很怪。5. 根治方案从状态机角度彻底解决卡BUSY问题5.1 方案一回调里只置标志主循环启动下一帧发送这是最稳妥、也最符合HAL库状态机设计逻辑的做法。不要在HAL_SPI_TxCpltCallback里直接调用HAL_SPI_Transmit_DMA只置一个标志位volatile uint8_t spi_tx_done 0; void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi hspi1) { spi_tx_done 1; } }在主循环里判断标志位再启动下一次发送if (spi_tx_done) { spi_tx_done 0; HAL_SPI_Transmit_DMA(hspi1, (uint8_t *)frame_buffer, frame_len); }这样做的本质是让HAL_SPI_Transmit_DMA在函数退出、句柄锁完全释放之后才被再次调用彻底避开重入问题。虽然多了一个主循环的调度延迟但对于屏幕刷新、传感器采集这类非极致实时场景完全够用。5.2 方案二如果你必须在中断回调里连续发送有一些场景比如音频流播放数据必须连续不能断。这种情况下可以在回调里先停止本次DMA再启动下一次void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi hspi1) { HAL_SPI_DMAStop(hspi1); while (hspi1.State ! HAL_SPI_STATE_READY) { /* 等状态恢复 */ } HAL_SPI_Transmit_DMA(hspi1, (uint8_t *)next_buffer, frame_len); } }但说句实话这不是我最推荐的写法因为HAL_SPI_DMAStop本身会把State重置为READY而中断里还有后续的锁定流程稍不注意就会变成双重复位。至少在我的项目里用“标志位主循环”的方案比这个稳定得多。如果对代码体积不敏感也可以考虑直接用LL库LL库的函数不经过状态机和句柄锁直接操作寄存器适合对时序极其敏感并且你能完全掌控流程的场景。HAL和LL的区别就在这里HAL省心但存在隐形的状态管理开销LL灵活但所有流程都要自己维护。5.3 方案三为SPI操作封装一层“安全发送函数”如果你不愿意大改业务逻辑只需要保证不卡死可以封装一个带超时保护的发送函数HAL_StatusTypeDef spi_dma_send_safe(SPI_HandleTypeDef *hspi, uint8_t *buf, uint16_t len) { uint32_t tick HAL_GetTick(); while (hspi-State ! HAL_SPI_STATE_READY) { if (HAL_GetTick() - tick 100) { __HAL_SPI_DISABLE(hspi); hspi-State HAL_SPI_STATE_READY; __HAL_UNLOCK(hspi); return HAL_TIMEOUT; } } return HAL_SPI_Transmit_DMA(hspi, buf, len); }注意这个函数只能作为防御手段真正的根因如果不解决超时重置状态也只是治标不治本。我在代码里加了状态重置逻辑是因为在长时间无人值守的项目里宁可牺牲一次发送也不能让整个系统挂死。5.4 CubeMX配置里必须检查的几个点如果你是用CubeMX生成的工程请打开SPI配置和DMA配置页面逐个核对这几点SPI模式必须正确比如主机模式下全双工、只发送配置成断电导致异常。 DMA模式建议先使用Normal模式调通再考虑Circular模式或者双缓冲。Continuous Requests这个选项HAL库里有些版本默认使能后状态机管理会变得非常难以捉摸排查BUSY问题时先关掉。 DMA中断必须使能优先级按实际需求设置。 SPI的NVIC中断建议也使能这样错误事件能及时进回调。5.5 双缓冲DMA模式的使用补充如果你的DMA支持双缓冲可以考虑使用HAL_DMAEx_MultiBufferStart这种接口。发送Buffer A时Buffer B已经在后台准备两个缓冲区交替。这个方案的代码复杂度会上升一个档次但可以有效减少SPI发送的间歇时间而且由于每次传输都是重新开始的状态机的管理会更直接。但要注意双缓冲和HAL_SPI_Transmit_DMA是两套走法不要在同一个句柄上混用。如果用DMA双缓冲就不要再调用HAL_SPI_Transmit_DMA去启动传输否则状态机会冲突。6. 常见问题与排查速查一张表把坑踩平6.1 故障现象对照表现象直接原因处理办法HAL_SPI_Transmit_DMA返回HAL_BUSYState0x03上一次发送未完成或DMA中断没触发检查DMA中断配置等待状态机回到READYHAL_SPI_Transmit_DMA返回HAL_BUSYState0x01句柄锁占用不要在发送函数未退出时重入尤其注意回调第一次正常第二次开始卡死DMA完成中断没执行或回调中重入回调里只置标志主循环里启动下一次SPI读SR寄存器BSY一直为1DMA完成后的最后一个字节还在移位寄存器等待BSY清零或在发送完成后延时一小段时间状态机进入ERRORSPI溢出或模式配置错误检查ErrorCode执行状态复位再继续使用Circular DMA后无法停止连续请求导致状态机认为一直忙碌先配置为Normal模式调通后再改双缓冲这张表我每次排查SPI DMA问题都会拿来看一遍基本上能覆盖工作中遇到的大部分坑。6.2 一个特殊的坑共享DMA通道项目里多个外设共用同一个DMA通道也是常见问题。比如SPI1的TX和UART5的RX都用了DMA1的Stream5一旦SPI发送还没结束UART接收的DMA请求又把通道抢走了状态机就会处于混乱状态。解决办法是调整DMA通道分配或者封装一个DMA资源管理器每次启动DMA前判断通道是否被占用。简单项目里我更建议直接重新分Channel避免隐患。6.3 关于“HAL_SPI_Transmit_DMA在中断回调里第二次调用”的最终建议我再说得直白一点如果你的代码在HAL_SPI_TxCpltCallback里调用了HAL_SPI_Transmit_DMA而且返回的是HAL_BUSY那就先别怀疑DMA配置。你先把回调改成只置标志位然后主循环里发送十有八九马上就正常了。这是HAL库状态机机制决定的不算Bug而是一种需要理解的设计约束。实际开发中接受这个约束远比强行绕过它来的划算。我自己在后来做的好几个项目里包括传感器数据采集、屏幕刷新、Flash读写都统一采用“回调置标志主循环发数据”的写法再也没有出现过SPI DMA卡在BUSY状态的问题。最后再说一个小技巧如果你调试的是实时性要求不高的SPI外设每次发送前可以先短延时几十微秒这个时间足够SPI硬件完成最后一个字节的移位发送也能给状态机留出切换的余量。做嵌入式开发有时候就是这些不起眼的时序细节决定了系统稳不稳定。
返回列表