﻿# I-Cache 指令缓存 —— 代码是怎么喂进 CPU 流水线的

> [cache-organization.md](/concepts/cache/cache-organization.md) 讲透了**数据缓存(D-cache)**：物理地址拆三段、组相联查找、读写流程、逐级下探——全是站在"CPU 读/写一个数据"的视角。但 CPU 还有另一个同样高频的动作——**取指令(instruction fetch)**。取指也走缓存，而且它和数据访问走的**不是同一条硬件通路，不是同一个缓存**。


> 本篇补齐这个被忽略的另一半：I-cache 为什么和 D-cache 分开、指令取的路径长什么样、L2/L3 是 unified 意味着什么、代码膨胀/布局怎么影响 I-cache、iTLB 是什么、自修改代码怎么处理，以及怎么用 perf 观测。

## 一、为什么 L1 要拆成 I-cache 和 D-cache（哈佛结构的缓存层体现）

### 1.1 背景：取指和访存是两个同时进行的动作

CPU 执行一条指令（如 `add rax, [rdi]`），在流水线里至少同时发生两件事：

- **取指(fetch)**：从内存地址 `PC`（程序计数器）处把指令字节读进来 → 送进译码器。
- **访存(memory access)**：从内存地址 `[rdi]` 处读操作数。

这两个动作的地址来源不同（PC vs rdi），访问的是不同的内存区域（代码段 vs 数据段），而且**必须在同一拍里并行发生**——流水线的前端（取指）和后端（访存）是同时工作的。

### 1.2 如果 L1 只有一套，取指和访存会互相阻塞

假想只有一块统一的 L1 缓存，取指和访存同时来了两个不同的地址：

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<good>> #C8E6C9
  BorderColor<<good>>     #388E3C
  BackgroundColor<<bad>>  #FFCDD2
  BorderColor<<bad>>      #C62828
}
rectangle "统一 L1 缓存\n(只有一套 tag + data)" <<bad>> as L1
rectangle "取指: PC=0x400100\n需要读指令字节" <<good>> as IF
rectangle "访存: [rdi]=0x601000\n需要读数据" <<bad>> as MEM
IF -down-> L1 : 请求 A
MEM -down-> L1 : 请求 B（同时）
note bottom of L1
  冲突：tag 阵列只有一套
  一个周期只能查一个地址
  → 一个要等，流水线立刻停顿
end note
@enduml
```

> 这就是哈佛结构的动机。取指和访存是流水线上不同阶段的同时需求，共用一套缓存会让它们争抢唯一的查找端口——每拍都得排队。

### 1.3 解法：I-cache 和 D-cache 物理分离

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<ic>> #E3F2FD
  BorderColor<<ic>>     #1976D2
  BackgroundColor<<dc>> #FFE0B2
  BorderColor<<dc>>     #EF6C00
  BackgroundColor<<bus>> #FFF9C4
  BorderColor<<bus>>    #F9A825
}
rectangle "前端 (取指)" <<ic>> as FE
rectangle "后端 (访存)" <<dc>> as BE
rectangle "L1 I-Cache\n32KB, 8-way\n(只读，不写)" <<ic>> as L1I
rectangle "L1 D-Cache\n32~48KB, 8-way\n(读写，write-back)" <<dc>> as L1D
rectangle "Unified L2\n1~2MB, 16-way\n指令和数据混存" <<bus>> as L2
rectangle "Unified L3 / LLC\n几十 MB, 共享\n指令和数据混存" <<bus>> as L3
FE -down-> L1I : ① 取指，走 I-cache
BE -down-> L1D : ② 访存，走 D-cache
L1I -down-> L2 : miss
L1D -down-> L2 : miss
L2 -down-> L3 : miss
note right of L1I
  两套独立的 tag 阵列 + data 阵列
  取指和访存同时查各自的缓存
  → 互不阻塞，并行无冲突
end note
@enduml
```

**I-cache 和 D-cache 拥有各自独立的 tag 阵列、data 阵列和查找端口**——取指和访存可以同一拍并行访问，互不阻塞。这是哈佛结构在缓存层的直接体现（经典哈佛结构是"指令内存"和"数据内存"物理分离，现代 CPU 在 L1 层保留了这一分离，L2/L3 则合并回普林斯顿式的 unified 缓存）。

