引言
CPU 的 ALU 一个周期就能算出结果,而一次 DDR 访问要几百个周期。这个 200 倍以上的差距是整个计算机体系结构里最大的矛盾,也是存储层次存在的唯一理由。Cache 的任务就是用一小块快速存储,把程序访问中「有局部性」的那部分挡住,让平均访存延迟降到可以接受的水平。
Cache 设计的难点不在概念,而在它同时被三个方向拉扯:命中率要高(要更大的容量、更高的相联度),命中延迟要低(要更小的容量、更简单的比较),带宽要够(要支持多端口、非阻塞、多级流水)。这三个目标互相冲突,每一代处理器的 Cache 参数都是在这场拔河里找到的平衡点。RISC-V 的好处是 ISA 层面对 Cache 几乎没有约束(只定义了 fence 与内存模型),实现自由度极大,从 MCU 上的 2KB 直接映射到服务器核的 64KB 八路组相联都有。
本文按「组织 → 策略 → 缺失分析 → 多级与一致性 → 地址翻译 → 优化」的顺序展开。总线的部分(AXI 突发、乱序、DMA 一致性)不在这里展开,本文从 Cache 侧看同一件事。前置阅读建议是 RISC-V 流水线 CPU 与冒险处理 ,因为 Cache 的命中路径就插在流水线的访存级上。
目录
- 为什么需要存储层次
- Cache 组织:映射方式与地址划分
- 组相联、相联度与访问延迟
- 替换策略与写策略
- 3C 缺失分类与 miss 分析
- 多级 Cache 与非阻塞设计
- 一致性协议:MESI 与 MOESI
- 目录协议与一致性域
- TLB 与 MMU 地址翻译
- 预取与访存优化
- AMAT 性能建模与 CPI 影响
- 可综合实现片段与验证
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)。
| 层级 | 典型容量 | 相联度 | 延迟 | 私有/共享 |
|---|---|---|---|---|
| L1I | 32~64 KB | 4~8 | 3~4 拍 | 私有 |
| L1D | 32~64 KB | 4~8 | 3~4 拍 | 私有 |
| L2 | 256KB~2MB | 8~16 | 10~20 拍 | 私有 |
| L3(LLC) | 8~64 MB | 12~16 | 30~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 里各有副本。一致性协议要保证两条不变式:
- 单写多读:任一时刻,一个地址要么只有一个核可写,要么多个核只读。
- 最终可见:一个核写入的值,最终能被其他核读到。
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,可以进入 操作系统
专题的内存管理部分。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。