﻿# 缓存的组织结构与寻址 —— 物理地址如何定位一条 cache line

> 本文讲透缓存的**单核机制**：**物理地址怎么拆、怎么判命中，直接映射/全相联/组相联三种结构的原理与取舍，读写流程(逐级下探/write-back)，以及时钟域与访问开销**。这是理解一致性协议（[msi.md](/concepts/cache/msi.md) / [mesi.md](/concepts/cache/mesi.md) 等）和伪共享的物理基础；"为什么需要一致性"从 [msi.md](/concepts/cache/msi.md) 讲起。

## 一、前提：缓存按物理地址组织

程序访问用**虚拟地址(VA)**，但 VA 先经 **MMU + TLB** 查页表翻译成**物理地址(PA)**，缓存再拿 PA 来查（PIPT）。用 PA 而非 VA 的原因：同一物理页可能被多个虚拟地址映射（共享内存、多进程共享 `.so`）——用 VA 索引会让同一份数据在缓存里存多份且互不一致（aliasing）。

```plantuml
@startuml
skinparam shadowing false
skinparam componentStyle rectangle
skinparam rectangle {
  BackgroundColor<<va>>  #E3F2FD
  BorderColor<<va>>      #1976D2
  BackgroundColor<<mmu>> #FFF9C4
  BorderColor<<mmu>>     #F9A825
  BackgroundColor<<pa>>  #C8E6C9
  BorderColor<<pa>>      #388E3C
}
rectangle "程序发出\n虚拟地址 VA" <<va>> as VA
rectangle "MMU + TLB\n查页表翻译" <<mmu>> as MMU
rectangle "物理地址 PA" <<pa>> as PA
rectangle "缓存用 PA 查找" <<pa>> as C
VA -right-> MMU
MMU -right-> PA
PA -right-> C
note bottom of MMU : TLB 命中则翻译≈0 延迟；\nTLB miss 要走页表(慢)
@enduml
```

> 现代 L1 常用 **VIPT**（VA 低位做索引、PA 做 tag，让查 TLB 和查缓存并行以省延迟），但对外语义等价物理寻址。下文按"物理地址寻址"讲。

> **TLB 本身也是一层缓存,也会 miss。** VA→PA 翻译若 TLB miss,要走一次几十上百拍的 page walk——它能主导整个程序性能(一个"迭代次数不变、只改步长却暴跌 5 倍"的经典实验),详见 [tlb.md](/concepts/cache/tlb.md)。

## 二、物理地址拆成三段

缓存**不是按地址挨个存、逐个比对**，而是把物理地址切成三段，用中间段直接算出"只可能在哪一组"，再比对 tag。这样查找是 O(1)。

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<tag>> #E1BEE7
  BorderColor<<tag>>     #6A1B9A
  BackgroundColor<<idx>> #FFF9C4
  BorderColor<<idx>>     #F9A825
  BackgroundColor<<off>> #C8E6C9
  BorderColor<<off>>     #388E3C
}
rectangle "Tag\n(高位)" <<tag>> as T
rectangle "Index\n(中间位)" <<idx>> as I
rectangle "Offset\n(低位)" <<off>> as O
T -right-> I
I -right-> O
note bottom of T : 确认“是不是它”
note bottom of I : 定位“在哪一组”
note bottom of O : 定位“行内第几字节”
@enduml
```

| 段 | 决定它的因素 | 作用 |
|----|-------------|------|
| **Offset** | cache line 大小（64B → 6 位）| 字节在 line 内的偏移 |
| **Index** | 组数 set 数（S 组 → log₂S 位）| **直接算出**落在哪一组，不查找 |
| **Tag** | 剩下的高位 | 存在行元数据里，**确认**该组某条行是不是目标 |

**位数公式**（物理地址共 `m` 位）：

```bash
Offset 位数 b = log2(行大小)      如 64B → 6 位
Index  位数 s = log2(组数)        如 64 组 → 6 位
Tag    位数   = m - s - b         剩下全给 tag
```

## 三、命中判定：一套硬件电路，不是一段程序

**先纠正一个常见误解**：缓存查找**不是 CPU 执行一段代码**去"遍历比对"，而是由**专门的硬件电路**在**一个时钟周期内**完成的组合逻辑。没有指令、没有循环、没有先后——地址一送进来，电路"通电"就同时出结果。所以缓存命中判定是真正的 O(1)（准确说是"一个门延迟级别"）。

判定要素还是那三步，但每一步对应的是**电路**而非运算：

| 逻辑步骤 | 硬件实现 | 代价 |
|----------|----------|------|
| 拆 Tag/Index/Offset | **纯连线**：把地址总线的不同位分接到不同模块 | 零（只是布线）|
| Index → 选中一组 | **译码器(decoder)** | 一级门延迟 |
| 组内 W 条 tag 与目标比对 | **W 个比较器(comparator)同时通电比** | 并行，一个周期 |
| tag 相等 且 valid=1 | 每路一个 **AND 门** | 并行 |
| 汇总"有没有命中"+ 选出数据 | **OR 门**(是否命中) + **多路选择器 MUX**(选中那条的数据) | 并行 |

### 硬件数据通路（组相联，W 路）

```plantuml
@startuml
skinparam shadowing false
skinparam componentStyle rectangle
skinparam rectangle {
  BackgroundColor<<addr>> #FFF9C4
  BorderColor<<addr>>     #F9A825
  BackgroundColor<<hw>>   #E3F2FD
  BorderColor<<hw>>       #1976D2
  BackgroundColor<<gate>> #FFCCBC
  BorderColor<<gate>>     #E64A19
  BackgroundColor<<out>>  #C8E6C9
  BorderColor<<out>>      #388E3C
}
rectangle "物理地址（分线接出）\nTag | Index | Offset" <<addr>> as ADDR
rectangle "译码器 decoder\nIndex → 选中 1 组" <<hw>> as DEC
rectangle "选中组的 W 条 way\n(各有 tag / valid / data)" <<hw>> as WAYS
rectangle "W 个比较器\n各 way.tag == Tag ?" <<gate>> as CMP
rectangle "W 个 AND 门\n(tag相等) AND (valid=1)" <<gate>> as AND
rectangle "OR 门\n有任一路成立 = hit" <<gate>> as OR
rectangle "MUX 多路选择器\n用命中信号选出那条 data\n再按 Offset 取字节" <<hw>> as MUX
rectangle "hit / miss 信号" <<out>> as HIT
rectangle "命中的数据字节" <<out>> as DATA
ADDR --> DEC : Index
DEC --> WAYS
WAYS --> CMP : 各 way 的 tag
ADDR --> CMP : Tag（同时送给所有比较器）
CMP --> AND
WAYS --> AND : 各 way 的 valid
AND --> OR
OR --> HIT
AND --> MUX : 命中信号(选哪路)
WAYS --> MUX : 各 way 的 data
ADDR --> MUX : Offset
MUX --> DATA
@enduml
```

要点：图里 **W 个比较器是同时工作**的——不是"比完第 0 路再比第 1 路"，而是所有 way 的 tag 一起送进各自比较器、同一瞬间出结果，OR 门一汇总就知道命不命中。**way 数 W 越大 = 比较器越多 = 芯片面积和功耗越大**，这正是相联度不能无限提高的物理原因。

### 逻辑等价的伪代码（仅用来说明"电路算什么"）

下面这段 C **不是缓存的实现**，缓存里没有 CPU 去跑它——它只是把上面那套电路"要判定什么"用大家熟悉的语法写出来，帮助理解。真实硬件里，`for` 那几路是**并行**的，不存在循环的先后：

```c
// ⚠️ 这是电路逻辑的“文字描述”，不是运行的程序
struct Line { bool valid; uint64_t tag; int state; uint8_t data[64]; };
struct Set  { Line way[W]; };          // 一组 = W 条 line
Set cache[S];                          // 整个缓存 = S 组
// 地址三段：纯取位（硬件是分线，不是移位运算）
int      offset = pa & ((1<<B)-1);
int      index  = (pa >> B) & (S-1);   // 译码器：选组
uint64_t tag    = pa >> (B + log2(S)); // 高位：送去比较
Set *set = &cache[index];
for (int w = 0; w < W; w++)            // 硬件里这 W 路【同时】比，不是循环
    if (set->way[w].valid && set->way[w].tag == tag)  // 比较器 + AND门
        return HIT(w);                 // OR门汇总 + MUX 选数据
