ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

STM32 USART2 DMA通信:解放CPU实现高效串口数据传输

2026/8/8 14:50:58 拓冰建站 浏览量
STM32 USART2 DMA通信:解放CPU实现高效串口数据传输 1. 项目概述为什么需要USART2 DMA通信如果你玩过一阵子STM32肯定对串口通信不陌生。无论是调试打印信息还是跟传感器、蓝牙模块、GPS模块打交道USART通用同步异步收发器都是最常用、最基础的通信接口之一。标准库或者HAL库里那个HAL_UART_Transmit和HAL_UART_Receive函数用起来简单直接一两个字节的数据收发没问题。但当你需要连续发送一长串数据比如刷新OLED屏幕的整帧图像数据或者以高波特率持续接收来自传感器的数据流时问题就来了。CPU会频繁被中断服务程序ISR打断每一个字节的发送或接收都要进一次中断CPU大部分时间都在“搬砖”——把数据从内存搬到USART的数据寄存器DR或者反过来。这种“阻塞式”或“中断式”的通信在数据量大、速率高时会严重消耗CPU资源导致主程序卡顿响应变慢甚至可能因为中断处理不及时而丢失数据。这时候DMA直接存储器访问就该登场了。你可以把DMA想象成你雇的一个“专职搬运工”。你只需要告诉它货在哪里源地址要搬到哪去目标地址要搬多少数据量然后就可以拍拍屁股去干别的活了。这个搬运工独立于CPU工作通过专用的总线搬运数据不占用CPU的计算资源。对于USART来说启用DMA发送就是让DMA控制器自动把内存里的一块数据一个字节一个字节地“喂”给USART的发送数据寄存器启用DMA接收则是让DMA把USART接收到的数据自动存放到你指定的一块内存缓冲区里。所以“STM32之USART2 DMA通信”这个项目核心目标就是解放CPU实现USART2接口的高效、稳定、大流量数据吞吐。无论是用于高速数据采集、实时通信还是驱动需要连续数据流的设备掌握DMA都是提升STM32项目性能的关键一步。接下来我会基于常见的STM32F1/F4系列以HAL库为例拆解从思路到代码再到调试避坑的完整过程。2. 核心思路与硬件资源规划在动手写代码之前理清思路和规划好硬件资源至关重要。盲目配置往往会导致各种奇怪的问题。2.1 为什么是USART2在STM32中USART1、USART2、USART3等是独立的串口外设。选择USART2通常基于以下几点考虑引脚复用灵活性以STM32F103C8T6蓝色药丸板为例USART2的TXPA2和RXPA3引脚通常不与常用的调试接口如SWD冲突方便布线。资源独立性在一些型号中USART1可能被用于系统启动时的串口打印如果开启了使用USART2可以避免冲突。USART2也通常连接到一个独立的DMA通道。项目需求可能你的模块如GPS、蓝牙硬件连接已经固定在了对应USART2的引脚上。2.2 DMA通道与数据流Stream映射这是DMA配置中最容易出错的一环。DMA控制器有多个通道Channel每个通道服务于特定的外设请求如USART2_TX, USART2_RX。在F1系列中结构相对简单在F4/F7/H7等系列中引入了更复杂的“数据流Stream”概念。以STM32F407为例USART2的DMA映射关系如下必须查数据手册确认USART2_TX(发送) 的DMA请求可以映射到DMA1 Stream6的Channel4。USART2_RX(接收) 的DMA请求可以映射到DMA1 Stream5的Channel4。注意这个映射关系是硬件固定的不能随意更改。错误的映射会导致DMA根本无法触发。最可靠的方法是查阅你所使用芯片型号的官方数据手册Datasheet或参考手册Reference Manual中的“DMA请求映射”表格。2.3 内存与缓冲区设计使用DMA本质上是让DMA在外设寄存器和内存之间搬运数据。因此我们需要在内存中规划好缓冲区。发送缓冲区TxBuffer一块内存区域存放你准备通过串口发送出去的数据。DMA会从这里读取数据并送到USART2-DR寄存器。接收缓冲区RxBuffer一块内存区域用于存放DMA从USART2-DR寄存器搬运过来的接收数据。它的大小设计是关键。大小缓冲区大小应至少大于你预期单次接收的最大数据包长度。例如你接收的每条传感器数据帧是50字节那么缓冲区可以设为64或100字节留有余量。环形缓冲区可选但推荐对于持续不断的流式数据接收单纯的一个线性缓冲区很容易被新数据覆盖旧数据。更高级的做法是配合DMA的“循环模式Circular Mode”和软件管理的读写指针实现一个环形缓冲区FIFO这是实现可靠流式接收的常见模式。2.4 中断策略何时知道DMA干完活了DMA虽然不占用CPU时间搬运数据但我们总需要知道“搬运工”什么时候干完活或者有没有出错。这就需要配置DMA传输完成中断TCTransfer Complete和半传输完成中断HTHalf Transfer以及传输错误中断TETransfer Error。发送场景通常我们只关心“发送完成中断”。当DMA把发送缓冲区的所有数据都搬运到USART后会触发TC中断。我们在中断回调函数里可以做一些后续操作比如改变LED状态、准备下一包数据或者将发送完成标志位置位通知主程序。接收场景这是重点。如果接收缓冲区很大我们可能不想等到缓冲区完全填满TC中断才去处理数据那样延迟太高。此时可以启用HT中断。当DMA接收到半缓冲区数据时触发HT中断完全填满时触发TC中断。这样我们可以在HT中断里处理前半部分数据在TC中断里处理后后半部分数据实现“双缓冲”机制减少数据处理延迟。更常见的做法是使用空闲中断Idle Interrupt我将在后面详细讲解。3. 使用STM32CubeMX进行图形化配置对于新手或者追求开发效率STM32CubeMX是绝佳的工具。它能直观地配置引脚、时钟、外设和中间件并生成初始化代码框架。3.1 基础外设使能选择芯片型号在CubeMX中正确选择你的STM32型号。配置系统时钟SYSDebug选项建议选“Serial Wire”方便ST-Link调试。配置时钟树RCC根据你的板载晶振配置HSE高速外部时钟并设置系统主频SYSCLK。更高的主频意味着更高的处理能力和DMA带宽。配置USART2模式选择“Asynchronous”异步通信。配置波特率如115200、字长8位、停止位1位、校验位无。引脚PA2和PA3会自动配置为USART2_TX和USART2_RX。3.2 DMA配置详解这是CubeMX配置的核心部分。添加DMA通道在“DMA Settings”标签页或Pinout视图的USART2配置中点击“Add”。为USART2_TX添加一个DMA请求。方向Direction选择“Memory To Peripheral”内存到外设。为USART2_RX添加一个DMA请求。方向选择“Peripheral To Memory”外设到内存。参数设置以USART2_RX的DMA为例ModeNormal普通模式传输完指定数据量后DMA停止需要软件重新使能。适合发送固定长度数据包。Circular循环模式传输完指定数据量后自动从头开始循环传输。这是实现持续接收的推荐模式。缓冲区就像一个环新数据会覆盖旧数据配合软件读指针来取数据。Priority优先级根据系统需求设置如果只有这一个DMA传输设为Low即可。如果有多个DMA竞争总线高优先级的会先服务。Increment Address地址自增对于Memory内存端必须勾选Enable。因为数据是连续存放在内存数组中的DMA每搬运一个字节后内存地址要自动1指向下一个数据位置。对于Peripheral外设端必须不勾选。因为外设寄存器地址是固定的始终是USART2-DR这个地址DMA要一直往这个固定地址读/写。Data Width数据宽度Memory和Peripheral都选择Byte字节。因为USART通信的基本单位是字节8位。中断配置在NVIC Settings中使能USART2的全局中断如果需要使用串口本身的错误中断等。关键步骤在DMA配置中勾选对应的DMA通道中断。对于接收DMA至少使能“Transfer Complete Interrupt”传输完成中断。你也可以使能“Half Transfer Interrupt”半传输中断来实现双缓冲。生成代码设置好工程路径、工具链MDK-ARM/V5等生成代码。CubeMX会生成usart.c/.h和dma.c/.h中的初始化代码MX_USART2_UART_Init()和MX_DMA_Init()。4. 手把手代码实现与解析假设我们已经用CubeMX生成了基础框架现在需要编写应用层代码。我们以实现“循环DMA接收空闲中断处理”这一经典且实用的模式为例。4.1 宏定义与变量声明在main.c或你的通信模块头文件中定义// 接收缓冲区大小 #define RX_BUFFER_SIZE 256 // 发送缓冲区大小 #define TX_BUFFER_SIZE 128 // DMA接收缓冲区 uint8_t g_uart2_rx_buffer[RX_BUFFER_SIZE]; // 发送缓冲区 uint8_t g_uart2_tx_buffer[TX_BUFFER_SIZE]; // 用于记录空闲中断发生时DMA已经搬运了多少数据 volatile uint16_t g_uart2_rx_len 0; // 接收完成标志供主程序查询 volatile uint8_t g_uart2_rx_done_flag 0;注意g_uart2_rx_len和g_uart2_rx_done_flag这两个变量会在中断服务程序中被修改在主循环中被读取因此必须用volatile关键字修饰防止编译器进行错误的优化。4.2 初始化函数补充CubeMX生成的初始化代码使能了USART2和DMA但我们需要手动开启USART2的空闲中断并启动DMA接收。在main()函数的初始化部分调用完MX_USART2_UART_Init()后添加// 使能USART2的空闲中断IDLE Interrupt __HAL_UART_ENABLE_IT(huart2, UART_IT_IDLE); // 启动DMA接收让DMA开始监听USART2的数据并自动存放到g_uart2_rx_buffer // 参数串口句柄接收缓冲区缓冲区大小 HAL_UART_Receive_DMA(huart2, g_uart2_rx_buffer, RX_BUFFER_SIZE);代码解析__HAL_UART_ENABLE_IT(huart2, UART_IT_IDLE)这是一个HAL库底层宏用于使能指定串口的特定中断。UART_IT_IDLE就是空闲中断。当USART的RX线上超过一个字节的时间具体取决于波特率没有新数据时就会产生此中断。HAL_UART_Receive_DMA(...)这个HAL库函数做了几件事1. 配置DMA通道的源地址USART2-DR、目标地址我们的缓冲区、数据量。2. 使能DMA通道。3. 使能USART2的DMA接收请求。调用后DMA就进入待命状态一旦USART收到数据DMA会自动搬运。4.3 中断服务程序与回调函数HAL库采用了中断回调Callback机制。我们不需要直接修改stm32f4xx_it.c中的中断服务函数而是重写对应的弱定义回调函数。重写USART2空闲中断回调函数 在main.c或你的用户文件中添加以下函数// 重写USART空闲中断回调函数 void HAL_UART_IdleCpltCallback(UART_HandleTypeDef *huart) { // 判断是否是USART2触发的中断 if(huart-Instance USART2) { // 临时禁用DMA防止在计算长度时DMA仍在修改缓冲区 __HAL_DMA_DISABLE(huart-hdmarx); // 计算本次接收到的数据长度 // DMA当前存储地址 - 缓冲区起始地址 已存储的数据量 // hdmarx-Instance-NDTR 是DMA通道剩余未传输的数据量寄存器 g_uart2_rx_len RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(huart-hdmarx); // 设置接收完成标志 g_uart2_rx_done_flag 1; // 重新使能DMA准备接收下一包数据 // 注意因为之前是循环模式所以只需要重新设置数据长度并使能即可 __HAL_DMA_SET_COUNTER(huart-hdmarx, RX_BUFFER_SIZE); __HAL_DMA_ENABLE(huart-hdmarx); } }原理与避坑点长度计算__HAL_DMA_GET_COUNTER(huart-hdmarx)获取的是DMA通道剩余要传输的数据量。我们用总缓冲区大小减去剩余量就得到了已经传输的数据量也就是本次从空闲开始到空闲结束接收到的数据长度。先关DMA再计算这是一个非常重要的细节DMA传输是硬件行为与CPU异步。如果在计算过程中DMA又搬运了新数据会导致计算的长度不准甚至访问越界。因此先__HAL_DMA_DISABLE()暂停DMA是安全的做法。重新使能因为我们配置的是Circular模式缓冲区是环形的。在计算完长度并取走数据后我们需要重置DMA的计数器CNDTR寄存器为缓冲区大小并重新使能DMA。这样DMA就会从缓冲区开头或当前位置继续接收数据覆盖掉已经处理过的旧数据实现循环接收。可选重写DMA传输完成回调函数 对于发送我们可能更关心DMA发送完成。对于接收在“空闲中断循环DMA”模式下DMA的传输完成中断TC意义不大因为它意味着整个环形缓冲区被填满了一圈通常我们更早地在空闲中断里就处理了数据。但发送完成中断很有用。// 重写DMA发送完成中断回调函数 void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if(huart-Instance USART2) { // 可以在这里置位一个发送完成标志或者释放信号量等 // 例如uart2_tx_done 1; } }4.4 主循环中的数据发送与接收处理有了上述框架主程序的逻辑就非常清晰了。发送数据// 准备要发送的数据 uint8_t send_data[] Hello, DMA UART!\r\n; uint16_t send_len sizeof(send_data) - 1; // 去掉字符串结尾的\0 // 使用DMA发送 HAL_UART_Transmit_DMA(huart2, send_data, send_len); // 此时CPU可以立即去执行其他任务无需等待发送完成注意HAL_UART_Transmit_DMA是非阻塞的。调用后函数立即返回DMA在后台搬运数据。如果你需要等待本次发送完成才能进行下一次发送例如严格的数据包顺序那么你需要查询huart2.gState的状态或者等待HAL_UART_TxCpltCallback中设置的标志位。处理接收到的数据// 在主循环中 while (1) { // 检查接收完成标志 if(g_uart2_rx_done_flag 1) { // 清除标志 g_uart2_rx_done_flag 0; // 处理接收到的数据长度在 g_uart2_rx_len 中 // 例如将数据回显Echo回去 HAL_UART_Transmit_DMA(huart2, g_uart2_rx_buffer, g_uart2_rx_len); // 或者进行协议解析如解析MODBUS、自定义帧头帧尾等 // uart2_protocol_parse(g_uart2_rx_buffer, g_uart2_rx_len); // 重要处理完数据后最好将接收长度清零 // g_uart2_rx_len 0; // 但注意这个变量在中断里会被重新赋值清不清取决于你的逻辑 } // 其他任务... HAL_Delay(1); }5. 深度调试技巧与常见问题排查即使代码逻辑正确在实际硬件调试中也可能遇到各种问题。下面是我在多个项目中总结出的排查清单。5.1 数据收发全无最基础的问题检查1硬件连接TX、RX线是否接反MCU的TX要接对方设备的RX共地GND是否连接串口通信必须共地。使用USB转TTL模块时其VCC是否接对是3.3V还是5VSTM32通常是3.3V电平。检查2引脚配置在CubeMX中确认USART2的TX/RX引脚是否正确映射PA2/PA3。确认没有其他功能如ADC复用到这两个引脚上造成冲突。检查3时钟配置系统时钟HCLK是否成功配置并运行在预期频率USART2的时钟总线APB1是否已使能在CubeMX时钟树中确认APB1总线时钟不为0。关键点USART的波特率发生器时钟来源于APB1对于USART2。如果APB1时钟配置错误实际波特率会和设置值相差巨大导致无法通信。检查4软件初始化顺序确保在main()函数中先初始化MX_DMA_Init()再初始化MX_USART2_UART_Init()。因为USART初始化函数里可能会用到DMA相关的结构体。确保在调用HAL_UART_Receive_DMA启动接收之前所有外设初始化已完成。5.2 DMA接收能启动但收不到数据或数据错乱问题1DMA通道映射错误症状程序不报错但DMA计数器CNDTR永远不减少说明DMA根本没被触发。解决反复核对数据手册中的“DMA请求映射表”确认USART2_RX和USART2_TX对应的DMA数据流Stream和通道Channel是否与代码中huart2.hdmarx-Instance和huart2.hdmatx-Instance指向的DMA流一致。这是最经典的错误。问题2缓冲区溢出或数据覆盖症状能收到数据但数据包不完整或者新旧数据混杂。解决检查接收缓冲区RX_BUFFER_SIZE是否足够大。如果数据包源源不断而主程序处理速度跟不上DMA接收速度缓冲区会被写满并从头覆盖。此时应增大缓冲区或提高主程序处理速度优化代码、使用DMA双缓冲HT/TC中断。在HAL_UART_IdleCpltCallback中计算完长度g_uart2_rx_len后是否立即将数据拷贝到另一个处理缓冲区如果还在原缓冲区进行复杂的协议解析而DMA已经重新使能并开始接收新数据就会发生数据覆盖。务必在中断回调中尽快将数据移走或做好标记。问题3空闲中断不触发症状能收到单个字符用查询方式验证但g_uart2_rx_done_flag永远不为1。解决确认已调用__HAL_UART_ENABLE_IT(huart2, UART_IT_IDLE)使能了空闲中断。检查USART2的全局中断是否在NVIC中使能CubeMX通常会自动配置。空闲中断的触发条件是“RX线空闲时间超过一个字符的传输时间”。如果发送方发送的数据帧之间没有停顿间隔小于一个字符时间就不会触发空闲中断。对于无间隔的流数据应使用“循环DMA定时器”或“循环DMA定期查询DMA计数器”的方式来提取数据。在stm32f4xx_it.c中找到USART2_IRQHandler函数确保它内部调用了HAL_UART_IRQHandler(huart2)。这个HAL库通用中断处理函数会判断中断来源并调用对应的回调函数如HAL_UART_IdleCpltCallback。5.3 DMA发送问题问题1发送卡住程序阻塞在发送函数症状调用HAL_UART_Transmit_DMA后程序似乎停住了。解决检查huart2.gState。HAL库的UART驱动有一个状态机。如果上一次DMA发送还未完成huart2.gState ! HAL_UART_STATE_READY再次调用发送函数可能会返回HAL_BUSY或进入错误处理。解决方案是等待上一次发送完成。可以简单用while(huart2.gState ! HAL_UART_STATE_READY){}轮询不推荐会阻塞更好的方法是用发送完成回调函数设置标志位在主程序中基于标志位进行状态控制。问题2发送数据丢失最后几个字节症状发送一串数据接收端总是少收最后1-2个字节。解决DMA传输完成中断TC触发时意味着DMA已经把最后一个数据从内存搬到了USART的数据寄存器DR。但是USART硬件将数据从DR移位到TX引脚发送出去还需要一定时间取决于波特率。如果在TC中断回调里立即进行关闭串口、复位等操作可能会打断最后几个字节的物理发送。解决方法在TC中断回调中不要立即关闭外设。如果需要确保所有数据都已从TX引脚发出可以等待USART状态寄存器中的“发送完成TCTransmission Complete”标志位置位。HAL库提供了HAL_UART_GetState(huart2)和检查HAL_UART_STATE_TC的方法但更简单的做法是在TC中断回调后延迟一小段时间例如计算发送所有字节所需时间再加几个毫秒再进行后续操作。5.4 高级调试手段使用逻辑分析仪或示波器这是最直接的硬件调试方法。可以观察TX、RX引脚上的实际波形确认波特率是否正确、数据内容是否符合预期、帧格式起始位、数据位、停止位是否正确。利用ST-Link和IDE调试器查看外设寄存器在Keil或STM32CubeIDE的调试模式下可以查看USART2-SR状态寄存器、USART2-DR数据寄存器、DMA通道的CNDTR计数器寄存器、CCR配置寄存器等的实时值。这对于理解DMA和USART的工作状态至关重要。设置数据断点可以在接收缓冲区g_uart2_rx_buffer的特定位置设置数据写入断点当DMA向该地址写入数据时程序会暂停方便你观察是何时、如何写入的。软件模拟与调试信息在关键位置如DMA TC/HT中断回调、空闲中断回调翻转一个GPIO引脚点个灯用示波器观察中断响应是否及时。在确保基本通信正常后可以先使用简单的HAL_UART_Transmit函数发送一些调试信息如g_uart2_rx_len的值帮助判断程序逻辑。但注意打印函数本身可能占用时间影响实时性。6. 性能优化与进阶应用当基础功能跑通后可以考虑以下优化和进阶用法让你的DMA串口通信更加强大和稳定。6.1 实现“双缓冲”接收以降低延迟前面提到的“空闲中断循环DMA”模式其数据处理延迟等于一包数据的时间。如果想进一步降低延迟可以使用DMA的半传输中断HT和传输完成中断TC来实现双缓冲Ping-Pong Buffer。原理将接收缓冲区在逻辑上分为前半部分Buffer A和后半部分Buffer B。当DMA接收到一半数据填满Buffer A时触发HT中断。在HT中断回调中你可以处理Buffer A的数据而此时DMA正在向Buffer B写入后半部分数据。当DMA接收完所有数据填满Buffer B时触发TC中断。在TC中断回调中你可以处理Buffer B的数据而此时DMA已经循环回来开始向Buffer A写入新数据。这样数据处理的延迟从“一包时间”降低到了“半包时间”并且CPU处理数据和DMA接收数据几乎是并行的效率更高。配置方法是在CubeMX中使能DMA通道的“Half Transfer Interrupt”和“Transfer Complete Interrupt”并分别实现HAL_UART_RxHalfCpltCallback和HAL_UART_RxCpltCallback回调函数。6.2 与RTOS如FreeRTOS结合在实时操作系统中长时间阻塞是不可接受的。DMAUSART与RTOS是绝配。发送可以创建一个发送任务或线程和一个发送队列。其他任务需要发送数据时只需将数据指针和长度放入队列。发送任务从队列中取出数据调用HAL_UART_Transmit_DMA然后挂起自己等待一个二值信号量Semaphore。这个信号量在HAL_UART_TxCpltCallback发送完成中断中释放。这样发送任务只在有数据发送和发送完成时被调度极大节省CPU资源。接收在HAL_UART_IdleCpltCallback空闲中断中计算数据长度后不要进行复杂的处理。而是将接收缓冲区的数据拷贝到一个RTOS的消息队列Message Queue或直接释放一个计数信号量。由一个专门的数据处理任务Consumer Task等待这个队列或信号量并在其上下文进行协议解析等耗时操作。这符合RTOS的“中断快进快出”原则避免了在中断中执行复杂代码。6.3 超时管理与错误恢复工业应用要求通信稳定可靠。需要增加超时和错误恢复机制。接收超时除了空闲中断可以开启一个硬件定时器。每次收到任何一个字节可以在DMA传输中断中重置定时器就重置定时器。如果定时器超时则认为一帧数据结束即使没触发空闲中断。这可以应对一些通信协议中帧间隔不固定的情况。错误处理重写HAL_UART_ErrorCallback回调函数。当发生溢出错误ORE、噪声错误NE、帧错误FE或校验错误PE时HAL库会调用此函数。在这里你需要清除错误标志__HAL_UART_CLEAR_FLAG并根据情况重新初始化DMA接收HAL_UART_Receive_DMA否则通信可能会永久停止。DMA传输错误同样DMA本身也可能产生传输错误中断如总线错误。需要在DMA的中断服务程序中处理或检查相关的错误标志位。6.4 内存对齐与性能考量内存对齐DMA访问内存时如果数据地址没有对齐到字Word4字节或半字Half-Word2字节边界在某些架构下可能会导致额外的时钟周期性能下降或甚至产生硬件错误在严格对齐的系统中。虽然对于USART的字节传输这个问题不突出但一个好的习惯是将DMA缓冲区用__attribute__((aligned(4)))进行4字节对齐声明。uint8_t g_uart2_rx_buffer[RX_BUFFER_SIZE] __attribute__((aligned(4)));使用内存到内存的DMADMA不仅可以用于外设和内存之间还可以用于内存和内存之间Mem-to-Mem。例如你可以用DMA来快速搬运、初始化或比较大块的数据这比用CPU的memcpy或memset要快得多尤其是在有DMA2D二维DMA功能的系列上对图形操作有奇效。这展示了DMA作为一种系统级数据搬运工的通用价值。从最基本的配置到稳定可靠的“空闲中断循环DMA”模式再到与RTOS结合、实现双缓冲和错误恢复STM32的USART DMA通信是一个可以不断深入挖掘的主题。它完美体现了嵌入式开发中“硬件加速”的思想——将CPU从简单重复的IO操作中解放出来专注于核心的业务逻辑和算法。理解并熟练运用DMA是迈向高效嵌入式系统开发的必经之路。在实际项目中建议从一个简单的例子开始逐步增加功能并用逻辑分析仪和调试器仔细观察每一步的硬件行为积累下来的经验会让你在面对更复杂的通信场景时游刃有余。