DMA控制器详解:工作原理、传输模式与通道配置

DMA,全称Direct Memory Access,直接存储器访问。说白了,就是让数据绕过CPU,直接在内存和外设之间搬来搬去。我刚开始做嵌入式时,总觉得CPU啥都能干,干嘛还要多一个DMA?直到有一次项目里UART接收大量传感器数据,CPU被中断淹没了,系统几乎卡死。嗯,从那以后,我再也不敢小看DMA了。

DMA工作原理:谁在干活?

DMA本质上是一个硬件协处理器。它有自己的总线控制权,能直接读写内存和外设寄存器。CPU只需要告诉它三件事:从哪里搬、搬到哪里、搬多少。然后CPU就可以去干别的了。

我习惯把DMA的工作流程拆成四个步骤:

  1. 请求阶段:外设(比如UART)发出DMA请求信号。数据准备好了,快来拿。
  2. 响应阶段:DMA控制器向总线仲裁器申请总线控制权。如果CPU正在用总线,就得等一等。
  3. 传输阶段:DMA拿到总线后,直接在外设和内存之间搬运数据。一次搬一个字节或一个字。
  4. 结束阶段:传输完成,DMA释放总线,发个中断告诉CPU:活干完了。

关键点:DMA传输期间,CPU可以继续执行指令,只要不访问同一块内存区域。这就是性能提升的核心——并行处理。

我曾经在一个项目中,CPU主频只有72MHz,UART波特率却要跑到921600。如果每个字节都进中断,CPU几乎100%都在处理中断。用了DMA之后,CPU占用率直接降到15%。差距就是这么大。

传输模式:三种玩法

DMA控制器通常支持三种传输模式。我按实际项目中使用频率来排序:

模式 特点 典型场景 我踩过的坑
单次传输 一次配置,传输固定长度数据,完成后自动停止 UART发送一帧数据、ADC单次采样 忘记重新配置传输长度,导致第二次传输失败
循环传输 传输完成后自动重载起始地址和长度,无限循环 音频流、连续ADC采样、环形缓冲区 缓冲区溢出没检测,旧数据被新数据覆盖
内存到内存 源和目标都是内存地址,不需要外设触发 数据拷贝、图像处理、协议栈缓冲区移动 内存对齐问题导致传输效率极低

单次传输模式

这是最基础的模式。配置好源地址、目标地址、传输长度,启动后DMA就干活。传完了,状态寄存器里会置一个完成标志位。

我建议你在中断服务函数里检查这个标志位,而不是轮询。为什么?因为轮询会浪费CPU时间,违背了用DMA的初衷。

// 单次传输配置示例(伪代码)
DMA_InitTypeDef dma_config;
dma_config.Direction = DMA_PERIPH_TO_MEMORY;  // 外设到内存
dma_config.BufferSize = 256;                   // 传输256个字节
dma_config.PeriphAddr = (uint32_t)&USART1->DR; // UART数据寄存器地址
dma_config.MemoryAddr = (uint32_t)rx_buffer;   // 内存缓冲区地址
DMA_Init(DMA1_Channel5, &dma_config);
DMA_Cmd(DMA1_Channel5, ENABLE);

循环传输模式

循环模式很有意思。传输完成后,DMA自动把地址指针重载到初始值,长度计数器也恢复。这样就能持续不断地搬运数据。

我记得有个音频项目,麦克风以16kHz采样率输出数据。如果用单次模式,每次传输完都要CPU重新配置,根本来不及。换成循环模式后,DMA自己循环跑,CPU只负责处理数据。

小技巧:循环模式下,可以配合半传输中断和完成传输中断。半传输中断时处理前半缓冲区,完成中断时处理后半缓冲区。这就是双缓冲区的雏形。

内存到内存模式

这个模式不需要外设触发。配置好源内存地址和目标内存地址,软件启动后DMA就开始搬。搬完拉倒。

你可能会问:用memcpy不就行了?为什么还要DMA?

答案是:memcpy是CPU在搬,DMA搬的时候CPU可以干别的。如果你有大块数据要拷贝,比如从网络缓冲区搬到应用缓冲区,DMA能省下不少CPU时间。

