﻿# 保留站（Reservation Station）—— 解耦译码与执行，乱序发射的发生地

> 承 [pipelining-superscalar.md](/concepts/microarch/pipelining-superscalar.md) 的流水线 IS 级和 [cpu-out-of-order.md](/concepts/microarch/cpu-out-of-order.md) 的乱序执行五大部件。保留站是 Tomasulo 算法的核心发明——把"所有指令顺序排队等执行"变成"谁的操作数先齐了谁先走"。

## 一、保留站解决什么问题

没有保留站时，指令从译码级直接进执行单元。一旦某条指令的操作数没就绪（比如 I3 的源寄存器正被 I1 占用，I1 还在算），I3 就卡在发射级。**但译码级是顺序的**——I3 卡住，I4、I5、I6 即使操作数全齐了、跟 I3 毫无依赖关系，也被堵在门外进不了发射级。

> **关于流水级与访存**：下面的时序图只画了 IF→ID→IS→EX 四级，因为示例全是寄存器操作(DIV/ADD/MUL)，不涉及访存。真实流水线中，load/store µop 也经过保留站，但它们走的流水级更长：
> | µop 类型 | 保留站→发射到 | EX 级做了什么 | 之后发生了什么 |
> |---------|-------------|-------------|---------------|
> | **ALU** (ADD/MUL/DIV) | ALU 端口(Port 0/1/5/6) | 真正的算术/逻辑运算 | 结果直接送 MEM 级透传 → WB 写回 PRF |
> | **Load** (LD) | AGU 端口(Port 2/3) | **只算地址**(base+index×scale+offset)，不读内存 | 发射到 LSU → 向 **D-Cache 发起读请求** → L1 hit ~4~5 cycle 后数据到达 → WB 写回 PRF。**等 D-Cache 的这 5 cycle 期间，该 µop 已经离开 RS 了**——RS 槽位已释放，被其他 µop 复用 |
> | **Store** (ST) | AGU 端口(Port 7) | 算地址 | 地址送入**Store Buffer**；数据从 PRF 读到 Store Data 端口(Port 4) → Store Buffer 合并 → 退休后异步写入 D-Cache。Store Buffer 也是 LSU 的一部分，与 RS 无关 |
> 所以你原来的理解没错：**访存的"执行"分两步——AGU 算地址在 EX 级做(D-Cache 还没碰)，真正读/写 D-Cache 是 MEM 级的事**。两者的分界点就是保留站：µop 从 RS 发射那一拍脱离 RS 的控制，后续的 D-Cache 访问、写回等都走 LSU→MEM→WB 链，不再占用 RS 槽位。

```plantuml
@startuml
skinparam shadowing false
title 没有保留站时：一条 RAW 冒险堵死整条流水线
participant "取指\nIF" as IF
participant "译码\nID" as ID
participant "发射\nIS" as IS
participant "执行\nEX" as EX
== 拍 1（I1 是长延迟指令，进 EX 算除法，需 20 拍） ==
IF -> ID: I1:DIV
ID -> IS: I1 DIV r1,r2→r3
IS -> EX: I1→DIV
note over EX #FFCDD2: I1 除法开始\n结果 20 拍后才就绪
== 拍 2 ==
IF -> ID: I2:ADD
ID -> IS: I2 ADD r4,r5→r6
IS -> EX: I2→ADD
note over EX #C8E6C9: I2 跟 I1 无依赖\n1 拍完成
note over EX #E3F2FD: 为什么 I2 不卡?\nEX 级内部有多个独立执行单元:\nI1(DIV) → 除法器(慢,20拍)\nI2(ADD) → ALU 端口(快,1拍)\n两个单元并行,互不阻塞\n→ 这与保留站无关——即使最简单的\n  顺序流水线,不同功能单元也天然并行
== 拍 3（I3 依赖 I1 的结果 r3——卡住了!） ==
IF -> ID: I3:MUL
ID -[#FFCDD2]> IS: I3 MUL r3,r7→r8
note over ID,IS #FFCDD2: ❌ I3 读 r3——但 r3 被 I1 占用!\nI3 必须在 IS 级等待 r3 就绪\nI3 卡住 = IS 级被占满 = ID 无法推进
== 拍 4（I4 完全独立，也被堵死） ==
IF -[#ECEFF1]> ID: I4:ADD
note over IF,ID #ECEFF1: ❌ I4 取指完 → 进入 ID 译码完毕\n要进 IS……但 I3 还卡在 IS!\n→ ID 无法腾空 → IF 也无法取 I5\nI4 明明跟任何人无依赖\n却被 I3 活活堵在 ID 门口 😱
== 拍 5~20（全部空转等 I1） ==
note over IF,EX #FFCDD2: 流水线冻结:\nIF 空转、ID 被 I4 占满、IS 被 I3 占满\nEX 只剩 I1 在算、其余 ALU 全空闲\n\nI5、I6 连取指都进不去——\n即使它们的操作数根本不依赖 I1、I3、I4\n\n浪费资源: ALU 7/8 空转、FPU 4/4 空转\n前端译码带宽 4 µop/拍 → 0
== 拍 21（I1 终于算完 r3） ==
EX -> IS: r3 就绪!
note over IS #C8E6C9: I3 等到 r3 → 发射到 EX
ID -> IS: I4→ADD
IS -> EX: I4
== 拍 22（管子终于重新流动） ==
@enduml
```

