缓存与存储层次

系统讲解 RISC-V 处理器的缓存与存储层次:直接映射与组相联组织、替换与写策略、3C 缺失分类与 miss 代价、多级 Cache 与非阻塞 MSHR、MESI/MOESI 一致性协议、TLB 与 MMU、预取与访存优化,并给出 AMAT 性能建模与可综合的 Cache 实现片段。

引言

CPU 的 ALU 一个周期就能算出结果,而一次 DDR 访问要几百个周期。这个 200 倍以上的差距是整个计算机体系结构里最大的矛盾,也是存储层次存在的唯一理由。Cache 的任务就是用一小块快速存储,把程序访问中「有局部性」的那部分挡住,让平均访存延迟降到可以接受的水平。

Cache 设计的难点不在概念,而在它同时被三个方向拉扯:命中率要高(要更大的容量、更高的相联度),命中延迟要低(要更小的容量、更简单的比较),带宽要够(要支持多端口、非阻塞、多级流水)。这三个目标互相冲突,每一代处理器的 Cache 参数都是在这场拔河里找到的平衡点。RISC-V 的好处是 ISA 层面对 Cache 几乎没有约束(只定义了 fence 与内存模型),实现自由度极大,从 MCU 上的 2KB 直接映射到服务器核的 64KB 八路组相联都有。

本文按「组织 → 策略 → 缺失分析 → 多级与一致性 → 地址翻译 → 优化」的顺序展开。总线的部分(AXI 突发、乱序、DMA 一致性)不在这里展开,本文从 Cache 侧看同一件事。前置阅读建议是 RISC-V 流水线 CPU 与冒险处理 ,因为 Cache 的命中路径就插在流水线的访存级上。

目录

  1. 为什么需要存储层次
  2. Cache 组织:映射方式与地址划分
  3. 组相联、相联度与访问延迟
  4. 替换策略与写策略
  5. 3C 缺失分类与 miss 分析
  6. 多级 Cache 与非阻塞设计
  7. 一致性协议:MESI 与 MOESI
  8. 目录协议与一致性域
  9. TLB 与 MMU 地址翻译
  10. 预取与访存优化
  11. AMAT 性能建模与 CPI 影响
  12. 可综合实现片段与验证

1. 为什么需要存储层次

存储介质的「容量、速度、成本」三者不可兼得:SRAM 快但贵且小,DRAM 便宜且大但慢,Flash/磁盘更慢更便宜。存储层次的做法是把它们串成金字塔,让 90% 以上的访问落在顶层。

寄存器     ~1 拍      几百字节
L1 Cache   3~4 拍     32~64 KB     SRAM
L2 Cache   10~15 拍   256KB~2MB    SRAM
L3 Cache   30~50 拍   8~64 MB      SRAM
主存 DDR   100~300 拍 几 GB        DRAM

层次能工作的前提是局部性原理:时间局部性(刚访问过的数据很可能马上再被访问)与空间局部性(相邻地址很可能被连续访问)。循环体、栈帧、数组遍历都是局部性的典型来源。

一次访问在层次中的行为可以用 AMAT(Average Memory Access Time) 描述:

AMAT = 命中时间 + 缺失率 × 缺失代价

L1 命中 4 拍,L1 缺失率 5%,缺失代价(到 L2)12 拍
AMAT = 4 + 0.05 × 12 = 4.6 拍

若 L1 缺失率降到 2%:AMAT = 4 + 0.02 × 12 = 4.24 拍  → 提升 8%

这个公式说明两件事:降低缺失率和降低缺失代价同样重要,而后者往往被忽视。增加一级 Cache 的本质就是用「命中时间 +1~2 拍」换「缺失代价降一半」。

2. Cache 组织:映射方式与地址划分

一个 Cache 由若干 line(行) 组成,每行包含数据、标签(tag)和状态位。地址被切成三段:

| tag | index | offset |
        ↑        ↑
     选组      行内偏移(决定 line 大小)
  • offset 位宽 = log2(line 大小)。64 字节 line → 6 位。
  • index 位宽 = log2(组数)。决定映射到哪一组。
  • tag 是剩下的高位,用于比较确认命中。

