4. DMA控制器原理:传输流程、地址配置与突发传输

DMA,全称是Direct Memory Access,直接存储器访问。说白了,就是让外设和内存之间能直接“对话”,不用CPU一遍遍地当搬运工。我早年做第一个带LCD的嵌入式项目时,就是靠CPU逐像素刷屏,结果CPU占用率飙到90%以上,系统几乎干不了别的。后来换成DMA,CPU瞬间就解放了。

这一节,我们就把DMA的底裤扒干净。从传输流程、地址配置,到传输宽度和突发传输,一个一个说清楚。

4.1 DMA传输流程:一次完整的搬运

一次典型的DMA传输,可以拆成5个步骤。我习惯把它叫作“请求→响应→搬运→计数→结束”。

  1. 外设发起DMA请求:比如SPI的接收缓冲区非空,或者ADC转换完成,外设会拉高DMA请求信号。
  2. DMA控制器仲裁:如果系统里有多个DMA通道,控制器会按优先级决定先响应谁。
  3. DMA获得总线控制权:CPU暂时交出总线,DMA成为总线主人。
  4. DMA执行传输:从源地址读数据,写到目的地址。每次传输后,源/目的地址和计数器都会更新。
  5. 传输完成,释放总线:当计数器减到0,DMA产生完成中断,把总线还给CPU。

核心要点:整个过程中,CPU只需要在开始配置DMA,在结束处理中断。中间的数据搬运,CPU完全不用管。

嗯,这里要注意:DMA传输期间,CPU虽然不能访问总线,但可以继续执行内部指令——只要不涉及内存访问。所以系统整体吞吐量是提升的。

4.2 源地址与目的地址配置

DMA传输的核心,就是告诉控制器“从哪读,写到哪”。这两个地址的配置方式,决定了你能实现什么样的传输模式。

地址类型 常见配置 典型场景
源地址 外设数据寄存器地址 SPI_DR、ADC_DR、UART_DR
目的地址 内存缓冲区地址 数组、帧缓冲、环形缓冲区
源地址 内存缓冲区地址 从内存发送数据到SPI
目的地址 外设数据寄存器地址 DAC输出、PWM占空比更新

地址递增模式也很关键。大多数DMA控制器支持三种模式:

  • 固定地址:每次传输后地址不变。适合外设寄存器——你永远只想读写同一个寄存器。
  • 递增地址:每次传输后地址+1(或+传输宽度)。适合内存缓冲区,数据要连续存放。
  • 递减地址:用得少,但某些协议栈的栈操作会用到。

我的习惯:配置外设到内存的传输时,源地址设成固定,目的地址设成递增。反过来,内存到外设,源地址递增,目的地址固定。这个组合能覆盖90%以上的场景。

4.3 传输宽度:一次搬多少

传输宽度,就是DMA每次搬运的数据位数。常见的有8位、16位、32位。有些高端控制器还支持64位甚至128位。

为什么要有这个参数?因为外设和内存的数据宽度可能不一样。比如:

  • SPI硬件是8位寄存器,但你的帧缓冲是32位像素。
  • ADC是12位结果,但内存是16位数组。

这时候就需要DMA做宽度匹配。我遇到过最坑的情况:某款MCU的DMA不支持半字(16位)传输,只能用字节或字。结果ADC结果存到内存里,高低字节全乱了。后来我查了三天手册才发现这个限制。

传输宽度 适用外设 注意事项
8位(字节) UART、SPI(8位模式)、I2C 地址递增步长=1
16位(半字) SPI(16位模式)、定时器、ADC 地址递增步长=2,注意对齐
32位(字) LCD控制器、DAC、帧缓冲 地址必须4字节对齐

避坑指南:我曾经在一个项目里,把DMA传输宽度设成32位,但源地址是外设的8位寄存器。结果DMA一次读了4个字节,把相邻寄存器的值也读进来了。数据全错。记住:源和目的的实际宽度必须匹配,或者至少你清楚DMA的打包/拆包行为。

4.4 突发传输:批量搬运的效率革命

突发传输(Burst Transfer),是DMA提升效率的关键手段。普通传输一次只搬一个数据单元,然后释放总线。突发传输则是一次请求,连续搬多个数据单元,期间一直占用总线。

你想想看,每次申请总线都有开销——仲裁、握手、切换。如果一次申请能搬4个、8个甚至16个数据,总开销就摊薄了。