保留站做的事很简单：**在译码级和执行单元之间插入一个缓冲池**。译码级不管后面卡不卡，µop 全部扔进池子。每条 µop 在池内独立监控自己的操作数——谁的操作数先齐，谁先发射到空闲执行单元。**译码和执行的进度彻底解耦。**

```plantuml
@startuml
left to right direction
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<fe>>  #BBDEFB
  BorderColor<<fe>>      #1565C0
  BackgroundColor<<rs>>  #C8E6C9
  BorderColor<<rs>>      #2E7D32
  BackgroundColor<<ex>>  #FFE0B2
  BorderColor<<ex>>      #E65100
}
rectangle "译码级\n每拍产出 4~6 µop" <<fe>> as DECODE
rectangle "═══════ 保留站 (Reservation Station) ═══════\n┌─────────────────────────────────┐\n│ µop_A(read r1, r2)  → ⏳等 r1   │\n│ µop_B(read r4, r5)  → ✅齐了    │\n│ µop_C(read r6, r7)  → ✅齐了    │\n│ µop_D(read r2, r8)  → ⏳等 r2   │\n│ µop_E(read r9, r10) → ⏳等 r9   │\n│ ...                             │\n│ 调度器每拍挑选操作数就绪的发射   │\n└─────────────────────────────────┘" <<rs>> as RS
rectangle "执行单元\nINT: 8 ALU + 2 分支\nFP:  4 FMA + 4 SIMD" <<ex>> as EX
DECODE -down-> RS : 译码后 µop 全部写入
RS -down-> EX : 操作数就绪 → 发射
@enduml
```

核心价值就两条：

- **解耦**：译码不被执行卡住。即使 RET 堵在 ROB 头部等 L3 cache miss，前端照样取指→译码→往 RS 里塞 µop。
- **乱序发射**：RS 内部每条 µop 只关心"我的操作数到了没"——到了就走，不管前面排了多少条还没就绪的。这就是"乱序"二字的物理实现。

## 二、功能拆解

保留站不是一个简单的 FIFO 队列，而是集成了三个功能：

| 功能 | 做什么 | 对应硬件 |
|------|--------|---------|
| **操作数监控** | 每条 µop 盯着自己的源操作数，等"就绪"信号 | tag 匹配电路——监听结果总线上的物理寄存器号，与自己的源 tag 比对 |
| **乱序挑选** | 每拍从所有就绪 µop 中选出若干条发射到空闲执行单元 | 仲裁/选择逻辑（pick logic），通常按 oldest-first 策略 |
| **缓冲池** | 容纳前端喂进来的 µop，吸收前端与后端的速率差 | SRAM 条目阵列，现代 CPU 通常 60~200 个 RS 条目 |

> RS 只是一个"调度等待区"——它不存数据，只存 µop 的控制信息（操作码、源操作数的物理寄存器号/就绪位、目的物理寄存器号）。数据本身在物理寄存器文件里，RS 只是拿 tag 去监听。

## 三、运作机制：一条 µop 在保留站里的完整生命周期

```bash
阶段             做了什么                         关键动作
─────────────────────────────────────────────────────────────────
① 分配(allocate)  译码完成后，分配一个 RS 槽位      写入 µop 操作码、源寄存器 tag、
                  把源操作数的当前状态锁存进去       目的寄存器 tag
─────────────────────────────────────────────────────────────────
② 等待(wait)      µop 在槽位里监听结果总线          每拍检查源 tag 是否匹配
                  源 tag=0 表示寄存器就绪            结果总线上刚完成的物理寄存器号
─────────────────────────────────────────────────────────────────
③ 唤醒(wakeup)    所有源操作数都已就绪              就绪标志置位，进入候选池
                  µop 进入"可发射"状态
─────────────────────────────────────────────────────────────────
④ 挑选(pick)      调度器从候选池中选 µop 发射        按 oldest-first（先进入 RS 的优先）
                  受发射宽度限制                     避免新指令饿死老指令
─────────────────────────────────────────────────────────────────
⑤ 发射(dispatch)  µop 送入执行单元                  读取物理寄存器文件取操作数值
                  释放 RS 槽位                       开始执行
─────────────────────────────────────────────────────────────────
```

### 3.1 操作数监控：tag 匹配

每条 µop 记录了源操作数的 **物理寄存器 tag**。物理寄存器还没被写入时，它的 tag 非零。当某条指令执行完成、请求写入物理寄存器时，会向结果总线广播"我写的是 P37"。RS 里所有 µop 的源 tag 都与广播比对——命中则意味着"我的操作数就绪了"。