三种映射方式的差别在于「一个 index 对应几个候选位置」:

映射方式组内路数命中判断特点
直接映射1比较 1 个 tag最快最省,冲突缺失多
全相联全部并行比较所有 tag命中率最高,比较器爆炸
组相联2/4/8/16并行比较 N 个 tag折中,主流选择
// 直接映射 Cache 的命中判断(单路,组合逻辑)
wire [TAG_W-1:0] tag    = addr[31:OFFSET_W+INDEX_W];
wire [IDX_W-1:0] index  = addr[OFFSET_W+INDEX_W-1:OFFSET_W];
wire             hit    = valid[index] && (tags[index] == tag);
wire [31:0]      rdata  = hit ? data[index] : 32'b0;

组相联的访问延迟随相联度上升:4 路需要 4 个 tag 比较器并联 + 4 选 1 的数据选择器。相联度从 1 涨到 4,命中率通常提升 35 个百分点;从 4 涨到 8 只提升 12 个;8 以上收益递减而延迟继续涨。这就是为什么主流 L1 停在 8 路,而 L2/L3 才用 12~16 路。

Victim Cache 是另一种省钱的方案:在直接映射 Cache 旁边挂一个小的全相联缓冲(4~16 行),被替换出去的行先放这里,下次访问冲突地址时有机会命中。它用极小的面积把直接映射的冲突缺失降低一大截。

3. 组相联、相联度与访问延迟

组相联 Cache 的关键路径由三部分组成:index 译码 → tag 并行比较 → 数据选择。为了压低这条路径,工程上有几种经典技巧。

Tag 与数据并行访问:不等 tag 比较出结果再读数据,而是把所有路的数据都读出来,等 hit 信号一到就用它做选择。代价是功耗(所有路都在读),收益是省掉「比较后再读」的串行延迟。

串行:  index → 读 tag → 比较 → 命中 → 读数据 → 选择   延迟高
并行:  index → 同时读 tag 和数据 → 比较 → 用 hit 选数据  延迟低、功耗高

流水线化:把 tag 比较和数据读出分成两级,L1 访问变成 2 拍,但频率可以提高。这是高端核常见做法,代价是分支预测错误时惩罚周期数增加。

路预测(way prediction):用上次命中的路号预测这次命中哪一路,先只读那一路,预测对了就省掉比较,预测错了下一拍再全比较。ARM Cortex-A 系列与部分 RISC-V 核用这招把 L1 命中延迟做到 2~3 拍。

微标签(micro-TAG):在 L1 后面挂一个极小的全相联缓冲,存最近用过的 TLB 项对应的 tag,绝大多数命中在这里完成,只有微标签缺失才走完整 L1 路径。

4. 替换策略与写策略

替换策略决定一组满了以后踢谁:

策略实现效果
随机伪随机计数器面积最小,命中率略低
LRU每路 age 位命中率最好,N 路需 N log N 位
伪 LRU(树形)N-1 位二叉树接近 LRU,面积小很多
轮转(FIFO)指针简单,忽略重用距离

真 LRU 在 8 路上需要 8 × 3 = 24 位年龄状态和复杂的更新逻辑,所以实际设计多用伪 LRU(tree-PLRU):把 N 路组织成一棵二叉树,每次访问把路径上的位指向另一边,替换时沿位指示的路径走到底。8 路只需 7 位,命中率与真 LRU 相差不到 1%。

写策略是另一个关键选择:

  • 写直达(write-through):写同时更新 Cache 和下级存储。简单,但每次写都产生总线流量。常配 write buffer 缓解。
  • 写回(write-back):只写 Cache,置脏位,替换时才写回下级。总线流量小,但需要脏位和更复杂的替换逻辑。
  • 写分配(write-allocate):写缺失时先把行读进来再写(配合写回)。空间局部性好的场景有效。
  • 非写分配(no-write-allocate):写缺失直接写下级,不读入(配合写直达)。适合写一次就不用的数据。