return MISS;
```

三个别被伪代码误导的点：

1. **拆三段是"分线"不是"运算" **：`pa & mask`、`pa >> B` 在硬件里就是把地址总线的某几根线直接接到对应模块，没有 ALU 参与、零延迟。
2. **那个 `for` 在硬件里是并行的**：W 个比较器 + W 个 AND 门同时出结果，一个周期内 OR 门就汇总完。伪代码写成循环只是语法所限。
3. **命中 = `valid && tag 相等` 两个条件都满足**：valid=0 表示这条 line 是空的/被失效过（如被别的核 invalidate），即使 tag 恰好相等也不算命中。

### 数字 demo：查地址 `0xD6` 在不在

用一个便于手算的小缓存：**8 位物理地址、line = 4 字节、直接映射 4 组**（即 B=2, S=4, 每组 1 条）。手算就是"硬件把这几根地址线怎么分"：

```bash
地址 0xD6 = 1 1 0 1 0 1 1 0   (二进制)
            └──┬──┘ └┬┘ └┬┘
             Tag    Idx  Off
            bit7:4  3:2  1:0
```

电路一次到位（下面写成"步骤"只为讲解，硬件是同时发生）：

```bash
· 取 bit[1:0] = 0b10 = 2   → Offset，MUX 最后按它选行内第 2 字节
· 取 bit[3:2] = 0b01 = 1   → Index，译码器选中第 1 组
· 取 bit[7:4] = 0b1101=0xD → Tag，送进比较器
· 比较器：cache[1].tag == 0xD ?  且 cache[1].valid==1 ?
     都成立 → hit 信号=1，MUX 输出 cache[1].data[2]
     否则   → miss 信号=1，触发去 L2/内存取整条 line 填进 cache[1]
```

> 换成 8 路组相联时，Index 定到第 1 组后，该组 **8 个比较器同时**把各自 way 的 tag 和 0xD 比，8 个 AND 门 + 一个 OR 门汇总——仍是一个周期出 hit/miss。这就是"如何根据 PA 判断数据是否在 cache"的硬件真相。

### cache 里存了物理地址吗？—— 没有，Index 隐含、只存 Tag

一个自然的疑问：既然用物理地址来判断"在不在"，**cache 里存了这个地址吗**？否则怎么确认某条 line 装的就是地址 PA 的数据？

答案：**cache 不存完整物理地址，每条 line 只存 Tag**。复原一个地址要三段，但三段的信息来源不同：

| 段 | 从哪来 | 存进 cache 吗 |
|----|--------|:---:|
| **Index** | **由这条 line 待在第几组"隐含"表达** | ❌ 不存 |
| **Tag** | **显式存在这条 line 的元数据里** | ✅ 存 |
| **Offset** | 只定位行内第几字节，与"是不是这条 line"无关 | ❌ 不存 |

**核心洞察：一条 line 待在第几组，本身就等于记住了它的 Index。** 因为规则是"只有 Index==组号的地址才会被放进这一组"——所以看到某条 line 在第 1 组，就知道它的 Index 一定是 1，无需再存。于是 cache 只额外存 Tag，就能和"隐含的 Index"拼出完整地址来判断：

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<q>>   #FFF9C4
  BorderColor<<q>>       #F9A825
  BackgroundColor<<pos>> #E1BEE7
  BorderColor<<pos>>     #6A1B9A
  BackgroundColor<<st>>  #E3F2FD
  BorderColor<<st>>      #1976D2
  BackgroundColor<<out>> #C8E6C9
  BorderColor<<out>>     #388E3C
}
rectangle "查询地址 PA\nTag_q | Index_q | Offset" <<q>> as Q
rectangle "(1) Index_q 选中第 Index_q 组\n(位置本身 = 隐含的 Index)" <<pos>> as POS
rectangle "(2) 这条 line 里存的:\nTag_stored + valid\n(没存 Index，没存整地址)" <<st>> as ST
rectangle "(3) 命中判定:\nvalid==1 且 Tag_stored==Tag_q" <<out>> as HIT
Q --> POS : Index_q
POS --> ST : 定位到 line
Q --> HIT : Tag_q
ST --> HIT : Tag_stored, valid
note bottom of HIT
  组号(隐含Index) + 存下的Tag = 完整地址高位
  = 等价于物理地址匹配，却没存整个地址
end note
@enduml
```

**用 0xD6 的例子复原**：`0xD6 = 1101 01 10`，落在第 1 组。cache[1] 里**只存了 `tag=1101` + valid**，既没存 `01`(Index)、也没存整段 `11010110`。判定时：译码器用 `Index=01` 选中第 1 组（这一步就"用掉"了 Index）→ 比 `cache[1].tag(1101)==查询Tag(1101)` → 相等且 valid → 命中。要复原这条数据的地址高 6 位，就是 `组号01 拼 tag1101 = 110101`——**Index 从"它在第几组"免费得到，不必存**。

为什么这么设计？**省存储**。若每条 line 都存完整 48 位物理地址，几千条 line 光地址就好几 KB 纯开销；而 Index 由位置隐含、免费，只需存 Tag（高位那几十位）。这也带出一个连带规律：**相联度越高 → Index 位越少 → Tag 越长 → 每条 line 的 tag 存储开销越大**（全相联无 Index，整段高位都得进 tag，这是全相联"贵"的原因之一，见第四节）。

> 一句话：**cache 不存完整地址，只存 Tag；Index 由"数据落在第几组"隐含表达，Offset 与身份无关。译码器用 Index 选组、比较器比 Tag，两者合起来唯一确定物理地址——既能判定，又省存储。**

### 地址能"唯一精准定位"到一条 line 吗？——定位到组是精准的，组内不是

一个常见直觉：给一个物理地址，cache 里就有它唯一、精准、确定的位置。**这半对半错——必须把"组"和"组内 way"分开看**：

| 定位到 | 由什么决定 | 是否唯一精准 |
|--------|-----------|:---:|
| **哪一组 (set)** | Index，从地址**算**出来 | ✅ 唯一、精准、确定——同一地址永远落同一组 |
| **组内哪条 way** | 数据装入时由**替换策略动态选**的空槽 | ❌ 不唯一（除非直接映射）|

分结构看：

- **直接映射**（每组 1 条 line）：组 = 唯一位置，所以"地址 → 唯一精准位置"**成立**。
- **N 路组相联**：地址只能精准定位到**一个组**，它在组内 N 条 way 的**哪一条不确定**——所以查找时才要"组内并行比 N 个 tag"去**找**。如果地址能直接算出唯一 way，就根本不需要比 tag 了。
- **全相联**：地址连组都不限定，可能在任意 line。

