3. DMA原理与工作模式

做嵌入式DMA传输,绕不开缓存。说白了,CPU和内存之间的速度差距太大了。CPU跑得飞快,内存跟不上,中间就得加一层缓存。我刚开始接触STM32H7系列时,就被它的缓存架构坑过一次——DMA搬完数据,CPU读到的全是旧值。嗯,从那以后,我每次做DMA传输都会先问自己一句:缓存一致了吗?

3.1 CPU缓存架构:L1/L2/L3

现代ARM Cortex-M7/M85内核,以及Cortex-A系列,都采用多级缓存架构。我画了一张图,帮你快速建立整体认知。

CPU多级缓存架构与DMA传输路径 CPU Core 0 CPU Core 1 L1 I-Cache / D-Cache L1 I-Cache / D-Cache L2 Cache (私有/共享) L3 Cache (系统共享) 主存储器 (DDR/SRAM) DMA控制器 外设 (UART/SPI/ADC) ⚠ 关键问题 DMA直接读写主存,不经过CPU缓存。 CPU读的是缓存里的旧副本—— 这就是缓存一致性问题。

这张图里,你注意看DMA控制器的位置。它直接连到主存和外设,完全不经过L1/L2/L3缓存。这意味着什么?CPU可能正美滋滋地读着缓存里的数据,而DMA已经把主存里的内容改掉了。两边数据不一致,程序就跑飞了。

各级缓存的关键参数(以Cortex-M7为例)

缓存级别 典型大小 延迟(周期) 特点
L1 I-Cache 16 KB 1-2 只缓存指令,与数据缓存分离
L1 D-Cache 16 KB 1-2 只缓存数据,与指令缓存分离
L2 Cache 256 KB - 1 MB 8-12 部分芯片私有,部分共享
L3 Cache 1 MB - 8 MB 20-40 系统级共享,多核间通信

3.2 缓存行与伪共享

缓存不是按字节管理的。它按缓存行(Cache Line)来组织。ARM Cortex-M7的缓存行大小是32字节,Cortex-A系列通常是64字节。你想想看,哪怕你只修改了1个字节,CPU也会把整个缓存行都加载进来。

这就引出了一个经典问题——伪共享(False Sharing)。我在一个多通道数据采集项目里遇到过:两个核心各自处理不同的通道,数据放在相邻的内存地址上。结果两个核心互相把对方的缓存行踢出去,性能直接腰斩。

伪共享的典型场景

  • 多核各自写不同的变量,但变量落在同一个缓存行内
  • DMA写入的数据与CPU频繁读写的变量相邻
  • 环形缓冲区的头指针和尾指针在同一个缓存行

我的建议:关键变量用 #[repr(align(32))] 或 #[repr(align(64))] 对齐到缓存行边界。Rust里可以这样写:

// 避免伪共享:对齐到缓存行
#[repr(align(64))]
struct ChannelData {
    value: f32,
    status: u8,
    // 填充到64字节,确保不与其他核心冲突
    _pad: [u8; 59],
}

// 或者用 core::cell::CachePadded(如果可用)
use core::cell::CachePadded;

struct SharedState {
    producer: CachePadded<AtomicU32>,
    consumer: CachePadded<AtomicU32>,
}

3.3 MESI协议简介

多核CPU怎么保证缓存一致性?靠的就是MESI协议。每个缓存行都有四种状态,我习惯用一句话记:改了就是Modified,独占就是Exclusive,共享就是Shared,无效就是Invalid。

状态 全称 含义 该缓存行是否与主存一致
M Modified 本核独占且已修改 不一致(脏数据)
E Exclusive 本核独占,未修改 一致
S Shared 多核共享,未修改 一致
I Invalid 无效,需要重新加载 —

MESI协议的核心思想是总线嗅探(Bus Snooping)。每个缓存控制器都在监听总线上的读写请求。举个例子:

  1. Core 0读地址A,缓存未命中,从主存加载到L1,状态设为E(独占)
  2. Core 1也读地址A,总线嗅探发现Core 0有副本,Core 0的状态降为S(共享),Core 1也设为S
  3. Core 0写地址A,总线发出"写失效"信号,Core 1的缓存行变为I(无效),Core 0变为M(已修改)
  4. Core 1再读地址A,发现缓存行无效,重新从主存或Core 0的缓存中获取最新数据

DMA场景下的MESI陷阱

我曾经在一个项目中,DMA把ADC采样数据直接写入DDR,CPU通过L1缓存读取。结果CPU读到的全是旧值。为什么?因为DMA写主存时,不会触发MESI协议的总线嗅探。CPU的L1缓存里还是旧数据,状态还是S或E,根本不知道主存已经被DMA改了。

解决办法:

  • DMA写入前,CPU主动失效(Invalidate)相关缓存行
  • DMA读取前,CPU主动清理(Clean/Flush)相关缓存行
  • 使用非缓存(Non-cacheable)内存区域做DMA缓冲区

3.4 在Rust中操作缓存

Rust的core::arch::arm模块提供了缓存操作指令。我常用的几个:

// 注意:以下代码在Cortex-M7/M85上可用
use core::arch::asm;

/// 失效(Invalidate)L1数据缓存中的指定地址范围
/// 调用后,CPU下次读取会从主存重新加载
pub unsafe fn invalidate_dcache_by_addr(addr: *mut u8, size: usize) {
    // 对齐到缓存行边界(32字节)
    let aligned_addr = (addr as usize) & !0x1F;
    let end = ((addr as usize + size + 31) & !0x1F) as u32;
    let mut current = aligned_addr as u32;

    while current < end {
        asm!("DSB",);
        asm!("MCR p15, 0, {}, c7, c6, 1", in(reg) current); // DCIMVAC
        current += 32;
    }
    asm!("DSB");
    asm!("ISB");
}

/// 清理(Clean)L1数据缓存中的指定地址范围
/// 调用后,脏数据会被写回主存
pub unsafe fn clean_dcache_by_addr(addr: *mut u8, size: usize) {
    let aligned_addr = (addr as usize) & !0x1F;
    let end = ((addr as usize + size + 31) & !0x1F) as u32;
    let mut current = aligned_addr as u32;

    while current < end {
        asm!("DSB",);
        asm!("MCR p15, 0, {}, c7, c10, 1", in(reg) current); // DCCMVAC
        current += 32;
    }
    asm!("DSB");
    asm!("ISB");
}

DMA传输的标准缓存操作流程

传输方向 操作 说明
DMA → 内存 → CPU读 先 Invalidate,再读 确保CPU读到的是DMA写入的新数据
CPU写 → 内存 → DMA读 先 Clean,再启动DMA 确保DMA读到的是CPU写入的最新数据
DMA双向传输 Clean + Invalidate 先刷出CPU的脏数据,再失效旧缓存

嗯,这里要注意:不要滥用缓存操作。每次Invalidate或Clean都会刷掉整个缓存行,频繁操作会严重降低性能。我个人的习惯是:

  • DMA缓冲区单独分配,用#[repr(align(32))]对齐
  • 缓冲区大小是缓存行大小的整数倍
  • 只在DMA传输完成中断里做一次缓存操作
  • 如果性能要求极高,考虑使用MPU(内存保护单元)将DMA缓冲区设为非缓存区域

说到MPU,这是另一个话题了。但核心思想不变:理解缓存架构,才能写出正确的DMA驱动。我在调试一个4路音频采集项目时,就是因为漏了一次Invalidate,导致音频数据全是爆音。排查了整整两天才发现是缓存一致性问题。从那以后,我每次写DMA代码,都会在注释里标清楚:这里需要Clean,那里需要Invalidate。