主流组合是 写回 + 写分配(L1/L2),MMIO 区域则用 写直达 + 非写分配——因为 MMIO 写是副作用,绝不能延迟或合并。

// 写回 + 写分配的写命中路径
if (hit && we) begin
    data[way][offset] <= wdata;
    dirty[way]        <= 1'b1;      // 置脏,替换时写回
end
// 写缺失:先发起 line 填充(read-allocate),填充完成后再写

5. 3C 缺失分类与 miss 分析

经典的三类缺失(3C)是分析 Cache 性能的基本框架:

类型原因消除手段
强制缺失(Compulsory)首次访问,Cache 里没有预取、增大 line
容量缺失(Capacity)Cache 太小装不下工作集增大容量、分块算法
冲突缺失(Conflict)多个地址映射到同一组提高相联度、Victim Cache

后来补充了第四类:一致性缺失(Coherence)——多核场景下别的核修改了数据,本核的副本被 invalidate,再访问就成了缺失。

定位缺失类型的方法是用**栈距离(stack distance)**分析:模拟一个全相联 LRU Cache,记录每次访问距上次访问的距离,累积分布就是命中率曲线。把实测命中率与全相联曲线对比,差距就是冲突缺失;把全相联曲线在不同容量下的差距就是容量缺失。

# 用 perf 采集 RISC-V 上的 Cache 事件(若实现了 HPM 计数器)
perf stat -e L1-dcache-loads,L1-dcache-load-misses,\
LLC-loads,LLC-load-misses ./workload

# 典型输出解读
#  L1-dcache-load-misses 占 loads 的 5.2%  → L1 尚可
#  LLC-load-misses 占 LLC-loads 的 42%    → 工作集超出 LLC,考虑分块

分块(blocking)是消除容量缺失最有效的软件手段。矩阵乘的朴素实现按行/列访问,工作集随 N 线性增长;分块后工作集固定在「块大小² × 元素大小」,可以完全装进 L1:

// 64×64 分块矩阵乘:工作集 3 × 64 × 64 × 4B = 48KB,能进 L1
for (int ii = 0; ii < N; ii += 64)
  for (int kk = 0; kk < N; kk += 64)
    for (int jj = 0; jj < N; jj += 64)
      for (int i = ii; i < ii+64; i++)
        for (int k = kk; k < kk+64; k++) {
          register float a = A[i][k];
          for (int j = jj; j < jj+64; j++) C[i][j] += a * B[k][j];
        }

6. 多级 Cache 与非阻塞设计

多级 Cache 的分工是:L1 追求低延迟(小、相联度中等、写回),L2 追求命中率(大、相联度高、通常包含 L1 的内容),L3 作为多核共享的末级(LLC)。

层级典型容量相联度延迟私有/共享
L1I32~64 KB4~83~4 拍私有
L1D32~64 KB4~83~4 拍私有
L2256KB~2MB8~1610~20 拍私有
L3(LLC)8~64 MB12~1630~60 拍多核共享

L1I 与 L1D 分离(哈佛结构)是标配,原因与流水线里的结构冒险一致:取指和数据访问不能争同一个端口。

非阻塞 Cache(non-blocking / lockup-free) 是现代 Cache 的必备特性:一次 miss 不阻塞后续访问,命中后续地址的请求可以继续在 Cache 里完成。实现靠 MSHR(Miss Status Holding Register):

MSHR 条目内容:
  - 缺失的 line 地址(tag + index)
  - 该 line 下挂起的多个请求(每个记录目标寄存器与字节偏移)
  - 状态:等待填充 / 已发出请求 / 已返回

一次 miss 只占一条 MSHR;同 line 的后续 miss 合并到同一条目
MSHR 深度 = 同时未完成的 miss 数,典型 8~16

