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采集

避坑指南:循环模式下,DMA地址会自动回绕到初始值。如果你用双缓冲区,记得在中断里切换缓冲区指针。我曾经忘了处理这个,结果数据一直在同一个缓冲区里覆盖,查了两天才发现。

3.5 数据宽度与对齐:细节决定成败

数据宽度是指DMA每次传输的数据大小。常见的有字节(8位)、半字(16位)、字(32位)。这个看似简单,但配错了就会出大问题。

特性 单次模式 循环模式
传输次数 一次,完成后停止 无限循环,自动重载
中断触发 传输完成时触发一次 每次循环完成都触发
典型场景 一次性数据搬移、协议帧发送 连续数据流、音频播放、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一次完整传输的流程,从配置到完成:

DMA传输核心流程 配置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位的内存缓冲区。这时候怎么办?

我一般用两种方法:

  1. 自动打包/解包:部分DMA控制器支持自动将多个小数据打包成大数据(比如4个字节打包成一个字)
  2. 手动对齐:在内存中分配对齐的缓冲区,用字节传输模式,然后在软件里做数据重组

注意:不是所有DMA控制器都支持自动打包。如果不支持,千万别硬配成不同宽度,否则DMA会按最小宽度传输,导致数据错位。我曾在某国产芯片上吃过这个亏。

3.9 本章小结

DMA控制器的工作原理,说白了就是三件事:从哪搬、搬到哪、怎么搬。M2M、M2P、P2M三种模式覆盖了绝大多数场景。单次模式和循环模式的选择取决于你的应用是“一次性”还是“持续流”。数据宽度和对齐是细节,但往往就是这些细节决定了系统能不能稳定工作。

我个人觉得,掌握DMA的关键不在于背寄存器配置,而在于理解数据流动的路径和约束。当你真正理解了DMA是怎么“抢”总线、怎么“搬”数据、怎么“通知”CPU的,你就能在项目中游刃有余了。