**关键因果**：正因为地址**不能**唯一确定组内 way，才需要 Tag 比对——Tag 的作用就是"在这一组的 N 个候选里，认出到底哪条（如果有）是我"。

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<q>>   #FFF9C4
  BorderColor<<q>>       #F9A825
  BackgroundColor<<set>> #E1BEE7
  BorderColor<<set>>     #6A1B9A
  BackgroundColor<<way>> #E3F2FD
  BorderColor<<way>>     #1976D2
  BackgroundColor<<hit>> #C8E6C9
  BorderColor<<hit>>     #388E3C
}
rectangle "物理地址" <<q>> as Q
rectangle "Index → 算出唯一组\n(精准、确定)" <<set>> as SET
rectangle "组内 way0" <<way>> as W0
rectangle "组内 way1 (它其实在这)" <<hit>> as W1
rectangle "组内 way2" <<way>> as W2
rectangle "组内 way3" <<way>> as W3
Q --> SET : ① 算 Index(唯一)
SET --> W0 : ② 比 tag
SET --> W1 : ② 比 tag → 命中
SET --> W2 : ② 比 tag
SET --> W3 : ② 比 tag
note bottom of W1
  组内哪条 way 不是算出来的
  要并行比 N 个 tag 才知道在哪条
end note
@enduml
```

> 一句话：**"算到组"是精准唯一的（Index 决定，O(1) 译码）；"组内定到 way"要靠比 tag（不是算的）。两步合起来——算到组 + 比出 way——才唯一确定一条 line。** 只有直接映射因为"每组就 1 条"，才让地址直接等于唯一位置。

### 一条 cache line 里到底存了什么（不只是数据和 Tag）

除了 64B 业务数据和 Tag，每条 line 还挂着一串**元数据(metadata)**——这些位是缓存能正确工作的关键：

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<meta>> #FFF9C4
  BorderColor<<meta>>     #F9A825
  BackgroundColor<<data>> #C8E6C9
  BorderColor<<data>>     #388E3C
}
rectangle "一条 cache line" {
  rectangle "Valid\n1 位" <<meta>> as V
  rectangle "Tag\n(地址高位)" <<meta>> as T
  rectangle "MESI 状态\nM/E/S/I" <<meta>> as S
  rectangle "Dirty\n(单核语境)" <<meta>> as D
  rectangle "替换位\nLRU/PLRU 年龄" <<meta>> as R
  rectangle "数据 Data\n64 字节业务数据" <<data>> as DA
}
note bottom of DA : 元数据(左侧)对程序不可见，\n但缓存/一致性协议全靠它们运转
@enduml
```

| 字段 | 作用 | 何时需要 |
|------|------|----------|
| **Valid** | 这条 line 有没有装有效数据（空的/被 invalidate 过则为 0）| 总是 |
| **Tag** | 地址高位，命中判定用（见上一节）| 总是 |
| **Dirty** | 缓存里改过、比内存新（write-back 用，决定驱逐时要不要写回）| 写回缓存 |
| **MESI 状态** | 多核一致性状态 M/E/S/I | 多核（单核只需 valid+dirty，见 [mesi.md](/concepts/cache/mesi.md)）|
| **替换位** | LRU/PLRU 的"年龄"，组满时决定踢谁 | 组相联/全相联 |
| （ECC 校验位）| 检错纠错 | 看设计 |

> 注意：MESI 状态其实**包含了** valid/dirty 的信息——I 就相当于 invalid，M 就相当于 dirty。所以多核缓存里，"valid+dirty"和"MESI 状态位"往往合二为一，用几个状态位统一表达。这些位对软件完全透明，但**命中判定要看 valid、驱逐要看 dirty、一致性协议要看 MESI、替换要看 LRU 位**——业务数据只是 line 的一部分。

### miss 之后：填充与替换

miss 之后由缓存控制器（也是硬件状态机，非软件）去下一级取数据、按替换策略腾位置：

```plantuml
@startuml
skinparam shadowing false
skinparam activity {
  BackgroundColor #E3F2FD
  BorderColor #1976D2
  DiamondBackgroundColor #FFF9C4
  DiamondBorderColor #F9A825
}
start
:lookup(pa) 未命中;
:向下一级(L2/L3/内存)取回整条 64B line;
if (该组还有空 way (valid=0)?) then (有)
  :填进空 way，valid=1;
else (满)
  :按替换策略(LRU 等)选一条踢出;
  if (被踢的是脏行 state==M?) then (是)
    :先写回下一级(write-back);
  else (否)
    :直接丢弃(干净行，下层有相同副本);
  endif
  :新数据填进这个位置;
endif
stop
@enduml
```

要点：命中 O(1)；miss 且组满时按替换策略踢一条，**若踢的是脏行(MSI 的 M 态)要先写回**——这就是"驱逐(evict)触发写回"。

## 四、三种映射结构：一个地址能放在哪些位置

### 4.0 先搞清三个词：line、组(set)、way

这三个词贯穿全篇，先一次讲清它们的关系——**缓存是个二维网格**：

- **cache line（缓存行）**：缓存的最小存储/管理单位，一条固定 64 字节，是网格里的一个"格子"。
- **组 (set)**：网格的**一行**。整个缓存被切成 S 个组；**一个物理地址只能落进其中某一个组**（由 Index 决定）。
- **way（路）**：网格的**一列**，也就是"每个组里有几条 line"。N 路(N-way) = 每组有 N 条 line。

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<hdr>> #FFF9C4
  BorderColor<<hdr>>     #F9A825
  BackgroundColor<<cell>> #E3F2FD
  BorderColor<<cell>>     #1976D2
  BackgroundColor<<sel>> #C8E6C9
  BorderColor<<sel>>     #388E3C
}
rectangle "缓存 = 组(行) × way(列) 的二维网格 (示例: 4 组 × 2 way)" {
  rectangle " " <<hdr>> as H0
  rectangle "way0" <<hdr>> as H1
  rectangle "way1" <<hdr>> as H2
  rectangle "组0" <<hdr>> as R0
  rectangle "line" <<cell>> as C00
  rectangle "line" <<cell>> as C01
  rectangle "组1" <<hdr>> as R1
  rectangle "line" <<cell>> as C10
  rectangle "line" <<cell>> as C11
  rectangle "组2 (Index 选中)" <<hdr>> as R2
  rectangle "line" <<sel>> as C20
  rectangle "line" <<sel>> as C21
  rectangle "组3" <<hdr>> as R3
  rectangle "line" <<cell>> as C30
  rectangle "line" <<cell>> as C31
}
note bottom of C21
  地址先用 Index 选中【一整行=一个组】(如组2)
  它可能在这行的任意一列(way0 或 way1)
  → 组内并行比 2 个 tag 才知道在哪条