MSHR 深度直接决定内存级并行度(MLP)。深度从 4 提到 16,内存密集型负载的吞吐可以提升 30% 以上。MSHR 满了以后,新的 miss 只能阻塞——这就是「Cache 打满」的真正含义。MSHR 与总线 outstanding 能力是一一对应的:MSHR 发起的填充请求走 AXI 读通道,靠 AxID 区分。

7. 一致性协议:MESI 与 MOESI

多核(或多主设备)共享内存时,同一份数据可能在多个 Cache 里各有副本。一致性协议要保证两条不变式:

  1. 单写多读:任一时刻,一个地址要么只有一个核可写,要么多个核只读。
  2. 最终可见:一个核写入的值,最终能被其他核读到。

MESI 用四个状态描述每行的状态:

状态含义本核权限其他核有副本
M(Modified)脏且独占可读写无
E(Exclusive)干净且独占可读写无
S(Shared)干净且共享只读有
I(Invalid)无效不可用—

状态迁移由本地请求(PrRd/PrWr)和总线请求(BusRd/BusRdX)驱动:

本地读命中 M/E/S → 保持,返回数据
本地读缺失     → 发 BusRd,其他核若为 M 则写回并降级为 S,本核进入 S
本地写命中 M/E → 保持 M(E 升级为 M,不发总线)
本地写命中 S   → 发 BusRdX,其他核 invalidate,本核进入 M
本地写缺失     → 发 BusRdX,拿到数据后进入 M
收到 BusRd     → M 则写回并降级 S;E 降级 S;S 保持
收到 BusRdX    → 任意状态降级 I(M 需写回)

MOESI 在 MESI 上加了 O(Owned) 状态:当一个核持有脏数据、同时允许其他核共享只读副本时,进入 O。它把「写回内存」和「共享」两件事解耦——M 状态下的共享需要先写回内存再转 S,而 O 状态可以直接把数据给请求方而无需写回。这在多核共享数据频繁的场景能显著减少内存流量。

// 简化的 MESI 本地状态迁移(组合逻辑片段)
always_comb begin
    next_state = state;
    case (state)
        M: if (bus_rd)      next_state = S;    // 共享出去,降级
           else if (bus_rdx) next_state = I;
        E: if (bus_rd)      next_state = S;
           else if (bus_rdx) next_state = I;
           else if (pr_wr)   next_state = M;    // 静默升级,无需总线
        S: if (bus_rdx)     next_state = I;
           else if (pr_wr)   next_state = M;    // 需先发 BusRdX
        I: if (pr_wr)       next_state = M;
           else if (pr_rd)  next_state = S;
    endcase
end

E 状态的价值在于「静默升级」:本核独占但干净时,一次写不需要任何总线事务,直接从 E 到 M。这省掉了大量单线程场景下的总线流量,因此几乎所有实现都会用 MESI/MOESI 而非 MSI。

8. 目录协议与一致性域

上面的 MESI 迁移依赖总线嗅探(snooping):每个 Cache 监听总线上的所有事务,自己判断是否要响应。嗅探的问题是广播不可扩展——核数一多,每次 miss 都要广播给所有核,总线带宽被嗅探流量吃光。

目录协议(directory-based) 用一张表记录每个 line 被哪些核持有:

目录项:{ line 地址, 状态(未缓存/共享/独占), 共享者位向量 }
核数 N → 位向量 N 位;也可用有限指针(limited pointer)省面积

本地读缺失 → 查目录 → 只给持有者发 invalidate/forward,不广播

目录协议的流量随「实际共享者数量」增长,而非核数,因此可扩展到几十上百核。代价是目录本身要存(每 line 几位的开销),且目录访问成为新的延迟瓶颈(通常把目录放在 LLC 的 slice 里,与 tag 一起访问)。

一致性域(coherence domain) 的划分是 SoC 设计的关键决策:CPU 核、GPU、DMA、PCIe 设备哪些在域内、哪些在域外。域外的设备必须用软件维护一致性(flush/invalidate),域内由硬件保证。RISC-V 生态里这块仍在演进,主流做法是「CPU 集群内部硬件一致,DMA 与外设靠软件 fence」。

9. TLB 与 MMU 地址翻译

