ARTICLE DETAIL

资讯详情

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

HAL库SPI DMA发送卡死?状态机机制与排查实战

HAL库SPI DMA发送卡死?状态机机制与排查实战 搞STM32的朋友十有八九都撞上过这个场景SPI用DMA发送数据第一次运行得好好的第二次调用HAL_SPI_Transmit_DMA直接返回HAL_BUSY或者程序跑着跑着就再也不发了调试器里一看hspi-State死死停在HAL_SPI_STATE_BUSY_TX。有人怀疑是DMA配置不对有人觉得是中断没开其实真正的病根在HAL库那套自动运行的SPI状态机机制里。这个问题的典型特征是“看起来能用用着用着就死”。如果只是简单发送一次很难暴露它一旦你的业务逻辑变成高频连续发送、多个外设共用DMA、或者在中断回调里顺手触发了下一次发送BUSY卡死就来了。这篇文章我把源码摊开讲状态机内部到底怎么跑、DMA发送为什么会卡在BUSY、一步步怎么排查以及最终怎么用工程手段绕开这个坑。适合已经会点HAL库、被SPI的“玄学卡死”折腾过的朋友也适合想在还没踩坑之前就搞懂底层原理的人。1. 先搞清楚HAL库SPI状态机到底在管什么1.1 状态字段就是外设的“运行日志”HAL库给每个SPI外设都配了一个SPI_HandleTypeDef句柄里面有个State成员类型是HAL_SPI_StateTypeDef。别小看这个枚举HAL库所有SPI接口函数的第一件事基本就是读它typedef enum { HAL_SPI_STATE_RESET 0x00U, /* 外设未初始化 */ HAL_SPI_STATE_READY 0x01U, /* 空闲可以接收新任务 */ HAL_SPI_STATE_BUSY 0x02U, /* 忙但不区分方向和模式 */ HAL_SPI_STATE_BUSY_TX 0x03U, /* 正在发送 */ HAL_SPI_STATE_BUSY_RX 0x04U, /* 正在接收 */ HAL_SPI_STATE_BUSY_TX_RX 0x05U /* 正在全双工收发 */ } HAL_SPI_StateTypeDef;这个枚举的作用相当于外设的“运行日志”和“门卫”。它是门卫是因为任何传输函数进来第一句都要检查State是不是HAL_SPI_STATE_READY不是就直接退出它是运行日志是因为你通过调试器看这个值就能知道SPI死在哪个环节。很多同事卡住以后只知道发不出去却不知道去看这个状态值等于病人都躺在手术台上了医生不看心电监护仪。可以这么理解State就是SPI外设的“忙碌灯”。灯亮着的时候你硬要发起新传输系统为了内部数据的一致性只能拒绝你于是返回HAL_BUSY。这个设计本身没错错的是我们经常忽略了这盏灯的存在。1.2 锁、状态、错误码的“三位一体”设计除了State句柄里还有两个字段经常被忽略Lock和ErrorCode。Lock在HAL库里是一个__HAL_LOCK(hspi)宏本质是互斥标志防止两个线程/中断同时操作同一个SPIErrorCode用来记录最近一次发生的异常类型比如HAL_SPI_ERROR_OVR溢出、HAL_SPI_ERROR_DMADMA错误、HAL_SPI_ERROR_MODF模式错误。这三个成员的关系可以这样理解Lock管的是“代码能否重入”防止同一个函数被递归调用。State管的是“外设是否空闲”防止两个传输任务互相覆盖。ErrorCode管的是“出过什么错”给调试留证据。如果程序卡在HAL_BUSY你要先区分是Lock没释放还是State没恢复。实操中我见过一个案例用户在SPI的DMA发送完成回调里又直接调用了一次HAL_SPI_Transmit_DMA结果第二包发送一直失败。后来查源码才发现DMA完成回调里的执行顺序是先复位State、再释放Lock、最后才调用用户回调。如果你拿到回调的时候HAL库内部其实已经把状态和锁都处理好了理论上是可以再发的。但如果用户回调里嵌套发送另一个版本的HAL库却先调回调再释放锁那就可能出现递归调用死锁——这就是不同HAL版本行为不一致带来的坑。1.3 阻塞、中断、DMA三种模式共用同一套状态机HAL库的SPI传输支持三种模式阻塞轮询HAL_SPI_Transmit、中断传输HAL_SPI_Transmit_IT、DMA传输HAL_SPI_Transmit_DMA。它们不是各用各的状态而是共用同一个State字段。这意味着你用阻塞发送还没结束此时调DMA发送会返回HAL_BUSY。你用DMA发送还没结束此时调中断发送也会返回HAL_BUSY。你在中断回调里处理的时间过长下一次DMA发送被拖后状态机依然认为“在忙”。很多人在这里犯迷糊以为“DMA方式是异步的应该随时能调”。实际上HAL库的异步只体现在“发送动作不阻塞CPU”但它在内部依然标记了外设处于忙状态。你要是在主循环里不加判断地高频调用DMA发送第二次调用大概率吃一个HAL_BUSY然后你以为没发出去又重试结果越重试越乱。顺带提一下经常被拿来对比的HAL库和LL库。HAL库搞得这么“重”就是为了让你不需要关心底层寄存器细节代价就是状态机逻辑复杂、源码量大、执行效率偏低LL库几乎不做状态管理操作直接映射到寄存器执行效率高但所有互斥逻辑要你自己写。如果你是被BUSY卡住的初学者别急着换LL库HAL的状态机本质上是在保护你真正的问题是你不了解它的保护规则。2. DMA发送为什么偏偏卡在BUSY——源码级拆解2.1 正常的一次DMA发送内部发生了什么要弄明白故障先得看正常流程。下面以HAL_SPI_Transmit_DMA为例把内部关键步骤理顺。不同系列、不同HAL库版本的源码细节略有差异但套路基本一致调用__HAL_LOCK(hspi)尝试获取互斥锁。如果锁已经被占用直接返回HAL_BUSY。检查hspi-State HAL_SPI_STATE_READY如果不等于说明外设正忙返回HAL_BUSY。把hspi-State置为HAL_SPI_STATE_BUSY_TX表示SPI已进入“发送忙”状态。配置发送DMA通道调用HAL_DMA_Start_IT把内存数据指针、SPI数据寄存器地址、发送长度传给DMA。置位SPI控制寄存器里的TXDMAEN位使能SPI的DMA发送请求然后使能SPI外设。DMA开始搬运数据数据从内存搬到SPI的DR寄存器SPI硬件自动把数据移位输出到MOSI。DMA传输计数减到0后DMA产生“传输完成中断”进入DMA中断服务函数。HAL库在DMA完成中断里执行SPI_DMATransmitCplt把State恢复为HAL_SPI_STATE_READY释放Lock最后调用用户回调HAL_SPI_TxCpltCallback。这段流程的源码简化后大致是这样HAL_StatusTypeDef HAL_SPI_Transmit_DMA(SPI_HandleTypeDef *hspi, uint8_t *pData, uint16_t Size) { /* 第一步抢锁 */ if (__HAL_LOCK(hspi) ! HAL_OK) { return HAL_BUSY; } /* 第二步检查状态机 */ if (hspi-State ! HAL_SPI_STATE_READY) { __HAL_UNLOCK(hspi); return HAL_BUSY; } /* 第三步标记忙 */ hspi-State HAL_SPI_STATE_BUSY_TX; /* 第四步配置DMA并启动 */ HAL_DMA_Start_IT(hspi-hdmatx, (uint32_t)pData, (uint32_t)hspi-Instance-DR, Size); /* 第五步使能SPI的DMA发送请求 */ __HAL_SPI_ENABLE(hspi); SET_BIT(hspi-Instance-CR2, SPI_CR2_TXDMAEN); return HAL_OK; }看到没有从第三步开始State就变成BUSY_TX了。它什么时候才能回到READY完全取决于后续DMA中断是否正常触发、中断服务函数是否正常执行。也就是说这个状态机的“复位”是异步的、依赖外部中断的而不是在函数内部同步完成的。这一点是理解所有BUSY卡死问题的钥匙。2.2 最常见的坑上一次还没结束你又来了一次大多数人说“DMA发送卡在BUSY”其实不是真的死机而是软件逻辑触发了“忙时发送”的拒绝分支。举个例子HAL_SPI_Transmit_DMA(hspi1, buf1, 100); HAL_SPI_Transmit_DMA(hspi1, buf2, 100); // 很可能返回 HAL_BUSY第一行调用后State立即变成BUSY_TX函数返回HAL_OK。但这不代表100个字节已经发完了它只是代表“DMA搬运任务已经登记”。如果紧接着调用第二行而此时DMA还没完成、状态机还停在BUSY_TX那么第二行在第二步状态检查时就直接返回HAL_BUSY数据根本没排上队。很多人的代码是这样写的主循环里根据某个标志位调用发送发送完把这个标志位清零。如果标志位清零放在“调用返回HAL_OK之后”而不是“发送完成回调里”那第二次循环就会因为上一次DMA还没完成而再次调用发送函数然后得到HAL_BUSY。代码看起来没毛病实际上逻辑时序全乱了。这种情况其实属于“状态机正常保护”不算故障但如果你没有用返回值判断成功与否而是直接把数据丢给SPI就会出现“数据偶尔丢一包”的现象。排查的时候先别怀疑HAL库或者芯片有问题先从业务调用时序入手。2.3 更隐蔽的坑DMA完成了但SPI硬件还没忙完还有一种情况比“发送被拒”更隐蔽DMA已经搬运完所有数据State也回到了READY但SPI外设的BSY标志位还置着。SPI的BSY是硬件层面的忙标志表示移位寄存器还在输出最后一个字节的位流。DMA把最后一个字节写进DR寄存器后SPI只是把数据锁存到了移位寄存器物理上MOSI引脚可能还在产生最后的时钟沿和位输出。如果在此时立刻做三件事就会翻车立刻拉高片选信号导致最后一个字节被截断。立刻对SPI执行__HAL_SPI_DISABLE强制关闭外设移位寄存器里剩余的数据可能没发完。立刻切换SPI的工作模式或重新初始化导致当前帧数据损坏。这个问题的根源在于DMA的“传输完成”和SPI的“物理发送完成”是两个不同的时间点。DMA只关心数据有没有从内存搬到SPI的DR寄存器不关心SPI把数据从移位寄存器发到引脚上用了多少个时钟周期。而HAL库的HAL_SPI_TxCpltCallback是在DMA完成中断里调用的它代表的是“数据已交给SPI”并不代表“数据已经从MOSI线上发完”。所以凡是看到DMA发送完成后紧接着拉高SPI片选、紧接着切SPI复用的场景都要在DMA完成回调里、拉高片选之前等待SPI的BSY标志清零。这是硬件时序的要求不是软件状态机的问题。2.4 真正的“死锁式卡BUSY”完成中断根本没来如果State一直停在BUSY_TX而且程序没有反复重入发送那就不是“发送被拒”这么简单了。这属于DMA完成中断根本没能触发、或者触发了但没走到状态复位代码导致状态机永远锁死在忙状态。这种情况常见原因有几个CubeMX里没有给SPI的TX分配DMA通道或者DMA通道的请求源映射不对。比如STM32F1系列里SPI1_TX的DMA请求映射在DMA1_Channel3上你手动配置成了Channel2SPI的中断请求发不出来DMA完全不工作。DMA中断被关了或者优先级低到被其他高频中断饿死。HAL库的HAL_DMA_Start_IT内部会开启DMA通道的传输完成中断但如果此时NVIC里根本没使能对应的DMA中断通道中断永远进不去。SPI侧发生了溢出错误。SPI在全双工模式下如果接收数据没及时读走OVR标志置位可能会进入错误处理分支错误处理如果没有正确复位状态机也会卡住。用户在主循环里用轮询方式直接操作DMA寄存器比如自己也调用了HAL_DMA_Start或HAL_DMA_Start_IT覆盖了SPI内部配置破坏了HAL库预期的事件顺序。这里我建议你在调试器里同时查看hspi-State、hspi-ErrorCode、hdmatx-State三个值。如果hspi-State是BUSY_TX、hdmatx-State也是BUSY说明DMA确实还在跑或者DMA中断没触发如果hdmatx-State已经是READY而hspi-State还是BUSY_TX说明DMA已经走完了问题出在SPI的完成回调链路里。这个判断方法几乎能定位90%以上的BUSY卡死问题。3. 现场排查一步一步把问题揪出来3.1 先看状态机的“仪表盘”排查任何“卡BUSY”的问题第一步不是改代码而是看状态。你用ST-Link连上调试器在程序卡住的时候暂停把hspi1.State、hspi1.ErrorCode、hspi1.hdmatx-State添加到Watch窗口。对照下面这张表基本能判断问题方向观察到的状态状态值通常原因hspi.State HAL_SPI_STATE_BUSY_TXhdma.State HAL_DMA_STATE_READYSPI 0x03DMA 0x01DMA已经完成搬运SPI侧完成中断/回调链路出问题或DMA中断没进hspi.State HAL_SPI_STATE_BUSY_TXhdma.State HAL_DMA_STATE_BUSYSPI 0x03DMA 0x02DMA还没搬完或DMA通道根本没被触发停在等待hspi.State HAL_SPI_STATE_BUSY_TXErrorCode不为0SPI 0x03ErrorCode非0SPI、DMA曾发生过错误状态机没能正常恢复hspi.State HAL_SPI_STATE_READY但发送还是断SPI 0x01状态机没问题问题在硬件时序、片选或SPI配置这个表可以理解为SPI的“仪表盘”。每次遇到卡死问题先把这三个值抄下来再分析比盲目改代码高效得多。3.2 再看寄存器和波形状态值只能说明软件走到哪一步真正确认卡点还需要看硬件寄存器。推荐按这个顺序看看SPIx-SR寄存器重点看TXE、BSY、OVR位。如果TXE是1说明发送缓冲区空SPI等待数据如果BSY是1说明移位寄存器还在输出如果OVR是1说明发生过溢出。看DMA_Channelx-CNDTR寄存器这个寄存器保存剩余待传输字节数。如果CNDTR还没减到0说明DMA搬运没完成如果已经是0说明DMA层面已经做完了问题不在DMA。看SPI引脚状态用示波器或者逻辑分析仪抓SCK、MOSI、CS。一帧数据到底发了几字节、CS信号是什么时候拉高的一目了然。这个方法对“最后一字节被截断”这类问题是唯一能直接实锤的手段。有一次我排查一个OLED屏的SPI显示问题现象是偶尔花屏。看状态机一切正常但用逻辑分析仪一抓发现CS信号在最后一个字节的最后一个时钟沿还没结束时就拉高了导致从机把最后一字节判成“无效”。原因是DMA完成中断里直接拉高CS没有等待BSY清零。加一个BSY等待之后花屏彻底消失。3.3 用“最小复现”缩小范围如果问题在你的业务代码里很难定位就做一个最小化测试屏蔽所有其他外设中断和任务单独循环调用HAL_SPI_Transmit_DMA发送固定长度的数据块。在HAL_SPI_TxCpltCallback里翻转一个GPIO用示波器看这个GPIO的翻转频率和波形。通过这个方式你可以快速判断回调有没有被调用没有说明中断链路有问题。回调被调用了但发送偶尔失败说明问题可能在SPI配置或者外部从机时序。回调正常、数据也发完了但接上原业务代码就卡说明问题大概率在业务调用时序而不是HAL库本身。这个“最小复现”的思路是用二分法把故障范围逐步缩小。很多看起来很玄学的SPI问题最后都会被缩小到一个非常具体的调用时机上。4. 治标和治本的改法4.1 发送前检查状态加一个“门卫”最粗暴的改法是在每次调HAL_SPI_Transmit_DMA之前检查状态if (HAL_SPI_GetState(hspi1) ! HAL_SPI_STATE_READY) { /* 说明上次传输还没结束本次不发送 */ return; } HAL_SPI_Transmit_DMA(hspi1, buffer, len);这种方法能挡住大部分“偶发丢包”问题但它治标不治本。因为“检查状态”和“调用发送函数”之间不是一个原子操作中间一旦有中断插入状态就可能发生变化你还是可能在极端时序下吃到HAL_BUSY。而且如果数据真的没发出去你的业务逻辑里没有通知机制数据就悄悄丢了。这种门卫式检查适合什么场景适合状态机本来就该空闲、只是偶尔要防一手的场合比如调试阶段、低频率命令发送。不适合高频数据传输、需要严格保证每一包不丢的场景。4.2 用完成回调驱动下一次发送要治本得改变思维方式不要“主循环主动调发送”而是“上一包发完后再发下一包”。利用HAL_SPI_TxCpltCallback回调把下一次发送串联起来uint8_t tx_buffer_a[128]; uint8_t tx_buffer_b[128]; volatile uint8_t send_index 0; void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi-Instance SPI1) { /* 上一包完成发送下一包 */ if (send_index 0) { send_index 1; HAL_SPI_Transmit_DMA(hspi1, tx_buffer_b, sizeof(tx_buffer_b)); } else { send_index 0; HAL_SPI_Transmit_DMA(hspi1, tx_buffer_a, sizeof(tx_buffer_a)); } } }为什么在回调里再次调用是安全的因为HAL库在调用用户回调之前已经先把State复位成READY、释放了Lock。所以回调里再发起下一次发送状态机是允许的。这也是官方推荐的“链式发送”写法。但要注意一个细节如果你的HAL库版本较老或者你在回调里做了大量耗时操作会直接影响下一次发送的启动时机。因此回调函数里只做“发起下一次发送”和“设置标志位”不要做打印、延时、复杂计算这类事情。4.3 发送队列彻底告别BUSY纠缠如果业务逻辑里发送请求来自很多地方按键、传感器、通信协议解析等用一个简单的发送队列把所有请求串起来是最稳的方案。核心思路是定义一块待发送缓冲区数组和一个“发送中”标志主循环或调用方只负责往队列里塞数据真正调用HAL_SPI_Transmit_DMA的时机只在“发送中标志为假”或者“上一包完成回调里”。伪代码大致是这样的#define SPI_TX_QUEUE_SIZE 8 typedef struct { uint8_t *data; uint16_t len; } spi_tx_item_t; static spi_tx_item_t tx_queue[SPI_TX_QUEUE_SIZE]; static uint8_t tx_queue_head 0; static uint8_t tx_queue_tail 0; static volatile uint8_t spi_tx_busy 0; int spi_tx_enqueue(uint8_t *data, uint16_t len) { uint8_t next (tx_queue_head 1) % SPI_TX_QUEUE_SIZE; if (next tx_queue_tail) { return -1; /* 队列满 */ } tx_queue[tx_queue_head].data data; tx_queue[tx_queue_head].len len; tx_queue_head next; if (spi_tx_busy 0) { spi_tx_busy 1; HAL_SPI_Transmit_DMA(hspi1, tx_queue[tx_queue_tail].data, tx_queue[tx_queue_tail].len); } return 0; } void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi-Instance SPI1) { tx_queue_tail (tx_queue_tail 1) % SPI_TX_QUEUE_SIZE; if (tx_queue_tail ! tx_queue_head) { HAL_SPI_Transmit_DMA(hspi1, tx_queue[tx_queue_tail].data, tx_queue[tx_queue_tail].len); } else { spi_tx_busy 0; } } }这个方案的好处是无论外部调用方多频繁、多随机HAL库状态机里始终只有一个传输任务在跑。所有请求都被排队消化不会再出现“第二次调用直接HAL_BUSY”的情况。代价是你要多花一点内存维护队列但在现代MCU上这点开销完全值得。我自己实测下来这个结构在处理SPI驱动LCD、驱动外部Flash、甚至连接ESP8266这类需要连续发送数据的场景稳定性提升非常明显。可以说理解了状态机的时序再用队列去配合它等于站在了HAL库的设计逻辑里写代码而不是跟它对着干。4.4 如果已经被BUSY卡死怎么强制恢复程序已经在跑又没有复位条件怎么办合理的方式是先尝试调用HAL_SPI_Abort让HAL库自己把状态机和DMA通道清一遍if (HAL_SPI_GetState(hspi1) ! HAL_SPI_STATE_READY) { HAL_SPI_Abort(hspi1); } HAL_SPI_Transmit_DMA(hspi1, buffer, len);老版本HAL库的F1系列可能没有实现HAL_SPI_Abort那就需要手动复位状态__HAL_SPI_DISABLE(hspi1); hspi1.State HAL_SPI_STATE_READY; __HAL_UNLOCK(hspi1);注意这个“手动复位状态”是治标手段相当于给状态机强行喂了一颗速效救心丸。使用时要格外小心如果SPI片上CS是硬件自动管理的强制DISABLE可能导致CS电平跳变外部从机可能解析到错误的帧尾或者进入不可预期的状态。所以这种方法只建议在“系统已经卡死、不恢复就无法继续”的兜底场景使用。5. 从状态机反推出来的工程教训5.1 回调里别做耗时操作HAL_SPI的DMA完成回调运行在DMA中断上下文里它不仅要跑你的回调逻辑还要维持整个系统的实时性。如果你在回调里做LCD刷屏、浮点运算、软件延时那你不仅拖慢了DMA中断响应还可能影响下一次发送启动。尤其要注意不要在回调里调用HAL_Delay因为HAL_Delay依赖SysTick中断而中断里延时会让系统调度变得极其混乱。我习惯的做法是回调里只做两件事。一是设置“传输完成”事件标志让主循环或RTOS任务去处理后续工作二是如果使用了发送队列就在回调里发起队列下一项。仅此而已。5.2 DMA与SPI中断优先级最好同组且DMA不低HAL库SPI的DMA发送依赖两个中断DMA传输完成中断、SPI错误中断。如果这两个中断相互抢占且优先级设置不合理可能出现死锁式卡顿。举个例子SPI中断优先级高于DMA中断SPI在等待DMA时DMA中断迟迟进不来状态机就一直停在BUSY。反过来DMA中断一直抢占SPI错误处理又被延后错误标志无法及时清除也可能卡住。建议在NVIC配置里把DMA中断优先级设置成等于或略高于对应外设中断优先级且两者在同一抢占优先级组内。这样既能保证DMA完成事件及时处理又不会出现优先级反转导致的意外阻塞。5.3 HAL库是状态机你的业务逻辑也该是状态机很多人写MCU程序习惯用一种“调用完就忘”的流水账思路顺序执行一个函数一个函数地往下走。但SPI DMA发送本质上是异步的调用发送函数只是“下达了任务”任务的完成时间在未来的某个中断里。如果你的业务逻辑不能接受这种异步性要么用阻塞发送要么就老老实实把业务逻辑也改成“状态机驱动”。比如一个简单的“发送一帧数据 - 等待应答 - 再发下一帧”的流程如果你把“等待应答”放在主循环里轮询一个事件标志比在发送函数后面直接死等要可靠得多。这个事件标志在DMA完成回调或者SPI接收回调里置位。这就是让状态的转移由中断事件驱动而不是由主观的时间顺序臆测驱动。5.4 HAL与LL如何选回到很多人纠结的HAL和LL库选择问题。状态机机制是HAL库的核心设计它带来了安全性和一致性但代价是代码量大、执行路径长、抽象层级多。LL库把寄存器操作暴露在面上不做状态保护写起来更“裸”效率也更高但所有互斥、时序、异常恢复逻辑全要自己维护。我的建议是项目进入稳定维护期、团队水平参差不齐、需要快速迭代的时候选HAL库但一定要花时间理解其状态机和回调机制如果产品对实时性要求极高、Flash/RAM资源紧张、而且团队对寄存器级编程很熟练再考虑LL库或者直接寄存器操作。至少对SPI DMA这种外设HAL库的状态机机制本身没有硬伤大部分问题出在使用者没有按它的规则来。最后再分享一个我自己固化下来的习惯每次进入SPI相关代码的调试第一件事永远是看hspi-State和hdmatx-State这两个值而不是急着改代码。状态机把外设的每一步都记录在案你只要会看它就等于一个免费的逻辑分析仪。把这一步养成肌肉记忆之后你会发现自己写的SPI代码越来越顺从“玄学调通”变成“一次跑通”。
返回列表