缓存一致性基础

做 DMA 调试绕不开缓存一致性。我记得刚接触嵌入式时,总以为 CPU 和 DMA 看到的是一样的内存。直到有一次,DMA 把数据搬到了内存,CPU 却读到了“旧”值——后来才知道,是缓存惹的祸。

这一节,我们聊聊缓存的结构、缓存行,还有那套保证一致性的协议。不扯太深的理论,只讲你调试时真正用得上的东西。

1. 缓存结构概览

现代 SoC 里,CPU 核心通常有多级缓存:L1(指令/数据分离)、L2(每核私有或共享)、甚至 L3。DMA 一般是直接访问主存(DDR)或片上 SRAM,它不经过缓存。这就是矛盾的根源——CPU 在缓存里操作了一版数据,DMA 却去主存里拿另一版。

我习惯把缓存看作一个“高速副本池”。CPU 读写副本,DMA 读写原件。一致性协议的工作,就是让这两个视图保持一致。

缓存层级 典型位置 对 DMA 的影响
L1 CPU 核内 DMA 完全不可见,最易引起不一致
L2 核内或共享 若共享则需一致性协议维护
L3 片内多核共享 通常由硬件一致性协议处理
主存 片外 DDR DMA 直接操作的目标
小经验: 调试时先确认你的 CPU 是否开启了硬件缓存一致性(比如 ACE 总线或 CCI 互联)。有些低功耗芯片根本没这套机制,那你就只能手动刷缓存。

2. 缓存行(Cache Line)—— 一致性的最小单元

缓存不是字节粒度管理的。它把内存切成等长的块,这个块就叫缓存行。常见大小是 64 字节(ARM、x86 都这么用)。说白了,你改 1 个字节,CPU 会先把整个 64 字节的缓存行加载进来,修改后再整行写回。

这对 DMA 调试意味着什么?我举个例子:如果你的 DMA 描述符刚好和某个数据变量在同一个缓存行里,CPU 改变量就会连带把描述符“刷新”掉——嗯,这就是伪共享(false sharing)的典型场景。

注意: DMA 缓冲区必须缓存行对齐。我曾遇到一个项目,因为缓冲区起始地址只 32 字节对齐,结果 DMA 和 CPU 互相踩踏,查了三天才发现是缓存行 split 的问题。

如何查看你的平台缓存行大小?Linux 下直接读这个文件:

cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size

大概率返回 64。裸机开发的话,查芯片参考手册的 Cache 章节,写的很清楚。

3. 缓存一致性协议简介

协议要回答一个核心问题:当一个核心(或 DMA)修改了某地址的数据,其他核心的缓存该如何反应?

最经典的是 MESI 协议(Modified、Exclusive、Shared、Invalid)。四个状态,配合总线嗅探或目录,保证多核视图一致。

MESI 缓存一致性状态转换图 MESI 协议状态机 (多核视角) 动作标识 Rd: 本核读请求 Wr: 本核写请求 Snoop: 其他核的嗅探 M (Modified) E (Exclusive) S (Shared) I (Invalid) Rd (独占) Rd (共享) Wr Wr + Snoop Invalidate Snoop Wr Snoop Rd

你想想看,如果 CPU0 持有一个缓存行是 Modified,此时 DMA 直接从内存读走旧数据,那画面多美——数据不一致就来了。协议的作用就是让 CPU0 在 DMA 访问前把修改写回内存,或者让 DMA 去“窥探”缓存。

4. 常见的缓存一致性协议

除了 MESI,还有 MOESI(增加 Owned 状态)、MESIF(Intel 用 Forward 状态),以及 ARM 平台上更复杂的 AMBA ACE 协议。但底层逻辑是一样的——状态 + 嗅探/目录。

下面这个表是 MESI 四个状态的简单对比,方便你快速回忆:

状态 含义 本核读写权限 其他核副本情况
M (Modified) 数据已修改,与主存不同 可读可写 无效
E (Exclusive) 数据干净,仅本核持有 可读可写 无副本
S (Shared) 数据干净,多核共享 仅可读(写需先失效其他核) 至少一个副本
I (Invalid) 数据无效,不可使用 无权限(需重新加载) 可能存在但不可靠
调试口诀: 看到 DMA 读回“脏”数据 → 检查目标地址对应的缓存行是否处于 M 或 E 状态 → 手动执行 cache flush 或将区域标记为 non-cacheable。

5. 调试中的关键检查点

基于上面的基础,我在调试 DMA 缓存一致性问题时,一般按这个顺序检查:

  1. 确认缓存行对齐。 缓冲区首地址和大小必须是缓存行整数倍。如果不对齐,用 __attribute__((aligned(64))) 或 memalign。
  2. 确认 DMA 描述符所在区域是否 cacheable。 很多 SoC 的 DMA 描述符要求放在 non-cacheable 内存里,否则描述符更新会被缓存。
  3. 在启动 DMA 前执行 clean cache。 把 CPU 已修改但还没写入内存的数据刷出去。
  4. 在 DMA 完成中断中执行 invalidate cache。 让 CPU 重新从内存读取最新数据。
  5. 用工具看实际状态。 调试器可以读 Cache 寄存器,能看到某地址是否命中、处于哪个状态。
我曾经踩过一个坑: 在一个 Cortex‑A7 双核平台上,DMA 中断 handler 里只 invalidate 了缓冲区的前半段,后半段没管。结果另一个核恰好持有后半段的缓存行,数据一直没更新。那之后我每次 invalidate 都会仔细算起始地址和长度,一步到位。

好了,这一节就聊这么多。缓存结构、缓存行、一致性协议,是理解后续所有调试技巧的基石。你接下来遇到任何 DMA 数据异常,都可以先回头看看这几个基础概念——大概率问题就出在这里。