DMA 基础与核心概念:它到底在忙什么?
各位同学,咱们今天聊 DMA。DMA 全称 Direct Memory Access,直接存储器访问。说白了,就是让数据在内存和外设之间自己跑,不用 CPU 一趟一趟地搬。
我刚开始做嵌入式的时候,总觉得 DMA 是个「黑魔法」。后来踩了几个坑才明白——它其实就是一个专门的硬件搬运工。CPU 把活派给它,它自己干完,完事了通知你一声。
DMA 的工作原理:一个硬件搬运工
想象一下这个场景:你正在写一个串口接收程序,每来一个字节,CPU 就要去读寄存器、存内存。如果数据量一大,CPU 就啥也干不了了,全在搬数据。
DMA 就是来解决这个问题的。它内部有几个关键部件:
- DMA 控制器:大脑,负责调度传输任务
- 源地址寄存器:数据从哪来
- 目的地址寄存器:数据往哪去
- 传输计数器:还要搬多少字节
- 控制寄存器:配置传输模式、触发条件
工作流程其实很简单:
- CPU 配置好 DMA 的源地址、目的地址、数据长度
- 外设发出请求(比如 ADC 转换完成、UART 收到数据)
- DMA 控制器拿到总线控制权,开始搬数据
- 搬完后,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 从配置到完成的完整流程:
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 从数据搬运中解放出来的硬件加速器。理解它的工作原理和三种传输模式,后面的实战内容才能用得顺手。