### 1.4 I-cache 的容量与组织结构

| 属性 | L1 I-cache | L1 D-cache |
|------|-----------|-----------|
| 典型容量 | **32 KB**（几乎不变）| 32~48 KB |
| 相联度 | 8-way（常见）| 8-way（常见）|
| 访问模式 | **只读** | 读写 |
| dirty 位 | ❌ 不需要 | ✅ 需要（write-back）|
| MESI 状态 | ❌ 不参与 | ✅ M/E/S/I 状态位 |
| 存什么 | 原始指令字节（机器码）| 程序的业务数据 |
| 寻址方式 | 同 D-cache：Tag/Index/Offset 三段，组相联查找 | 同左 |

> I-cache 的寻址原理（物理地址三段、组相联命中判定）和 D-cache **完全一样**。所有在 [cache-organization.md](/concepts/cache/cache-organization.md) 里讲的东西（地址拆分、译码器选组、并行比 tag、命中/缺失流程）在 I-cache 上同样成立。**唯一的核心区别是：I-cache 只读不写。**

## 二、只读带来的结构简化

### 2.1 I-cache 一条 line 存什么

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<meta>> #FFF9C4
  BorderColor<<meta>>     #F9A825
  BackgroundColor<<data>> #C8E6C9
  BorderColor<<data>>     #388E3C
  BackgroundColor<<diff>> #FFCDD2
  BorderColor<<diff>>     #C62828
}
rectangle "I-cache 一条 line" {
  rectangle "Valid\n1 位" <<meta>> as V
  rectangle "Tag\n(地址高位)" <<meta>> as T
  rectangle "替换位\nLRU/PLRU" <<meta>> as R
  rectangle "指令数据\n64 字节 = 16 条 × 4B" <<data>> as D
}
rectangle "对比 D-cache 多了什么" <<diff>> {
  rectangle "Dirty 位 — I-cache 不需要\n(永不写，永不脏)" <<diff>> as N1
  rectangle "MESI 状态 — I-cache 不需要\n(只读，不需要一致性协议)" <<diff>> as N2
}
note bottom of D
  结构更简单：省掉了 dirty + MESI 状态位
  但容量也更小（32KB vs 48KB），因为指令局部性足够好
end note
@enduml
```

因为 I-cache 只读，省掉了 D-cache 必须有的两套状态：

| 省掉的东西 | 为什么不需要 |
|-----------|-------------|
| **dirty 位** | 没有写操作，永远不需要写回。驱逐时直接丢弃即可，反正 L2 里有相同的副本 |
| **MESI 状态位** | 一致性协议处理的是"多个核修改同一份数据"，指令不会被修改 → I-cache 不参与 MESI |

> **I-cache 也永远不会触发写回(write-back)**，因为它永远不会脏。

### 2.2 I-cache miss 的处理流程（比 D-cache 简单）

```plantuml
@startuml
skinparam shadowing false
skinparam activity {
  BackgroundColor #E3F2FD
  BorderColor #1976D2
  DiamondBackgroundColor #FFF9C4
  DiamondBorderColor #F9A825
}
start
:前端发出取指请求
PC = 0x400100;
if (L1I 命中?) then (是)
  :返回指令字节给译码器;
  stop
else (否, L1I miss)
  :去 unified L2 找;
  if (L2 命中?) then (是)
    :指令填入 L1I;
    :返回给译码器;
    stop
  else (否)
    :去 L3 找;
    if (L3 命中?) then (是)
      :填入 L2 → L1I;
      stop
    else (否)
      :访问主内存
整条 64B line 拉上来
逐级回填 L3 → L2 → L1I;
      stop
    endif
  endif
endif
note right
  和 D-cache 的逐级下探一样
  区别：不需要写回这一步
  （被踢的 line 不脏，直接丢弃）