突发传输的典型配置参数:

  • 突发长度:一次突发传输中连续搬运的数据单元个数。常见值:4、8、16。
  • 突发类型:固定长度突发(FLB)和增量突发(INCR)。前者每次突发长度固定,后者可以变化。

我举个实际例子。用DMA从内存搬运一帧图像数据到LCD控制器:

  • 如果单次传输,每搬一个像素就要申请一次总线。一帧800×480的图像,要申请384,000次总线。
  • 如果突发长度=16,只需要申请24,000次。总线利用率大幅提升。

性能对比:突发传输能让DMA的有效带宽提升30%~50%,尤其是在系统总线负载较重的情况下。

但突发传输不是万能的。有些外设的FIFO深度很浅,比如只有4个字节。如果你突发长度设成16,外设FIFO会溢出。所以突发长度必须≤外设FIFO深度。

4.5 DMA传输的完整流程图

下面这张图,把整个DMA传输的流程和关键配置点串起来了。我建议你把它存下来,以后配置DMA时对照着看。

DMA传输完整流程图 DMA传输完整流程图 CPU配置DMA参数 外设发起DMA请求 DMA控制器仲裁 DMA获得总线控制权 执行DMA传输 源地址→目的地址 | 传输宽度 | 突发长度 传输完成,释放总线 关键配置参数 • 源地址模式 (固定/递增/递减) • 目的地址模式 (固定/递增/递减) • 传输宽度 (8/16/32位) • 突发长度 (1/4/8/16) • 传输总量 (计数器初值) • 优先级 (低/中/高) • 中断使能 (完成/半完成/错误)

4.6 实际配置示例:SPI接收的DMA设置

光说不练假把式。我们看一个实际代码片段,配置DMA从SPI接收数据到内存缓冲区。

// 以STM32 HAL库为例,配置DMA从SPI接收数据
// 假设:SPI2, DMA1通道4, 接收缓冲区rx_buf[256]

DMA_HandleTypeDef hdma_spi2_rx;

void DMA_Config(void)
{
    // 1. 使能DMA时钟
    __HAL_RCC_DMA1_CLK_ENABLE();
    
    // 2. 配置DMA句柄
    hdma_spi2_rx.Instance = DMA1_Channel4;
    hdma_spi2_rx.Init.Direction = DMA_PERIPH_TO_MEMORY;  // 外设到内存
    hdma_spi2_rx.Init.PeriphInc = DMA_PINC_DISABLE;      // 外设地址固定
    hdma_spi2_rx.Init.MemInc = DMA_MINC_ENABLE;          // 内存地址递增
    hdma_spi2_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;  // 8位
    hdma_spi2_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;     // 8位
    hdma_spi2_rx.Init.Mode = DMA_NORMAL;                 // 普通模式
    hdma_spi2_rx.Init.Priority = DMA_PRIORITY_HIGH;      // 高优先级
    
    // 3. 初始化DMA
    HAL_DMA_Init(&hdma_spi2_rx);
    
    // 4. 将DMA句柄关联到SPI
    __HAL_LINKDMA(&hspi2, hdmarx, hdma_spi2_rx);
    
    // 5. 启动DMA传输
    // 源地址:SPI2数据寄存器 &hspi2.Instance->DR
    // 目的地址:rx_buf
    // 传输长度:256字节
    HAL_SPI_Receive_DMA(&hspi2, rx_buf, 256);
}

我的建议:配置DMA时,先把Direction、PeriphInc、MemInc这三个参数想清楚。它们决定了数据流的方向和地址变化方式。这三个对了,剩下的就是宽度和长度的微调。

4.7 突发传输的配置要点

突发传输的配置,不同MCU差异很大。但有几个通用原则:

  • 突发长度 ≤ 外设FIFO深度:否则数据会丢失。我见过有人把突发长度设成16,但UART的FIFO只有8,结果数据错位。
  • 内存地址对齐:突发传输通常要求源和目的地址对齐到突发长度的整数倍。比如突发长度=4(32位),地址必须4字节对齐。
  • 总线锁定的影响:突发传输期间,DMA一直占用总线。如果系统里有对延迟敏感的外设(比如定时器),要考虑突发长度是否过大。

我曾经踩过的坑:在一个多主设备的总线系统里,我把DMA突发长度设成了16。结果DMA传输期间,CPU想读取一个定时器的计数值,等了十几个时钟周期才拿到总线。定时器中断响应延迟直接超标。后来我把突发长度降到4,问题解决。