1. 从一次数据搬运的“堵车”说起:为什么需要DMA?
如果你写过单片机程序,尤其是处理过像串口接收大量数据、ADC连续采样或者驱动LCD屏这类任务,你大概率遇到过一种困境:CPU太“忙”了。比如,你的主程序正在执行一个复杂的算法,此时串口收到一个字节的数据,触发中断,CPU必须立刻停下手中的活,跳转到中断服务函数里,把这个字节从串口数据寄存器读到内存的某个数组里,然后再返回主程序。如果数据以115200的波特率源源不断地涌来,意味着每86微秒CPU就要被打断一次。这就像你正在专心写代码,每隔几秒钟就有人敲门让你签收一个快递,你的工作效率会急剧下降,这就是所谓的“CPU被I/O操作绑架”。
更糟糕的是,对于一些高速外设,比如摄像头接口、高速ADC或者以太网,数据流的速度可能远超CPU处理中断的能力。CPU可能刚处理完上一个数据,下一个数据已经因为未被及时读取而丢失了。这种场景下,单纯靠中断已经力不从心。
于是,DMA(Direct Memory Access,直接存储器访问)技术应运而生。你可以把它想象成雇了一个专门的“数据搬运工”。这个搬运工非常专业,它独立于CPU工作。当外设(比如串口、ADC)准备好数据,或者内存需要向外设发送数据时,你只需要告诉DMA搬运工:“把这堆砖头(数据)从A地点(源地址)搬到B地点(目标地址),一共搬N块,搬完了告诉我一声。” 然后你就可以让CPU去干更重要的计算、逻辑判断等“脑力活”,而繁重的“体力活”——数据搬运,则完全交给DMA。在整个搬运过程中,数据直接从外设流向内存,或者从内存A流向内存B,完全不需要CPU参与,从而极大地解放了CPU。
我最初在STM32上使用USART的DMA传输时,最直观的感受就是主循环变得异常“清爽”。以前需要小心翼翼计算缓冲区、频繁进中断的标志位管理全部消失了,主循环里只需要检查DMA传输完成标志,然后处理一整块已经安静躺在内存里的数据即可,系统响应性和吞吐量提升了好几个数量级。
2. DMA控制器的核心架构:它到底是怎么干活的?
要理解DMA,不能只停留在“它是个搬运工”的概念上,我们需要拆开看看这个搬运工内部有哪些关键部件,以及它们是如何协同工作的。一个典型的DMA控制器(例如集成在STM32、GD32等MCU内部的DMA)主要由以下几部分组成,我们可以类比成一个物流公司的运营体系:
2.1 物流调度中心:DMA控制逻辑与仲裁器
这是DMA的大脑。它负责接收CPU的指令(配置),管理多个“搬运任务”(通道)的优先级。当多个外设同时请求DMA服务时(比如ADC和串口都准备好了数据),仲裁器会根据预设的优先级(固定优先级或循环优先级)来决定先处理哪个请求。这就像物流中心接到多个客户的发货订单,需要调度员来决定先派哪辆车。
2.2 任务订单:通道(Channel)
这是DMA执行具体搬运任务的单元。一个DMA控制器通常有多个独立的通道(如STM32F4有2个DMA控制器,各8个通道)。每个通道可以被分配给一个特定的外设(例如,通道1分配给USART1的发送,通道2分配给ADC1)。每个通道都有一套独立的“任务订单”,也就是我们接下来要说的寄存器组。
注意:通道和外设的映射关系是芯片硬件固定的,不能随意分配。在CubeMX或查阅数据手册时,必须确认你要用的外设支持哪些DMA通道。例如,
USART1_TX可能只能使用DMA1的通道4或通道7。
2.3 订单详情:通道寄存器组
这是DMA工作的核心配置所在,CPU通过设置这些寄存器来下达精确的搬运指令。主要包含以下几类:
- 源地址寄存器(PAR/M0AR):告诉DMA数据从哪里来。可以是外设数据寄存器的地址(如
&USART1->DR),也可以是内存地址。 - 目标地址寄存器(MAR/M1AR):告诉DMA数据搬到哪里去。可以是内存地址,也可以是外设数据寄存器的地址。这里就区分了DMA的三种传输方向:
- 外设到内存:如ADC采集数据到数组。源地址是ADC数据寄存器,目标地址是内存数组。
- 内存到外设:如从内存数组发送数据到串口。源地址是内存数组,目标地址是串口数据寄存器。
- 内存到内存:这是很多DMA控制器的高级功能,可以在内部RAM之间高速复制数据,源和目标都是内存地址。
- 传输数量寄存器(NDTR):要搬运的数据项数量。注意,这个“项”的大小由数据宽度决定。
- 配置寄存器(CR):这是最复杂的寄存器,包含了任务的完整规则:
- 数据宽度:每次搬运的数据大小,如字节(8位)、半字(16位)、字(32位)。源和目标的宽度可以不同,但通常需要对齐。
- 地址递增模式:搬运完一个数据后,源地址和目标地址是否自动增加。对于内存缓冲区,通常需要递增;对于外设寄存器地址(固定不变),则不能递增。
- 传输模式:
- 单次模式:配置好NDTR个数,触发一次,搬完指定数量后停止。
- 循环模式:搬完NDTR个数后,寄存器自动重载初始值,从头开始继续搬,永不停止。这是实现ADC连续采集+双缓冲、串口接收环形缓冲区的关键。
- 优先级:该通道在仲裁时的优先级。
- 中断使能:是否在传输完成一半、传输全部完成或发生错误时产生中断,通知CPU。
2.4 搬运动作的触发:请求与响应
DMA不会自己开始干活,它需要被“触发”。触发方式主要有两种:
- 硬件触发:最常用。外设(如ADC转换完成、USART收到数据)会向它关联的DMA通道发出一个硬件请求信号。DMA控制器仲裁后,响应这个请求,执行一次数据传输。这个过程对CPU完全透明。
- 软件触发:通过编程置位某个寄存器位来启动一次DMA传输。常用于内存到内存的传输,或者测试。
整个工作流程可以概括为:CPU配置DMA通道参数 -> 使能DMA通道和外设的DMA请求 -> 外设事件触发DMA请求 -> DMA仲裁器响应 -> DMA控制器接管总线,执行“源地址读 -> 目标地址写”的数据搬运 -> 搬运计数递减,地址按规则递增 -> 计数为零则根据模式停止或循环 -> 可选地产生中断通知CPU。
3. 三种经典应用场景的配置与避坑指南
理解了结构,我们来看怎么用。下面结合STM32 HAL库(因其普及度高)的代码片段,详解三个最典型的场景,并分享我踩过的坑。
3.1 场景一:ADC多通道扫描+循环DMA,实现“无感”数据采集
这是DMA的杀手级应用。假设我们需要用ADC1连续采集3个通道(CH0, CH1, CH2)的电压,并以最高速度实时刷新到一个数组中。
核心配置思路:
- ADC配置为连续扫描模式,DMA连续请求。
- DMA配置为循环模式,目标地址递增,指向一个足够大的内存数组(或双缓冲区)。
- 启动后,ADC会自动按顺序转换CH0, CH1, CH2,每转换完一个就触发DMA请求,DMA将数据搬运到数组。三个通道都转完一轮,ADC又开始下一轮,DMA则持续不断地将新数据覆盖或填充到数组后续位置。
关键代码与解析:
// 1. 定义缓冲区 #define ADC_BUFF_SIZE 1024 uint32_t adc_buffer[ADC_BUFF_SIZE]; // 假设ADC是12位,用16位或32位存储 // 2. ADC多通道配置(以STM32CubeMX生成代码为例) hadc1.Instance = ADC1; hadc1.Init.ScanConvMode = ENABLE; // 启用扫描模式 hadc1.Init.ContinuousConvMode = ENABLE; // 连续转换 hadc1.Init.DMAContinuousRequests = ENABLE; // DMA请求连续不断 hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT; // ... 其他配置 // 配置通道序列(规则组) sConfig.Channel = ADC_CHANNEL_0; sConfig.Rank = 1; HAL_ADC_ConfigChannel(&hadc1, &sConfig); sConfig.Channel = ADC_CHANNEL_1; sConfig.Rank = 2; // ... 以此类推 // 3. DMA配置 hdma_adc1.Instance = DMA2_Stream0; // 具体Stream/Channel查数据手册 hdma_adc1.Init.Channel = DMA_CHANNEL_0; hdma_adc1.Init.Direction = DMA_PERIPH_TO_MEMORY; // 外设到内存 hdma_adc1.Init.PeriphInc = DMA_PINC_DISABLE; // 外设地址不增(ADC数据寄存器固定) hdma_adc1.Init.MemInc = DMA_MINC_ENABLE; // 内存地址递增 hdma_adc1.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD; // 外设数据对齐(ADC 16位) hdma_adc1.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD; // 内存数据对齐 hdma_adc1.Init.Mode = DMA_CIRCULAR; // 循环模式!!! hdma_adc1.Init.Priority = DMA_PRIORITY_HIGH; // ... FIFO等配置(对于简单情况可默认) HAL_DMA_Init(&hdma_adc1); // 关联DMA到ADC __HAL_LINKDMA(&hadc1, DMA_Handle, hdma_adc1); // 4. 启动传输 HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buffer, ADC_BUFF_SIZE);避坑经验:
- 缓冲区溢出与数据覆盖:循环模式下,数据会不停地写入缓冲区。如果你的处理速度跟不上采集速度,新数据会覆盖旧数据。解决方案是使用“双缓冲区”技术:配置DMA传输长度为缓冲区一半,在半传输完成中断和传输完成中断中切换处理指针。HAL库的
HAL_ADC_ConvHalfCpltCallback和HAL_ADC_ConvCpltCallback就是用于此目的。 - 数据对齐与大小:确保
PeriphDataAlignment和MemDataAlignment与实际情况匹配。如果ADC是12位右对齐,数据寄存器读取是16位,这里应设为HALFWORD。缓冲区类型也应是uint16_t或uint32_t。 - NDTR与缓冲区大小:
HAL_ADC_Start_DMA的最后一个参数就是初始的NDTR值。在循环模式下,这个值不会被清零,而是完成一次循环后自动重载。因此缓冲区大小必须至少等于这个值。
3.2 场景二:USART串口DMA收发与不定长数据接收
串口通信中,发送一大段数据(如打印日志)和接收不定长数据包是常见需求。DMA能完美解决这两个问题。
发送(内存到外设):配置相对简单。将待发送数据的数组地址作为源,串口数据寄存器地址作为目标,设置好数据长度,启动DMA传输。CPU即可去干别的事,DMA会自动将数据逐个字节送入串口发送。需要等待发送完成中断或查询标志位,以知道何时可以安全修改发送缓冲区。
接收(外设到内存)不定长数据:这是难点。因为我们不知道对方会发多长的数据。一个经典且可靠的方案是:DMA循环模式 + 串口空闲中断。
- 配置DMA接收为循环模式:指向一个足够大的环形缓冲区,比如
uart_rx_buffer[256]。DMA会一直将串口收到的数据循环写入这个缓冲区。 - 使能串口空闲中断:当串口总线上一段时间没有收到数据时,会产生空闲中断。
- 在空闲中断服务函数中处理:
- 计算从上次处理位置到当前DMA写入位置之间的数据长度。
- 将这段数据复制出来进行处理。
- 更新下次处理的起始位置。
关键代码片段(思路):
// 变量定义 uint8_t uart_rx_buffer[256]; volatile uint16_t dma_last_pos = 0; // 上次处理到的位置 // 初始化:启动串口DMA循环接收 HAL_UART_Receive_DMA(&huart1, uart_rx_buffer, 256); // 在串口空闲中断回调函数中(需先使能空闲中断 __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE)) void USART1_IRQHandler(void) { if(__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 清除空闲中断标志 // 1. 获取当前DMA写位置 uint16_t current_pos = 256 - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); // 注意:NDTR是剩余未传输数 // 2. 计算本次接收到的数据长度 uint16_t data_len = (current_pos >= dma_last_pos) ? (current_pos - dma_last_pos) : (256 + current_pos - dma_last_pos); // 处理环形缓冲区回绕 // 3. 处理从 dma_last_pos 开始,长度为 data_len 的数据 process_uart_data(&uart_rx_buffer[dma_last_pos], data_len); // 4. 更新上次处理位置 dma_last_pos = current_pos; } HAL_UART_IRQHandler(&huart1); }避坑经验:
- DMA计数器(NDTR)的含义:
__HAL_DMA_GET_COUNTER获取的是剩余未传输的数据项数,而不是已传输数。因此缓冲区大小 - 剩余数 = 当前写入位置。 - 环形缓冲区索引计算:必须处理回绕情况,上面的三元判断是标准做法。
- 数据覆盖:如果处理速度太慢,DMA可能会追上并覆盖未处理的数据。需要根据通信波特率和最大数据包长度,合理设置缓冲区大小,并确保处理函数足够快。也可以使用双缓冲机制。
- GD32等芯片的注意事项:有些品牌的MCU(如GD32)的库函数可能略有不同,但原理完全一致。关键在于理解“循环DMA+空闲中断”这个组合拳。
3.3 场景三:内存到内存传输,加速大数据块操作
这是DMA一个容易被忽略但极其有用的功能。当你需要在芯片内部RAM之间复制、填充或移动大块数据时(例如,复制图像帧缓冲区、初始化大数组、进行内存数据搬移用于算法处理),使用CPU一个字节一个字节地复制会消耗大量时钟周期。此时,内存到内存的DMA可以以硬件最高速度(通常接近总线速度)完成这个工作,CPU在此期间可以休眠或执行其他不访问总线的简单指令。
配置要点:
- 方向:
DMA_MEMORY_TO_MEMORY。 - 源和目标地址递增:通常都使能。
- 数据宽度:选择总线位宽(32位)通常效率最高。
- 触发:通常使用软件触发,配置好后手动启动。
HAL库示例:
// 假设复制两个大数组 uint32_t src_array[1000]; uint32_t dst_array[1000]; hdma_memtomem.Instance = DMA2_Stream0; // 注意:不是所有Stream都支持M2M,查手册 hdma_memtomem.Init.Channel = DMA_CHANNEL_0; // M2M模式下Channel可能可任意选或固定 hdma_memtomem.Init.Direction = DMA_MEMORY_TO_MEMORY; hdma_memtomem.Init.PeriphInc = DMA_PINC_ENABLE; // 源内存地址递增 hdma_memtomem.Init.MemInc = DMA_MINC_ENABLE; // 目标内存地址递增 hdma_memtomem.Init.PeriphDataAlignment = DMA_PDATAALIGN_WORD; hdma_memtomem.Init.MemDataAlignment = DMA_MDATAALIGN_WORD; hdma_memtomem.Init.Mode = DMA_NORMAL; // 单次模式,复制完即停 hdma_memtomem.Init.Priority = DMA_PRIORITY_HIGH; HAL_DMA_Init(&hdma_memtomem); // 启动传输 HAL_DMA_Start(&hdma_memtomem, (uint32_t)src_array, (uint32_t)dst_array, 1000); // 等待传输完成 HAL_DMA_PollForTransfer(&hdma_memtomem, HAL_DMA_FULL_TRANSFER, HAL_MAX_DELAY);避坑经验:
- 总线冲突:在内存到内存传输期间,DMA会持续占用总线。如果CPU也需要访问总线(比如从Flash取指令),会产生冲突,可能导致传输速度下降或CPU取指等待。对于时间敏感的应用,需要规划好DMA传输的时机。
- Cache一致性(针对Cortex-M7等带Cache的芯片):这是个大坑!如果你的源或目标内存区域开启了数据Cache(D-Cache),DMA操作的是物理内存,而CPU操作的是Cache里的数据副本,这会导致数据不一致。必须在DMA传输前,对相关内存区域执行Clean(确保DMA读到的是最新数据)或Invalidate(确保CPU读到的是DMA刚写入的数据)操作。HAL库通常提供
SCB_CleanDCache_by_Addr等函数。忘记处理Cache是很多人在STM32H7等高性能MCU上使用DMA时数据出错的根本原因。
4. 高级话题与疑难杂症排查
当你基本掌握DMA的使用后,可能会遇到一些更复杂的情况和棘手的bug。下面分享几个进阶主题和排查思路。
4.1 双缓冲与环形缓冲区的精妙设计
在高速连续数据流(如音频流、摄像头数据)中,简单的单缓冲区循环模式会遇到“处理速度”和“采集速度”赛跑的问题。双缓冲是解决此问题的银弹。
原理:分配两个大小相等的缓冲区BufferA和BufferB。DMA配置为循环模式,但传输长度设为单个缓冲区的大小。DMA首先填充BufferA,填满后触发“半传输完成中断”,此时DMA开始向BufferB填充,而CPU可以安全地处理BufferA里的数据。当BufferB填满时,触发“传输完成中断”,DMA又切回BufferA填充,CPU则处理BufferB。如此循环往复,实现了采集和处理的完美并行。
配置关键:在DMA初始化时,使能半传输完成中断(HTIE)和传输完成中断(TCIE)。在HAL库中,对应的回调函数是HAL_ADC_ConvHalfCpltCallback和HAL_ADC_ConvCpltCallback。
环形缓冲区(Circular Buffer):对于串口不定长接收这种场景,我们手动实现的“循环数组+索引计算”就是一种软件环形缓冲区。它的好处是读写指针可以独立移动,更加灵活。DMA的硬件循环模式为我们自动管理了“写指针”,我们只需要在中断中更新“读指针”即可。
4.2 DMA中断与CPU中断的协同与竞争
DMA中断(传输完成、半传输完成、传输错误)是通知CPU“活干完了”或“出问题了”的重要机制。但中断服务函数(ISR)的设计至关重要。
- 快进快出原则:DMA ISR应该只做最必要的操作,如设置标志位、切换缓冲区指针、通知任务等。繁重的数据处理应放到主循环或低优先级任务中。长时间阻塞在ISR中,可能导致丢失后续的中断或DMA请求。
- 中断优先级管理:如果系统中同时有多个中断源(如多个DMA通道、定时器、串口),需要合理设置NVIC中断优先级。一个通用的原则是:数据流相关的中断(如DMA完成)优先级应高于处理相关的中断(如数据处理任务),但低于那些对实时性要求极高的中断(如电机控制的PWM定时器中断)。错误的优先级可能导致数据流中断被阻塞,造成缓冲区溢出。
- 共享资源保护:如果DMA ISR和主循环都会访问同一个全局变量(如缓冲区索引、状态标志),必须考虑临界区保护。对于简单的标志位,使用
volatile关键字;对于复杂的结构,可能需要暂时关闭中断或使用信号量(在RTOS中)。
4.3 典型问题排查链路:以“数据错乱”或“传输停止”为例
当你发现DMA传输的数据不对,或者传着传着就停了,可以按照以下链路排查:
- 检查时钟:这是最基本也最容易被忽略的一点。DMA控制器和外设(如USART、ADC)的时钟是否都已使能?
__HAL_RCC_DMA1_CLK_ENABLE()和__HAL_RCC_USART1_CLK_ENABLE()都调用了吗? - 检查地址:源地址和目标地址设置是否正确?特别是外设寄存器的地址,是否用了
&USART1->DR这样的形式?地址递增模式是否设反了?(内存地址应递增,外设固定地址不应递增)。 - 检查数据对齐与宽度:这是数据错乱的常见原因。如果源是8位(字节),目标是32位(字),并且目标地址递增,那么一个字节的数据会被写入一个32位的空间,导致内存视图严重错位。务必保证
PeriphDataAlignment和MemDataAlignment与实际数据宽度匹配,或者你清楚地知道不对齐传输的后果。 - 检查传输数量(NDTR):传输数量是否为0?在启动传输前,NDTR必须被正确写入一个大于0的值。在循环模式下,传输完成后NDTR会自动重载,但如果初始配置就是0,则不会发生任何传输。
- 检查外设的DMA请求使能:你配置了DMA,但外设是否被设置为发出DMA请求?例如,对于USART,需要调用
HAL_UART_Receive_DMA或单独使能USART_CR3寄存器中的DMAT(发送DMA使能)或DMAR(接收DMA使能)位。对于ADC,需要使能ADC_CR2中的DMA位。 - 检查DMA通道映射:确认你使用的DMA Stream/Channel与数据手册上此外设的请求映射一致。USART1_TX可能只能映射到DMA1 Stream7 Channel4,用错了Stream或Channel会导致DMA根本收不到外设的请求信号。
- 检查中断与标志位:是否使能了相关中断?是否因为发生了传输错误(如访问非法地址)导致DMA被自动禁用?可以检查DMA中断状态寄存器或HAL库提供的错误回调函数
HAL_DMA_ErrorCallback。 - 检查缓冲区边界与溢出:在循环模式下,你的处理代码是否及时“消费”了数据?如果处理太慢,DMA写指针追上了读指针,会导致未处理的数据被覆盖。使用调试器观察缓冲区的读写指针位置,或者加入溢出检测标志。
- 检查Cache一致性(仅限带Cache的MCU):如果你用的是STM32H7、F7等系列,数据不一致几乎可以首先怀疑Cache。确保在DMA传输前后,对涉及的缓冲区内存地址执行了正确的Cache维护操作(Clean/Invalidate)。
4.4 不同厂商MCU的DMA特性差异
虽然DMA核心原理相通,但不同厂商、不同系列的MCU在实现上会有差异:
- STM32的DMA/BDMA/MDMA:在复杂系列(如H7)中,可能存在多个DMA控制器。DMA1/DMA2用于外设,BDMA用于低速外设或特定总线,MDMA(Master DMA)则更强大,支持更复杂的链表传输和内存到内存的高性能操作。需要仔细查阅参考手册。
- GD32的DMA:与STM32高度相似,API也兼容,但细节可能有别,例如某些寄存器位定义、中断标志清除方式。移植代码时需对照手册。
- NXP(如i.MX RT, LPC):可能使用不同的名称,如eDMA,功能通常更强大,支持通道链接、分散-聚集(Scatter-Gather)等高级特性,但配置也相对复杂。
- 通道与Stream:STM32 F4/F7/H7等系列引入了Stream概念,一个Stream可以映射到多个Channel(外设请求),增加了灵活性但也增加了配置复杂度。而F1系列则简单直接,是Channel到外设的固定映射。
掌握DMA,本质上是在理解你所用芯片的存储器架构和总线矩阵。它不是一个孤立的模块,而是连接CPU、内存、外设这座城市中各个建筑的高速货运专线。规划好这条专线,你的嵌入式系统才能流畅运行,CPU才能真正专注于核心决策。从最初的配置寄存器的手忙脚乱,到后来能娴熟地设计双缓冲、处理Cache一致性,这个过程本身就是对计算机体系结构最生动的实践。下次当你面对需要搬运大量数据的任务时,不妨先问问自己:“这件事,能不能交给DMA?”