注意:内存到内存模式下,DMA会一直占用总线。如果此时CPU也要访问内存,就得等。所以大块内存拷贝时,CPU其实是被阻塞的。这个模式适合在CPU空闲时使用。

通道配置:选对通道,事半功倍

DMA控制器通常有多个通道。每个通道可以连接不同的外设请求。以STM32为例,DMA1有7个通道,每个通道对应一组外设映射。

我习惯先看数据手册里的DMA请求映射表。比如:

通道 外设请求1 外设请求2 外设请求3
通道1 ADC1 TIM2_CH3 USART1_TX
通道2 SPI1_RX USART1_RX TIM1_CH1
通道3 SPI1_TX USART2_TX TIM2_UP

配置通道时,有几个参数必须设对:

  • 优先级:高优先级通道可以打断低优先级通道的传输。我一般把实时性要求高的外设(比如音频)设成高优先级。
  • 数据宽度:外设和内存的数据宽度可以不同。比如外设是8位,内存是16位。DMA会自动处理对齐。
  • 地址增量:外设地址通常固定不变,内存地址需要递增。这个别搞反了。

我曾经犯过一个低级错误:配置UART接收时,把外设地址增量设成了使能。结果每次传输后,DMA都去读下一个地址,读到的全是垃圾数据。排查了整整半天才发现。

知识结构总览

下面这张图是我自己总结的DMA知识体系,你可以对照着看:

DMA控制器知识结构 DMA控制器 工作原理 请求 → 响应 → 传输 → 结束 传输模式 单次 / 循环 / 内存到内存 通道配置 优先级 / 数据宽度 / 地址增量 UART + DMA 协同应用 中断通知 + DMA搬运 = CPU零等待 核心思路 DMA负责数据搬运,CPU负责数据处理。两者并行工作,互不干扰。

配置实战:一个UART接收的例子

说了这么多理论,咱们来点实际的。下面是一个UART接收的DMA配置流程:

  1. 使能DMA时钟:别笑,我真见过有人忘了这一步,折腾了半天。
  2. 配置DMA通道:选择对应UART接收的通道,设置外设地址、内存地址、传输长度。
  3. 配置传输参数:数据方向(外设到内存)、地址增量(外设不变,内存递增)、数据宽度(8位)。
  4. 使能DMA中断:传输完成中断、半传输中断,根据需要开启。
  5. 使能DMA通道:启动传输。
  6. 使能UART的DMA请求:告诉UART,有数据就通知DMA,别烦CPU。
// UART接收DMA配置完整示例
void UART_RX_DMA_Config(void)
{
    // 1. 使能DMA时钟
    RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);
    
    // 2. 配置DMA通道
    DMA_DeInit(DMA1_Channel5);
    DMA_InitTypeDef dma_config;
    dma_config.Direction = DMA_PERIPH_TO_MEMORY;
    dma_config.PeriphInc = DMA_PINC_DISABLE;    // 外设地址不增
    dma_config.MemInc = DMA_MINC_ENABLE;        // 内存地址递增
    dma_config.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
    dma_config.MemDataAlignment = DMA_MDATAALIGN_BYTE;
    dma_config.Mode = DMA_MODE_CIRCULAR;        // 循环模式
    dma_config.Priority = DMA_Priority_High;
    dma_config.PeriphAddr = (uint32_t)&USART1->DR;
    dma_config.MemoryAddr = (uint32_t)uart_rx_buf;
    dma_config.BufferSize = RX_BUF_SIZE;
    DMA_Init(DMA1_Channel5, &dma_config);
    
    // 3. 使能传输完成中断
    DMA_ITConfig(DMA1_Channel5, DMA_IT_TC, ENABLE);
    
    // 4. 使能DMA通道
    DMA_Cmd(DMA1_Channel5, ENABLE);
    
    // 5. 使能UART的DMA接收请求
    USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE);
}

个人经验:配置完成后,先发一个字节测试一下。看DMA的剩余传输长度寄存器有没有减少。如果没变化,八成是通道映射错了。

嗯,DMA这部分内容就到这里。记住一句话:DMA是帮你省CPU时间的,不是给你增加配置负担的。配置一次,受益终身。