end note
@enduml
```

关键关系与记忆：

| 词 | 是什么 | 决定它的 |
|----|--------|----------|
| **line** | 一个格子（64B 数据 + tag + 状态位）| —— |
| **组 set** | 一行（S 个组）| 地址的 **Index** 唯一选中一个组 |
| **way** | 一列（每组 N 条 line）| 芯片设计固定；也叫"相联度 N" |

- **总容量 = 组数 S × way 数 N × line 大小(64B)**。比如 32KB = 64 组 × 8 way × 64B。
- **地址 → 组：算出来的、唯一**（Index 译码，见 3.x）；**组 → 具体 way：比出来的、不唯一**（组内并行比 N 个 tag，因为数据可能在这组的任意一 way）。
- **"几路(N-way)"就是"每组几条 line" **：1 路=每组1条=直接映射；N 路=每组 N 条；组数=1(全部 line 挤一组)=全相联。下面 4.1–4.3 就是把同样 8 条 line 按不同的"组×way"切法排列。

### 4.1 三种结构：一个地址能放在哪些位置

三种结构的唯一区别是：**一个物理地址，允许被放进缓存的哪些位置。**（下面 4.2 起用同一串地址逐个演示。）用**停车场**类比最直观（一个车位 = 一条 cache line，一个区 = 一个组，车牌尾号 = 地址的 Index）：

| 结构 | 停车规则 | 找车（判断在不在）|
|------|----------|-------------------|
| **直接映射** | 每辆车**只能停自己车牌对应的那一个固定车位**（尾号 3 就只能停 3 号位）| 只看那 1 个车位 |
| **全相联** | 车**随便停任意空位** | 得把整个停车场每个位都看一遍 |
| **N 路组相联** | 车牌尾号决定停哪个**区(组)**，区内有 N 个位(way)随便停 | 只看那个区的 N 个位 |

直接映射找得最快但最容易"车位被占只能赶走别人"；全相联最灵活但找车最慢；组相联折中——**先按尾号缩小到一个区(组)，再在区内 N 个位(way)里找**。

#### 换个视角：这其实是一个"会冲突的硬件哈希表"

用 Index 定位到组，本质就是一次**哈希**：`h(addr) = (addr >> Offset位) & (组数-1)`——取地址中间几位做哈希值，硬件用它**直接索引**到桶（组），O(1)。所以"根据物理地址查缓存"确实可以理解为**硬件实现的哈希查找**。

但**关键：这个哈希绝不保证不冲突，恰恰相反，冲突是必然且频繁的**。地址空间（2⁴⁸）远大于组数（几十~几千），无数不同地址会哈希到同一组（Index 相同）——这就是**冲突失效**。缓存不消除冲突，而是用两层机制**管理**冲突：

| 哈希表概念 | 缓存里对应什么 | 作用 |
|-----------|---------------|------|
| 哈希函数 | Index = 取地址中间位 | 直接定位到桶（组），会冲突 |
| 哈希桶 | 一个 set（组）| 冲突的地址落进同一个桶 |
| 桶的固定深度 | **每组 N 个 way** | 允许 N 个冲突地址**共存**（桶满就得踢一个）|
| 桶内精确比对 | **Tag 比对** | 区分"哈希撞桶但其实不是同一地址"的不同 key |

所以准确说：**Index 是会冲突的哈希、way 是每个桶的固定槽数（容纳冲突）、Tag 是桶内精确判定（区分撞桶的不同地址）**。它是一个"**桶深固定为 N、用开放定址而非链表**"的哈希表——桶满(N 个 way 都占了)不能无限加链，只能按替换策略踢一个出去。这正是"提高相联度 = 加深桶 = 少冲突"的由来。

#### 三种结构是"物理焊死"的，差异 = 地址允许落脚的位置数

再强调一点：**一颗 CPU 的 L1/L2/L3 各是几路组相联，是芯片设计时用晶体管布线定死的**——比较器数量、译码器、way 的槽位都是实打实的电路，**运行时不可改**（少数服务器芯片支持 way-partitioning 把 way 划给不同核，改的是分配、不是结构本身）。三种结构的差异，归根到底就是**"一个物理地址允许落脚的位置数" **：

| 结构 | 地址 X 能放的位置 | 位置自由度 |
|------|------------------|-----------|
| 直接映射 | **只能**去 `Index(X)` 那一个位置 | 1 |
| N 路组相联 | 只能去 `Index(X)` 那一组，组内 N 个 way **任选** | N |
| 全相联 | **任意**位置 | 全部 |

位置越自由 → 冲突越少 → 但要并行比的 tag 越多、硬件越贵。下面用同一地址在三种结构里走一遍，把"位置自由度"看具体。

下面为了能横向对比，**统一用同一设定**：缓存共 **8 条 line**、line 大小 4B（Offset=2 位）、物理地址 8 位。只改"这 8 条 line 怎么编组"，看同一个地址 `0x6C` 落在哪、要比几个 tag。核心一句话：**way = 一组里有几条 line；改相联度，本质是改"组数 × 每组 way 数"的分配，8 条 line 总量不变。**

### 4.2 直接映射（Direct-Mapped）= 每组 1 条 line（8 组 × 1 way）

8 条 line 各自成组 → **8 组，每组 1 条**。Index 需 3 位（2³=8 选 1），一个地址**只有唯一一个能去的位置**。

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<q>>    #FFF9C4
  BorderColor<<q>>        #F9A825
  BackgroundColor<<line>> #F5F5F5
  BorderColor<<line>>     #9E9E9E
  BackgroundColor<<hit>>  #C8E6C9
  BorderColor<<hit>>      #388E3C
}
rectangle "地址 0x6C = 011 011 00\nTag=011 | Index=011 | Off=00" <<q>> as Q
package "缓存: 8 组，每组 1 条" {
  rectangle "组0" <<line>> as S0
  rectangle "组1" <<line>> as S1
  rectangle "组2" <<line>> as S2
  rectangle "组3 (Index=011=3)\n只能放/找这里" <<hit>> as S3
  rectangle "组4" <<line>> as S4
  rectangle "组5" <<line>> as S5
  rectangle "组6" <<line>> as S6
  rectangle "组7" <<line>> as S7
}
Q --> S3 : Index=3 唯一定位，只比【1】个 tag
@enduml
```

- **地址拆分**：`Tag(3) | Index(3) | Offset(2)`——Index 占 3 位（8 组）。
- **查找**：Index 直接选中唯一一组，**只比 1 个 tag**，最快最省电。
- **致命伤——冲突失效**：所有 `Index=011` 的地址（0x6C、0x2C、0xAC…）都**抢组3 这一个位**，来一个踢一个，哪怕别的组全空。循环里交替访问两个同 Index 地址 → 反复互踢（thrashing），命中率归零。

### 4.3 全相联（Fully-Associative）= 1 组，8 条随便放