这就是 Tomasulo 算法的核心：**结果直接通过总线 tag 广播，"一写多听"，不需要 CPU 再逐条检查依赖。**

### 3.2 挑选逻辑：oldest-first

RS 里同一拍可能有几十条 µop 都处于"就绪"状态，但发射宽度有限（INT 最多 4 条/拍，FP 最多 4 条/拍）。挑选逻辑按 **oldest-first** 策略：进入 RS 更早的 µop 优先发射。这避免了"新来的永远抢到、老的一直等"的饥饿问题。

### 3.3 解耦缓冲：吸收速率差

前端每拍最多产 4~6 µop，但后端可能因为 cache miss 等几拍甚至几百拍才消化一个 µop。RS 的几十到两百个槽位就是中间的弹性缓冲——前端先填满 RS，后端按自己能消化的速率挑 µop 执行。只要 RS 不满，前端就能继续取指译码。

## 四、限制与瓶颈

| 限制 | 说明 | 表现 |
|------|------|------|
| **RAW 依赖链** | µop 的操作数来自前面未完成指令的结果，依赖链多长就得等多久 | RS 里堆满"就绪但等数据"的 µop，IPC 掉到接近 1 |
| **发射宽度** | INT 最多 4 条/拍，FP 最多 4 条/拍，再多的就绪 µop 也发不出去 | 8 个 ALU 有 6 个空闲、RS 里 30 条就绪 µop 排队 |
| **RS 容量耗尽** | RS 满了，前端不能再发射 µop，取指/译码停止 | `stalled-cycles-frontend` 飙升，取指停摆 |
| **端口冲突** | 多条就绪 µop 都要用同一个执行端口（如全是 ADD），多出来的等下拍 | 编译器优化中的交错调度（`-O2`+）缓解此问题 |

> **RS 容量是最硬的物理限制**。超标量架构把执行单元加到 18 个、退休宽度加到 8 条，但如果 RS 太小（比如只有 32 个槽位），前端灌满后就停摆，后面的宽执行单元全白加。所以 Intel Skylake 把 RS 扩到 97 个、Zen 4 扩到 68+68（INT+FP），Zhuque/Kunpeng 等服务器 CPU 也有 120+ 条目。

## 五、和周边部件的关系

```bash
译码 ──→ 寄存器重命名 ──→ 保留站 ──→ 执行单元 ──→ ROB
         (消除假依赖)     (乱序发射)              (顺序退休)
```

- **寄存器重命名**是 RS 的前置——如果没有重命名消除 WAR/WAW 假依赖，RS 里很多 µop 明明操作数齐了，却被"伪冲突"拦着不能发射。详见 [register-renaming.md](/concepts/microarch/register-renaming.md)。
- **ROB** 是 RS 的后置——RS 乱序发射让指令乱序执行、乱序完成，ROB 保证它们按序退休。详见 [rob.md](/concepts/microarch/rob.md)。
- **执行单元**是 RS 的下游——RS 只负责"挑谁走"，不负责执行。执行单元的并行度（端口数 × 每端口槽位数）决定了 RS 能多快消化积压。详见 [pipelining-superscalar.md §三](/concepts/microarch/pipelining-superscalar.md)。
- **数据冒险**：[data-hazards.md](/concepts/microarch/data-hazards.md)——RS 中 RAW 依赖的 µop 操作数未就绪时被阻塞在槽位里，调度器自动跳过它们选择其他就绪 µop 发射。
- **纵切面走读**：[uop-pipeline-walkthrough.md](/concepts/microarch/uop-pipeline-walkthrough.md)——RS 在完整流水线中接收重命名 µop 并发射到执行单元的全时序路径。

## 六、观测：保留站相关的 perf 事件

```bash
perf stat -e uops_executed.stall_cycles,resource_stalls.rs,resource_stalls.any ./app
# uops_executed.stall_cycles — RS 空闲（没有就绪 µop 可发射）的周期数
# resource_stalls.rs         — 因 RS 满导致前端停摆的周期数
# resource_stalls.any        — 因任何后端资源不足（含 RS 满）导致的停顿
```

- `resource_stalls.rs` 高 → RS 太小，前端填满了就停，考虑加 RS 容量（没法改硬件，但可检查是否依赖链太长导致 µop 堆积）。
- `uops_executed.stall_cycles` 高 → RS 里没就绪 µop 可发，大概率是 load miss 拖住了保留站里的依赖指令。

## 七、一句话总结

> **保留站是译码和执行之间的弹性缓冲 + 乱序调度器——它最关键的洞察是把"译码被卡住的指令堵住"变成"卡住的指令在池子里等，别影响译码继续产 µop"。Tomasulo 的 tag 匹配广播机制让所有 µop 独立监听操作数，谁先齐谁先走，把超标量加宽的硬件真正喂饱。**