end note
@enduml
```

与 D-cache miss 的关键区别：**miss 时被驱逐的 line 不需要写回**（永远不脏），直接丢弃、用新指令覆盖即可。

## 三、指令取指路径：PC → 译码，中间发生了什么

### 3.1 完整链路

```plantuml
@startuml
skinparam shadowing false
skinparam sequenceMessageAlign center
participant "分支预测器" as BP
participant "前端\n取指单元" as FE
participant "TLB\niTLB" as TLB
participant "L1 I-Cache" as L1I
participant "L2\n(unified)" as L2
participant "译码器" as DEC
BP -> BP : ① 预测下一条 PC\n(下条顺序执行 / 分支目标)
BP -> FE : ② 送出预测的 PC
FE -> TLB : ③ VA → PA 翻译\n走 iTLB
TLB -> L1I : ④ 用 PA 查 I-cache
alt L1I 命中
  L1I -> DEC : ⑤ 指令字节 → 译码
else L1I miss
  L1I -> L2 : ⑥ 去 L2 找
  L2 --> L1I : ⑦ 回填 L1I
  L1I -> DEC : ⑧ 指令字节 → 译码
end
note over BP, DEC
  取指带宽：现代 x86 每拍可取 16~32 字节(4~8 条指令)
  前端必须在译码器"饿"之前持续喂指令
  I-cache miss = 前端停顿(frontend stall)
end note
@enduml
```

关键：

- **取指地址来自 PC**（不是 load/store 指令的地址），由**分支预测器**决定下一拍取哪个地址。
- **翻译走 iTLB**（独立于 dTLB，见第六节）。
- **取指带宽**：现代 x86 每拍可取 **16~32 字节**（4~8 条指令），远大于译码器的消化速度（通常 4~6 条/拍）。这个"超额取"是为了应对分支导致的取指气泡。
- **I-cache miss = frontend stall**：译码器等不到新指令，流水线前端停顿，执行单元集体等米下锅。

### 3.2 取指也是按 64B cache line 整条拉

和 D-cache 一样，每次 I-cache miss 从下层取回的是整条 **64 字节的 cache line**。64 字节 = **16 条 x86 指令**（若平均指令 4 字节），这意味着：

- **一次 I-cache miss 的代价会被 16 条指令摊薄**——如果接下来的 15 条指令都在同一条 line 里（顺序执行），它们不需要再 miss。
- **但如果代码跳来跳去、每条 jump 都飞到一个新 line 上**，那每跳一次都可能 I-cache miss → 指令局部性同样至关重要。

## 四、L2/L3 是 Unified 的 —— 代码和数据会互相踢

### 4.1 统一缓存意味着什么

L1I 和 L1D 虽然分开，但它们的下一级缓存（L2、L3）是 **unified** 的——指令和数据混存在同一块物理缓存里，不区分"指令专用区"和"数据专用区"。

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<ic>>  #E3F2FD
  BorderColor<<ic>>      #1976D2
  BackgroundColor<<dc>>  #FFE0B2
  BorderColor<<dc>>      #EF6C00
  BackgroundColor<<l2>>  #C8E6C9
  BorderColor<<l2>>      #388E3C
}
rectangle "L1 I-Cache\n(只装指令)" <<ic>> as L1I
rectangle "L1 D-Cache\n(只装数据)" <<dc>> as L1D
rectangle "Unified L2 (1~2MB)\\n——\\n每核私有，指令和数据混存\\n\\n指令line · 数据line · 指令line · 数据line\\n数据line · 指令line · 数据line · 数据line\\n\\n同一块物理缓存，不区分专有区域" <<l2>> as L2
L1I -down-> L2 : I-cache miss → 来 L2 找
L1D -down-> L2 : D-cache miss → 也来 L2 找
note bottom of L2
  密集的 memcpy 会把热代码从 L2/L3 挤出去
  密集的代码执行也会把热数据挤出去
  → 两者互相竞争 L2/L3 的有限空间
end note
@enduml
```

**现实后果**：

| 场景 | 发生了什么 |
|------|-----------|
| 密集 `memcpy`（大块数据搬运） | 大量数据 line 冲进 L2/L3，把热代码 line 挤出去 → 后续取指 L1I miss → L2 也 miss → frontend stall |
| 大代码路径（模板展开、过度 inlining） | 指令占满 L2/L3，热数据被挤出 → 后续数据访问 L1D miss → L2 也 miss → backend stall |
| 两者抢 L3 带宽 | 代码和数据同时在 L3 里竞逐有限的几十 MB → LLC miss 率双双上升 |