8 条 line 全在**同一组**，地址可放进任意一条 → **无 Index 段**（不必选组），地址只拆成 `Tag | Offset`。

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<q>>    #FFF9C4
  BorderColor<<q>>        #F9A825
  BackgroundColor<<line>> #E3F2FD
  BorderColor<<line>>     #1976D2
}
rectangle "地址 0x6C = 011011 00\nTag=011011 | Off=00\n（没有 Index！）" <<q>> as Q
package "缓存: 1 组，8 条 line 任意放" {
  rectangle "line0" <<line>> as L0
  rectangle "line1" <<line>> as L1
  rectangle "line2" <<line>> as L2
  rectangle "line3" <<line>> as L3
  rectangle "line4" <<line>> as L4
  rectangle "line5" <<line>> as L5
  rectangle "line6" <<line>> as L6
  rectangle "line7" <<line>> as L7
}
Q --> L0
Q --> L2
Q --> L4
Q --> L7
note bottom : 不知道在哪 → 必须【8】个比较器同时比全部 tag\ntag 也更长(6位，因为没 Index 分走位)
@enduml
```

- **地址拆分**：`Tag(6) | Offset(2)`——**没有 Index**，本该给 Index 的位全并进 Tag，故 tag 更长（存储开销更大）。
- **查找**：数据可能在任意一条，**必须同时比全部 8 个 tag**（8 个比较器）。
- **优点**：无冲突失效——只要还有空位就能放，空间利用率最高。
- **缺点**：比较器数量 = line 总数，面积/功耗随容量爆炸。**只用于条目极少的结构**——最典型是 **TLB**（几十~几百项）。L1/L2/L3 上千条 line，用不起。

### 4.4 N 路组相联（N-Way）= 折中（这里 4 组 × 2 way）

8 条 line 分成 **4 组、每组 2 条(2-way)**。Index 选组（4 组 → 2 位），组内 2 条随便放、并行比 2 个 tag。这是 L1/L2/L3 的实际做法。

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<q>>    #FFF9C4
  BorderColor<<q>>        #F9A825
  BackgroundColor<<line>> #F5F5F5
  BorderColor<<line>>     #9E9E9E
  BackgroundColor<<hit>>  #C8E6C9
  BorderColor<<hit>>      #388E3C
}
rectangle "地址 0x6C = 0110 11 00\nTag=0110 | Index=11 | Off=00" <<q>> as Q
package "缓存: 4 组 x 2 way" {
  package "组0" {
    rectangle "组0·w0" <<line>> as W00
    rectangle "组0·w1" <<line>> as W01
  }
  package "组1" {
    rectangle "组1·w0" <<line>> as W10
    rectangle "组1·w1" <<line>> as W11
  }
  package "组2" {
    rectangle "组2·w0" <<line>> as W20
    rectangle "组2·w1" <<line>> as W21
  }
  package "组3 (Index=3 选中)" {
    rectangle "组3·w0" <<hit>> as W30
    rectangle "组3·w1" <<hit>> as W31
  }
}
Q --> W30 : (1) Index=3 选组
Q --> W31 : (2) 组内 2 个 tag 并行比
@enduml
```

- **地址拆分**：`Tag(4) | Index(2) | Offset(2)`——Index 只需 2 位（4 组），介于直接映射(3位)和全相联(0位)之间。
- **查找**：Index 先缩到一组，**只比 2 个 tag**（不是全部 8 个）——比较器数量可控。
- **缓解冲突**：同 `Index=11` 的地址现在有 **2 个位**可共存（w0/w1），不像直接映射来一个踢一个。
- **N 越大越接近全相联**：冲突越少，但比较器越多、越慢越耗电。**L1 常用 8-way，是甜点。**

### 4.5 三种结构其实是一条连续谱

同样 8 条 line，只是"怎么编组"不同——**直接映射和全相联就是组相联的两个极端**：

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor #E3F2FD
  BorderColor #1976D2
}
rectangle "直接映射\n8 组 × 1 way\nIndex 3位 · 比 1 tag\n冲突最多 · 最便宜" as A
rectangle "2 路组相联\n4 组 × 2 way\nIndex 2位 · 比 2 tag" as B
rectangle "4 路组相联\n2 组 × 4 way\nIndex 1位 · 比 4 tag" as C
rectangle "全相联\n1 组 × 8 way\nIndex 0位 · 比 8 tag\n无冲突 · 最贵" as D
A -right-> B : way翻倍/组减半
B -right-> C : way翻倍/组减半
C -right-> D : way翻倍/组减半
@enduml
```

| 相联度 | 组数 | Index 位 | 比几个 tag | 冲突失效 | 成本 |
|--------|:---:|:---:|:---:|:---:|:---:|
| 直接映射(1-way) | 8 | 3 | 1 | 最多 | 最低 |
| 2-way | 4 | 2 | 2 | 中 | 低 |
| 4-way | 2 | 1 | 4 | 少 | 中 |
| 全相联(8-way) | 1 | 0 | 8 | 无 | 最高 |

**一句话**：相联度 = 一组里的 way 数。way 越多 → 同 Index 的地址有越多共存位、冲突越少；但要并行比的 tag 越多、Index 位被挤短而 Tag 变长 → 越贵越耗电。真实 CPU 选 8~16 way，是"够低冲突 + 成本可接受"的平衡点。

> 两个极端其实是组相联的特例：**直接映射 = 1-way**；**全相联 = 1 组、W = 全部行**。

### 4.6 同一串访问，三种结构的 demo 对比

最能看清区别的方式：拿**同一串地址访问序列**，在只有 **4 条 line** 的缓存上，分别用三种结构跑一遍，看谁 miss 多。

设 line=1（简化，忽略 Offset），访问地址序列反复交替：**`0, 4, 0, 4, 0, 4`**。缓存共 4 条 line。

**① 直接映射（4 组，每组 1 条）**：Index = 地址 % 4。

```bash
地址 0 → Index 0；地址 4 → Index 0   ← 两个地址撞同一组！
访问 0 : Set0 空 → miss，装入 0
访问 4 : Set0 里是 0，tag 不符 → miss，踢掉 0 装入 4
访问 0 : Set0 里是 4 → miss，踢掉 4 装入 0
访问 4 : miss …… 每次都互相踢
结果：6 次访问 6 次 miss（0% 命中）—— 典型 cache thrashing
       哪怕 Set1/2/3 全空着也没用，因为 0 和 4 只能放 Set0
```

**② 全相联（1 组，4 条 line 任意放）**：

```bash
访问 0 : miss，装入 line0
访问 4 : miss，装入 line1（还有空位，不踢）
访问 0 : line0 命中 ✅
访问 4 : line1 命中 ✅
访问 0 : ✅   访问 4 : ✅
结果：6 次访问 2 次 miss（前两次冷启动）—— 0 和 4 和平共处
```

**③ 2 路组相联（2 组，每组 2 way）**：Index = 地址 % 2。

```bash
地址 0 → Index 0；地址 4 → Index 0   ← 又撞同一组，但这组有 2 个 way！
访问 0 : Set0.way0 空 → miss，装入 0
访问 4 : Set0.way1 空 → miss，装入 4（同组第二个位）
访问 0 : Set0.way0 命中 ✅
访问 4 : Set0.way1 命中 ✅ ……
结果：6 次访问 2 次 miss —— 组内 2 个位让 0、4 共存，冲突消失
```

**对比结论**：同样的访问序列、同样 4 条 line 的容量——

| 结构 | miss 次数 | 为什么 |
|------|:---:|--------|
| 直接映射 | **6/6** | 0 和 4 争抢唯一的 Set0，反复互踢 |
| 2 路组相联 | 2/6 | Set0 有 2 个 way，0、4 各占一个 |
| 全相联 | 2/6 | 随便放，只有冷启动 miss |

> 这就是**冲突失效(conflict miss)** 的本质：直接映射不是没空间，是"0 和 4 的 Index 相同、只准放同一个位"。提高相联度（更多 way）就是给同 Index 的地址更多共存位置——代价是要多比几个 tag。真实 CPU 选 8~16 way 正是这个折中。

## 五、三种结构对比

| | 直接映射 | N 路组相联 | 全相联 |
|---|---|---|---|
| 每组 line 数 | 1 | N | 全部 |
| 地址拆分 | Tag+Index+Offset | Tag+Index+Offset | **Tag+Offset**（无 Index）|
| 一个地址能放 | 唯一位置 | 一组内 N 选 1 | 任意位置 |
| 比 tag 次数 | 1 | N | 全部 |
| 冲突失效 | 多（严重）| 少 | 无 |
| 硬件成本/功耗 | 最低 | 中 | 最高 |
| 典型用途 | 早期/简单缓存 | **L1/L2/L3（主流）** | TLB、victim cache 等小结构 |

## 六、一个具体例子：算出各段位数

设：物理地址 **48 位**，L1 数据缓存 **32 KB**、**8 路组相联**、**cache line 64 B**。

```bash
① 每组大小 = 8 way × 64 B = 512 B
② 组数 S   = 32 KB / 512 B = 64 组
③ Offset 位 b = log2(64)  = 6 位
④ Index  位 s = log2(64)  = 6 位
⑤ Tag    位   = 48 − 6 − 6 = 36 位
```

于是一个 48 位物理地址被这样解读：

```bash
 47 ............... 12 | 11 ...... 6 | 5 ..... 0
 └──── Tag (36) ──────┘└─ Index(6) ─┘└ Offset(6)┘
