STM32串口DMA通信实战:HAL库不定长数据接收方案与避坑指南

📅 2026/7/30 5:43:34
STM32串口DMA通信实战:HAL库不定长数据接收方案与避坑指南
1. 项目缘起从阻塞到DMA串口通信的进阶之路搞过STM32串口通信的朋友肯定都经历过几个阶段。最开始我们可能用HAL库最简单的HAL_UART_Transmit和HAL_UART_Receive这是阻塞模式程序发数据或者等数据的时候CPU就卡在那儿啥也干不了效率最低。然后我们学会了中断模式用HAL_UART_Transmit_IT和HAL_UART_Receive_IT数据来去都靠中断服务函数通知CPU被解放出来处理其他任务响应也及时了。但中断模式有个问题每次收发一个或几个字节就进一次中断如果数据量一大频繁的中断开销反而成了负担CPU大部分时间都在进出中断的现场保护和恢复上实际干活的效率又下来了。这时候DMA就该登场了。DMA直接存储器访问是STM32内部一个独立于CPU的“数据搬运工”。它的核心思想是“你告诉我从哪里搬、搬到哪里、搬多少剩下的我来搬完了通知你一声”。在串口通信中这意味着我们可以把一大块要发送的数据的起始地址和长度告诉DMADMA就会自动、连续地从内存比如一个数组把数据搬运到串口的数据寄存器DR里直到搬完。接收也是同理DMA自动把串口DR收到的数据搬到我们指定的内存缓冲区。在整个搬运过程中CPU几乎不需要干预可以全力去执行其他算法、逻辑判断等任务只在DMA传输完成时通过中断或标志位获知一下结果即可。这对于需要高速、大数据量、低CPU占用的串口通信场景比如与模块通信、数据采集转发、文件传输等是质的飞跃。然而HAL库对DMA的封装在带来便利的同时也埋下了一些“坑”。网上很多教程只告诉你用CubeMX点点点就能生成代码但很少深入讲清楚DMA传输的几种模式正常模式、循环模式、缓存区的管理、传输完成和半传输中断的应用以及最让人头疼的——如何安全、高效地处理接收不定长数据。这篇文章我就结合自己实际项目中的多次踩坑经历抛开那些简单的点灯例程深入聊聊STM32 HAL库下串口DMA通信特别是接收不定长数据的几种实战方案和避坑要点。2. CubeMX基础配置与关键参数解析使用HAL库第一步自然是CubeMX的图形化配置。这里我们以STM32F103系列和USART1为例目标是配置一个使用DMA进行发送和接收的串口。2.1 USART参数配置在Connectivity-USART1中我们进行基本设置Mode: 选择Asynchronous异步通信。Baud Rate: 根据需求设置例如115200。Word Length:8 Bits最常用。Parity:None。Stop Bits:1。Over Sampling:16 Samples标准配置。这些是基础关键在于下面DMA的配置。2.2 DMA配置——发送与接收的分离通道在DMA Settings标签页点击Add添加DMA请求。DMA Request: 选择USART1_TX。这意味着我们添加了一个用于串口发送的DMA通道。Direction:Memory To Peripheral内存到外设。数据从我们定义的数组内存搬运到USART1的数据寄存器外设。Priority: 根据系统实时性要求选择通常Low或Medium即可。如果此串口用于非常关键的数据流可选High。Mode:Normal。这是发送DMA最常用的模式。在Normal模式下DMA传输完指定的数据量Data Width后就会自动停止并产生传输完成中断。下次需要发送时必须重新使能DMA通道。这符合我们“一次性发送一包数据”的常规逻辑。再次点击Add添加USART1_RX通道。Direction:Peripheral To Memory外设到内存。Priority: 同样根据需求设定。Mode:这里的选择至关重要是后续实现不定长接收的基础。我们通常选择Circular循环模式。注意很多初学者在这里混淆。发送用Normal接收用Circular这是经过实践检验的常用组合。发送是主动的、一次性的接收是被动的、持续性的。循环模式可以让DMA在接收缓冲区末尾自动回到开头继续填充形成一个“环形缓冲区”非常适合持续接收可能随时到来的数据。Data Width: 两个通道都设置为Byte字节因为串口通常以字节为单位通信。Increment Address: 对于发送通道Memory内存地址需要勾选Increment因为我们要顺序发送数组中的每个字节Peripheral外设地址不勾选因为始终是同一个USART-DR寄存器。对于接收通道同理Memory勾选IncrementPeripheral不勾选。配置完成后记得在NVIC Settings中使能USART1的全局中断和DMA通道的中断特别是接收DMA的传输完成中断和半传输中断后面会用到。生成代码后CubeMX会帮我们初始化好USART和DMA并在main.c中生成MX_DMA_Init()和MX_USART1_UART_Init()函数。但真正的核心操作需要我们手动编写。3. 串口DMA发送启动、等待与回调处理发送相对简单直接。假设我们要发送一个字符串。// 定义发送缓冲区 uint8_t tx_buffer[] Hello, DMA!\r\n; uint16_t tx_len sizeof(tx_buffer) - 1; // 去掉字符串结尾的\0 // 启动一次DMA发送 HAL_UART_Transmit_DMA(huart1, tx_buffer, tx_len);这一行代码就启动了DMA发送。此时CPU可以立即去执行后面的代码DMA会在后台默默搬运数据。这里有几个关键点和坑1. 缓冲区生命周期问题HAL_UART_Transmit_DMA函数只是启动了DMA并告诉DMA源地址是tx_buffer。在DMA传输完成之前绝对不能释放或修改tx_buffer数组的内容否则DMA会搬走错误的数据。通常我们将发送缓冲区定义为全局数组或静态局部数组。2. 如何知道发送完成有三种方式阻塞等待调用HAL_UART_Transmit_DMA后可以调用HAL_DMA_PollForTransfer(hdma_usart1_tx, HAL_DMA_FULL_TRANSFER, 1000)进行轮询等待或者判断__HAL_DMA_GET_FLAG(hdma_usart1_tx, DMA_FLAG_TC1)标志位。但这会阻塞CPU失去了使用DMA的意义不推荐。中断回调最常用的方式。DMA传输完成后会触发DMA传输完成中断进而调用HAL库的HAL_UART_TxCpltCallback回调函数。我们只需重写Weak这个回调函数在里面处理发送完成后的逻辑比如点亮一个LED或者准备下一包数据。// 在合适的地方如main.c用户代码区重写发送完成回调函数 void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { // USART1发送完成可以在此处进行后续操作 // 例如置位一个发送完成标志让主循环处理 tx_complete_flag 1; } }3. 连续发送的坑在HAL_UART_TxCpltCallback回调函数被调用之前DMA通道处于“忙碌”状态。如果你在上一包数据还没发完时再次调用HAL_UART_Transmit_DMAHAL库会返回HAL_BUSY。因此安全的做法是设置一个状态机或标志位确保只有在上一次发送完成后才启动下一次发送。4. 串口DMA接收不定长数据三种实战方案详解接收是DMA应用的难点核心问题在于数据是不定长的我们如何知道一帧数据什么时候结束这里介绍三种经过实战检验的方案。4.1 方案一DMA循环模式 空闲中断Idle Interrupt这是目前最主流、最优雅的方案。其原理是DMA配置为循环模式如上所述为接收DMA配置Circular模式并指定一个固定大小的缓冲区例如rx_buffer[256]。DMA会在这个缓冲区里循环写入数据写满末尾后自动回到开头覆盖旧数据。开启串口空闲中断空闲中断在串口总线上一段时间大于一个字符传输时间没有新数据时触发。这正好标志着一帧数据的结束。工作流程上电后启动DMA接收HAL_UART_Receive_DMA(huart1, rx_buffer, RX_BUFFER_SIZE)。注意即使配置了循环模式这里传入的Size参数依然有效它决定了DMA的“传输计数器”初始值但在循环模式下计数器减到0后会重载传输不会停止。DMA持续将收到的数据写入rx_buffer。当一帧数据发送完毕总线空闲触发串口空闲中断。在空闲中断服务函数或HAL库的HAL_UARTEx_RxEventCallback回调较新版本中我们计算出自上次处理数据后到当前空闲时刻DMA一共接收了多少字节。计算公式接收长度 RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx)。__HAL_DMA_GET_COUNTER获取的是DMA传输计数器当前剩余的值。用缓冲区总大小减去剩余计数就得到了已经搬运的数据量。根据这个长度从缓冲区中取出有效数据进行处理。处理完后无需重新启动DMA因为是循环模式DMA会继续在缓冲区后续位置接收新数据。优点几乎不占用CPU实时性好能准确捕获帧结束缓冲区利用率高。缺点需要芯片支持串口空闲中断绝大部分STM32都支持。需要小心计算缓冲区读写指针防止数据覆盖。如果两帧数据间隔时间小于空闲检测时间可能会被合并成一帧。关键代码示例在stm32f1xx_it.c中修改USART1中断服务函数void USART1_IRQHandler(void) { /* USER CODE BEGIN USART1_IRQn 0 */ // 检测是否是空闲中断 if((__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE) ! RESET)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); // 必须清除空闲中断标志 // 计算接收到的数据长度 uint16_t rx_len RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); if(rx_len 0) { // 处理数据例如拷贝到另一个解析缓冲区 memcpy(parse_buffer, rx_buffer, rx_len); data_ready_flag 1; // 设置标志在主循环中解析 // 注意此时DMA仍在循环工作下次计算长度需要基于当前DMA计数器位置 // 一种常见做法是记录本次处理时的“基地址” } // 可以在这里重新设置DMA目标地址和计数器但循环模式下通常不需要 } /* USER CODE END USART1_IRQn 0 */ HAL_UART_IRQHandler(huart1); /* USER CODE BEGIN USART1_IRQn 1 */ /* USER CODE END USART1_IRQn 1 */ }4.2 方案二DMA正常模式 定时器超时判断如果硬件不支持空闲中断或者空闲时间不稳定可以采用此方案。DMA配置为正常模式接收DMA也使用Normal模式。开启一个基本定时器定时器周期设置为略大于一个字节传输时间的几倍例如115200波特率下传输1字节约87us可设置定时器为5ms。工作流程启动DMA接收HAL_UART_Receive_DMA(huart1, rx_buffer, RX_BUFFER_SIZE)。同时启动定时器。每次串口收到数据DMA搬运并在串口接收中断中重置重启定时器计数。如果数据流持续定时器不断被重置不会溢出。当一帧数据结束不再有新的串口中断来重置定时器定时器达到设定周期后溢出产生定时器中断。在定时器中断中判断为“接收超时”此时一帧数据已完整存入DMA缓冲区。计算接收长度RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER处理数据。处理完成后必须重新调用HAL_UART_Receive_DMA来启动下一次接收因为DMA是正常模式传输完指定数量后就停止了。优点适应性广不依赖空闲中断。超时时间可灵活调整。缺点需要占用一个定时器。每次接收完成需重新启动DMA有微小的时间窗口可能丢失数据。需要处理好串口接收中断与定时器中断的协作。4.3 方案三双缓冲Ping-Pong Buffer与半传输中断这是一种用于处理高速、连续数据流的进阶方案结合了DMA的循环模式和半传输完成中断HT。配置DMA接收仍为循环模式缓冲区大小设为实际需要的两倍例如rx_buffer[512]。使能DMA的“半传输完成中断”Half Transfer Complete Interrupt和“传输完成中断”Transfer Complete Interrupt。原理DMA在循环填充缓冲区时会产生两个中断半传输中断HT当DMA搬运了缓冲区前半部分前256字节时触发。传输完成中断TC当DMA搬运完整个缓冲区512字节时触发。工作流程上电启动DMA循环接收。当数据填满前半部分缓冲区触发HT中断。在HT中断回调函数HAL_UART_RxHalfCpltCallback中我们可以安全地读取rx_buffer[0]到rx_buffer[255]的数据进行处理因为此时DMA正在后半部分[256]到[511]写入。当数据继续填满整个缓冲区触发TC中断。在TC中断回调函数HAL_UART_RxCpltCallback中我们可以安全地读取后半部分rx_buffer[256]到rx_buffer[511]的数据进行处理此时DMA又回到了前半部分开始写入。如此往复形成了“乒乓”操作读写互不干扰。优点完美解决了读写冲突非常适合高速连续数据流采集。缺点对于不定长、非连续的数据帧如AT指令响应帧的边界可能被HT或TC中断强行切断需要上层协议做额外的帧拼接处理逻辑稍复杂。5. 避坑指南与实战经验总结在实际项目中仅仅跑通Demo是远远不够的。下面这些坑都是我实实在在踩过的。坑1DMA传输完成中断不触发现象数据明明发送/接收完了但对应的CpltCallback回调函数就是没被调用。排查检查NVIC配置在CubeMX和代码中确认DMA通道的中断已经使能HAL_NVIC_EnableIRQ。检查中断优先级确保DMA中断没有被其他更高优先级的中断长时间阻塞。检查传输计数器对于发送确保你启动的传输数据量大于0。对于接收在正常模式下DMA必须收满你指定的数量才会触发TC中断。检查DMA通道状态在调试时查看hdma_usartx_tx/rx.State的状态是否为HAL_DMA_STATE_READY或HAL_DMA_STATE_BUSY。坑2数据覆盖或丢失场景使用循环模式接收数据处理速度跟不上接收速度。解决方案增大缓冲区这是最简单粗暴但有效的方法。提高数据处理优先级将数据处理任务放在更高优先级的中断或实时性强的线程中。使用双缓冲方案三从根本上隔离读写区域。流控如果对方设备支持启用硬件RTS/CTS流控让对方在己方缓冲区快满时暂停发送。坑3重新启动DMA接收的时机问题在正常模式超时判断的方案中处理完数据后需要重启DMA。如果在重启前已经有新的数据到来这部分数据就会丢失。经验在定时器超时中断中处理数据的代码要尽可能快。处理完后立即调用HAL_UART_Receive_DMA重启接收。可以将数据拷贝到另一个队列中断里只做标记和拷贝复杂解析放到主循环。坑4HAL库状态机导致的“死锁”现象调用HAL_UART_Transmit_DMA返回HAL_BUSY但明明上次发送早就完成了。根因HAL库内部有状态机huart-gState,huart-RxState。在某些异常情况下如中断处理不当、提前操作状态可能没有正确回归到HAL_UART_STATE_READY。解决仔细检查你的中断回调函数不要在里面进行可能导致状态冲突的UART操作。在确保安全的情况下比如确定上一次传输确实异常结束了可以尝试调用HAL_UART_Abort或HAL_UART_AbortTransmit等函数来强制重置状态然后再启动新的传输。但这要非常小心。坑5内存对齐问题现象DMA传输数据特别是传输uint32_t数组时发现数据错乱。原因DMA对源地址和目标地址有对齐要求例如某些DMA控制器要求字传输时地址是4字节对齐的。解决确保你用于DMA传输的缓冲区在内存中对齐。可以使用编译器指令如GCC的__attribute__((aligned(4)))或者C11的alignas(4)来定义数组。uint8_t tx_buffer[128] __attribute__((aligned(4))); // 4字节对齐最后关于调试。当DMA不工作时除了看代码一定要善用调试器。查看DMA通道的CNDTR寄存器当前传输计数器它会在传输过程中递减。查看CPAR外设地址和CMAR内存地址寄存器是否正确。查看CCR寄存器中的配置位方向、循环模式、中断使能等是否与预期一致。这些寄存器视图能帮你最直接地定位是配置问题还是运行问题。STM32的HAL库串口DMA用熟了之后绝对是提升项目效率和稳定性的利器尤其是处理那些唠叨个不停的传感器模块。核心思路就是让DMA这个“专职搬运工”去干重活累活CPU这个“老板”只负责发号施令和最终验收。把上面几种接收方案和避坑点吃透大部分串口通信的需求都能稳稳拿下。