> **这是 I-cache 视角下最重要的洞察**：优化代码大小不只是省内存，更是**为 L2/L3 留空间给热数据**。一个过度内联膨胀的二进制，其指令会挤占 L2/L3 本该装热数据的容量。

### 4.2 L2/L3 的替换策略对谁更公平

大多数 L2/L3 用 LRU 或近似 LRU 替换，**不区分指令和数据**——谁的访问时间戳更老就踢谁。如果数据访问模式是"扫一遍不再回来"（流式），而指令是"循环体反复执行"（热点），那么：

- 流式数据用量大、时间戳新 → 可能把热代码挤出 L2
- 热代码反复命中、时间戳不断更新 → 又可以保护自己不被踢

谁赢取决于访问频率的对比，没有固定结论。但一个膨胀的二进制会让代码本身占用更多的 L2 容量，在其他条件不变时**降低整体缓存的有效利用率**。

## 五、I-cache 特有的性能问题

### 5.1 代码膨胀(code bloat)—— 最容易被忽视的 I-cache 杀手

**过度内联、模板展开、大函数体**都会让热路径的指令总量膨胀，超出 L1I 的 32KB 容量。

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<ok>>  #C8E6C9
  BorderColor<<ok>>      #388E3C
  BackgroundColor<<bad>> #FFCDD2
  BorderColor<<bad>>     #C62828
}
rectangle "紧凑代码\\n(热循环 < 32KB)\\n——\\n· 内联关键路径上的小函数\\n· 热循环体 ≤ 几百条指令\\n· L1I 装得下，几乎全命中\\n· 前端顺畅喂指令" <<ok>> as OK
rectangle "膨胀代码\\n(热循环 > 32KB)\\n——\\n· 过度内联，每个调用点复制一份\\n· 模板展开，每个特化一份\\n· 热路径指令 > 32KB，L1I 装不下\\n· 反复 I-cache miss，frontend stall" <<bad>> as BAD
OK -right-> BAD : 过度内联/模板展开导致
note bottom of BAD : 实测：把大函数拆小或关掉部分内联\n前端 stall 比例可能下降 10~30%
@enduml
```

**典型案例**：

```cpp
// ❌ 过度内联：每次调用都展开，调用点多了热路径指令总量爆炸
template<typename T>
inline void process(T& obj) {
    // 100 行模板逻辑，每行都可能在多个特化里展开
    obj.doA(); obj.doB(); /* ... 几十步 ... */
}
// ✅ 拆分：热点路径保持紧凑，冷逻辑提到外面非内联函数
inline void process_hotpath(T& obj) {
    obj.doA();                    // 只内联真正热的几步
    process_coldpath(obj);        // 冷的调到外面（非内联）
}
```

**诊断信号**：`perf stat` 看 `frontend_stall` 占比高，且 `L1-icache-load-misses` 高（见第七节）。

### 5.2 代码布局(code layout)—— 热代码应相邻、冷代码应隔离

和 D-cache 的"顺序访问"同出一理：CPU 取指也是顺序的（沿 PC 递增），**热路径的指令在内存里越连续，I-cache 利用率越高**。

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<hot>>  #FFCDD2
  BorderColor<<hot>>      #C62828
  BackgroundColor<<cold>> #90A4AE
  BorderColor<<cold>>     #546E7A
}
rectangle "理想布局\n热代码连续排列" {
  rectangle "函数入口\n(热)" <<hot>> as H1
  rectangle "热路径基本块" <<hot>> as H2
  rectangle "热路径基本块" <<hot>> as H3
  rectangle "函数返回" <<hot>> as H4
}
rectangle "差布局\n热代码夹杂冷代码" {
  rectangle "函数入口\n(热)" <<hot>> as H5
  rectangle "错误处理\n(冷，几乎不走)" <<cold>> as C5
  rectangle "热路径基本块" <<hot>> as H6
  rectangle "调试输出\n(冷，几乎不走)" <<cold>> as C6
}
note bottom of H4
  PGO/LTO 的 "function reordering" "basic block reordering"
  就是做这件事——让热路径连续、冷代码移开
end note
@enduml
```

手段：

