2. 计算机体系结构基础

理解 Linux 内核缓存一致性与 DMA 性能优化,必须先回到计算机体系结构的底层模型。现代 CPU 的执行速度远超主存访问速度,二者之间存在数百个时钟周期的差距。为了弥补这一鸿沟,硬件设计者引入了多级缓存层次,并通过缓存行、写策略和一致性协议来维持多核之间的数据视图一致。本节将围绕 CPU 缓存层次、缓存行、写回与写直达、MESI 协议、内存屏障与乱序执行六个核心概念展开,为后续 DMA 与共享内存优化打下基础。

一、CPU 缓存层次:速度、容量与代价的权衡

典型的现代处理器采用三级缓存结构。L1 缓存离核心最近,容量最小(通常 32KB~64KB),访问延迟仅 3~5 个周期,分为指令缓存(I-Cache)和数据缓存(D-Cache)。L2 缓存容量更大(256KB~1MB),延迟约 10~20 个周期,通常为每个核心私有。L3 缓存由多个核心共享,容量可达数十 MB,延迟约 30~50 个周期。再往下是主存(DRAM),延迟可达 200~300 个周期。

这种层次结构的核心思想是“局部性原理”:时间局部性指刚被访问的数据很可能再次被访问;空间局部性指相邻地址的数据很可能被连续访问。缓存正是利用这两点,把热点数据保留在离核心更近的位置。对内核开发者而言,这意味着:数据布局是否紧凑、访问是否连续,直接决定了缓存命中率,进而影响 DMA 与共享内存场景下的整体吞吐。

CPU 缓存层次与访问延迟 CPU Core 执行单元 / 寄存器 L1 Cache 32~64KB · 3~5 cyc L2 Cache 256KB~1MB · 10~20 cyc L3 Cache 共享 · 30~50 cyc 主存 DRAM 容量大 · 200~300 cyc · 带宽受限 相对延迟(对数示意) L1 ≈ 1x L2 ≈ 4x L3 ≈ 10x DRAM ≈ 60x

二、缓存行:数据搬运的最小单位

缓存并不是按字节逐字节加载的,而是以“缓存行”(Cache Line)为单位整块搬运。x86 与 ARM 主流平台的缓存行大小通常为 64 字节。这意味着即使只读取一个 4 字节的 int,硬件也会把该地址所在的整个 64 字节块从主存拉入缓存。

缓存行带来两个重要后果。其一是“伪共享”(False Sharing):两个核心分别修改同一缓存行内的不同变量时,虽然逻辑上互不相干,但硬件仍会因缓存行所有权反复迁移而导致性能骤降。其二是 DMA 对齐问题:DMA 传输的缓冲区若未按缓存行对齐,一次传输可能跨越两个缓存行,导致部分数据在缓存中、部分在内存中,引发一致性维护的额外开销。因此内核中常见 ____cacheline_aligned 等修饰,用于强制对齐。

/* 避免伪共享:让每个核心的计数器独占缓存行 */
struct per_cpu_counter {
    unsigned long count;
} ____cacheline_aligned;

/* 查看系统缓存行大小 */
$ getconf LEVEL1_DCACHE_LINESIZE
64

三、写回与写直达:两种写策略

当 CPU 修改缓存中的数据时,硬件需要决定何时把新值同步到下一级存储。写直达(Write-Through)在每次写缓存的同时立即写入主存,实现简单、一致性容易维护,但写操作频繁时总线压力大。写回(Write-Back)只在缓存行被替换或显式刷出时才写回主存,期间该缓存行被标记为“脏”(Dirty)。写回显著减少了内存写流量,是现代 CPU 的主流策略。

写回策略的代价是:主存中的数据可能暂时是“旧”的。这正是 DMA 场景的核心矛盾——设备直接读写主存,若 CPU 刚修改的数据还停留在脏缓存行中,设备读到的就是过期数据。因此驱动必须在 DMA 传输前后执行缓存刷新或失效操作,例如 dma_sync_single_for_device() 与 dma_sync_single_for_cpu()。

四、MESI 协议:多核缓存一致性的基石

多核系统中,每个核心都有自己的私有缓存,同一份数据可能在多个缓存中同时存在副本。MESI 协议用四个状态描述缓存行的归属与有效性:

状态 名称 含义 典型行为
M Modified 本缓存独占且已修改,与主存不一致 写回主存后转为 S 或 I
E Exclusive 本缓存独占,与主存一致 本地写后转为 M,无需总线事务
S Shared 多个缓存共享,与主存一致 写前需先使其他副本失效
I Invalid 缓存行无效,不可直接使用 读时需从主存或其他缓存获取

MESI 通过总线嗅探(Snooping)或目录协议维护状态迁移。当一个核心要写某缓存行时,必须先取得独占权,使其他核心的副本变为 I 状态。这个过程会产生缓存行“弹跳”(Cache Line Bouncing),是伪共享性能问题的根源。理解 MESI 有助于解释为什么共享内存场景下,频繁跨核写同一变量会带来巨大开销。

五、内存屏障与乱序执行

现代 CPU 为了提升指令级并行度,会在保证单核程序语义的前提下对访存指令进行重排序。编译器同样可能重排指令。这在单线程中通常无感,但在多核共享内存或 DMA 场景中,会导致严重的可见性问题:核心 A 先写数据再写标志位,核心 B 可能先看到标志位更新,却读到尚未写入的数据。

内存屏障(Memory Barrier)正是用来约束这种重排序的指令。Linux 内核提供了多种屏障原语:

  • barrier():仅阻止编译器重排,不生成 CPU 指令。
  • smp_rmb() / smp_wmb():读写屏障,约束多核间的访存顺序。
  • smp_mb():全屏障,读写均不可跨越。
  • dma_wmb() / dma_rmb():面向 DMA 场景的轻量屏障。
/* 生产者:先写数据,再写标志位 */
data = value;
dma_wmb();          /* 确保 data 先于 flag 对设备可见 */
flag = 1;

/* 消费者:先读标志位,再读数据 */
if (flag) {
    dma_rmb();      /* 确保读到 flag 后才读 data */
    consume(data);
}
常见误区:很多开发者认为“代码顺序就是执行顺序”,从而忽略屏障。在 x86 上由于较强的内存模型,部分乱序不易暴露;但在 ARM、PowerPC 等弱内存模型平台上,缺少屏障几乎必然导致间歇性数据错乱。这类 bug 往往难以复现,排障成本极高。

六、案例与复盘:一次 DMA 数据错乱的排查

某网络驱动在 ARM64 平台上偶发丢包,表现为设备读到的描述符内容与 CPU 写入不一致。排查发现:驱动在填充描述符后直接写门铃寄存器,未插入 dma_wmb()。ARM64 的弱内存模型允许门铃写先于描述符写到达设备,设备因此读到旧描述符。修复方式是在写门铃前加入 dma_wmb(),问题消失。

复盘要点:缓存一致性问题通常具备三个特征——偶发、与平台强相关、与并发时序相关。定位时应优先检查:缓存行对齐、写回脏数据是否及时刷出、跨核共享变量是否加屏障、DMA 缓冲区是否使用一致性映射(dma_alloc_coherent)或流式映射配合正确的同步调用。