ARTICLE DETAIL

资讯详情

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

STM32串口DMA发送卡死?从HAL库源码到工程实践的完整解决方案

STM32串口DMA发送卡死?从HAL库源码到工程实践的完整解决方案 我遇到过串口DMA发送只能成功一次第二次调用就卡死系统像被人掐住了脖子调试助手那边一个字节都收不到。查了半天代码逻辑看着没问题HAL_UART_Transmit_DMA也的确被调用了但就是发不出去。这个坑在STM32 HAL库里太经典了从F1到F4到H7只要用DMA发送几乎人人都要踩一遍。网上问的人多但很多回答只告诉你加个标志位或者在回调里重新调用一次根本没说清楚底层到底发生了什么。今天我就把这套机制彻底掰开揉碎从HAL库源码层面告诉你为什么会卡死以及怎么设计一套真正可靠、能连续发送的DMA发送框架。1. 现象复现第一次能发第二次“假死”先还原一下典型场景。用STM32CubeMX配置好USART1的DMA发送通道然后在代码里写个简单的发送逻辑uint8_t data[10] {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A}; // 第一次调用工作正常 HAL_UART_Transmit_DMA(huart1, data, 10); // 过一会儿第二次调用程序仿佛卡死在这里 HAL_UART_Transmit_DMA(huart1, data, 10);实测结果是第一次数据正常发出上位机串口调试助手能收到完整的10个字节。紧接着第二次调用调试助手一个字节也收不到程序也没有进入HardFault就是单纯地发不出去。如果你在第二次调用前后加了调试断点或LED翻转会发现HAL_UART_Transmit_DMA函数确实被执行了但函数内部很快就返回了数据根本没有进入DMA传输流程。这种情况就是典型的HAL_UART_Transmit_DMA只能发一次的坑。为什么第二次调用没有效果问题出在HAL库的状态管理机制上。HAL库把所有外设都抽象成一个状态机huart1.gState就是UART的全局状态标志。第一次调用发送函数时HAL库会把gState从HAL_UART_STATE_READY就绪切换成HAL_UART_STATE_BUSY_TX发送忙。发送完成后DMA中断虽然触发了但HAL库在中断处理里只做了清理工作并没有把gState恢复成READY。第二次调用时HAL库进来第一件事就是检查gState。发现不是READY直接返回HAL_BUSY根本不往下执行DMA配置和启动代码。于是你的第二次发送请求实际在函数入口就被拦下了。2. 源码级排查HAL库到底在忙什么要彻底理解这个卡死必须打开HAL库源码一行一行看HAL_UART_Transmit_DMA的实现。不同版本的HAL库代码略有差异但核心逻辑一致。以F1系列HAL库为例主要流程如下HAL_StatusTypeDef HAL_UART_Transmit_DMA(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size) { // 第一层检查状态机是否就绪 if (huart-gState HAL_UART_STATE_READY) { // 第二层检查发送缓冲区是否有数据 if ((pData NULL) || (Size 0)) { return HAL_ERROR; } // 设置发送状态为忙 huart-gState HAL_UART_STATE_BUSY_TX; // 配置发送参数 huart-TxXferSize Size; huart-TxXferCount Size; // 配置DMA发送通道 // 设置外设地址、内存地址、数据长度等 // 使能DMA通道 // 使能UART的DMA发送请求和TC中断 SET_BIT(huart-Instance-CR3, USART_CR3_DMAT); __HAL_UART_ENABLE_IT(huart, UART_IT_TC); return HAL_OK; } else { return HAL_BUSY; } }关键点就在开头这个状态检查。第一次调用时gState是READY顺利通过设置成BUSY_TX启动DMA发送。DMA搬运完数据触发UART的TC传输完成中断HAL库进入中断回调函数处理收尾工作。这部分代码分散在UART_IRQHandler里核心逻辑如下// UART中断处理函数中的段落 if ((tmp1 UART_IT_TC) ! (uint32_t)RESET) { // 清除TC中断标志 __HAL_UART_CLEAR_IT(huart, UART_IT_TC); // 如果DMA模式是Normal单次传输关闭DMA发送请求 // 检查DMA控制块的状态 huart-TxISR NULL; // 清空中断服务函数指针 huart-TxXferCount 0; // 清空剩余字节数 huart-gState HAL_UART_STATE_READY; // 恢复gState新版本库才会做 // 调用用户回调函数 HAL_UART_TxCpltCallback(huart); }不同版本的HAL库这里有个关键差异。旧版HAL库比如早期F1的1.6.0版本在这个中断处理里没有恢复gState HAL_UART_STATE_READY所以第二次发送直接返回HAL_BUSY。新版库比如F1的1.8.0之后的版本已经有了恢复代码但如果你用的芯片型号对应的HAL库本身存在这个漏网之鱼或者你的CubeMX生成的代码里包含了额外的状态判断逻辑同样会卡。还有一个更隐蔽的问题DMA中断标志。如果DMA配置为Normal模式单次传输在DMA完成一次传输后如果没有正确清除DMA的传输完成标志TCIF第二次启动DMA时DMA控制器可能因为标志悬挂而拒绝新的传输请求。这在HAL库的DMA处理代码里也有体现通常在HAL_DMA_IRQHandler中处理。所以这个只能发一次的问题本质上是HAL库状态未复位和DMA通道标志未清理两个因素叠加的结果。要解决它不能只靠记忆几条经验得理解整个状态流转过程。我建议你把HAL库的stm32f1xx_hal_uart.c和stm32f1xx_hal_dma.c两个文件源码打开对照着本文内容看一遍。别怕看源码HAL库本身写得很规范注释也多读懂了之后再遇到类似问题排查效率会高很多。3. 为什么在回调里再次调用HAL_UART_Transmit_DMA会死锁网上不少人会建议你这么做在HAL_UART_TxCpltCallback回调函数里直接再次调用HAL_UART_Transmit_DMA来实现连续发送。比如void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { HAL_UART_Transmit_DMA(huart1, next_data, len); // 错误做法 } }表面上看逻辑挺顺上一次发送完成中断触发回调在回调里立刻启动下一次发送。但这个做法在多数情况下会直接踩进死锁陷阱。原因很简单这时候你还在中断上下文里而HAL_UART_Transmit_DMA的状态检查依然生效。第一次发送完成后进入TC中断时HAL库先执行了中断清理逻辑然后才调用用户回调函数。在这个时刻gState字段的赋值顺序决定了一切。如果是新版HAL库TC中断处理中先把gState恢复为READY再调用回调函数那么在回调里再次调用发送函数是可以成功的。但如果是旧版库或者你在回调里执行了某些操作导致gState状态不对或者HAL_UART_Transmit_DMA函数内部因为DMA状态检查没通过而返回HAL_BUSY你的回调里调用发送函数这一行代码看似执行了实际上什么都没做。更要命的情况是你在回调里再次调用发送函数如果HAL库版本处理不当可能导致递归中断或者临界资源竞争。UART中断和DMA中断是不同的中断通道它们之间优先级配置不当就可能发生时序错乱出现数据错乱、发一半、DMA覆盖等问题。实际工程里我强烈不建议在HAL_UART_TxCpltCallback回调里直接启动下一次发送。这并不是说HAL库不允许这样做而是这种代码设计会把传输控制逻辑分散到中断上下文和主循环上下文两处状态一旦复杂起来你自己都很难排查问题。正确做法是回调函数里只设置标志位主循环里根据标志位决定是否发起下一次发送。4. 手把手修复一套稳定可靠的DMA发送方案说了这么多原理直接上解决方案。我这里给出一个工程上验证过很多次的DMA发送框架按步骤操作保证能解决只能发一次的问题而且能稳定连续发送。4.1 第一步检查并修改HAL库状态复位逻辑如果你用的HAL库版本偏旧先打开stm32f1xx_hal_uart.c或其他系列对应文件找到UART中断处理TC中断的段落确认有没有huart-gState HAL_UART_STATE_READY;这行代码。如果没有手动补上。if ((tmp1 UART_IT_TC) ! (uint32_t)RESET) { __HAL_UART_CLEAR_IT(huart, UART_IT_TC); // 如果DMA模式是Normal关闭DMA的发送请求 // 恢复UART状态为就绪 —— 关键的一行 huart-gState HAL_UART_STATE_READY; // 清除发送相关标志 huart-TxXferCount 0; // 调用用户完成回调 HAL_UART_TxCpltCallback(huart); }这里有个细节如果你使用的是最新版CubeMX生成的HAL库通常已经包含了状态恢复逻辑无需修改。但如果遇到问题一定要检查确认这是第一个关键点。4.2 第二步在CubeMX里正确配置DMACubeMX配置串口DMA发送时注意以下参数DMA Mode选择Normal单次传输模式。不要选Circular除非你要做环形缓冲发送否则Circular模式会让DMA一遍遍地重复搬运同一块内存根本停不下来。Data Width外设和存储器都选Byte8位如果你要发送16位或32位数据再相应调整但串口发送一般以字节为最小单位。Memory Increment开启Enable。这样DMA在搬运数据时内存地址会自动递增否则每次搬运的都是同一个地址的数据。优先级根据实际需要设置High/Medium/Low都行一般Medium足够。生成代码后MX_USART1_UART_Init和MX_DMA_Init会自动配置好所有寄存器。注意一个容易忽略的坑DMA初始化必须在UART初始化之前完成。CubeMX生成的代码顺序一般是MX_DMA_Init在MX_USART1_UART_Init之前如果你手动修改过初始化顺序可能导致DMA通道无法正常工作。4.3 第三步设计发送状态管理机制这是整个方案的核心。我们不直接在中断回调里发送而是用标志位加主循环轮询的方式。先定义发送状态结构体和标志位typedef enum { TX_IDLE 0, // 空闲可以发送新数据 TX_BUSY, // 正在发送 TX_COMPLETE, // 本次发送完成等待处理 } UART_TxStatus_t; // 定义串口发送控制块 typedef struct { UART_TxStatus_t status; // 发送状态 uint16_t tx_len; // 待发送数据长度 } UART_TxCtrl_t; UART_TxCtrl_t uart1_tx_ctrl; // 发送缓冲区根据实际需求调整大小 #define TX_BUFFER_SIZE 256 uint8_t uart1_tx_buffer[TX_BUFFER_SIZE];然后编写发送接口函数uint8_t UART1_SendData_DMA(uint8_t *data, uint16_t len) { // 检查参数 if (data NULL || len 0 || len TX_BUFFER_SIZE) { return 0; // 参数错误 } // 关键检查只有在空闲状态下才能启动新发送 if (uart1_tx_ctrl.status ! TX_IDLE) { return 0; // 忙拒绝发送 } // 拷贝数据到发送缓冲区 memcpy(uart1_tx_buffer, data, len); // 更新状态 uart1_tx_ctrl.status TX_BUSY; uart1_tx_ctrl.tx_len len; // 启动DMA发送 if (HAL_UART_Transmit_DMA(huart1, uart1_tx_buffer, len) ! HAL_OK) { uart1_tx_ctrl.status TX_IDLE; return 0; // 启动失败 } return 1; // 发送请求已接受 }这里把待发送数据先拷贝到自己的发送缓冲区里避免直接把外部传入的临时数组地址给DMA。这是个很好的习惯因为DMA是异步搬运如果外部函数栈或局部变量在发送完成前就被释放DMA还在读那块内存数据就乱了。4.4 第四步在中断回调里只置标志void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { // 发送已经完成更新状态 // 注意这里不要调用HAL_UART_Transmit_DMA uart1_tx_ctrl.status TX_COMPLETE; } }回调里只做一件事把状态从TX_BUSY改成TX_COMPLETE。然后主循环里检查这个状态决定是否继续下一个发送。4.5 第五步主循环里处理发送完成事件while (1) { // 其他任务代码... // 处理串口发送完成事件 if (uart1_tx_ctrl.status TX_COMPLETE) { // 把状态恢复为空闲 uart1_tx_ctrl.status TX_IDLE; // 如果有后续数据需要发送可以在这里调用UART1_SendData_DMA // 例如UART1_SendData_DMA(next_data, next_len); // 通知其他模块发送完成通过回调函数指针、事件标志、消息队列等 User_OnUart1TxComplete(); } // 定时调用发送接口发送数据 // 比如周期性上报传感器数据 static uint32_t last_send_time 0; if (HAL_GetTick() - last_send_time 1000) // 每秒发送一次 { uint8_t test_data[] Hello UART DMA\r\n; UART1_SendData_DMA(test_data, strlen((char*)test_data)); last_send_time HAL_GetTick(); } }这套框架的逻辑非常简单清晰状态机驱动。发送接口只在TX_IDLE时接受新请求发送过程中任何新的发送请求都会被拒绝发送完成后通过回调置位标志主循环检测到标志后恢复空闲状态继续处理下一个发送。相比在中断回调里直接启动下一次发送这种设计有几个好处一是发送流程完全在主循环控制下逻辑顺序清晰二是不会发生递归中断和状态竞争三是方便扩展比如发送超时处理、多串口管理、环形缓冲发送等。5. 进阶排查为什么发完一次后DMA不再触发上面解决了HAL库状态未复位的问题但还有一个更隐蔽的场景HAL_UART_Transmit_DMA调用返回HAL_OK外部看起来函数正常执行了但DMA就是不搬运数据一个字节都发不出去。这个问题的根源通常出在DMA控制器的配置或标志清理上。排查步骤按顺序来第一步检查DMA通道的使能状态。在HAL_UART_Transmit_DMA函数内部会调用HAL_DMA_Start_IT来配置并启动DMA传输。HAL_DMA_Start_IT内部会检查DMA通道是否已经使能如果上一次传输的DMA通道没有被正确失能本次传输就可能启动失败。你可以加一段代码在启动DMA发送前手动失能DMA通道__HAL_DMA_DISABLE(hdma_usart1_tx); // 清除DMA标志 __HAL_DMA_CLEAR_FLAG(hdma_usart1_tx, DMA_FLAG_TC | DMA_FLAG_HT | DMA_FLAG_TE | DMA_FLAG_DME);这段代码放在HAL_UART_Transmit_DMA调用之前能避开不少DMA遗留标志的问题。但注意如果HAL_UART_Transmit_DMA启动的时候DMA通道本来就是失能的这里的操作没有副作用放心加。第二步检查DMA中断是否正常触发。在HAL_DMA_IRQHandler里打断点看看发送完成是否进入了DMA中断。如果DMA中断没有触发有可能是在CubeMX里没勾选DMA的中断使能或者中断优先级配置不合理。第三步检查DMA配置是用寄存器还是用句柄。如果工程里手动改过DMA的控制块hdma_usart1_tx注意检查里面的Init结构体参数是否被意外修改。比如Mode被改成了DMA_CIRCULAR数据搬运就会异常PeriphInc被改成了DMA_PINC_ENABLE外设地址也会递增这在串口DMA里是错误配置要求外设地址固定。排查询问按这个顺序走90%的DMA不触发问题都能定位。剩下10%可能出在硬件层面比如杜邦线接触不良、TXD/RXD接反、共地问题等这些用示波器或逻辑分析仪一眼就能看出来。5.1 一个真实案例DMA正常但发送数据乱码我在一个项目里遇到过更诡异的情况第一次发送正常第二次发送的数据前几个字节是对的后面就变成乱码或错位。排查发现是内存缓冲区复用的问题。第一次发送时调用方传入的是一个局部数组的地址。函数返回后局部数组在栈上的空间被释放。但DMA还在异步搬运这块内存的数据第二次调用发送函数时同一块栈内存被其他变量覆盖DMA搬运到一半数据就被篡改了。这个案例的教训就是DMA发送的缓冲区必须具备足够的生命周期。要么用全局数组或静态数组要么用malloc分配堆内存后由专门模块管理释放要么就是我前面设计的发送框架里那样在发送接口入口处先拷贝到专用缓冲区。6. 避坑清单DMA发送的13个常见坑速查根据我踩过的坑和帮别人排查的经验这里整理一份DMA发送高频问题速查表每一条都是实战验证过的序号问题现象根本原因解决方案1只能发一次第二次调用无响应HAL库gState未恢复READY检查HAL库版本手动在TC中断里恢复gState2调用返回HAL_BUSY状态机仍处于BUSY_TX用状态管理机制等上一次发送完成再发3回调里再调用发送函数死锁中断上下文重复进入发送流程回调里只置标志主循环里启动新发送4DMA不启动直接返回OK但无数据DMA通道未失能或标志残留发送前失能DMA并清除标志5发送数据错位、乱码局部变量缓冲区已释放使用专用发送缓冲区并拷贝6数据发送一半就停了TXE中断和DMA中断竞争只使能DMA发送不使能TXE中断7发送过程中断优先级冲突UART和DMA中断优先级不合理UART中断优先级低于DMA避免抢占8连续发送时数据帧粘连发送状态未完全复位等待TC中断完成后再启动下一帧9F7/H7芯片发送数据不一致D-Cache未刷新发送前调用SCB_CleanDCache刷新缓存10接收和发送共用DMA混乱同一DMA通道被重复配置分别使用独立DMA通道11低功耗模式下DMA失效DMA时钟被关闭低功耗前完整停止DMA并恢复配置12复位后第一次发送异常DMA时钟未同步检查RCC时钟树配置和DMA使能时序13使用FreeRTOS时任务阻塞发送接口在临界区被断发送接口加互斥量回调里用信号量通知任务6.1 关于Cache一致性F7/H7系列必看如果你用的是STM32F7或H7系列上面表格里第9条必须特别重视。这两个系列的内核带有D-Cache数据缓存DMA控制器访问内存时不经过CPU缓存直接走AHB总线访问物理内存。如果CPU写数据到缓冲区后只停留在D-Cache里还没写回物理内存DMA直接搬运物理内存拿到的就是旧数据。解决方法是在启动DMA发送前主动把缓冲区数据刷回物理内存#if defined(__DCACHE_PRESENT) (__DCACHE_PRESENT 1U) SCB_CleanDCache_by_Addr((uint32_t *)uart1_tx_buffer, len); #endif // 然后再启动DMA发送 HAL_UART_Transmit_DMA(huart1, uart1_tx_buffer, len);对应的DMA接收场景需要在接收完成后调用SCB_InvalidateDCache_by_Addr让CPU重新从物理内存读取有效数据。H7系列因为多了D2域SRAM和AXI SRAM的区别还要注意DMA访问的存储区域是否配置为non-cacheable或write-through这些在HAL库的MPU_Config里可以配置。如果你在F7/H7上做DMA通信这部分一定要花时间搞透。7. 拓展思路从“发送卡死”到“完整收发框架”解决了发送卡死的问题后我建议你趁热把整个UART收发框架搭起来因为ADC采集、传感器读取、OTA固件升级等高频场景都依赖一个稳定的串口通信底层。一个项目级的UART DMA方案通常包含三部分DMA发送 DMA接收 空闲中断。发送上面已经讲了接收最常见的做法是// 启动DMA接收数据到达时自动存入缓冲区 void UART1_StartReceive_DMA(void) { HAL_UART_Receive_DMA(huart1, uart1_rx_buffer, RX_BUFFER_SIZE); } // 空闲中断回调一帧数据接收完成后触发 void HAL_UART_IdleCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { // 计算当前这一帧数据长度 uint16_t rx_len RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); // 处理接收到的数据 User_OnUart1DataReceived(uart1_rx_buffer, rx_len); // 重新启动接收 HAL_UART_Receive_DMA(huart1, uart1_rx_buffer, RX_BUFFER_SIZE); } }这里的核心是DMA配置为固定的接收缓冲区数据源源不断写入缓冲区什么时候算“一帧结束”利用UART的空闲中断IDLE来标记帧边界。数据到达后总线空闲IDLE中断触发在中断里通过DMA计数器算出本帧数据长度处理完数据后重新启动接收。这是目前行业内最主流的串口接收方案比传统的逐字节接收效率和稳定性都要高得多。需要留意的是DMA接收如果缓冲区满了会产生溢出错误Overrun Error这时HAL库会把UART状态置为HAL_UART_STATE_READY但DMA通道可能停住。稳妥做法是在接收中断处理里判断__HAL_UART_GET_FLAG(huart, UART_FLAG_ORE)溢出时先停止DMA接收、清标志、再重启接收。7.1 发送超时与异常恢复机制实际项目中DMA发送偶尔会卡死。比如DMA控制器异常、总线冲突、外设误触发等可能导致TX_COMPLETE标志永远不来发送状态一直卡在BUSY后续所有发送都被拒绝。这种情况需要引入发送超时保护。思路很简单发送时记录时间戳主循环里定期检查发送是否超时超时后强制复位状态机并重新初始化DMA通道#define UART1_TX_TIMEOUT_MS 100 // 在发送启动时记录时间 uart1_tx_ctrl.start_tick HAL_GetTick(); // 主循环里周期性检查 void UART1_TxTimeoutCheck(void) { if (uart1_tx_ctrl.status TX_BUSY) { if (HAL_GetTick() - uart1_tx_ctrl.start_tick UART1_TX_TIMEOUT_MS) { // 发送超时强制复位 __HAL_DMA_DISABLE(hdma_usart1_tx); __HAL_DMA_CLEAR_FLAG(hdma_usart1_tx, DMA_FLAG_TC | DMA_FLAG_HT | DMA_FLAG_TE | DMA_FLAG_DME); // 恢复状态 huart1.gState HAL_UART_STATE_READY; uart1_tx_ctrl.status TX_IDLE; Error_Handler(); } } }超时参数的选择需要根据你的波特率和最长数据帧计算。比如115200波特率10个字节大约耗时0.87ms100个字节大约8.7ms。保守起见超时设置为主观上最慢帧耗时3到5倍比较合理。代码里还要考虑极端情况如果DMA控制器本身挂死恢复状态机后DMA通道可能还需要重新初始化而不能仅仅清除标志。8. 我个人的最终建议串口DMA发送“只能发一次”的问题说到底是HAL库状态机设计和用户使用习惯之间的一次错位。HAL库封装了底层寄存器操作但并没有替你做业务层发送管理。如果你直接把HAL_UART_Transmit_DMA当作普通发送接口到处调用踩坑几乎是必然的。我现在的习惯是每个项目都维护一套独立的串口通信中间层封装DMA发送、DMA接收、空闲中断、环形缓冲区、解析协议等基础能力。应用层永远不直接碰HAL库的UART接口只管调用中间层提供的UART_SendData和UART_RegisterRxCallback。这套设计帮我在后续很多项目里省了大量排查时间OTA升级、Modbus通信、日志系统全部复用同一套底层。如果你目前卡在“第二次发送无效”这个问题上按本文第4节的方案操作应该很快就能跑通。后续再把接收框架、超时保护、Cache处理等都补上DMA这套体系就算真正吃透了。
返回列表