- **`__builtin_expect` / `[[likely]]` / `[[unlikely]]`**：告诉编译器哪条分支是热的，编译器会把冷分支代码移到函数末尾，让热路径更紧凑（详见 [branch-prediction.md](/concepts/microarch/branch-prediction.md)）。
- **PGO (Profile-Guided Optimization)**：编译器根据真实运行的 profile 重排代码布局。
- **LTO (Link-Time Optimization)**：跨编译单元看到全局，做更激进的重排和内联决策。

### 5.3 I-cache 与分支预测的交互

分支预测错误不只浪费已进入流水线的指令（它们被 flush 掉），还可能触发**额外的 I-cache miss**——这条链路是性能杀手：

```plantuml
@startuml
skinparam shadowing false
skinparam sequenceMessageAlign center
participant "分支预测器" as BP
participant "取指单元" as FE
participant "L1I" as L1I
participant "执行单元" as EX
BP -> FE : ① 预测"不跳"，PC=下一条
FE -> L1I : ② 按预测取指，可能 L1I miss
L1I -> FE : ③ 指令喂进流水线
FE -> EX : ④ 投机执行...
EX -> BP : ⑤ 结果出来：其实该跳！
BP -> FE : ⑥ 修正 PC=分支目标
note right: flush 掉 ③④ 那批指令
FE -> L1I : ⑦ 重新取指到新地址
note right #FFCDD2: 新地址若不在 L1I\n→ I-cache miss！
L1I -> FE : ⑧ 重新喂指令
note over BP, L1I
  <b>一次误预测的代价 =</b>
  flush 惩罚(浪费的流水级) 
  + 可能的新 I-cache miss（跳到冷代码地址）
  + 可能的新 iTLB miss（跳到别的页）
  = <b>几十 ~ 上百拍</b>
end note
@enduml
```

> 这就是为什么分支预测准确率对性能如此关键——不只是 flush 本身浪费的那些拍，更是**跳到新地址后，那个地址的指令和翻译大概率不在缓存里**。

## 六、iTLB —— 指令地址翻译的 TLB

### 6.1 I-TLB 和 D-TLB 是分开的

如同 L1I 和 L1D 分开，地址翻译的缓存也有两份——iTLB 和 dTLB。**iTLB 缓存的是"指令地址(PC)的 VPN→PFN"映射**，dTLB 缓存的是"数据地址的映射"。

[tlb.md](/concepts/cache/tlb.md) 全文聚焦 dTLB，"改步长性能暴跌"的实验也是数据访问视角。iTLB 有自己的容量和覆盖范围：

| | iTLB（指令）| dTLB（数据）|
|---|---|---|
| 典型 L1 条目 | **128 条目**（比 dTLB 的 64 多，因为取指比访存更规律）|
| L2 (STLB) | 和 dTLB **共用**同一个 STLB（~1536~2048 条目）|
| 命中时 | 翻译 ≈ 0 拍 | 同 |
| miss 时 | page walk，代价同上 | 同 |

### 6.2 什么时候 iTLB 崩

**大代码段**——一个二进制文件的热路径分布在大量不同的 4KB 页上：

```bash
一个 200MB 的二进制，有函数千上万个
如果热点在几百个不同的函数、散落在几百个不同的页上
→ 128 条 L1 iTLB 装不下 → 频繁 iTLB miss
→ 每次 miss 走 page walk 读指令页表 → 几十~上百拍
```

这是"大二进制 + 多模块动态链接"场景下的典型问题。**大页(huge pages)对代码段同样有效**——一个 2MB 大页覆盖 512 倍的指令地址范围。

### 6.3 perf 怎么区分 iTLB 和 dTLB

```bash
# iTLB miss
perf stat -e iTLB-load-misses ./app
# dTLB miss（tlb.md 里用的）
perf stat -e dTLB-load-misses ./app
# 整体 STLB 统计
perf stat -e stlb_hit,stlb_miss ./app
```

## 七、自修改代码与 I-D 缓存一致性

### 7.1 问题：写代码和取代码走不同的缓存

JIT 编译器、动态代码生成器会在运行时"写一段新代码，然后跳过去执行"：

```bash
1. 把新指令字节 store 到某块内存 → 数据进了 L1D（甚至还在 store buffer 里）
2. 跳转到那块内存执行 → CPU 去 L1I 取指 → 但 L1I 里可能还是旧的！
```