Cache 索引用的是物理地址还是虚拟地址,决定了地址翻译与 Cache 访问的先后关系,这是 MMU 设计的核心问题。

方案做法优点缺点
PIPT先翻译再访问 Cache无别名问题延迟叠加
VIPT用虚拟地址并行查 Cache 与 TLB延迟低需保证 index 位在页内
VIVT全程虚拟地址最快别名/同名问题严重

VIPT 是主流:虚拟地址的 offset 位在页内,与物理地址的 offset 相同,所以可以用 offset + index 并行去查 Cache,同时用虚拟地址查 TLB;等 TLB 返回物理页号后再比较 tag。要保证「index 位不跨越页边界」,需要满足:

组数 × line 大小 ≤ 页大小
例:页 4KB,line 64B → 组数 ≤ 64 → 相联度 = 容量 / (64 × 64)
   32KB L1 → 32KB / (64B × 64) = 8 路

这解释了一个经典现象:32KB 的 L1 为什么是 8 路——正是 4KB 页 + 64B line 下 VIPT 允许的最大组数倒推出来的。

TLB 是页表项的 Cache,通常两级:L1 TLB 全相联、3264 项、1 拍命中;L2 TLB 组相联、5122048 项。TLB 缺失要走页表遍历(page walk),RISC-V 的 Sv39/Sv48 需要最多 3~4 次访存,硬件页表遍历器(PTW)会把这些访存流水化。

Sv39 三级页表:VPN[2] → VPN[1] → VPN[0] → 物理页号
每次 walk 需要 3 次内存访问(有页表 Cache 时命中率可到 90%+)
TLB 缺失代价 ≈ 3 × 访存延迟 ≈ 300~900 拍

大页(2MB/1GB)是降低 TLB 缺失最有效的手段:一个 2MB 页的 TLB 项覆盖 512 倍的地址范围,能把大工作集的 TLB 缺失率降一个数量级。Linux 的透明大页(THP)与 RISC-V 的 Svnapot 扩展都是这个方向。

10. 预取与访存优化

预取把「缺失代价」从关键路径上挪走:在 CPU 真正需要之前,提前把数据搬进 Cache。

硬件预取器的常见类型:

类型原理适用
顺序预取检测到顺序访问就预取下一行数组遍历、memcpy
步长预取记录访问步长,按步长预取固定步长循环
流缓冲(stream buffer)单独的 FIFO 旁路 Cache一次性流数据
关联预取学习 miss 地址之间的关联指针追逐、图遍历

硬件预取的难点是准确率与及时性的平衡:预取太早会挤掉有用数据,太晚则来不及。工程上通常用「置信度计数器」控制:连续多次预取命中才继续预取,一旦预取无用就降级。预取绝不应污染 L1——通常预取到 L2,或者用标记为「预取」的低优先级替换。

软件预取用 RISC-V 的 prefetch.r/prefetch.w 提示(Zicbop 扩展),或编译器自动插入:

// 手动预取:提前 8 个迭代预取,隐藏 DDR 延迟
for (int i = 0; i < n; i++) {
    __builtin_prefetch(&a[i + 8], 0, 1);   // 读预取,预取到 L2
    sum += a[i] * b[i];
}

其他访存优化:软件流水(software pipelining)让加载与计算重叠;数据布局优化(AoS → SoA)提高 line 利用率;非临时访问(non-temporal)绕过 Cache 直接写内存,避免污染;fence 的粒度控制(RISC-V 的 fence.rw.rw 比全 fence 便宜)。这些与 GPU 内核优化里的访存合并、bank 冲突属于同一类问题的不同解法。

11. AMAT 性能建模与 CPI 影响

把 Cache 行为折算成 CPI,才能看到它对整机性能的真实影响:

CPI = 1 + 每指令访存次数 × 缺失率 × 缺失代价

