在实际嵌入式开发中尤其是使用 STM32、GD32 这类微控制器时当数据量稍大或者对实时性有要求直接使用 CPU 搬运数据就会成为性能瓶颈。这时开发者手册和例程里反复出现的“DMA”就成了必须掌握的技术。但对于刚接触底层开发的新手来说DMA 相关的术语——通道、传输模式、中断、外设请求——常常让人一头雾水配置起来也容易出错比如数据传丢了、中断进不去或者根本不知道传输什么时候完成。这篇文章的目标就是彻底讲清楚 DMA 到底是什么它解决了什么问题以及如何在一个具体的场景比如串口收发中正确地配置和使用它。我们会从 DMA 最核心的“搬运工”比喻讲起然后深入到它的工作机制最后通过一个 STM32 的 USART DMA 收发实例手把手带你完成配置、编写代码、验证结果并梳理出最常见的几个坑和排查路径。读完本文你将能独立完成一个基于 DMA 的串口不定长数据收发项目并理解其背后的原理。1. 先彻底搞懂 DMA为什么 CPU 需要它来“打杂”1.1 DMA 解决的核心矛盾CPU 时间 vs. 数据搬运在没有 DMA 的系统中如果外设比如串口、ADC、SPI需要与内存交换数据整个过程必须由 CPU 亲自参与。CPU 需要执行类似这样的流程等待外设数据准备好或准备好发送数据的标志位。从外设数据寄存器读取一个字节到 CPU 寄存器。再将这个字节从 CPU 寄存器写入到内存的目标地址。更新内存地址指针和剩余数据计数。判断是否传输完成若未完成则回到步骤1。这个过程被称为“程序控制传输”或“轮询”。它的致命缺点是CPU被大量简单的数据搬运工作所占用无法执行更有价值的计算或逻辑任务。对于高速外设如摄像头、高速ADC或大数据量传输如文件读写、网络包CPU 的利用率会急剧下降系统整体性能受损。DMADirect Memory Access直接存储器访问就是为了解放 CPU 而设计的专用硬件模块。你可以把它想象成一个智能且独立的“数据搬运工”。它的工作流程是CPU 预先告诉 DMA 搬运工源地址在哪里如外设数据寄存器、目标地址在哪里如内存中的数组、要搬多少数据、怎么搬一次搬多少、地址是否递增。CPU 下达“开始搬运”指令后就可以去处理其他任务了。当外设准备好数据或准备好接收数据时它会向 DMA 控制器发出一个“请求”信号。DMA 控制器收到请求在系统总线上“借用”一个周期直接完成从源到目标的数据传输完全绕过 CPU。传输完成后DMA 控制器可以通过中断等方式通知 CPU“老板你交代的搬运任务完成了”。这样CPU 只在传输开始和结束时参与中间的搬运过程被完全卸载实现了“零拷贝”意义上的高效数据传输。1.2 DMA 的关键组件与工作流程理解 DMA需要弄清楚几个核心概念它们通常对应着配置寄存器时的选项通道ChannelDMA 控制器通常有多个独立的通道每个通道可以服务于一个特定的外设如 USART1_TX, ADC1, SPI2_RX 等。通道之间优先级可配置防止冲突。仲裁器Arbiter当多个通道同时发出传输请求时仲裁器根据预设的优先级软件优先级或硬件固定优先级决定哪个通道先使用总线。外设请求Peripheral Request传输的发起者。通常是外设认为“我准备好了”时发出的硬件信号。例如USART 发送数据寄存器空TXE时会请求 DMA 送下一个数据来USART 收到数据RXNE时会请求 DMA 把数据从寄存器搬走。传输模式外设到内存Peripheral-to-Memory如 ADC 采集数据到内存数组。内存到外设Memory-to-Peripheral如内存中的字符串通过 USART 发送出去。内存到内存Memory-to-MemoryDMA 控制器在两个内存区域间搬运数据这是纯 DMA 功能无需外设请求。数据流Stream/ 控制器Controller在一些高级的 DMA 架构如 STM32F4/F7/H7 的 DMA2D中概念更复杂但本质仍是管理传输的实体。中断InterruptDMA 传输完成、传输一半或发生错误时可以产生中断通知 CPU 进行后续处理如处理接收到的数据包或准备下一批待发送数据。1.3 为什么串口通信特别适合使用 DMA串口USART/UART通信是嵌入式中最常见的外设之一。在以下场景使用 DMA 优势明显不定长数据接收结合串口空闲中断IDLE可以在收到一帧完整数据后由 DMA 传输完成中断或空闲中断通知 CPUCPU 只需处理整包数据无需逐个字节中断。大数据量或高速率传输例如通过串口发送大量日志或固件数据使用 DMA 可以避免因 CPU 处理不及时造成的数据丢失或发送延迟。降低 CPU 负载在 RTOS 或多任务系统中让 DMA 处理串口数据搬运可以释放 CPU 给其他高优先级任务。2. 环境准备与项目目标设定在开始写代码之前必须把环境对齐。本文以 STM32F103C8T6BluePill 核心板为例使用 STM32CubeMX 进行图形化配置HAL 库进行开发。这套组合在学习和中小项目中非常普遍。2.1 硬件与软件环境清单项目说明备注开发板STM32F103C8T6 最小系统板其他 F1 系列或 STM32 系列原理类似IDE/工具链STM32CubeIDE 或 Keil MDK本文使用 STM32CubeIDE它集成了 CubeMX 和 GCC 编译器固件库STM32CubeF1 HAL 库通过 CubeMX 自动集成调试器ST-Link V2 或 DAPLink用于下载和调试串口工具任意串口助手如 Putty, SecureCRT用于发送和接收数据2.2 本实例达成的目标我们将实现一个经典的、实用的串口 DMA 应用DMA 发送CPU 将一段字符串Hello DMA!的地址和长度交给 DMA由 DMA 自动通过串口发送出去发送完成后产生中断通知 CPU。DMA 接收不定长配置 DMA 循环接收模式到一个足够大的缓冲区。当串口线上有一段时间没有新数据产生空闲中断时我们认为一帧数据接收完毕。在空闲中断中计算出本次接收的数据长度然后处理数据并重置 DMA 接收以备下一帧。这个组合避免了轮询发送占用 CPU也解决了不定长接收的难题是很多实际项目如 Modbus、自定义串口协议的基础。3. 使用 STM32CubeMX 配置 DMA 与 USART图形化配置能极大减少底层寄存器操作的错误。我们按步骤进行。3.1 创建工程与时钟配置打开 STM32CubeIDE新建 STM32 项目选择 MCU 型号STM32F103C8Tx。在Pinout Configuration视图的System Core RCC中将High Speed Clock (HSE)设置为Crystal/Ceramic Resonator。我们的开发板通常有 8MHz 外部晶振。进入Clock Configuration标签页进行时钟树配置。一个常见的配置是HSE 输入 8MHz经过 PLL 倍频到 72MHz 作为系统时钟SYSCLK。APB1 总线时钟PCLK1设为 36MHzAPB2 总线时钟PCLK2设为 72MHz。确保 USART1 所在的 APB2 总线时钟正确因为它影响串口波特率计算。3.2 配置 USART1在Pinout视图找到USART1。将模式设置为Asynchronous异步通信。此时PA9 和 PA10 引脚会自动被配置为 USART1_TX 和 USART1_RX这是默认复用功能。在Configuration标签页下点击USART1进入参数设置Baud Rate:115200Word Length:8 BitsParity:NoneStop Bits:1Over Sampling:16 Samples其他保持默认。3.3 配置 DMA核心步骤在Configuration标签页选择DMA设置。点击Add添加一个新的 DMA 通道。DMA Request: 选择USART1_RX。这意味着我们将为 USART1 的接收功能分配一个 DMA 通道。Direction: 选择Peripheral To Memory。数据从外设USART1 数据寄存器搬运到内存。Priority: 选择Medium。根据系统需求调整。Mode: 选择Circular循环模式。这是实现不定长接收的关键在此模式下DMA 接收数据到缓冲区末尾后会自动回到缓冲区开头继续接收永不停止。配合空闲中断我们就能截取任意长度的一帧数据。再次点击Add添加第二个 DMA 通道。DMA Request: 选择USART1_TX。Direction: 选择Memory To Peripheral。数据从内存搬运到外设。Priority:Medium。Mode:Normal正常模式。发送完指定长度数据后DMA 传输停止并产生传输完成中断。下次发送需要重新启动。注意Normal和Circular模式的选择是配置的关键点。发送通常用Normal发完即停接收为了持续监听常用Circular。分别对两个通道进行如下高级参数配置点击已添加的通道行Increment Address:对于USART1_RX(Peripheral To Memory):Peripheral不递增地址固定为 USART1-DR 寄存器Memory递增这样数据才能依次存放到缓冲区数组里。对于USART1_TX(Memory To Peripheral):Peripheral不递增Memory递增。Data Width:Peripheral和Memory都设置为Byte因为 USART 是 8 位数据。如果外设是 16 位或 32 位如 ADC这里需要对应修改。Memory Burst / Peripheral Burst: 在 F1 系列保持默认Single。3.4 配置中断在NVIC Settings标签页DMA 配置页面或 System Core NVIC 中使能以下中断USART1 global interrupt我们需要用到它的空闲中断IDLE。DMA1 channel4 global interrupt对应 USART1_TX DMA用于发送完成回调。DMA1 channel5 global interrupt对应 USART1_RX DMA可用于接收半满、全满等中断本例中我们主要用空闲中断可以暂时不使能 DMA 通道5中断以简化流程。设置合适的优先级。对于实时性要求高的接收可以给 USART1 中断更高的优先级。3.5 生成工程代码进入Project Manager标签页设置项目名称、路径选择 IDE 为STM32CubeIDE。在Code Generator部分选择Generate peripheral initialization as a pair of ‘.c/.h’ files per peripheral这样代码更模块化。点击GENERATE CODE生成初始化代码并打开工程。4. 编写代码实现 DMA 发送与不定长接收生成了基础框架后我们需要在main.c和stm32f1xx_it.c中添加业务逻辑。4.1 定义全局变量与缓冲区在main.c的/* USER CODE BEGIN PV */区域定义变量/* Private variables ---------------------------------------------------------*/ #define RX_BUFFER_SIZE 256 // 接收缓冲区大小 uint8_t tx_buffer[] Hello DMA!\r\n; // 待发送数据 uint8_t rx_buffer[RX_BUFFER_SIZE]; // DMA循环接收缓冲区 volatile uint16_t rx_len 0; // 接收到的数据长度 volatile uint8_t rx_flag 0; // 接收完成标志位rx_bufferDMA 循环接收的目标缓冲区。必须足够大以免在 CPU 处理上一包数据时新数据覆盖了未处理的数据。rx_len和rx_flag用于在中断服务程序ISR和主循环之间通信。使用volatile防止编译器优化。4.2 启动 DMA 接收在main函数的/* USER CODE BEGIN 2 */区域启动 USART1 的 DMA 接收/* USER CODE BEGIN 2 */ // 启动串口DMA接收数据将自动存入rx_buffer循环模式永不停止 HAL_UART_Receive_DMA(huart1, rx_buffer, RX_BUFFER_SIZE); // 使能串口空闲中断IDLE—— 这是HAL库没有直接提供的功能 __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); // 发送测试字符串 HAL_UART_Transmit_DMA(huart1, tx_buffer, sizeof(tx_buffer) - 1); // 减1是为了去掉字符串结尾的\0 /* USER CODE END 2 */关键点HAL_UART_Receive_DMA启动了 DMA 循环接收。从此串口收到的每一个字节都会由 DMA 自动存入rx_buffer存满后回到开头继续存。__HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE)是使能空闲中断的关键宏。CubeMX 生成的代码默认不会使能此中断需要手动开启。空闲中断在串口数据线上超过一个字符传输时间没有新数据时触发是判断一帧数据结束的理想方式。4.3 处理串口空闲中断核心逻辑我们需要修改中断服务程序。打开stm32f1xx_it.c找到USART1_IRQHandler函数。void USART1_IRQHandler(void) { /* USER CODE BEGIN USART1_IRQn 0 */ uint32_t tmp_flag 0; uint32_t tmp_it_source 0; // 检查是否是空闲中断 tmp_flag __HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE); tmp_it_source __HAL_UART_GET_IT_SOURCE(huart1, UART_IT_IDLE); if((tmp_flag ! RESET) (tmp_it_source ! RESET)) { // 清除空闲中断标志重要 __HAL_UART_CLEAR_IDLEFLAG(huart1); // 暂时关闭DMA接收以安全地计算接收到的数据长度 HAL_UART_DMAStop(huart1); // 计算本次接收到的数据长度 // __HAL_DMA_GET_COUNTER 获取DMA通道剩余未传输的数据量 rx_len RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx); if(rx_len 0) { // 设置标志位通知主循环有数据待处理 rx_flag 1; // 可以在这里直接处理数据但为了中断快进快出通常只设标志位。 // process_rx_data(rx_buffer, rx_len); } // 重新配置DMA接收缓冲区地址和长度并启动DMA // 注意因为之前是循环模式停止后需要重新设置 huart1.hdmarx-Instance-CNDTR RX_BUFFER_SIZE; // 重新设置传输数据量 huart1.hdmarx-Instance-CMAR (uint32_t)rx_buffer; // 重新设置内存地址可选如果缓冲区不变可不设 __HAL_DMA_ENABLE(huart1.hdmarx); // 使能DMA通道 // 如果不重新调用 HAL_UART_Receive_DMA需要手动清除一些HAL库状态 // 更稳妥的做法是调用 HAL_UART_Receive_DMA 重新启动 // HAL_UART_Receive_DMA(huart1, rx_buffer, RX_BUFFER_SIZE); } /* USER CODE END USART1_IRQn 0 */ HAL_UART_IRQHandler(huart1); /* USER CODE BEGIN USART1_IRQn 1 */ /* USER CODE END USART1_IRQn 1 */ }代码逻辑解释判断中断源首先检查触发的中断是否是空闲中断IDLE。清除标志必须清除空闲中断标志位否则会持续进入中断。停止 DMA在计算接收长度前停止 DMA防止计算过程中 DMA 仍在修改缓冲区或计数器导致数据不一致。计算长度DMA 传输计数器CNDTR存储着剩余要传输的字节数。用缓冲区总大小减去剩余数量就得到了已经接收到的数据长度rx_len。通知主循环设置rx_flag 1让主循环知道有新数据包。重启 DMA这是最容易出错的一步。因为之前停止了 DMA需要重新初始化 DMA 通道的传输数据量CNDTR和内存地址CMAR然后使能通道。更简单且不易出错的方法是直接再次调用HAL_UART_Receive_DMA。重要在中断服务程序中处理速度要快避免执行耗时操作如printf。所以这里只设置标志位实际的数据处理如解析协议、应答应放到主循环中。4.4 主循环处理接收数据回到main.c的while (1)循环中我们检查并处理接收完成标志。/* Infinite loop */ /* USER CODE BEGIN WHILE */ while (1) { /* USER CODE END WHILE */ /* USER CODE BEGIN 3 */ if(rx_flag) { rx_flag 0; // 清除标志 // 1. 处理接收到的数据 (rx_buffer 中前 rx_len 个字节) // 例如回显接收到的数据 HAL_UART_Transmit_DMA(huart1, rx_buffer, rx_len); // 2. 处理完后可以清空缓冲区或直接覆盖循环模式 // memset(rx_buffer, 0, rx_len); // 可选 // 注意此时DMA接收已经在空闲中断中重启无需额外操作 } // 其他任务... HAL_Delay(1); // 简单延时实际项目中可能由RTOS调度 } /* USER CODE END 3 */4.5 处理 DMA 发送完成中断可选如果需要在发送完成后执行特定操作如关闭射频、切换状态可以使用 DMA 发送完成中断。HAL 库为我们提供了回调函数机制。在main.c中重写发送完成回调函数/* USER CODE BEGIN 4 */ // DMA发送完成回调函数 void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if(huart-Instance USART1) { // 发送完成可以点亮一个LED或者进行下一步操作 // HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); } } /* USER CODE END 4 */这个函数会在 DMA 发送完成中断中被 HAL 库自动调用。5. 编译、下载与验证编译工程点击 STM32CubeIDE 中的锤子图标进行编译确保无错误。连接硬件用 USB 转串口模块连接开发板的 PA9TX、PA10RX和 GND 到电脑。同时连接 ST-Link 进行下载和调试。下载程序点击 IDE 中的下载按钮。验证打开串口助手选择正确的 COM 口波特率 115200。复位开发板你应该看到串口助手收到Hello DMA!。在串口助手的发送框输入任意长度字符串不超过RX_BUFFER_SIZE点击发送。观察接收区开发板会通过 DMA 将你发送的内容原样回传回来。6. 常见问题与深度排查指南配置 DMA 时遇到的问题往往比较隐蔽。下面是一个系统的排查清单。6.1 数据根本收不到或发不出现象可能原因检查点与解决方案完全无数据串口引脚配置错误1. 检查 CubeMX 中 USART 引脚是否已正确映射PA9/PA10。2. 检查硬件连接TX/RX 是否交叉连接。时钟未正确配置1. 检查SystemClock_Config()函数是否被调用。2. 使用HAL_RCC_GetSysClockFreq()打印系统时钟验证。3. 检查 APB2 总线时钟USART1 时钟源是否使能且频率正确。DMA/USART 外设未使能1. 确认MX_DMA_Init()和MX_USART1_UART_Init()被main函数调用。2. 在调试模式下查看USART1-CR1和DMA1_Channel4-CCR等寄存器确认ENABLE位是否被置1。中断未使能或优先级冲突1. 检查 NVIC 配置USART1 全局中断和 DMA 通道中断是否使能。2. 检查是否有更高优先级中断长时间阻塞。只能收到第一个字节或零星字节DMA 传输模式错误1. 发送模式应为Memory to Peripheral,Normal。2. 接收模式应为Peripheral to Memory,Circular不定长或Normal定长。3. 检查Memory Address Increment是否使能。DMA 传输计数器未正确设置1. 发送前确认HAL_UART_Transmit_DMA的长度参数正确。2. 在调试器观察DMA1_Channel4-CNDTR发送或DMA1_Channel5-CNDTR接收寄存器启动后其值应为预设的数据长度并随传输递减。外设请求未连接1. 在 CubeMX 中确认 DMA Request 正确关联到了USART1_TX和USART1_RX。2. 检查参考手册确认所用 USART 的 TX/RX DMA 请求映射到了正确的 DMA 通道对于 F103USART1_TX 是 DMA1_Channel4RX 是 DMA1_Channel5。6.2 不定长接收相关的问题现象可能原因检查点与解决方案空闲中断不触发空闲中断未使能1. 确认在启动接收后调用了__HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE)。2. 在USART1-CR1寄存器中查看IDLEIE位是否为1。数据流从未停止空闲中断在第一个字符接收完成后总线空闲一个字符时间后触发。如果数据持续不断例如波特率错误导致误码也被当作数据可能不会产生空闲。检查发送端。计算的长度 (rx_len) 不对DMA 计数器读取时机不对必须在停止 DMA后再读取__HAL_DMA_GET_COUNTER。如果在 DMA 运行中读取该值可能正在变化导致计算错误。缓冲区溢出如果一帧数据长度超过RX_BUFFER_SIZEDMA 会从缓冲区头部开始覆盖导致rx_len计算逻辑失效RX_BUFFER_SIZE - CNDTR可能小于实际帧长。必须确保缓冲区足够大或使用双缓冲半传输中断机制。数据错位或重复DMA 重启逻辑错误在空闲中断中处理完数据后如果选择重新配置 DMA 寄存器CNDTR, CMAR必须确保配置顺序正确并最终使能 DMA。最稳妥的方法是直接再次调用HAL_UART_Receive_DMA让 HAL 库处理底层细节。主循环处理太慢主循环在rx_flag1后如果处理数据耗时过长DMA 可能已经收到了新数据并覆盖了缓冲区中未处理的部分。解决方案1. 加大缓冲区。2. 使用双缓冲在中断中将数据快速拷贝到另一个处理缓冲区。3. 提高主循环处理优先级或使用 RTOS 任务。6.3 DMA 发送完成中断不触发检查中断使能在 CubeMX 中确认该 DMA 通道的传输完成中断TCIE已使能。在代码中HAL_UART_Transmit_DMA会自动使能相关中断。检查回调函数确保重写了HAL_UART_TxCpltCallback函数且函数体在/* USER CODE BEGIN 4 */和/* USER CODE END 4 */之间这样代码生成器不会覆盖它。检查传输模式如果 DMA 模式设置为Circular则不会产生传输完成中断因为它会循环发送。6.4 性能与稳定性进阶考量内存对齐如果 DMA 传输的数据宽度是Half Word16位或Word32位要确保源和目标地址都按相应字节对齐2字节或4字节边界否则可能导致硬件错误。缓存一致性对于 Cortex-M7 等带 Cache 的芯片如果 DMA 操作的内存区域是可缓存的在 DMA 写入后CPU 读取前或 CPU 写入后DMA 读取前需要手动执行SCB_CleanDCache_by_Addr或SCB_InvalidateDCache_by_Addr来维护缓存一致性否则会读到旧数据。双缓冲Double Buffer对于高速、连续的数据流如音频、摄像头使用 DMA 双缓冲模式可以几乎无缝地处理数据。配置两个缓冲区DMA 填满缓冲区 A 时产生中断CPU 处理 A同时 DMA 转向填充缓冲区 B如此交替。使用__HAL_LOCK检查HAL 库使用__HAL_LOCK机制防止对句柄的并发访问。如果在一个 DMA 传输尚未完成时再次调用HAL_UART_Transmit_DMA函数会返回HAL_BUSY。在实际项目中需要实现一个简单的发送队列或状态机来处理这种情况。7. 最佳实践与扩展方向掌握了基础的 DMA 串口收发后可以将其应用到更复杂的场景并遵循以下最佳实践封装为独立模块将串口 DMA 初始化和收发逻辑封装成独立的uart_dma.c/.h文件提供清晰的接口如uart_send_packet(),uart_get_packet()提高代码可移植性和可读性。实现环形缓冲区FIFO在主循环和 DMA 接收中断之间增加一个软件环形缓冲区。DMA 空闲中断将数据快速拷贝到环形缓冲区并释放 DMA 缓冲区。主循环从环形缓冲区读取处理。这彻底解耦了接收和处理的速度是工业级应用的标准做法。协议解析与超时除了空闲中断还应加入超时机制。如果一帧数据被分成了多个包或者中间有较长间隔空闲中断可能提前触发。可以配合一个定时器在收到第一个字节时启动在超时时间内未收到新字节则认为一帧结束。错误处理完善 DMA 和 USART 的错误中断回调函数HAL_UART_ErrorCallback处理帧错误、噪声错误、溢出错误等并记录日志。扩展到其他外设同样的 DMA 思想可以应用到ADC连续扫描多个通道DMA 将结果搬运到数组实现高速数据采集。SPI/I2S与音频编解码器、存储器、显示屏通信搬运大量音频像素数据。SDIO/FSMC与外部存储器交换数据。理解 DMA 不仅仅是记住配置步骤更是建立起“CPU 与 DMA 协同工作”的思维模型。在资源受限的嵌入式系统中合理运用 DMA 是优化性能、保证实时性的关键技能。从这个小例子出发尝试修改参数观察现象并逐步应用到你的实际项目中才能真正掌握这项“解放 CPU”的核心技术。