3. 缓存一致性基础:CPU缓存架构与MESI协议
做嵌入式DMA传输,绕不开缓存。说白了,CPU和内存之间的速度差距太大了。CPU跑得飞快,内存跟不上,中间就得加一层缓存。我刚开始接触STM32H7系列时,就被它的缓存架构坑过一次——DMA搬完数据,CPU读到的全是旧值。嗯,从那以后,我每次做DMA传输都会先问自己一句:缓存一致了吗?
3.1 CPU缓存架构:L1/L2/L3
现代ARM Cortex-M7/M85内核,以及Cortex-A系列,都采用多级缓存架构。我画了一张图,帮你快速建立整体认知。
这张图里,你注意看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)。每个缓存控制器都在监听总线上的读写请求。举个例子:
- Core 0读地址A,缓存未命中,从主存加载到L1,状态设为E(独占)
- Core 1也读地址A,总线嗅探发现Core 0有副本,Core 0的状态降为S(共享),Core 1也设为S
- Core 0写地址A,总线发出"写失效"信号,Core 1的缓存行变为I(无效),Core 0变为M(已修改)
- 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。