单核场景(L1 命中 4 拍,L2 命中 12 拍,内存 200 拍):
  L1 缺失率 5%,其中 30% 穿透到内存
  平均访存延迟 = 0.95×4 + 0.05×(0.7×12 + 0.3×200) = 3.8 + 3.4 = 7.2 拍
  每 4 条指令一次访存 → CPI 增加 (7.2-4)×0.25 = 0.8

加入 2MB L2 后(L1 缺失率 5%,L2 缺失率 20%):
  平均延迟 = 0.95×4 + 0.05×(0.8×12 + 0.2×200) = 3.8 + 2.48 = 6.28 拍
  CPI 增加 (6.28-4)×0.25 = 0.57   → 相对提升 4%

这个模型还能量化并行度的收益。如果支持 8 笔 outstanding,内存延迟被并行掩盖:

吞吐受限时的有效延迟 ≈ 内存延迟 / min(outstanding, MLP_可用)
200 拍延迟、8 笔 outstanding → 有效延迟 ≈ 25 拍

这就是为什么乱序执行核要花大力气做访存队列与消歧:只有让足够多的访存同时在途,才能把内存延迟藏起来。

12. 可综合实现片段与验证

一个 4 路组相联、写回、伪 LRU 的 L1D 骨架:

module l1d #(
    parameter LINE_B = 64, WAYS = 4, SETS = 128     // 32KB
)(
    input  wire        clk, rst_n,
    input  wire        req, we,
    input  wire [31:0] addr, wdata,
    input  wire [3:0]  wstrb,
    output reg  [31:0] rdata,
    output reg         hit,
    // 到下级存储的填充接口
    output reg  [31:0] fill_addr,  output reg fill_req,
    input  wire        fill_valid, input  wire [511:0] fill_data
);
    localparam OFF_W = 6, IDX_W = 7, TAG_W = 32-OFF_W-IDX_W;
    reg [511:0] data [0:WAYS-1][0:SETS-1];
    reg [TAG_W-1:0] tag [0:WAYS-1][0:SETS-1];
    reg [WAYS-1:0]  valid, dirty;
    reg [WAYS-2:0]  plru [0:SETS-1];     // 树形伪 LRU

    wire [IDX_W-1:0] idx = addr[OFF_W+IDX_W-1:OFF_W];
    wire [TAG_W-1:0] t   = addr[31:OFF_W+IDX_W];
    wire [WAYS-1:0]  way_hit = {WAYS{1'b1}} & (
        {valid[3] && tag[3][idx]==t, valid[2] && tag[2][idx]==t,
         valid[1] && tag[1][idx]==t, valid[0] && tag[0][idx]==t});
    wire [1:0] hit_way = way_hit[3] ? 2'd3 : way_hit[2] ? 2'd2 :
                         way_hit[1] ? 2'd1 : 2'd0;

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin valid <= 0; dirty <= 0; fill_req <= 1'b0; end
        else begin
            hit <= |way_hit;
            if (req && |way_hit) begin
                rdata <= data[hit_way][idx][ {addr[5:2],2'b0} +: 32 ];
                if (we) begin dirty[hit_way] <= 1'b1;
                              data[hit_way][idx][ {addr[5:2],2'b0} +: 32 ] <= wdata; end
                plru[idx] <= upd_plru(plru[idx], hit_way);   // 更新伪 LRU
            end else if (req) begin
                fill_req <= 1'b1; fill_addr <= {addr[31:6], 6'b0};  // 行对齐
                plru[idx] <= upd_plru(plru[idx], victim_way(plru[idx]));
            end
            if (fill_valid) begin
                data[victim_way(plru[idx])][idx] <= fill_data;   // 填充
                tag [victim_way(plru[idx])][idx] <= t;
                valid[victim_way(plru[idx])] <= 1'b1;
                dirty[victim_way(plru[idx])] <= 1'b0;
                fill_req <= 1'b0;
            end
        end
    end
endmodule

验证 Cache 不能只靠定向测试,因为状态空间是「地址序列 × 替换状态 × 一致性状态」的组合爆炸。工程上的做法是:

  • 参考模型(reference model):用软件实现一个行为级的 Cache 模型,与 RTL 做逐访问比对。这是最有效的手段,能覆盖任意随机序列。
  • 一致性断言:单写多读不变式、脏行被替换前必须写回、invalidate 后不得命中。
  • 覆盖率:所有状态迁移(M→S、S→M、E→I 等)都要覆盖,所有相联度下都要命中过。
  • 形式验证:对一致性协议本身做模型检验(如用 Murphi 或 CMurphi 建模),能在设计早期发现死锁与状态不可达。

完整的验证方法论(interface、约束随机、SVA、覆盖率驱动)见 SystemVerilog 验证与 testbench ,Cache 与一致性协议是它最有价值的应用场景之一。

权衡取舍

决策点选项 A选项 B
相联度直接映射:快省、冲突多8 路:命中率高、延迟高
写策略写直达:简单、流量大写回:流量小、需脏位
line 大小32B:粒度细、tag 开销大128B:空间局部性好、浪费带宽
索引方式PIPT:无别名、延迟叠加VIPT:快、受页大小约束
一致性嗅探:简单、不可扩展目录:可扩展、面积大
预取硬件预取:自动、可能污染软件预取:精准、需改代码
阻塞行为阻塞式:简单、MLP 低非阻塞 MSHR:吞吐高、逻辑复杂
大页4KB 页:灵活、TLB 压力大2MB 页:TLB 命中高、内存碎片

常见坑清单

  • VIPT 索引跨页:组数 × line 大小超过页大小,同一物理行出现两个虚拟别名,数据不一致。必须约束组数或做别名检测。
  • 脏行替换未写回:写回式 Cache 替换脏行时忘了发起写回,数据永久丢失。
  • MMIO 走了 Cache:外设寄存器被缓存或写合并,读到旧值、连续写被吞。MMIO 必须映射为非缓存属性。
  • fence 粒度不当:用全 fence 保护局部数据,性能损失巨大;用 fence.rw.rw 又不覆盖设备访问,导致顺序错误。
  • MSHR 溢出无处理:MSHR 满了以后新 miss 未阻塞,请求被丢弃或乱序完成。
  • TLB 与 Cache 不同步:修改页表后未 sfence.vma,TLB 里仍是旧映射,访问到错误物理页。
  • 一致性协议死锁:升级请求与降级响应互相等待,需要仔细设计消息通道与冲突解决(如 NACK 重试)。
  • 预取污染 L1:预取数据挤掉正在使用的行,命中率反而下降;预取应只到 L2 或标为低优先级。
  • line 填充未对齐:填充地址未按 line 对齐,tag 与 index 对不上,产生伪缺失。
  • 写缓冲与读顺序:write buffer 未与后续读做转发,同一地址先写后读拿到旧值。
  • wstrb 未逐字节应用:非对齐或部分写把相邻字节一起改了。

小结

存储层次的设计可以归结为一场关于「延迟、命中率、带宽」的三角权衡:L1 用小组相联换取低延迟,L2/L3 用大容量高相联换取命中率,MSHR 与乱序访存把缺失代价从关键路径上挪开,一致性协议让多核共享同一份数据成为可能,TLB 与页大小决定了大工作集下的地址翻译开销。 这些机制叠加在一起,才让 200 拍的 DDR 延迟在 CPI 里只留下零点几个周期的痕迹。

对 RISC-V 实现者来说,好消息是 ISA 对 Cache 几乎不设限,可以按目标场景自由取舍;坏消息是内存模型(RVWMO)与 fence 语义必须严格实现,否则多核程序会出现极难复现的错误。Cache 的验证成本通常高于设计成本,参考模型加一致性断言是必须的投入。

下一步建议阅读 RISC-V 指令集架构详解 ,理解 RVWMO 内存模型与 fence 语义如何约束 Cache 与一致性协议的行为;如果想了解操作系统侧如何配合页表与 TLB,可以进入 操作系统 专题的内存管理部分。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「芯片与体系结构」更多文章

  1. 可测性设计与测试
  2. 低功耗数字设计
  3. RISC-V 向量扩展 RVV