**L1I 和 L1D 是两块物理独立的缓存，它们之间没有自动同步**——你改了 L1D 里的副本，不代表 L1I 自动看到了。

### 7.2 x86 的做法：硬件自动维护（但贵）

x86 的缓存系统在硬件层面处理了 I-D 一致性：当 store 修改了某条 cache line，硬件会**自动使 L1I 中同地址的 line 失效**，下次取指时重新从 L2 加载。这个机制叫**自修改代码(self-modifying code, SMC)检测**。

代价：

- 硬件维护 I-D 一致性本身有开销（store buffer 要额外检查）
- 更重要的是：**写了新代码 → L1I 被失效 → 下次取指 I-cache miss → 走 L2 重取**——每次 JIT 编译完的第一次执行，必然有一次 I-cache miss

### 7.3 ARM 的做法：显式指令，软件负责

ARM 架构**不**在硬件层维护 I-D 一致性，软件必须显式执行缓存维护指令：

```bash
// ARM 自修改代码的标准序列（简化）
DC CVAU, addr    // 1. 把 D-cache 里这个地址的脏数据刷到 unified L2（Point of Unification）
DSB ISH           // 2. 数据同步屏障，确保前面的 store 对所有人可见
IC IVAU, addr     // 3. 使 I-cache 里这个地址失效（下次取指去 L2 拿）
DSB ISH           // 4. 指令同步屏障
ISB               // 5. 刷新流水线，确保后续取指看到新指令
```

> 这是 ARM 上 JIT 编译器（如 V8 JavaScript 引擎的 ARM 后端）必须正确处理的序列。写错 = 执行的是旧代码 = 随机 crash 或安全漏洞。

### 7.4 不是 JIT 就没事？—— 动态库加载也是同类问题

`dlopen` 加载一个新的 .so 时，动态链接器（`ld.so`）把 .so 的代码段 `mmap` 到进程地址空间。现代 Linux 默认开启了 **W^X**（内存要么可写、要么可执行，不能同时两者），做法是：

1. `mmap` 时先映射为可写 → 链接器重定位（修改 GOT/PLT 等）
2. `mprotect` 改为只读+可执行（`PROT_READ | PROT_EXEC`）

第二步的 `mprotect` 切权限时，内核会做必要的一级缓存维护（x86 上触发了 `invlpg` 等），确保后续取指能看到正确的代码。代价是"加载后的第一次调用"必然经历一轮 I-cache/iTLB 冷启动。

> 相关：W^X 原理和 `mprotect` 风险见 [../elf/memory-layout.md](/concepts/elf/memory-layout.md)。

## 八、I-cache 与缓存一致性协议的关系

**简短答案：I-cache 不参与 MESI 协议。**

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<ic>> #E3F2FD
  BorderColor<<ic>>     #1976D2
  BackgroundColor<<dc>> #FFE0B2
  BorderColor<<dc>>     #EF6C00
  BackgroundColor<<coh>> #FFCDD2
  BorderColor<<coh>>    #C62828
}
rectangle "核0 L1I\n(不参与 MESI)" <<ic>> as I0
rectangle "核0 L1D\n(MESI: M/E/S/I)" <<dc>> as D0
rectangle "核1 L1I\n(不参与 MESI)" <<ic>> as I1
rectangle "核1 L1D\n(MESI: M/E/S/I)" <<dc>> as D1
rectangle "MESI 协议\n跨核数据一致性" <<coh>> as COH
D0 -down-> COH
D1 -down-> COH
note bottom of COH
  I-cache 不连 MESI 总线
  因为指令不会被修改，没有"多核看到不同版本"的问题
  唯一的例外：自修改代码 / JIT
  但那是另一条通路（见第七节），不走 MESI 协议
