1. DMA基础概念:工作原理、传输类型与协同模式

大家好,我是你们的老朋友。今天咱们来聊聊DMA——这个在嵌入式系统中不可或缺的搬运工。说实话,我刚开始接触嵌入式时,对DMA的理解也就停留在「它能让数据自己跑」这个层面。直到有一次做高速数据采集项目,CPU被中断淹没了,我才真正意识到:没有DMA,很多高性能场景根本玩不转。

1.1 DMA工作原理:说白了就是个硬件搬运工

DMA的全称是Direct Memory Access,直接存储器访问。它的核心思想很简单:让数据在内存和外设之间直接传输,不需要CPU逐字节参与

你想想看,如果没有DMA,CPU要搬数据得怎么做?

  • 从源地址读一个字节到寄存器
  • 再从寄存器写到目标地址
  • 重复以上步骤,直到搬完

这效率太低了。CPU本来应该做更重要的事——跑算法、处理逻辑、响应中断。结果全耗在搬数据上了。

DMA的工作原理,我习惯用「三个要素」来概括:

要素 说明
源地址 数据从哪里来(内存地址或外设寄存器)
目标地址 数据到哪里去(内存地址或外设寄存器)
传输长度 要搬多少数据(字节数或传输次数)

DMA控制器内部有一个状态机。配置好这三个要素后,给它一个启动信号,它就开始干活了。每搬完一个单元(字节/半字/字),它会自动更新地址和计数器,直到计数器归零,然后产生一个传输完成中断。

核心要点:DMA的本质是「配置即运行」。你配好参数,它自己跑,跑完告诉你一声。CPU全程可以睡大觉或者干别的。

1.2 DMA传输类型:三种常见场景

在实际项目中,我遇到的DMA传输场景基本就三种。咱们一个一个说。

1.2.1 内存到内存(Memory-to-Memory)

这种模式最简单。就是把数据从内存的一块区域搬到另一块区域。比如你要做数据拷贝、缓冲区整理、或者图像处理中的像素搬运。

配置要点:

  • 源地址和目标地址都是内存地址
  • 通常需要设置地址递增(源和目标都递增)
  • 传输完成后产生中断
// 伪代码示例:内存到内存DMA配置
dma_config.src_addr = (uint32_t)src_buffer;
dma_config.dst_addr = (uint32_t)dst_buffer;
dma_config.transfer_size = 1024;  // 1024字节
dma_config.src_increment = 1;     // 源地址递增
dma_config.dst_increment = 1;     // 目标地址递增
dma_config.mode = DMA_MEM_TO_MEM;
dma_start(&dma_config);

我的经验:内存到内存传输时,要注意DMA的带宽。我曾经在一个项目中用DMA做大量数据拷贝,结果DMA占用了太多总线带宽,导致CPU访问内存变慢。后来我改用「分时传输」策略,把大块传输拆成小块,中间穿插CPU访问,问题就解决了。

1.2.2 内存到外设(Memory-to-Peripheral)

这种模式很常见。比如你要通过SPI发送一批数据,或者通过UART发送一串字符。传统做法是CPU一个一个字节往发送寄存器里写,效率极低。

用DMA就简单了:

  • 源地址:内存中的数据缓冲区
  • 目标地址:外设的数据寄存器(固定地址,不递增)
  • 外设每准备好一次,DMA就送一个数据过去

这里有个关键点:外设的请求信号。DMA不是自己闷头传,而是等外设说「我准备好了,给我数据」。这个请求信号由外设硬件产生,连接到DMA控制器的请求线路上。

注意:配置内存到外设传输时,目标地址通常设置为不递增。因为外设数据寄存器就一个地址,你递增了反而写错地方。我见过新手犯这个错,数据全写到外设的「隔壁寄存器」去了,调试了半天。

1.2.3 外设到内存(Peripheral-to-Memory)

