DMA 基础与核心概念:它到底在忙什么?

各位同学,咱们今天聊 DMA。DMA 全称 Direct Memory Access,直接存储器访问。说白了,就是让数据在内存和外设之间自己跑,不用 CPU 一趟一趟地搬。

我刚开始做嵌入式的时候,总觉得 DMA 是个「黑魔法」。后来踩了几个坑才明白——它其实就是一个专门的硬件搬运工。CPU 把活派给它,它自己干完,完事了通知你一声。

DMA 的工作原理:一个硬件搬运工

想象一下这个场景:你正在写一个串口接收程序,每来一个字节,CPU 就要去读寄存器、存内存。如果数据量一大,CPU 就啥也干不了了,全在搬数据。

DMA 就是来解决这个问题的。它内部有几个关键部件:

  • DMA 控制器:大脑,负责调度传输任务
  • 源地址寄存器:数据从哪来
  • 目的地址寄存器:数据往哪去
  • 传输计数器:还要搬多少字节
  • 控制寄存器:配置传输模式、触发条件

工作流程其实很简单:

  1. CPU 配置好 DMA 的源地址、目的地址、数据长度
  2. 外设发出请求(比如 ADC 转换完成、UART 收到数据)
  3. DMA 控制器拿到总线控制权,开始搬数据
  4. 搬完后,DMA 可以触发中断告诉 CPU:「活干完了」

关键点:DMA 传输期间,CPU 可以继续执行其他代码,或者进入低功耗模式。这才是 DMA 的价值所在——让 CPU 从繁重的数据搬运中解放出来。

三种传输模式:单次、循环、链表

不同的应用场景,需要不同的传输模式。我根据项目经验,把它们整理成了表格:

模式 工作方式 典型场景 注意事项
单次传输 配置一次,传输一次,完成后停止 一次性数据采集、文件传输 每次传输完需要重新配置
循环传输 传输完成后自动从头开始,无限循环 音频流、连续 ADC 采样 注意缓冲区覆盖问题
链表传输 预配置多个传输描述符,自动切换 复杂数据流、多缓冲区管理 描述符内存对齐要求高

单次传输最简单。配置好源地址、目的地址、长度,启动,等完成中断。我在做传感器数据采集时经常用这个模式——每次触发采集,DMA 把数据搬到内存,CPU 处理完再等下一次。

循环传输就更有意思了。你想想看,音频播放的时候,数据是源源不断的。如果用单次模式,每次传输完都要重新配置,中间会有间隙。循环模式就不一样了——它自动回卷,数据流不会断。

我曾经踩过的坑:循环模式下,如果 CPU 读数据的速度跟不上 DMA 写的速度,就会发生缓冲区覆盖。老数据还没处理完,新数据已经写进来了。解决方案是用双缓冲(ping-pong buffer),或者用链表模式做更精细的控制。

链表传输是最高级的一种。它维护一个描述符链表,每个描述符包含一次传输的配置。传输完一个,自动加载下一个。我做过一个多通道数据采集系统,每个通道的采样率、缓冲区大小都不一样,用链表模式一条链搞定。

DMA 与 CPU 的协同机制

DMA 不是独立工作的,它需要和 CPU 紧密配合。这里有几个关键机制:

  • 总线仲裁:DMA 和 CPU 共享总线,谁用谁申请。DMA 有「周期窃取」和「突发传输」两种方式
  • 中断通知:传输完成、传输错误等事件通过中断告诉 CPU
  • 内存一致性:DMA 直接操作内存,CPU 的缓存可能和实际内存不一致

说到总线仲裁,我个人习惯用突发传输——一次申请总线,连续搬多个数据,效率高。但要注意,如果 DMA 占总线太久,CPU 的实时响应会受影响。这个平衡点需要根据实际场景调。

内存一致性问题在 Rust 里尤其重要。因为 Rust 的所有权模型默认不允许共享可变状态,而 DMA 恰恰需要共享内存。我一般用 core::sync::atomic 或者 volatile 来保证可见性。

小技巧:在 Rust 中处理 DMA 缓冲区时,建议用 core::cell::UnsafeCell 配合 core::sync::atomic::Ordering。虽然 unsafe,但性能比用 Mutex 好得多。当然,前提是你得确保访问顺序正确。

DMA 传输的核心流程(SVG 图)

下面这张图展示了 DMA 从配置到完成的完整流程:

DMA 传输核心流程 DMA 传输核心流程 CPU 配置 DMA 外设发出 DMA 请求 DMA 获取总线控制权 DMA 传输数据 传输完成,触发中断 说明 CPU 只需配置一次, 后续传输由 DMA 独立完成。 CPU 可在此期间执行 其他任务或进入休眠。

Rust 中的 DMA 配置示例

说了这么多理论,咱们看看 Rust 代码怎么写。以 STM32 的 DMA 为例:

// 配置 DMA 进行单次传输
let dma = dp.DMA1.split();
let mut dma_channel = dma.1;

// 配置源地址(外设数据寄存器)、目的地址(内存缓冲区)
let config = dma::TransferConfig {
    source_address: &periph.dr as *const _ as u32,
    destination_address: &mut buffer as *mut _ as u32,
    transfer_size: 256,  // 传输 256 个字节
    source_increment: false,  // 外设地址不递增
    destination_increment: true,  // 内存地址递增
    circular_mode: false,  // 单次模式
};

// 启动传输
let transfer = dma_channel.start_transfer(config);

// CPU 可以去做其他事情
// ...

// 等待传输完成
transfer.wait_for_complete();

这段代码看起来简单,但有几个细节要注意:

  • source_increment 和 destination_increment 要根据实际情况设置。外设寄存器通常固定地址,内存缓冲区需要递增
  • circular_mode 设为 false 就是单次模式,设为 true 就是循环模式
  • Rust 的所有权系统会确保你不会同时访问同一个缓冲区——除非你用 UnsafeCell

性能要点:DMA 传输的粒度很重要。传输太小的数据块(比如几个字节),配置开销可能超过收益。我一般建议单次传输至少 32 字节以上,才能真正体现 DMA 的优势。

避坑指南:DMA 使用中的常见问题

做 DMA 开发这么多年,我总结了几条血泪教训:

  • 缓冲区对齐:很多 DMA 控制器要求缓冲区地址按 4 字节或 8 字节对齐。不对齐会导致硬件错误或性能下降
  • 缓存一致性问题:如果 CPU 有缓存,DMA 直接写内存,CPU 读到的可能是缓存中的旧数据。需要手动刷新缓存或使用非缓存内存区域
  • 中断优先级:DMA 完成中断的优先级要合理设置。太高会抢占关键任务,太低可能导致数据丢失
  • 描述符内存:链表模式下的描述符必须放在 DMA 可访问的内存区域,有些芯片要求放在特定的 SRAM 中

嗯,这些坑我基本都踩过。有一次做音频项目,DMA 循环模式跑得挺好,但声音总有杂音。查了两天才发现是缓存一致性问题——CPU 从缓存读数据,DMA 已经把新数据写进内存了。解决方案是在读取前加一条数据同步屏障指令。

好了,DMA 的基础概念就聊到这儿。记住一句话:DMA 是让 CPU 从数据搬运中解放出来的硬件加速器。理解它的工作原理和三种传输模式,后面的实战内容才能用得顺手。