end note
@enduml
```

原因很简单：**MESI 协议解决的是"多核修改同一份数据"的一致性问题**。指令在运行时不会被修改（除非自修改代码/JIT），所以 I-cache 不需要跟踪 M/E/S/I 状态——每个核拿到的指令副本永远一样，不存在"我的比你新"的问题。

**这意味着**：I-cache line 被驱逐时不需要写回（不脏），被其他核 snoop 时不需要响应（不参与协议），一致性总线完全和 I-cache 无关。这也是 I-cache 可以设计得更简单、更快的原因之一。

## 九、怎么用 perf 观测 I-cache

### 9.1 专用事件

```bash
# I-cache miss
perf stat -e L1-icache-load-misses ./app
# I-cache miss 率
perf stat -e L1-icache-loads,L1-icache-load-misses ./app
# iTLB miss
perf stat -e iTLB-load-misses ./app
# 前端停顿（frontend stall）—— 包含 I-cache/iTLB/分支误预测
perf stat -e stalled-cycles-frontend ./app
# 前端 vs 后端停顿对比
perf stat -e stalled-cycles-frontend,stalled-cycles-backend ./app
# 若 frontend stall 占比显著高 → I-cache/iTLB/分支预测有问题
# 若 backend stall 占比显著高 → D-cache miss/执行端口争用
# 综合诊断
perf stat -e cycles,instructions,\
L1-icache-loads,L1-icache-load-misses,\
iTLB-load-misses,\
stalled-cycles-frontend,stalled-cycles-backend ./app
```

### 9.2 判读

| 观测 | 含义 | 指向什么问题 |
|------|------|-------------|
| `L1-icache-load-misses` 高 | I-cache 频繁 miss | 代码膨胀，热路径 > 32KB，或代码跳转模式差 |
| `iTLB-load-misses` 高 | 指令地址翻译频繁 miss | 大二进制，热点函数跨越过多 4KB 页 |
| `stalled-cycles-frontend` 高 | 前端喂不上指令 | 综合：I-cache miss + iTLB miss + 分支误预测 |
| frontend >> backend | 瓶颈在前端 | 优先排查代码大小、布局、iTLB；而非数据访问 |

### 9.3 只取某一个函数的统计（采样模式）

```bash
# 采样看哪个函数 I-cache miss 最集中
perf record -e L1-icache-load-misses ./app
perf report                        # 看 miss 集中在哪些函数
# 看前端停顿集中在哪里
perf record -e stalled-cycles-frontend ./app
perf report
```

## 十、和本仓库其他文档的关系

- **同一个寻址原理**：[cache-organization.md](/concepts/cache/cache-organization.md) 的 Tag/Index/Offset 三段、组相联命中判定，全部适用于 I-cache
- **前端停顿的另一端**：[cpu-microarch-overview.md](/concepts/microarch/cpu-microarch-overview.md) 的 frontend stall 诊断，I-cache miss 是其中一个主因
- **分支预测的交汇**：[branch-prediction.md](/concepts/microarch/branch-prediction.md) 的错误预测会触发 I-cache/iTLB miss
- **TLB 的另一半**：[tlb.md](/concepts/cache/tlb.md) 聚焦 dTLB，iTLB 是本文第六节补齐的另一半
- **D-cache 对照**：[cache-organization.md](/concepts/cache/cache-organization.md) 的读写流程、一致性协议——I-cache 不参与 MESI（本文第八节）
- **缓存友好的代码**：[cache-friendly-code.md](/concepts/cache/cache-friendly-code.md) 讲数据侧的局部性，代码侧的已在本篇第五、六节覆盖
- **JIT/动态加载**：[../elf/memory-layout.md](/concepts/elf/memory-layout.md) 的 W^X、[../crash/valgrind.md](/crash/valgrind.md) 的 JIT 检测
- **上下文切换**：[../process/context-switch.md](/concepts/process/context-switch.md) 提到切换后 L1I 冷启动
- **观测**：`perf stat -e L1-icache-load-misses` / `iTLB-load-misses` / `stalled-cycles-frontend`

## 十一、一句话总结

> **CPU 不只从缓存读数据，也从缓存取指令——而且走的是另一条独立的硬件通路（L1I，哈佛结构）。I-cache 只读不写、不参与 MESI、结构更简单，但它有自己的坑：代码膨胀会让 L1I 装不下热指令、大二进制会让 iTLB 崩、分支误预测会连带触发 I-cache miss、JIT 需要处理 I-D 一致性。L2/L3 是 unified 的，意味着代码和数据会互相抢占容量。观测用 `perf stat` 看 `L1-icache-load-misses` 和 `stalled-cycles-frontend`。**