```

给你一个地址，硬件取 bit[11:6] 作 Index 选中 64 组里的某组，取 bit[5:0] 作行内偏移，剩下 bit[47:12] 和该组 8 条 way 的 tag 并行比对——匹配且 valid 即命中。这就是"如何根据物理地址判断是否在 cache"的完整答案。

## 七、替换策略（组满了踢谁）

组相联/全相联下，一组满了要踢一条腾位置，常见策略：

| 策略 | 说明 |
|------|------|
| **LRU**（least-recently-used）| 踢最久没用的。效果好但精确 LRU 硬件贵，实际多用近似 LRU（如 tree-PLRU）|
| **随机/伪随机** | 简单，效果尚可，高相联度时常用 |
| **FIFO** | 踢最早进来的 |

被踢的行若是**脏的（MSI 的 M 态）**，要先写回下层——这是"写回策略"和"替换"的交汇点（write-back 详见本文第十节）。

## 八、和一致性、伪共享的联系

- **一致性状态挂在每条 line 上**：多核对同一物理地址（同 Tag+Index）的那条 line，各自维护 MESI 状态（[mesi.md](/concepts/cache/mesi.md)）。
- **伪共享的地址级根源**：两个变量只要 **Tag+Index 相同**（落在同一条 64B line），就共享这条 line 的**同一个 MESI 状态**——一个核写它，整行状态变化波及另一核。所以规避伪共享 = 让热点变量的地址**落到不同 line**（cache line 对齐，见 [mesi.md 第五节](/concepts/cache/mesi.md)）。
- **组相联也影响伪共享/冲突**：即便变量不在同一 line，若大量热点地址 Index 相同、超过组的 way 数，也会冲突失效——高性能代码有时要注意数据的"跨距(stride)"别都撞进同一组。

> 相关：缓存访问流程与写回见本文第十节；时钟域与开销见本文第十一节；一致性问题起点 [msi.md](/concepts/cache/msi.md)；一致性状态机 [mesi.md](/concepts/cache/mesi.md)；伪共享观测 `perf c2c` 见 [mesi.md 第六节](/concepts/cache/mesi.md)；协议家族对比 [comparison.md](/concepts/cache/comparison.md)；微架构视角 [data-hazards.md](/concepts/microarch/data-hazards.md)——L1 miss 是 load→use RAW 延迟的硬件根源，miss 周期表直接决定冒险的严重程度。

## 九、现代服务器 CPU 的缓存容量（参考）

前面用 32KB/8-way 举例，这里给出**当前主流服务器 CPU 的真实量级**，方便建立直觉。记住三层的组织特点：**L1/L2 每核私有、L3(LLC) 一组核共享**；容量逐级放大、延迟逐级升高（L1 ~1ns → L3 ~10ns 级 → 内存 ~100ns）。

### 每核私有的 L1 / L2（各核一份，比较稳定）

| 层级 | 典型容量（每核）| 说明 |
|------|----------------|------|
| **L1 数据 (L1d)** | **32 ~ 48 KB** | Intel 近代 48KB、AMD Zen 32KB；一般 8-way 左右 |
| **L1 指令 (L1i)** | **32 KB** | 与 L1d 分开（哈佛结构），详见 [i-cache.md](/concepts/cache/i-cache.md) |
| **L2** | **1 ~ 2 MB** | AMD Zen4/Zen5 = 1MB/核；Intel Sapphire/Emerald Rapids = 2MB/核 |

> L1 容量几十年基本卡在 32~48KB 没怎么涨——因为它必须在**一个时钟周期**内命中，太大就快不起来（VIPT 还受页大小×相联度约束）。想更大只能往 L2/L3 要。

### 多核共享的 L3 / LLC（总量很大，是"卖点"）

L3 是一堆核共享的大缓存，总容量取决于核数/CCD 数，各家差别大：

| 平台（代号）| L3 组织 | 全芯片 L3 总量（典型高端）|
|------|---------|------|
| **Intel Xeon** Sapphire/Emerald Rapids | 所有核共享一大块 | 约 **105 ~ 320 MB** |
| **AMD EPYC** Genoa/Turin (Zen4/5) | **每 CCD 32MB**，多 CCD 拼起来 | 最高约 **384 MB**（如 96 核 12 CCD）|
| **AMD EPYC Genoa-X**（带 3D V-Cache）| 每 CCD 堆叠到 **96MB** | 最高约 **1.1 GB** —— 目前量产最大 |

> AMD 的 L3 是"**每个 CCD（核簇）自带一块、彼此不共享**"——所以跨 CCD 访问 L3 要走片间互联，延迟更高。这其实是 [NUMA](/concepts/numa/numa.md) 思想在片内的延伸（有时叫 "NUMA within socket / sub-NUMA"），做性能优化时要注意把协作的线程放在同一 CCD。

### 一颗典型服务器 CPU 的三层速览

以 **AMD EPYC 9004(Zen4) 96 核**为例的量级：

```bash
每核:  L1d 32KB + L1i 32KB   L2 1MB
每 CCD(8核): 共享 L3 32MB
整芯片(12 CCD): L3 合计 384MB   +   内存(DDR5) 数百 GB ~ TB
```

**读文档里的例子时换算**：32KB/8-way/64B line 是**一个核的 L1d**的典型配置——正是第六节那个"64 组、Index 6 位"的算例。L2/L3 更大、相联度更高（如 L2 16-way、L3 十几~几十 way），但寻址原理（Tag/Index/Offset + 组相联）完全一样，只是位数不同。

> ⚠️ 具体数字随代际和型号变化，上表是"**2023–2025 主流高端服务器的量级**"，用于建立直觉而非精确规格。查你自己机器的真实值：`lscpu`（看 L1d/L1i/L2/L3 cache 行）或 `cat /sys/devices/system/cpu/cpu0/cache/index*/{level,size,ways_of_associativity,coherency_line_size}`。

```bash
lscpu | grep -i cache                                    # 一眼看四级容量
cat /sys/devices/system/cpu/cpu0/cache/index0/size       # L1d 大小
cat /sys/devices/system/cpu/cpu0/cache/index0/ways_of_associativity   # 相联度
cat /sys/devices/system/cpu/cpu0/cache/index3/size       # L3 大小
```

## 十、CPU 访问数据的流程：逐级下探与写回

上面讲了"一个地址如何在某一级缓存里判命中"，这里补齐**跨层级**的视角：一次读、一次写在 L1→L2→L3→内存这条链上到底怎么走。核心规则：**CPU 只直接和 L1 打交道；L1 未命中才逐级向下（L2→L3→内存）找，数据取回后逐级填充。**

### 10.1 读一份数据：命中就返回，未命中逐级下探

```plantuml
@startuml
skinparam shadowing false
skinparam activity {
  BackgroundColor #E3F2FD
  BorderColor #1976D2
  DiamondBackgroundColor #FFF9C4
  DiamondBorderColor #F9A825
}
start
:CPU 要读地址 X;
if (L1 命中?) then (是, ~1ns)
  :直接返回;
  stop