这个更常用。ADC采集数据、UART接收数据、I2S接收音频……都是外设往内存里塞数据。

配置要点:

  • 源地址:外设的数据寄存器(固定地址)
  • 目标地址:内存缓冲区(递增)
  • 外设每产生一个数据,DMA就把它搬到内存

嗯,这里要注意一个坑:缓冲区溢出。如果外设产生数据的速度比DMA搬运速度快,或者CPU来不及处理,缓冲区就会溢出。我曾经在一个音频采集项目中,因为DMA中断优先级设置太低,导致缓冲区被覆盖,声音出现了严重的爆音。

1.3 DMA与CPU的协同工作模式

DMA不是独立工作的,它和CPU共享系统总线。怎么协同?我总结了三种模式。

1.3.1 周期窃取模式(Cycle Stealing)

这是最常见的模式。DMA在CPU不访问总线的时候,偷偷「窃取」一个总线周期来传输数据。CPU几乎感觉不到延迟。

优点:对CPU影响小
缺点:传输速度相对较慢(因为要等总线空闲)

适合场景:低速外设(UART、I2C)、小数据量传输

1.3.2 突发传输模式(Burst Mode)

DMA一次性占用总线,连续传输多个数据,传输完才释放总线。CPU在这段时间内无法访问内存。

优点:传输速度快
缺点:CPU会被「饿死」一段时间

适合场景:高速数据传输(图像、音频)、大数据块拷贝

我的建议:在实时性要求高的系统中,慎用突发模式。我曾经在一个电机控制项目中用了突发DMA传输编码器数据,结果CPU因为拿不到总线,导致PWM更新延迟,电机出现了抖动。后来改成周期窃取模式,虽然传输慢了点,但系统稳定多了。

1.3.3 链表传输模式(Scatter-Gather / Linked List)

这个模式比较高级。DMA可以维护一个「任务链表」,每个节点描述一次传输的参数(源地址、目标地址、长度等)。DMA自动按链表顺序执行,执行完一个节点自动跳到下一个。

优点:可以连续传输多个不连续的内存块,无需CPU干预
缺点:配置复杂,需要额外的内存来存储链表

适合场景:网络数据包处理、音视频流处理、需要频繁切换缓冲区的场景

// 链表节点结构体示例
struct dma_link_node {
    uint32_t src_addr;
    uint32_t dst_addr;
    uint32_t length;
    struct dma_link_node *next;  // 指向下一个节点
    uint32_t control;            // 控制标志(中断使能等)
};

// 配置三个传输任务
struct dma_link_node node1 = {buf1, periph, 64, &node2, INT_ENABLE};
struct dma_link_node node2 = {buf2, periph, 128, &node3, INT_ENABLE};
struct dma_link_node node3 = {buf3, periph, 256, NULL, INT_ENABLE | LAST_NODE};

dma_config_linked_list(&node1);
dma_start();

避坑指南:我曾经在调试链表DMA时,发现传输到一半就停了。查了半天,原来是链表节点放在了一个被DMA覆盖的内存区域。记住:链表节点本身的内存区域不能被DMA传输覆盖。最好把链表放在专用的、不会被改写的内存段中。

1.4 小结:DMA的核心价值

说了这么多,其实DMA的核心价值就一句话:解放CPU。让CPU从繁琐的数据搬运中解脱出来,专注于它最擅长的逻辑控制和算法处理。

在实际项目中,我判断要不要用DMA的标准很简单:

  • 数据量大于几十字节?用DMA
  • 数据产生频率固定且较高?用DMA
  • CPU负载已经很高了?用DMA
  • 就传几个字节,CPU闲着也是闲着?那就不用DMA,直接轮询或中断

好了,这一章的基础概念就讲到这里。下一章咱们会深入DMA控制器的寄存器配置,包括如何设置传输宽度、优先级、中断处理等实战内容。到时候我会拿一个实际项目中的配置代码来拆解,保证干货满满。