如果你 是 计算机 专业 的 学生 , 一定 对 Cache 又 爱 又 恨 。 爱 它 是 因为 没有 它 , 你的 CPU 就要 天天 等着 内存 慢悠悠 地 响应 ; 恨 它 是 因为 组相联 、 全相联 这些 概念 在 考试 题 里 翻来覆去 地 折腾 人 。 今天 我 就 结合 自己 当年 啃 课本 、 做 实验 、 刷 题 的 经历 , 把 Cache 的 组相联 和 全相联 映射 彻底 讲 清楚 。 我会 先 从 为什么 需要 Cache 说起 , 再 用 一个 64KB 主存加 4KB Cache 的 例子 把 三种 映射 的 地址 结构 算 给 你 看 , 最后 聊 几句 教材 和 实验 的 心得 。 这篇 东西 不 只是 帮 你 应付 考试 , 更 多 的 是 帮 你 建立 对 缓存 设计 的 直觉 。
1. 为什么 要 搞懂 Cache 映射 ? 先 理解 冯 · 诺依曼 瓶颈
1.1 CPU 和 内存 的 速度 鸿沟
在 计算机 组成 原理 的 体系 里 , CPU 的 时钟 周期 以 纳秒 计 , 而 内存 的 访问 延迟 通常 是 几十 到 几百 纳秒 , 两者 差 着 一个 数量级 以上 。 如果 CPU 每次 取 指令 、 读 数据 都 直接 访问 内存 , 大量 时间 会 浪费 在 “ 等待 内存 返回 ” 上 , 这个 现象 俗称 “ 冯 · 诺依曼 瓶颈 ” 。 为了 缓解 这个 矛盾 , 现代 处理 器 在 CPU 和 内存 之间 加 了 一层 或多 层 Cache 。 Cache 一般 用 SRAM 实现 , 速度 接近 CPU , 但 容量 比 内存 小 得 多 。 你 可以 把 它 理解成 一个 随身 携带 的 小 笔记本 : 你 工作 时 把 最 常用 的 几 页 资料 抄 在 上面 , 需要 时 先 翻 本子 , 找 不到 再 去 大 图书馆 ( 内存 ) 查 。 不 过 , 从 内存 到 Cache 的 搬运 是 按 “ 块 ” 进行 的 , 不是 按 单个 字节 。 一个 块 通常 是 32 到 128 字节 , 包含 连续 的 地址 数据 。 这个 块 到底 该 放 到 Cache 的 哪个 位置 , 就是 映射 问题 。
1.2 局部性 原理 是 Cache 的 存在 前提
为什么 不用 一个 巨大 的 Cache 把 整个 程序 都 装 下 ? 一个 是 成本 问题 , 另一个 是 容量 和 速度 的 矛盾 。 但 更 关键 的 是 , 程序 运行 时 具有 时间 局部性 和 空间 局部性 。 时间 局部性 是 说 你 刚 访问 过 的 数据 很 可能 很快 又 被 访问 , 比如 循环 变量 ; 空间 局部性 是 说 你 访问 一个 地址 后 , 周围 地址 的 数据 也 很 可能 被 访问 , 比如 数组 的 顺序 遍历 。 正 因为 有 局部性 , 我们 才 敢 把 内存 中 少数 几 块 数据 拷贝 到 小 容量 的 Cache 里 , 赌 它们 在 未来 一段 时间 内 会 被 反复 使用 。 如果 程序 毫无 局部性 , Cache 的 命中率 会 低 得 可怕 , 缓存 也 就 失去 意义 了 。 所以 你 在 学 映射 之前 , 先 把 局部性 原理 刻在 脑子 里 , 后面 讨论 冲突 和 替换 时 才 不会 觉得 是 在 背 规则 。
1.3 三种 映射 方式 的 本质 : 主存 块 与 Cache 行 的 位置 关系
Cache 的 容量 远小于 主存 , 所以 主存 中 的 非常 多 块 必须 共享 数量 有限 的 Cache 行 。 “ 映射 ” 就是 规定 它们 之间 的 对应 关系 。 直接 映射 规定 每个 主存 块 只能 放 到 唯一 一个 Cache 行 , 存放 位置 等于 主存 块号 对 Cache 行数 取 模 。 全相联 映射 规定 每个 主存 块 可以 放 到 任意 一个 Cache 行 , 去 哪个 位置 都 行 。 组相联 映射 则 是 把 Cache 分成 若干 组 , 规定 每个 主存 块 只能 进入 特定 的 一组 , 但 在 该 组 内 可以 选 任意 一行 存放 。 三个 方案 的 核心 矛盾 是 同一个 : 位置 越 灵活 , 冲突 越 少 , 但 查找 开销 越 大 ; 位置 越 固定 , 查找 越 快 , 但 冲突 越 容易 把 有用 数据 挤 掉 。 后面 两 节 我 分别 展开 。
1.4 一个 容易被 忽略 的 前提 : Cache 行 里 到底 存 了 什么
很多 人 把 注意力 全 放在 地址 位 的 划分 上 , 却 忽略 了 Cache 行 的 结构 。 一个 Cache 行 通常 包含 三 部分 : 数据块 、 标记 、 有效位 。 数据块 就是 从 主存 复制 过来 的 连续 字节 ; 标记 记录 这个 数据块 来自 哪个 主存 块 ; 有效位 表示 这一 行 是否 有 合法 数据 。 系统 上电 时 所有 有效位 都 是 0 , 表示 Cache 里 空空 如也 。 访问 时 只有 标记 相等 且 有效位 为 1 , 才 算 命中 。 有些 情况下 还 有 脏位 , 用来 记录 该 行 是否 被 CPU 修改 过 , 以便 写回 策略 使用 。 这些 附加 字段 虽然 不 属于 地址 划分 , 但 在 计算 Cache 总 容量 的 时候 往往 会 被 考 到 , 所以 我 建议 把 它们 和 映射 方式 一起 记忆 。
2. 直接 映射 和 全相联 : 两个 极端 的 对照
2.1 直接 映射 : 简单 但 冲突 率 高
直接 映射 的 地址 被 分成 三 段 : 标记 、 行号 、 块内 偏移 。 行号 的 位数 等于 log2( Cache 行数 ) , 它 决定 主存 块 应该 去 哪 一行 。 块内 偏移 位数 等于 log2( 块大小 ) 。 剩下 高位 就是 标记 , 用于 判断 当前 这一 行 中 存 的 到底 是 哪个 主存 块 。 举个 具体 例子 : 主存 64KB = 2^16 B , Cache 4KB = 2^12 B , 块大小 64B = 2^6 B 。 主存 共 1024 块 , Cache 共 64 行 。 那么 块内 偏移 6 位 , 行号 6 位 ( 因为 64 行 ) , 标记 4 位 。 地址 A 的 块号 = A / 64 , 行号 = 块号 mod 64 , 标记 = 块号 / 64 。 如果 一个 程序 反复 访问 块号 0 和 块号 64 的 数据 , 它们的 行号 都 是 0 , 就会 频繁 互相 覆盖 , 造成 一种 叫 “ Cache 颠簸 ” 的 现象 。 虽然 查找 时 只用 一次 比较 就能 判断 命中 与否 , 结构 简单 , 但 这种 固定 位置 的 设计 对 访问 模式 很 敏感 。
2.2 全相联 映射 : 灵活 但 成本 高
全相联 映射 的 地址 只 分 两 段 : 标记 和 块内 偏移 。 因为 主存 块 可以 去 任意 Cache 行 , 所以 不 需要 行号 字段 。 在 刚才 的 例子 里 , 块内 偏移 6 位 , 标记 10 位 。 访问 任意 地址 时 , CPU 需要 把 该 地址 的 标记 与 Cache 中 所有 行 的 标记 同时 比较 , 只要 有 一行 的 标记 相等 且 有效 位 为 1 , 就 命中 ; 如果 都 不 匹配 , 就 从 内存 读 入 一个 块 , 放 到 任意 一个 空闲 行 , 若 全部 占满 则 按 替换 算法 淘汰 一行 。 全相联 的 好处 是 冲突 率 极低 , 只有 在 Cache 全部 占满 且 访问 新 块 时 才 发生 替换 。 坏处 也 很 明显 : 并行 比较 的 比较器 数量 必须 等于 Cache 行数 。 64 行 就 要 64 个 比较器 , 1024 行 就 要 1024 个 。 这些 比较器 会 占用 大量 芯片 面积 , 而且 比较 链路 长 了 会 增加 访问 延迟 。 所以 全相联 一般 只 用于 条目 数量 很少 的 缓存 , 比如 TLB 。 Cache 行数 到了 几十 以上 , 全相联 的 硬件 代价 就 变得 不 现实 了 。
2.3 两个 极端 的 代价 对比
为了 直观 , 我 把 直接 映射 和 全相联 的 特点 放在 一起 看 。
| 对比 项 | 直接 映射 | 全相联 映射 |
|---|---|---|
| 主存块可放位置 | 固定一行 | 任意一行 |
| 查找方式 | 按行号定位,比较1个标记 | 与所有行并行比较标记 |
| 比较器数量 | 1 | 等于Cache行数 |
| 冲突率 | 高 | 最低 |
| 地址字段 | 标记+行号+偏移 | 标记+偏移 |
在 64KB 主存 、 4KB Cache 、 64B 块 的 例子 里 , 直接 映射 的 标记 只有 4 位 , 行号 6 位 ; 全相联 的 标记 10 位 。 标记 位 数 变 多 , 意味着 每个 Cache 行 需要 额外 存 更 多 的 标记 信息 。 也许 你 觉得 10 位 和 4 位 差 不了 多少 , 但 如果 主存 4GB 、 Cache 4MB 、 块 64B , 主存 块号 有 26 位 , Cache 行号 16 位 , 直接 映射 标记 为 10 位 , 全相联 标记 则 为 26 位 。 标记 位 数 影响 标记 存储 容量 和 比较 器 位宽 。 所以 实际 系统 几乎 不会 用 全相联 做 大 容量 Cache , 而 是 在 直接 映射 和 全相联 之间 找 折中 。
2.4 用 生活 场景 记住 三种 映射
我 自己 记忆 的 时候 喜欢 用 停车 来 类比 。 直接 映射 就是 你 的 车 被 指定 了 一个 固定 车位 , 车位 号 由 车牌 号 对 车位 总数 取 模 决定 ; 这样 找 车 很 快 , 但 如果 两 个 人 的 车牌 号 恰好 映射 到 同 一个 车位 , 就 只能 挪 别人 的 车 。 全相联 是 整个 停车场 的 任意 车位 都 可以 停 , 找 车 的 时候 必须 把 全场 扫 一遍 。 组相联 是 把 停车场 分成 几 个 区域 , 你 只能 停 到 指定 区域 , 但 那个 区域 里 的 所有 车位 都 由 你 挑 。 这个 类比 能 帮 你 快速 记住 : 映射 解决 的 是 “ 能 停 哪里 ” 和 “ 怎么 找 ” 的 平衡 。
3. 组相联 映射 : 折中 的 艺术
3.1 组相联 的 地址 结构 与 索引 过程
组相联 把 Cache 划分 成 G 个 组 , 每 组 有 W 行 , W 就是 相联度 。 地址 划分 为 标记 、 组索引 、 块内 偏移 。 组索引 的 位数 g = log2(G) 。 访问 一个 地址 时 , 先用 中间 的 g 位 找到 对应 的 组 , 然后 在 该 组 的 W 行 中 并行 比较 标记 。 如果 组 内 某一 行 的 标记 与 地址 的 标记 一致 且 有效 位 为 1 , 则 命中 。 若 不 命中 , 就 从 内存 读 块 , 放 入 该 组 内 的 任一 空闲 行 ; 若 该 组 所有 行 都 满 , 则 在 组 内 按 替换 算法 选 一行 覆盖 。 注意 这个 过程 的 特点 : 主存 块 的 去 向 由 组号 决定 , 而 组号 是 主存 块号 对 组数 取 模 得到 的 ; 一旦 进入 组 内 , 就 有 W 个 候选 位置 。 用 生活 类比 : 直接 映射 像 你 只能 去 固定 的 停车位 停车 , 全相联 像 整个 停车场 任意 车位 都 可以 停 , 组相联 则 像 把 停车场 分成 若干 区域 , 你 只能 去 指定 区域 , 但 该 区域 里 的 车位 随便 选 。
3.2 相联度 与 组数 、 行数 的 关系
Cache 行数 C 、 组数 G 、 相联度 W 之间 满足 C = G × W 。 相联度 W = 1 时 , 组 数 = 行数 , 组索引 就是 行号 , 退化 为 直接 映射 。 相联度 W = C 时 , 组数 = 1 , 没有 组索引 位 , 退化 为 全相联 。 所以 组相联 是 一个 连续 谱系 , 调整 W 就 能 在 冲突 率 和 硬件 复杂度 之间 取 平衡 。 实际 CPU 中 常见 的 是 2 路 、 4 路 、 8 路 组相联 。 相联度 越 高 , 冲突 率 越 低 , 但 需要 比较 的 标记 越 多 , 比较器 的 数量 和 功耗 也 越 高 。 此外 , 相联度 也 影响 标记 位 数 : 因为 组数 变 少 , 组索引 位 变 少 , 标记 位 就 变 多 。 例如 64 行 Cache , 直接 映射 组索引 6 位 , 标记 4 位 ; 2 路 组相联 组 数 =32 , 组索引 5 位 , 标记 5 位 ; 4 路 组相联 组 数 =16 , 组索引 4 位 , 标记 6 位 ; 全相联 组 数 =1 , 组索引 0 位 , 标记 10 位 。 可以 看到 , 相联度 越 高 , 每个 行 要 存 的 标记 越 多 。 不过 在 块大小 不变 的 前提 下 , 标记 位 增多 带来 的 存储 开销 通常 远 小于 冲突 减少 带来 的 收益 。 而 比较器 的 数量 从 1 个 变成 W 个 , 这 才是 硬件 主要 的 代价 。
3.3 替换 算法 与 硬件 实现
组相联 Cache 在 组 内 满 的 时候 需要 决定 替换 哪 一行 。 最 常用 的 是 LRU ( 最近 最少 使用 ) 。 LRU 需要 跟踪 组 内 各行 最近 一次 被 访问 的 时间 。 对于 W 路 组相联 , 每 组 需要 一组 状态 位 记录 使用 次序 。 2 路 组相联 很 容易 : 1 个 bit 表示 “ 最近 使用 了 哪 一行 ” , 替换 时 选择 另 一行 。 4 路 可以 用 2 位 状态 表示 伪 LRU 树 , 或者 维护 一个 循环 队列 。 实现 LRU 的 方式 很多 , 在 计算机 组成 原理 的 习题 里 , 你 只要 会 按照 “ 最近 没有 被 使用 ” 来 淘汰 即可 。 另一个 常见 算法 是 FIFO , 先进 先出 , 硬件 更 简单 , 但 可能 把 正在 频繁 使用 的 行 替换 掉 , 性能 一般 不如 LRU 。 还有 随机 替换 , 实现 成本 最低 , 在 较大 相联度 下 表现 也 不错 。 在 硬件 上 , 组相联 的 查找 分 两 步 : 第一步 用 组索引 选择 一 组 , 第二 步 将 地址 的 标记 与 该 组 内 的 W 个 标记 比较 , 同时 检查 有效位 。 即使 Cache 有 几千 行 , 一次 访问 也 只需 要 W 个 比较器 , 而 不是 几千 个 。 这 就是 组相联 相比 全相联 的 最大 优势 : 用 少量 比较器 换 来 接近 全相联 的 命中率 。 当然 , 组相联 的 替换 逻辑 比 直接 映射 复杂 , 需要 额外 的 状态 存储 和 控制 电路 , 但 这些 代价 相对 比较器 和 功耗 来说 是 可以 接受 的 。
3.4 举例 : 一个 64KB 主存 、 4KB Cache 的 组相联 计算
我们 用 之前 的 参数 完整 算 一次 : 主存 64KB , Cache 4KB , 块 64B , 2 路 组相联 。 主存 地址 16 位 。 Cache 行数 = 4KB/64B = 64 行 。 组 数 = 64/2 = 32 组 。 所以 块内 偏移 6 位 , 组索引 log2(32) = 5 位 , 剩下 标记 16 - 6 - 5 = 5 位 。 假设 访问 地址 0x1234 , 二进制 为 0001 0010 0011 0100 。 低 6 位 offset = 110100 ( 即 52 ) 。 中间 5 位 index = 01000 ( 即 8 ) 。 高 5 位 tag = 00010 ( 即 2 ) 。 也 可以 先 算 主存 块号 = 0x1234 / 64 = 72 ( 十进制 ) , 块号 的 低 5 位 是 组号 8 , 高 5 位 是 标记 2 。 因此 这个 数据 块 只能 进入 第 8 组 , 可以 放 在 第 8 组 的 两 行 中 任意 一行 。 这样 的 地址 划分 与 直接 映射 对比 : 直接 映射 的 行号 是 块号 的 低 6 位 = 72 mod 64 = 8 , 标记 是 块号 /64 = 1 。 全相联 的 标记 是 块号本身 72 。 同一 个 地址 , 三种 映射 的 字段 完全 不同 , 说明 映射 方式 决定 了 Cache 控制 器 的 硬件 线路 。 刷 题 的 时候 一定 要 把 这个 计算 过程 写 一遍 , 能 算 清楚 标记 位 和 索引 位 , 后面 的 命中率 分析 才 有 基础 。
3.5 组相联 的 命中 判断 与 访问 流程
把 流程 拆开 看 , 有 四 步 : 第一 步 计算 地址 字段 , 把 标记 、 组索引 、 偏移 分别 取 出来 。 第二 步 用 组索引 从 Cache 中 选定 一个 组 , 这 一步 类似 于 数组 下标 访问 。 第三 步 并行 比较 组 内 所有 行 的 标记 和 有效位 , 如果 有 任何 一行 满足 tag 相等 且 valid=1 , 则 命中 , CPU 根据 偏移 在 数据块 内 取 出 对应 字节 。 第四 步 如果 未 命中 , 则 进入 替换 流程 : 在 本 组 内 找 一个 空闲 行 , 或者 用 替换 算法 选 一行 , 把 主存 块 数据 写入 该 行 , 更新 标记 和 有效位 。 整个 流程 看 起来 复杂 , 但 在 硬件 上 用 组合 逻辑 就 能 完成 , 关键 是 比较器 的 位宽 和 数量 都 有限 。 学习 的 时候 建议 把 这个 流程 画 成 状态 图 或 伪代码 , 会 加深 印象 。
4. 从 理论 到 实践 : 不同 映射 方式 的 应用 场景
4.1 CPU Cache 中 的 组相联 应用
在 现代 处理器 中 , L1 Cache 的 行数 往往 在 几百 到 几千 之间 。 如果 用 全相联 , 比较器 数量 无法 接受 ; 如果 用 直接 映射 , 冲突 率 又 太 高 。 因此 组相联 成为 主流 。 常见 配置 如 L1 Data Cache 8 路 组相联 , L2 Cache 16 路 组相联 。 相联度 的 选择 是 一个 权衡 : 提高 相联度 能 提升 命中率 , 但 也 增加 了 访问 延迟 和 功耗 。 研究 表明 , 从 2 路 到 8 路 命中率 提升 明显 , 超过 16 路 后 收益 递减 , 而 功耗 和 面积 增加 显著 。 这是 为什么 你 几乎 看不到 64 路 的 L1 Cache 。 另 一个 细节 是 替换 策略 : Intel 和 AMD 的 处理器 中 很多 使用 伪 LRU 或 随机 替换 , 因为 完全 LRU 在 高 相联度 下 状态 位 和 电路 都 很 复杂 。 你 在 学 组成 原理 时 掌握 的 LRU 是 理论 上 的 理想 方案 , 实际 硬件 为 了 速度 会 做 各 种 简化 。
4.2 TLB 和 页表 缓存 中 的 全相联 思想
全相联 并 不是 没有 用武之地 。 在 支持 虚拟 内存 的 系统 中 , 地址 翻译 需要 查询 页表 , 而 页表 在 内存 中 , 查询 一次 页表 很 慢 。 于是 CPU 内部 有 一个 专门 的 缓存 叫 TLB ( Translation Lookaside Buffer ) , 用于 缓存 最近 使用 的 页表 项 。 TLB 的 条目 数量 一般 几十 到 几百 个 , 相对 较小 , 使用 全相联 或 组相联 都 有 。 使用 全相联 时 , 可以 最大化 命中率 , 而且 条目 少 , 比较器 数量 不会 爆炸 。 这 让 我们 看到 一个 规律 : 缓存 容量 小 、 条目 少 时 采用 全相联 是 可行 的 ; 缓存 容量 大 时 必须 采用 组相联 。 另外 , 页表 本身 的 分级 结构 也 可以 看作 一种 索引 方式 , 类似 于 多级 组相联 的 思路 。 你 可以 通过 分析 TLB 的 全相联 设计 , 更 好 地 理解 “ 比较 器 与 容量 的 矛盾 ” 。
4.3 Linux 的 page cache 和 KV Cache 给 我们 的 启发
操作系统 层面 的 缓存 同样 遵循 这个 权衡 。 Linux 的 page cache 用于 缓存 磁盘 中 的 文件 页 。 它 不 使用 硬件 映射 , 而是 用 哈希 表 和 基数 树 来 定位 缓冲 页 , 本质 上 类似 于 全相联 —— 任意 数据 可以 放到 缓存 的 任意 位置 , 然后 通过 键 值 快速 找到 。 因为 内存 中 的 页面 数量 可以 很 多 , 比较器 的 方案 不 现实 , 所以 软件 用 哈希 表 实现 “ 相联 查找 ” 。 这 给 我们 的 启示 是 : “ 相联 ” 的 本质 是 在 位置 灵活 性 和 查找 开销 之间 做 权衡 , 硬件 用 比较器 实现 相联 , 软件 用 哈希 表 实现 相联 。 另外 , 最近 很 火 的 大模型 推理 里 的 KV Cache , 缓存 的 是 注意力 机制 中 的 key 和 value 向量 。 它 也 需要 解决 “ 缓存 放 哪里 、 不够 了 淘汰 谁 ” 的 问题 , 常用 的 策略 包括 LRU 、 FIFO 以及 针对 注意力 分数 的 特殊 策略 。 虽然 和 计算机 组成 原理 的 Cache 硬 映射 不是 同一 层 面 , 但 背后 的 时间 局部性 和 淘汰 策略 思想 是 相通 的 。 学 完 Cache 映射 后 , 你 再 去 看 这些 系统 级 缓存 设计 , 会 有 一种 熟悉 的 感觉 。
4.4 如何 根据 应用 选择 相联度
如果 你 自己 做 一个 缓存 系统 , 相联度 怎么 选 ? 一个 基本 的 判断 标准 是 看 缓存 容量 和 数据块 大小 。 缓存 行数 越 多 , 越 应该 用 组相联 而 不是 全相联 。 相联度 通常 从 2 路 起 步 , 然后 用 一个 测试 负载 去 测量 命中率 和 延迟 。 如果 冲突 占 主导 , 就 提高 相联度 ; 如果 功耗 和 面积 超标 , 就 降低 相联度 。 在 计算机 组成 原理 课程 里 , 你 不 需要 做 这么 复杂 的 优化 , 但 记住 一个 结论 : 相联度 越 高 , 冲突 越 少 , 但 硬件 越 复杂 ; 对于 大 容量 Cache , 8 到 16 路 通常 是 性价比 比较 高 的 区间 。
5. 学习 与 实验 中 的 常见 问题 排查
5.1 索引 位 长度 的 计算 误区
我 见过 很 多 人 在 做 题 时 把 组索引 和 行索引 混 在一起 。 比如 题目 说 “ Cache 有 64 行 , 4 路 组相联 ” , 有 人 直接 把 组索引 位数 算 成 log2(64) = 6 , 这 就 错 了 。 4 路 组相联 时 , 组数 = 64/4 = 16 , 组索引 位数 是 log2(16) = 4 。 正确 的 计算 顺序 是 : 先 根据 Cache 容量 和 块大小 求 出 总 行数 , 再 根据 相联度 求 组数 , 最后 求 索引 位 。 还有 一个 容易 错 的 地方 : 有效位 不 属于 地址 字段 。 地址 中 只有 标记 、 组索引 、 偏移 。 有些 题 会 问 “ Cache 行 的 总 位数 ” , 这时 要 把 数据位 、 标记位 、 有效位 都 加 上 , 有些 还 有 脏位 。 这些 要看 题目 说明 , 但 地址 映射 计算 中 不要 被 这些 额外 位 干扰 。 平时 刷 题 时 建议 在 草稿 纸 上 写 一个 固定 模板 : 块内 偏移 = log2(块大小) ; 组数 = Cache 行数 / 相联度 ; 组索引 = log2(组数) ; 标记 = 主存 地址 位数 - 组索引 - 偏移 。 按 这个 流程 走 , 基本 不会 错 。
5.2 替换 算法 的 实现 细节 与 经典 反例
LRU 的 更新 时机 是 一个 隐蔽 的 坑 。 有些 同学 以为 只有 发生 替换 时 才 需要 更新 状态 , 其实 每次 命中 也 要 更新 。 因为 LRU 要 反映 “ 最近 使用 ” , 一个 刚刚 被 命中 的 行 应该 变成 “ 最 新 使用 ” , 这样 它 在 后续 淘汰 时 才 不会 被 优先 踢 出 。 如果 命中 不 更新 , LRU 的 行为 会 退化 成 类似 FIFO 。 举 个 例子 , 2 路 组相联 的 某 组 初始 为空 , 访问 序列 是 A B A C 。 LRU 的 过程 : A miss 存入 ; B miss 存入 ; A hit , 此时 A 变 成 最新 , 组 内 顺序 是 B 旧 A 新 ; C miss , 需要 替换 B , 组 内 变成 C A 。 如果 命中 后 不 更新 顺序 , C miss 时 就 会 错误 地 替换 A , 因为 A 还是 旧 的 。 这个 细节 在 考试 中 经常 出现 。 另 一个 反直觉 的 点 是 : 在 2 路 组相联 下 , 某些 访问 序列 用 LRU 和 FIFO 结果 相同 , 但 在 高 相联度 下 差异 更 明显 。 学习 时 最好 自己 动 手 模拟 几个 序列 , 体会 替换 算法 的 影响 。
5.3 用 Quartus 或 Verilog 做 Cache 实验 的 思路
很多 学校 的 计算机 组成 原理 实验 会 要求 用 Quartus 原理图 或 Verilog 搭 一个 简单 Cache 。 这个 实验 的 关键 不在 于 写 代码 , 而 在于 把 地址 字段 理清 。 我 当时 做 的 是 一个 2 路 组相联 Cache , 地址 16 位 , 块 4 字节 , 共 4 行 ( 2 组 ) 。 步骤 是 这样 的 : 第一 步 画 出 地址 的 位 划分 , 确定 tag 、 index 、 offset 各 占 几位 , 用 原理图 里 的 总线 选择 把 三 段 地址 分别 引出 。 第二 步 用 比较器 比较 tag , 再 与 有效位 与 起来 作为 命中 信号 。 第三 步 设计 数据 存储 阵列 , 可以 用 双口 RAM 或 寄存器 组 , 按 索引 和 路号 选择 。 第四 步 是 替换 逻辑 , 对 每 组 维护 一个 LRU 位 , 用 状态 机 产生 写 使能 和 选路 信号 。 最后 用 波形 仿真 验证 几个 地址 的 读写 是否 正确 。 这个 过程 很 考验 你 对 组相联 的 理解 是否 扎实 , 因为 任何 一 位 的 划分 错误 都会 导致 仿真 结果 全 乱 。 如果 你 用 Verilog , 注意 always 块 中 的 时序 逻辑 要 用 时钟 驱动 , 避免 组合 逻辑 无限 循环 。 实验 报告 里 要 附上 地址 位 划分 图 和 波形 图 , 这 是 老师 最 关注 的 部分 。
5.4 王道 、 白中英 教材 怎么 看 更 高效
市面 上 讲 计算 机 组成 原理 的 教材 很 多 , 我 个人 的 经验 是 把 王道 和 白中英 配合 起来 用 。 王道 的 课程 视频 适合 应试 , 对 映射 的 计算 题 总结 得 很 清楚 , 比如 它 会 教 你 用 “ 按 块号 低位 索引 , 高位 标记 ” 的 方法 快速 解题 。 但 王道 对 硬件 实现 的 讲解 相对 简略 , 而 白中英 的 《 计算机 组成 原理 》 教材 里 有 详细 的 Cache 实验 和 硬件 描述 , 尤其 是 那些 Quartus 实验 部分 , 能 帮 你 补 上 从 公式 到 电路 的 环节 。 我 的 建议 是 : 先 用 王道 的 视频 把 章节 框架 拉 起来 , 然后 用 白中英 的 教材 扣 细节 , 特别 是 地址 划分 的 例题 和 课后 习题 。 两 个 配合 起来 , 不仅 会 做题 , 还能 知道 电路 里 到底 发生 了 什么 。 另外 注意 , 如果 你 是 软件 方向 的 学生 , 觉得 计组 离 自己 很 远 , 可以 想 一想 Linux 内核 中 的 cache 管理 、 数据库 的 缓存 系统 , 其实 都 是 同一 套 思想 。 有 了 这个 视角 , 学 起来 会 有 趣 很 多 。
5.5 一个 典型 的 考试 题 演练
我 拿 一道 常见 题 演示 一下 完整 分析 过程 。 题干 : 主存 容量 16MB , 按 字节 编址 , Cache 容量 8KB , 块大小 32B , 采用 4 路 组相联 映射 。 问 : 主存 地址 中 标记 、 组索引 、 块内 偏移 各 占 几 位 ? 解题 步骤 : 主存 16MB = 2^24 B , 地址 24 位 。 块大小 32B = 2^5 B , 所以 offset 占 5 位 。 Cache 8KB / 32B = 256 行 。 4 路 组相联 , 所以 组数 = 256/4 = 64 组 = 2^6 , index 占 6 位 。 标记 位 数 = 24 - 6 - 5 = 13 位 。 如果 改成 全相联 , 没有 组索引 , 标记 占 24 - 5 = 19 位 。 如果 改成 直接 映射 , 索引 是 行号 = log2(256) = 8 位 , 标记 占 24 - 8 - 5 = 11 位 。 很多 人 在 这里 出错 的 原因 是 没有 先 算 行数 而 直接 用 Cache 容量 除以 块大小 后 与 相联度 混淆 。 这种 题 做 三 遍 以上 就 会 形成 条件 反射 。
6. 一些 操作 心得 和 避坑 建议
6.1 动手 画 图 : 建立 直觉 的 最好 方式
我 特别 推荐 在 学习 映射 时 自己 画 一张 三层 表 : 第一 层 是 主存 的 块号 列表 , 第二 层 是 Cache 的 组或 行 分布 , 第三 层 是 一组 访问 序列 的 命中 过程 。 每个 地址 都 拆成 标记 、 索引 、 偏移 三 列 , 然后 手动 模拟 几 遍 。 画 过 三五个 例子 之后 , 你 自然 会 明白 为什么 组相联 的 命中率 比 直接 映射 高 : 因为 同样 映射 到 同一 组 的 “ 竞争对手 ” 变 少 了 。 比如 一个 只有 两 个 块 会 争抢 同一 行 的 访问 模式 , 在 直接 映射 下 会 颠簸 , 在 2 路 组相联 下 则 可以 安然 共存 。 这个 直觉 比 背 公式 重要 得 多 。 我 在 考试 中 遇到 任何 Cache 题 都 会 习惯 性 先 在 草稿 上 画 出 位 划分 示意 图 , 然后 再 开始 算 , 出错 率 低 了 很 多 。
6.2 用 一点点 代码 验证 你的 理解
如果 你 觉得 手 算 容易 出错 , 可以 用 脚本 写 一个 简单 的 组相联 Cache 模拟 器 。 这样 可以 验证 你 对 索引 、 标记 、 LRU 的 理解 是否 正确 。 下面 是 一个 最小 的 Python 示意 :
class SetAssocCache: def __init__(self, num_sets, ways): self.num_sets = num_sets self.ways = ways self.tags = [[-1] * ways for _ in range(num_sets)] self.lru = [[0] * ways for _ in range(num_sets)] # 0最旧, ways-1最新 def access(self, tag, set_idx): s = self.tags[set_idx] if tag in s: pos = s.index(tag) old = self.lru[set_idx][pos] for i in range(self.ways): if self.lru[set_idx][i] > old: self.lru[set_idx][i] -= 1 self.lru[set_idx][pos] = self.ways - 1 return True pos = min(range(self.ways), key=lambda i: self.lru[set_idx][i]) s[pos] = tag for i in range(self.ways): if self.lru[set_idx][i] < self.lru[set_idx][pos]: self.lru[set_idx][i] += 1 self.lru[set_idx][pos] = 0 return False这个 代码 只 是 示意 , 没有 考虑 有效位 初始化 和 写回 策略 , 但 足以 用来 模拟 访问 序列 的 miss 情况 。 你 可以 把 之前 的 地址 例子 转成 tag 和 set_idx , 跑 一下 看 是否 与 手 算 一致 。 写 代码 的 过程 会 强迫 你 理清 “ 哪 几 位 是 tag 、 哪 几 位 是 index ” , 比 单纯 看 书 有效 得 多 。 需要注意 的 是 , Python 里 取 位 的 时候 用(addr >> offset_bits) & ((1 << index_bits) - 1)来 取 组索引 , 不要 把 顺序 搞 反 。
6.3 一个 关于 “ 全相联 到底 贵 在 哪 ” 的 细节
最后 分享 一个 我 以前 忽略 的 细节 。 全相联 的 代价 不只 是 比较器 数量 多 , 还 在于 标记 比较 的 延迟 会 随 着 行数 增加 而 增加 。 假设 Cache 有 1024 行 , 全相联 需要 把 1024 个 tag 同时 与 地址 tag 比较 , 这 需要 一个 大型 的 优先 编码器 把 命中 信号 汇聚 起来 , 比较 器 的 串并 结构 会 让 关键 路径 变 长 , 可能 直接 拖慢 时钟 频率 。 而 组相联 先 用 索引 位 选择 一组 , 比较 范围 只有 W 个 , 关键 路径 短 得 多 。 所以 在 实际 芯片 中 , 高 相联度 不仅 带来 面积 开销 , 还 可能 带来 时序 风险 。 这 也 解释 了 为什么 处理 器 设计 者 宁可 用 多级 Cache 也 不用 一个 全相联 的 大 Cache 。 理解 了 这 一点 , 你 就 明白 组相联 不是 “ 妥协 ” , 而是 在 约束 下 的 最优 解 。
说到 这里 , 我 又 想 起 一个 做 题 习惯 : 每次 看到 “ 全相联 ” 三个 字 , 立刻 在 脑 里 蹦 出 “ 若干 比较器 + 无 index ” , 看到 “ 组相联 ” 就 想 到 “ 先 组 后 路 ” 。 这种 条件 反射 不是 天生 的 , 而是 靠 反复 画 地址 位 图 和 手动 模拟 练 出来 的 。 你 如果 现在 还 觉得 乱 , 不妨 找 一道 带 计算 的 题 , 按 我 上面 的 流程 从 头 写 一遍 , 写 完 再 用 Python 模拟 器 验 一遍 。 两 次 结果 一致 , 你 就 真正 掌握 了 。