else (否, L1 miss)
  if (L2 命中?) then (是, ~4ns)
    :数据填入 L1，返回;
    stop
  else (否, L2 miss)
    if (L3/LLC 命中?) then (是, ~30ns)
      :填入 L2、L1，返回;
      stop
    else (否, L3 miss)
      :访问主内存 (~100ns)\n把整条 cache line 读上来\n逐级填入 L3→L2→L1;
      stop
    endif
  endif
endif
@enduml
```

要点：

- **CPU 核只对 L1 发读请求**；L1 miss 才由缓存硬件自动去 L2 找，逐级下探。每一级都以 **cache line(64B)** 为单位搬运——哪怕你只读 1 个字节，也会把包含它的整条 64 字节行拉上来（这正是伪共享的物理基础）。
- **"击穿"某级 = 那一级 miss**：L1 miss 击穿到 L2，L2 也 miss 击穿到 L3，L3 还 miss 才真正访问内存。命中越靠上越快（1ns vs 100ns，差百倍）。
- 数据取回后**逐级回填**（inclusive 缓存里 L1 的行 L2/L3 也有副本；具体包含策略因微架构而异）。下次再读同一行就 L1 命中了。

### 10.2 写一份数据：write-back + write-allocate

现代 x86 的 L1 数据缓存基本都用 **写回(write-back) + 写分配(write-allocate)** 策略：

```plantuml
@startuml
skinparam shadowing false
skinparam activity {
  BackgroundColor #FFEBEE
  BorderColor #C62828
  DiamondBackgroundColor #FFF9C4
  DiamondBorderColor #F9A825
}
start
:CPU 要写地址 X;
if (X 在 L1 且可写?) then (是)
  :直接改 L1 里的副本\n标记该行为“脏(dirty)”;
  note right: 不立刻写内存！\n这就是 M 状态的由来
  stop
else (否, 写未命中)
  :write-allocate:\n先按“读流程”把整条 line\n从下层取上来填入 L1;
  :再在 L1 里改，标脏;
  note right: 多核下：取之前要先\n让其他核的副本失效(RFO)
  stop
endif
@enduml
```

两个关键点：

- **写回(write-back)**：写只改 L1 里的副本并标"脏(dirty)"，**不立即写内存**。脏行一直待在缓存，直到它被**驱逐(evict)**（缓存满了要腾位置）或**被别的核索要**时，才真正写回下层/内存。→ 这就是 MSI 里 **M(Modified) 状态**的物理含义：本核缓存比内存新。对立策略是写直达(write-through，每次写都同步到下层)，因太慢现代 L1 很少用。
- **写分配(write-allocate)**：写一个**不在缓存**的地址时，先把整条 line 读上来再改（而不是直接写内存）。因为程序往往"写了还会再读/再写"，先拉进缓存后续就快了。

### 10.3 一句话串起来，以及通向一致性

**读**：L1→L2→L3→内存 逐级找，命中即返回、未命中逐级下探再回填；**写**：只改 L1 标脏(write-back)、要写的行不在则先取上来(write-allocate)。

以上是**单核视角**。一旦多核，这套流程会分叉出两个"多核专属"的动作，它们正是缓存一致性协议的起点——**读到别核持有的脏行必须先拿最新值、写别核也持有的行必须先 RFO 抢独占**。这两点如何演化成 MSI/MESI 状态机，见 [msi.md](/concepts/cache/msi.md) 与主协议 [mesi.md](/concepts/cache/mesi.md)。

## 十一、缓存访问的时钟域与开销

前面用 `~1ns / ~100ns` 谈延迟、用"命中判定是一个周期的组合逻辑"谈速度，这里把**单位（周期）**和**真实芯片的时钟域、总线通信、代价**一次讲清——这也是理解"一致性通信为什么贵"的量化基础。

### 11.1 时钟周期、总线周期、指令周期：三个"周期"分清楚

硬件世界衡量快慢的通用单位是**周期(cycle)**。三个"周期"常被混：

| 术语 | 是什么 | 典型值（3 GHz CPU 为例）|
|------|--------|------------------------|
| **时钟周期 (clock cycle)** | CPU 主频的一拍，`1 / 主频`。是 CPU 内一切操作的最小时间刻度 | 3 GHz → **1 拍 ≈ 0.33 ns** |
| **指令周期 (instruction cycle)** | 完成"一条指令"（取指→译码→执行→写回）所需的时间 | 见下，**不是固定值** |
| **总线周期 (bus cycle)** | 通过**外部总线**完成一次数据传输（如访存）所需的时间；总线有自己的时钟，远慢于 CPU | 一次内存事务 ≈ 几十~上百 ns = **几百个 CPU 时钟周期** |

关键换算：**时钟周期是"CPU 的尺"，总线周期是"内存的尺"，两把尺差了近百倍。** 缓存存在的全部意义，就是让大多数访问停在"CPU 的尺"上，别掉到"内存的尺"上。

#### 一条 x86 指令要几个时钟周期？—— 没有单一答案

老教材说"一条指令 = 若干时钟周期"，但**现代 x86 是流水线 + 超标量**，得区分两个概念：

| 指标 | 含义 | 现代 x86 典型值 |
|------|------|----------------|
| **延迟 (latency)** | 一条指令从开始到出结果要几拍 | 简单 ALU（add/xor）**1 拍**；乘法 ~3 拍；除法 ~20–40 拍；L1 命中的 load **~4–5 拍** |
| **吞吐 (throughput / IPC)** | 平均每拍能**完成**几条指令 | 因为流水线重叠 + 多个执行端口，**每拍可退休 4~6 条**（IPC 可 >1）|

所以"一条指令几个周期"要看哪条指令、看延迟还是吞吐：

- **理想流水线满载**：虽然每条指令延迟好几拍，但靠重叠，**吞吐可达每拍 4+ 条**（比如一串独立的 add）。
- **一旦访存 miss**：一条 `load` 若打到内存，要等 **200~300+ 拍**——这一条就能让流水线堵住，把 IPC 拖到地板。这正是缓存 miss 为什么这么伤性能。

#### 用周期重新看缓存层次（3 GHz、1 拍≈0.33ns）

把前面的 ns 换算成时钟周期,"快慢差距"更直观:

| 访问位置 | 延迟 (ns) | ≈ 时钟周期 | 说明 |
|----------|:---:|:---:|------|
| 寄存器 | — | 0（当拍可用）| CPU 内部 |
| **L1 命中** | ~ 1 ns | **~ 4~5 拍** | load-to-use 延迟 |
| **L2 命中** | ~ 4 ns | **~ 12~15 拍** | |
| **L3 命中** | ~ 10~30 ns | **~ 40~75 拍** | 共享，跨核/跨 CCD 更慢 |
| **内存 (miss 到底)** | ~ 100 ns | **~ 200~300+ 拍** | 走**总线周期**，一次事务顶几百拍 |

> 一次内存访问 ≈ 几百个时钟周期 = 够 CPU 本可以执行上千条简单指令的时间。**这就是"L1 快内存慢"的量化真相**，也是第十节逐级下探、以及一致性协议里"跨核失效走互联很贵"的根本原因——跨核那套通信也是走总线/互联周期，不是 CPU 内部的时钟周期。

> 数字随主频/微架构/代际浮动，上表是"3 GHz 级现代 x86 的量级"，用于建立直觉。查真实延迟可用 `lmbench`、`mlc`(Intel Memory Latency Checker) 实测。

### 11.2 缓存控制器里的硬件

嗅探式一致性协议下，每个核的缓存控制器里主要有三块硬件：

```plantuml
@startuml
skinparam shadowing false
skinparam componentStyle rectangle
skinparam rectangle {
  BackgroundColor<<hw>>  #E3F2FD
  BorderColor<<hw>>      #1976D2
  BackgroundColor<<bus>> #FFF9C4
  BorderColor<<bus>>     #F9A825
}
rectangle "核0 缓存控制器" {
  rectangle "Tag + 状态阵列\n每 line: tag / MSI 2位状态" <<hw>> as T0
  rectangle "本核访问口\n(处理 PrRd/PrWr)" <<hw>> as P0
  rectangle "嗅探口 snoop\n(监听总线上别人的事务)" <<hw>> as S0
}
rectangle "核1 缓存控制器\n(对称结构)" <<hw>> as C1
rectangle "共享总线 / 互联\n(仲裁 + 广播 + 嗅探应答)" <<bus>> as BUS
rectangle "内存控制器 → DRAM" <<hw>> as MEM
P0 --> T0
S0 --> T0
T0 --> BUS
C1 --> BUS
BUS --> MEM
note bottom of BUS
  每个核有【两个口】同时查 Tag 阵列:
  一个服务本核读写、一个服务总线嗅探
  (故常用双端口/双份 tag，避免互相抢)
