3. DMA控制器原理:工作模式与数据搬运哲学
DMA,全称是Direct Memory Access,直接存储器访问。说白了,就是让数据在内存和外设之间自己跑,不用CPU一趟一趟地去搬。我刚开始接触嵌入式时,总觉得中断已经够用了,干嘛还要搞个DMA?直到有一次做高速数据采集,CPU被中断淹没了,系统几乎卡死——嗯,从那以后,DMA就成了我的标配。
核心思想:DMA控制器是一个独立的总线主设备,它可以接管总线控制权,在不需要CPU干预的情况下完成数据搬运。CPU只需要告诉DMA:“从哪搬,搬到哪,搬多少”,然后就可以去干别的事了。
3.1 DMA工作模式:三种搬运场景
DMA的工作模式,说白了就是数据源和数据目标的组合。我习惯把它分成三类:内存到内存(M2M)、内存到外设(M2P)、外设到内存(P2M)。这三种模式在实际项目中各有各的坑。
| 模式 | 数据源 | 数据目标 | 典型场景 |
|---|---|---|---|
| M2M | 内存 | 内存 | 内存拷贝、缓冲区搬移、图像数据重排 |
| M2P | 内存 | 外设 | DAC输出、SPI发送、UART发送 |
| P2M | 外设 | 内存 | ADC采集、SPI接收、UART接收 |
3.2 内存到内存(M2M):最直接的搬运
M2M模式,就是从一个内存地址把数据搬到另一个内存地址。你想想看,这不就是memcpy的硬件加速版吗?
我个人习惯在以下场景用M2M:
- 大块数据拷贝(超过几百字节)
- 需要固定周期的内存搬移
- 双缓冲区的数据交换
经验之谈:M2M模式下,DMA会占用总线带宽。如果CPU也在频繁访问内存,两者会争抢总线。我曾在项目中遇到DMA搬运时CPU响应变慢的问题,后来通过调整DMA优先级和传输粒度解决了。
3.3 内存到外设(M2P)与外设到内存(P2M)
这两种模式是嵌入式开发中最常用的。M2P负责把数据喂给外设,P2M负责从外设收数据。它们通常由外设的请求信号触发,而不是软件启动。
举个例子:
- M2P:SPI发送数据时,DMA把数据从内存搬到SPI的数据寄存器
- P2M:ADC转换完成后,DMA把结果从ADC的数据寄存器搬到内存缓冲区
注意:外设的DMA请求信号有时序要求。我曾经遇到一个坑:UART的TXE(发送寄存器空)信号在DMA配置完成前就触发了,导致第一次传输丢失。解决办法是先配置DMA,再使能外设的DMA请求。
3.4 循环模式与单次模式
这是DMA传输的两个基本模式,选择对了能省不少事。
| 特性 | 单次模式 | 循环模式 |
|---|---|---|
| 传输次数 | 一次,完成后停止 | 无限循环,自动重载 |
| 中断触发 | 传输完成时触发一次 | 每次循环完成都触发 |
| 典型场景 | 一次性数据搬移、协议帧发送 | 连续数据流、音频播放、ADC连续采集 |
| 配置复杂度 | 低 | 中(需处理缓冲区翻转) |
| 数据宽度 | 大小 | 典型外设 |
|---|---|---|
| 字节(Byte) | 8位 | UART、I2C、SPI(8位模式) |
| 半字(Half Word) | 16位 | 定时器、ADC(16位模式) |
| 字(Word) | 32位 | DAC、DMA控制器自身 |
对齐问题更关键。很多DMA控制器要求源地址和目标地址与数据宽度对齐。比如:
- 字传输(32位):地址必须是4的倍数
- 半字传输(16位):地址必须是2的倍数
- 字节传输(8位):无对齐要求
我曾经踩过的坑:在STM32上做SPI DMA传输,源数据在内存中没对齐(地址是0x20000102),配置了字传输。结果DMA直接报错,数据全乱了。后来改成字节传输,或者手动对齐缓冲区地址,问题才解决。
3.6 DMA传输的核心流程
下面这张图展示了DMA一次完整传输的流程,从配置到完成:
3.7 Rust中的DMA配置示例
在Rust嵌入式开发中,我们通常用寄存器操作或HAL库来配置DMA。下面是一个简单的M2P传输示例:
// 以STM32为例,使用M2P模式发送数据到UART
use stm32f4xx_hal::dma::{Dma, DmaChannel, Transfer, Config};
use stm32f4xx_hal::serial::Serial;
fn dma_uart_send(
dma: &mut Dma,
uart: &mut Serial,
data: &[u8]
) {
// 配置DMA通道
let dma_channel = dma.split().1; // 获取DMA1通道1
// 设置传输参数
let config = Config::new()
.memory_to_peripheral() // M2P模式
.transfer_complete_interrupt(true) // 使能完成中断
.priority_high(); // 高优先级
// 创建传输
let transfer = Transfer::init(
dma_channel,
data.as_ptr(), // 源地址(内存)
uart.registers().dr.as_ptr(), // 目标地址(外设)
data.len(), // 传输长度
config,
);
// 启动传输
transfer.start(|_| {
// 传输完成回调
// 这里可以处理后续逻辑
});
}
Rust优势:Rust的所有权系统能确保DMA传输期间数据不会被意外修改。你想想看,在C语言里,DMA正在搬运时,另一个线程把缓冲区改了,那数据就全乱了。Rust的借用检查器在编译时就杜绝了这种问题。
3.8 数据宽度不匹配的处理
有时候源和目标的数据宽度不一样,比如从8位的UART接收数据,要存到32位的内存缓冲区。这时候怎么办?
我一般用两种方法:
- 自动打包/解包:部分DMA控制器支持自动将多个小数据打包成大数据(比如4个字节打包成一个字)
- 手动对齐:在内存中分配对齐的缓冲区,用字节传输模式,然后在软件里做数据重组
注意:不是所有DMA控制器都支持自动打包。如果不支持,千万别硬配成不同宽度,否则DMA会按最小宽度传输,导致数据错位。我曾在某国产芯片上吃过这个亏。
3.9 本章小结
DMA控制器的工作原理,说白了就是三件事:从哪搬、搬到哪、怎么搬。M2M、M2P、P2M三种模式覆盖了绝大多数场景。单次模式和循环模式的选择取决于你的应用是“一次性”还是“持续流”。数据宽度和对齐是细节,但往往就是这些细节决定了系统能不能稳定工作。
我个人觉得,掌握DMA的关键不在于背寄存器配置,而在于理解数据流动的路径和约束。当你真正理解了DMA是怎么“抢”总线、怎么“搬”数据、怎么“通知”CPU的,你就能在项目中游刃有余了。