end note
@enduml
```

- **状态存储**：MSI 每 line 只需 **2 bit** 存状态（M/S/I 三态，2 位够）。这几 bit 和 tag 一起放在 line 的元数据里。
- **状态迁移逻辑**：一致性状态机在硬件上是一小块**有限状态机(FSM)电路**——输入"当前状态 + 本核操作/嗅探到的总线事务"，组合逻辑输出新状态。纯电路，无软件。
- **双端口 tag**：本核访问和总线嗅探**都要查 tag 阵列**，为免互相阻塞，硬件常做双端口或维护两份 tag（snoop tag）。

### 11.3 总线通信协议大致长什么样

经典嗅探总线的一次事务分几个"阶段(phase)"，各核在总线时钟的节拍上配合：

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  ArrowColor                 #1976D2
  LifeLineBorderColor        #90A4AE
}
participant "发起核" as A
participant "总线仲裁器" as ARB
participant "其他核(嗅探)" as O
participant "内存" as M
A -> ARB : ① 仲裁 Arbitration\n(多核抢总线，仲裁器授权一个)
A -> O   : ② 请求 Request\n广播地址+命令(BusRd/BusRdX/BusUpgr)
O -> A   : ③ 嗅探应答 Snoop response\n各核报告"我有没有、什么状态"
alt 某核处于 M
  O -> A : ④ 数据 Data\ncache-to-cache 转发最新值
else 无人持有最新
  M -> A : ④ 数据 Data\n从内存取
end
note over A, M
  地址线+命令线+数据线分开；
  仲裁保证同一时刻只有一个事务 → 天然“串行化”
end note
@enduml
```

- **仲裁(arbitration)**：多个核同时想用总线，仲裁器按策略(轮转/优先级)授权一个——这一步天然实现了协议要的**写串行化**（同一时刻只有一个事务在总线上）。
- **地址/命令/数据分线**：物理上有独立的地址线、命令线、数据线，可流水重叠。
- **原子性靠总线独占**：一次 BusRdX 的"广播+失效+取数"在总线看来是一个不可分割的事务，这也是 `lock` 前缀原子操作的底层依托（见 [atomic.md](/concepts/cache/atomic.md)）。
- **现代已不是"一根总线" **：真实多核用**环形(ring)/网格(mesh)互联 + 目录(directory)** 取代广播总线（广播不随核数扩展）。目录记录"每条 line 被哪些核缓存"，只**点对点**通知相关核，而非全广播。但对外表现的一致性语义不变——只是"嗅探所有人"变成"查目录再定向通知"。

### 11.4 时钟域：缓存/总线和 CPU 核不是同一个时钟

**不一样，这是关键。** 现代芯片是**多时钟域(clock domain)**：

| 部件 | 时钟 | 典型频率 |
|------|------|----------|
| CPU 核 + L1/L2 | 核心时钟（跟着核，可动态调频 DVFS）| 3~5 GHz |
| L3/LLC + 片上互联 | **Uncore / mesh 时钟**（独立，通常更低）| ~ 2~3 GHz |
| 内存总线 | 内存时钟（DDR，又是一个域）| 等效数 GT/s，实际控制器频率更低 |

- **为什么分开**：核要尽量高频，但 L3/互联/内存做不到那么快、也没必要；且核会 DVFS 频繁变频，而 uncore/内存要相对稳定。硬上同一时钟既不现实也浪费功耗。
- **代价——跨时钟域要"同步" **：信号从核心域进 uncore 域，要过**同步器/异步 FIFO**，带来额外几个周期的固定延迟（跨域握手）。这也是为什么 L3、跨核通信的延迟里有一块"跑不掉的固定开销"。
- 所以 11.1 那张延迟表里 **L3 ~40–75 拍、跨核更多**，一部分就来自"离开核心时钟域、走较慢的 uncore/互联时钟 + 跨域同步"。

### 11.5 总线/一致性通信的开销有多大

用 11.1 的"周期"尺子量：

| 操作 | 大致开销（以核心时钟拍计）| 说明 |
|------|--------------------------|------|
| L1 命中（无总线活动）| ~4–5 拍 | 根本不上总线，最快 |
| 嗅探命中、cache-to-cache 转发 | **~几十拍** | 一次总线/互联往返 + 跨时钟域同步 |
| 一次跨核失效 + 重取（BusRdX→Flush→重载）| **~几十到上百拍** | 一致性握手时序，走互联 |
| miss 到内存 | **~200–300+ 拍** | 最贵，走内存时钟域 |

三个决定开销的因素：

1. **要不要上总线/互联**：L1 命中不上，最便宜；一旦触发嗅探/失效/取数，就得付"离开核心时钟域 + 互联往返"的固定成本。
2. **广播 vs 目录**：核越多，广播式嗅探的流量越爆炸(O(N))；目录式只通知相关核，更可扩展——这是大核数服务器不用广播总线的原因。
3. **争用**：多核狂抢同一条 line（如热点原子量、伪共享），这条 line 在核间反复 RFO/失效"乒乓"，每次都付上面那笔互联开销——这就是伪共享/原子争用为什么这么伤（[mesi.md 第五节](/concepts/cache/mesi.md)、[atomic.md](/concepts/cache/atomic.md)）。

> 一句话：**一致性通信贵，贵在"要离开核心时钟域、走较慢的互联、可能多次往返"。** 优化的方向始终是"让数据尽量停在本核 L1、别让多核抢同一条 line"——这也是本仓库缓存/NUMA 所有优化建议的共同根。

> 把本文的缓存机制落成**具体编码习惯**(顺序访问、SoA、避免指针追逐、防伪共享……)——见 [cache-friendly-code.md](/concepts/cache/cache-friendly-code.md)。

---

## 十、与共享内存的关系

两个 CPU 核心同时写同一块共享内存——这是 MESI 缓存一致性的"活教材"：两个核的 L1d 各缓存了同一物理地址（PFN 相同）的不同副本，一核写就触发 BusRdX 失效另一核的 line，下一核再写又要 RFO 抢回所有权，line 在核间乒乓——这正是共享内存的典型性能陷阱。两套接口选型（POSIX vs System V）和同步方案见 [../elf/shared-memory.md](/concepts/elf/shared-memory.md)。

