﻿# 内核段管理 —— x86-64 下的代码段/数据段/段权限

> **核心问题**：用户态代码段 `CS=0x33` 为什么无法访问内核数据段 `DS=0x18`？`syscall` 凭什么能瞬间从 ring 3 跳到 ring 0？内核到底怎么管理这些段选择子和描述符？本文从 CPU 硬件机制到 Linux 内核实现，完整拆解段管理的来龙去脉。


> 本文是 [syscall-details.md](/concepts/process/syscall-details.md) 中段管理部分的独立深化版本——如果你想先了解"系统调用整体过程"，可以先看那篇。本文涉及大量段寄存器和 MSR 细节，不熟悉可先看 [x86-64-registers.md](/concepts/process/x86-64-registers.md)。

## 零、x86 段管理的历史演进（实模式 → 32位保护模式 → 64位长模式）

要理解"为什么 x86-64 的段管理是这样子"，必须先知道它从哪来。x86 的段机制经历了三次重大重构，每一次都为了解决上一代的痛。

### 0.1 实模式（16位）：极简分段，无保护

这是 x86 CPU 上电后的初始状态，为兼容 8086 处理器而保留，**关闭所有内存保护、分页机制、权限检查**。

**段寄存器直接存段基址**（没有"描述符"概念），每个寄存器仅 **16 位**（CS/DS/SS/ES），寻址公式极其简单：

```bash
物理地址 = 段寄存器值 × 16 + 偏移量（16位）
         = 段寄存器值 << 4 + 偏移量
```

- 地址总线 20 位，最大寻址空间仅 **1MB**（0x00000~0xFFFFF）
- 偏移量 16 位，单段最大 **64KB**
- 段基址按 16 字节对齐，段之间可重叠，完全无内存隔离

> 这种设计的"好处"是简单——CPU 上电就能跑，适合 BIOS 和系统引导阶段。代价是没有保护、没有隔离，一个程序写飞就能搞垮整个系统。

### 0.2 32位保护模式：完整段描述符 + 两级分页

32位保护模式**彻底重构**了内存管理，核心变化：

1. **段寄存器不再直接存段基址**，改为存 16 位**段选择子**（Segment Selector）
2. 真正的段属性（基址、界限、权限、类型）存在 **GDT/LDT 的段描述符**中，每描述符 8 字节
3. 引入**特权级机制**（Ring 0~3），内核与用户态隔离
4. 可选开启**两级分页**（页目录 + 页表），实现虚拟内存

**32位寻址完整流程：**

```bash
逻辑地址（段选择子:32位偏移量）
  → 解析选择子 → 索引 GDT/LDT → 读段描述符（基址+界限+权限）
    → 权限校验（偏移不越界、CPL/RPL ≤ DPL）
      → 线性地址 = 段基址 + 偏移量
        → [若开分页] 两级页表转换（CR3 → PDT → PT）
          → 物理地址
```

- 地址总线 32 位，最大寻址 **4GB**
- 段描述符关键字段：32位基址、20位界限、粒度位 G（G=1 时单位 4KB，最大段 4GB）、DPL（0~3）
- GDT 全局唯一（内核维护）、LDT 每进程私有

### 0.3 64位长模式：段地址翻译废弃，权限保留

到了 x86-64，CPU 设计师做了一个大胆决策——**段的地址翻译功能彻底扁平化**：

- CS/DS/ES/SS 的**基址强制置 0**、**界限强制全覆盖**，不再参与地址计算
- 逻辑地址直接等价为线性地址（段基址 0 + 偏移量 = 偏移量本身）
- **只保留 DPL 权限校验和段选择子机制**——这就是本文接下来要拆解的全部内容
- 分页升级为**四级页表**（PML4 → PDPT → PDT → PT），48 位有效虚拟地址

**为什么这样设计？** 因为：

1. 32 位保护模式的段+页双重翻译增加了硬件复杂度，大多数 OS 本质上只用"平坦模型"（段基址=0，界限=4GB）
2. 64 位地址空间巨大，用分段做地址隔离已经没必要——分页的 U/S 位 + 四级页表足够
3. 但**权限这一层不能丢**——内核/用户态的边界必须由硬件保证，所以 DPL 检查被完整保留

### 0.4 三模式段机制速览

| 维度 | 实模式（16位） | 32位保护模式 | 64位长模式 |
|------|--------------|-------------|-----------|
| 段寄存器存什么 | 直接存段基址（16位） | 16位段选择子（索引+TI+RPL） | 同32位，选择子 |
| 段属性存哪里 | 无——寄存器即属性 | GDT/LDT 中 8 字节描述符 | GDT 描述符（基址/界限忽略）|
| 地址翻译 | `段×16+偏移` → 物理地址 | 基址+偏移→线性地址（→分页→物理）| 偏移量=线性地址（扁平） |
| 界限检查 | 无（仅偏移 16 位限制） | 有（20位界限+粒度位） | **无**（强制最大值） |
| 权限检查 | **无** | CPL/RPL ≤ DPL | CPL/RPL ≤ DPL **严格执行** |
| 分页 | 无 | 两级（PDT+PT）| 四级（PML4+PDPT+PDT+PT）|
| 最大寻址 | 1MB | 4GB | 256TB（48位虚拟地址）|

> **演进脉络**：无保护单一寻址 → 段页结合保护寻址 → 分页主导、权限辅助寻址。核心目标是提升内存容量、安全性、多任务适配能力。

---

## 一、先破除一个常见误解：x86-64 并没有"去掉"段

很多资料说"x86-64 长模式下分段被废弃了，只靠分页做保护"。这个说法**不完全对**——从上节的演进可以看到：

- 确实，长模式下段的**基址和界限**被硬件强制为 0 和最大值（即"平坦模型"），分段不做地址翻译。
- 但**段描述符中的 DPL（Descriptor Privilege Level）字段和段选择子中的 RPL（Requested Privilege Level）仍然被 CPU 严格执行**，构成第一层保护。
- 此外，`syscall`/`sysret` 指令**依赖段选择子来切换特权级**——没有段机制，根本进不了内核。

```bash
段地址翻译功能的演进：
实模式                          32 位保护模式                    64 位长模式
─────────────────────────────────────────────────────────────────────────────
段寄存器=基址（16位）           选择子→描述符→基址（32位）      基址强制为 0，不做翻译
无界限检查                      界限检查（20位+粒度）             界限强制为最大值，不检查
无权限检查                      DPL 权限检查 ✓                  DPL 权限检查 ✓ 仍然执行
无分页                          两级分页（可选）                  四级分页（必须）
                              段选择子+RPL                       段选择子+RPL ✓ 仍然执行
                                                                 CS.L 位区分 32/64 位模式
```

> **一句话**：x86-64 废掉了分段的**地址翻译功能**，但保留了它的**权限管理功能**。

## 二、段选择子：16 位的"身份令牌"

每个段寄存器（CS/DS/SS/ES/FS/GS）实际存储的是一个 16 位的**段选择子**（Segment Selector），它告诉 CPU"我想用 GDT/LDT 中第几个描述符，以什么身份（特权级）去访问"。

### 2.1 段选择子的结构

```bash
15                                  3   2   1   0
├──────────────────────────────────┬───┬───────┤
│         Index (13 bits)          │ TI│ RPL   │
│   GDT/LDT 中的表项索引            │   │       │
└──────────────────────────────────┴───┴───────┘
TI (Table Indicator):  0 = GDT, 1 = LDT
RPL (Requested Privilege Level):  00 = ring 0, 11 = ring 3
```

以 `CS = 0x33` 为例拆解：

```bash
0x33 = 0b 0000 0000 0011 0011
       │              ││  └── RPL = 11 (ring 3)
       │              │└── TI = 0 (GDT)
       │              └── Index = 6 → 指向 GDT[6]
       └── 高 13 位 = 索引
```

### 2.2 Linux x86-64 中所有段选择子的值

| 寄存器 | 用户态值 | 内核态值 | 对应 GDT 索引 | DPL | 含义 |
|--------|---------|---------|:---:|:---:|------|
| **CS** | `0x33` | `0x10` | 用户 6 / 内核 2 | 3 / 0 | 代码段，CPL 由 CS 的 RPL 决定 |
| **SS** | `0x2B` | `0x18` | 用户 5 / 内核 3 | 3 / 0 | 栈段 |
| **DS** | `0x0` (被忽略) | `0x18` | — / 内核 3 | — / 0 | 数据段 |
| **ES** | `0x0` (被忽略) | `0x18` | — / 内核 3 | — / 0 | 附加数据段 |
| **FS** | 用户 TLS 值 | 内核 per-CPU 值 | 各自不同 | 3 / 0 | 线程局部存储 / per-CPU |
| **GS** | 用户 TLS 值 | 内核 per-CPU 值 | 各自不同 | 3 / 0 | 同 FS，swapgs 切换 |

> **注意**：用户态的 DS/ES 经常显示为 `0x0`，因为长模式下硬件不检查 DS/ES 的基址和界限，但它们的**隐性属性（DPL=3）仍然由 CPU 内部记住**。

### 2.3 为什么这些值是 0x10、0x18、0x33、0x2B

```bash
GDT 索引 × 8 = 段选择子中 Index 部分对应的偏移
0x10 = GDT[2]  → 内核代码段   (2 × 8 = 0x10, RPL=00)
0x18 = GDT[3]  → 内核数据段   (3 × 8 = 0x18, RPL=00)
0x33 = GDT[6]  → 用户代码段   (6 × 8 = 0x30, RPL=11 → 0x33)
0x2B = GDT[5]  → 用户数据段   (5 × 8 = 0x28, RPL=11 → 0x2B)
```

段选择子的低 3 位（TI + RPL）不参与索引计算，所以：

- GDT[2] → Index=2 → 选择子基值 `0x10`，加 RPL 后：内核态用 `0x10`（RPL=0），用户态不会用
- GDT[6] → Index=6 → 选择子基值 `0x30`，加 RPL=3 后：`0x33`

## 三、GDT（全局描述符表）：段管理的"配置中心"

### 3.1 GDT 是什么

GDT 是 CPU 级别的全局数据结构，存储所有任务共享的段描述符。每个 CPU 核心有自己的 GDTR 寄存器指向它。

```bash
GDTR 寄存器
┌──────────────────────────────────────┐
│ Base (64 bits)        │ Limit (16)   │
│ 指向 GDT 的线性地址     │ GDT 字节数-1  │
└──────────────────────────────────────┘
         │
         ▼
┌──────────────────────────────────────┐
│ GDT[0]  NULL 描述符（必须为空）        │ 8 bytes
│ GDT[1]  内核代码段 (32位兼容)          │ 8 bytes
│ GDT[2]  内核代码段 (64位) DPL=0       │ 8 bytes  ← CS=0x10 内核态代码
│ GDT[3]  内核数据段 (64位) DPL=0       │ 8 bytes  ← DS/SS=0x18 内核态数据/栈
│ GDT[4]  用户代码段 (32位兼容)          │ 8 bytes
│ GDT[5]  用户数据段 (64位) DPL=3       │ 8 bytes  ← DS=0x2B 用户态数据
│ GDT[6]  用户代码段 (64位) DPL=3       │ 8 bytes  ← CS=0x33 用户态代码
│ GDT[7]  （保留）                       │ 8 bytes
│ GDT[8]  TSS（低64位）                 │ 8 bytes
│ GDT[9]  TSS（高64位）                 │ 8 bytes
│ ...     per-CPU 的 TLS 描述符等        │
└──────────────────────────────────────┘
```

### 3.2 段描述符的 8 字节格式（深度拆解）

```bash
 63       55       47       39       31       23       15       7      0
├────────┼────────┼────────┼────────┼────────┼────────┼────────┼────────┤
│ Base   │ Flags  │ Limit  │ Access │ Base   │     Base[23:0]     │ Limit  │
│ 31:24  │  4bit  │ 19:16  │ Byte   │ 23:16  │                    │ 15:0   │
├────────┼────────┼────────┼────────┼────────┼────────┼────────┼────────┤
逐字节拆解：
字节 0-1:  Limit[15:0]        — 段界限低 16 位（长模式忽略）
字节 2-3:  Base[15:0]         — 段基址低 16 位（长模式忽略）
字节 4-5:  Base[23:16]        — 段基址中 8 位
字节 5:    Access Byte        — **最关键的权限字节**（见下）
字节 6:    Flags[3:0] + Limit[19:16] — 标志 + 界限高 4 位
字节 7:    Base[31:24]        — 段基址高 8 位
```

### 3.3 Access Byte —— 段权限的核心

```bash
Access Byte (字节 5) 的逐位含义：
Bit 7   6    5    4    3    2    1    0
├────┼────┼────┼────┼────┼────┼────┼────┤
│ P  │ DPL│ S  │  Type  │ A  │
└────┴────┴────┴────┴────┴────┴────┴────┘
P (Present):  1 = 段在内存中，0 = 不在（触发 #NP 异常）
DPL (Descriptor Privilege Level):  00=ring0, 01=ring1, 10=ring2, 11=ring3
S (System):   1 = 代码/数据段，0 = 系统段（TSS/LDT/调用门等）
Type[3:0]:    S=1 时定义段的读写执行属性
A (Accessed): CPU 自动设置，表示该段被访问过
```

**Type 字段的详细定义（S=1，即代码/数据段）：**

```bash
Type 的 bit 3 区分代码段还是数据段：
数据段 (Type bit 3 = 0):
  bit 2  E (Expand-down)   0=向上扩展, 1=向下扩展（栈）
  bit 1  W (Writable)      0=只读, 1=可写
  bit 0  A (Accessed)
  0010 = 可读写数据段 (Linux 内核/用户数据段)
  0110 = 向下扩展可读写数据段（栈段）
代码段 (Type bit 3 = 1):
  bit 2  C (Conforming)    0=非一致代码段, 1=一致代码段
  bit 1  R (Readable)      0=只执行, 1=可读可执行
  bit 0  A (Accessed)
  1010 = 可执行可读代码段 (Linux 内核/用户代码段)
```

### 3.4 Linux 中实际设置 GDT 的代码

```c
// arch/x86/kernel/cpu/common.c (简化示意)
// 内核代码段描述符 (GDT[2])
static void set_kernel_code_desc(struct desc_struct *desc) {
    desc->limit0   = 0xFFFF;
    desc->base0    = 0;
    desc->base1    = 0;
    desc->type     = 0xA;   // 1010 = 可执行可读代码段
    desc->s        = 1;     // 代码/数据段
    desc->dpl      = 0;     // DPL = 0 (ring 0)
    desc->p        = 1;     // Present
    desc->limit1   = 0xF;
    desc->avl      = 0;
    desc->l        = 1;     // 64-bit 代码段
    desc->d        = 0;     // 不是 32-bit
    desc->g        = 0;     // 粒度（长模式忽略）
    desc->base2    = 0;
}
// 内核数据段描述符 (GDT[3])
static void set_kernel_data_desc(struct desc_struct *desc) {
    desc->limit0   = 0xFFFF;
    desc->base0    = 0;
    desc->base1    = 0;
    desc->type     = 0x2;   // 0010 = 可读写数据段
    desc->s        = 1;
    desc->dpl      = 0;     // DPL = 0 (ring 0)
    desc->p        = 1;
    desc->limit1   = 0xF;
    desc->l        = 0;     // 长模式保留
    desc->d        = 1;     // 32-bit 数据段
    desc->g        = 1;     // 4KB 粒度
    desc->base2    = 0;
}
// 用户代码段描述符 (GDT[6]) — 与内核代码段几乎一样，只是 DPL=3
// 用户数据段描述符 (GDT[5]) — 与内核数据段几乎一样，只是 DPL=3
```

**关键观察**：内核代码段和用户代码段的描述符几乎一模一样——相同的基址（0）、相同的 Type（可执行可读），**唯一的区别就是 DPL**。这就是段权限隔离的核心：DPL 不同，能加载这些段的代码也不同。

## 四、段权限检查：CPU 怎么阻止用户态加载内核段

### 4.1 加载段寄存器时的权限检查

每当代码执行 `mov ds, ax` 或 `pop ds` 等指令加载段寄存器时，CPU 硬件自动执行以下检查：

```bash
if (目标段选择子指向一个数据段或非一致代码段) {
    // 必须同时满足两个条件：
    // 1. CPL <= DPL（当前特权级不低于描述符要求的特权级）
    // 2. RPL <= DPL（请求的特权级不低于描述符要求的特权级）
    if (max(CPL, RPL) > DPL) {
        触发 #GP(0) 异常  // 权限不足！
    }
}
```

**具体例子**：

```bash
用户态尝试加载内核数据段：
  mov ax, 0x18     ; 内核数据段选择子, RPL=0
  mov ds, ax       ; 触发检查: max(CPL=3, RPL=0) = 3 > DPL=0 → #GP!
内核态可以加载任何段：
  mov ax, 0x2B     ; 用户数据段选择子, RPL=3
  mov ds, ax       ; 触发检查: max(CPL=0, RPL=3) = 3 ≤ DPL=3 → OK
  ; 内核可以访问用户数据，但通常不会这样做
```

### 4.2 代码段切换的特殊规则

代码段的加载不是通过 `mov cs, ax`（这在长模式下被禁止），而是通过以下方式：

| 切换方式 | CPL 变化 | 规则 |
|---------|---------|------|
| `jmp far` / `call far` 到非一致代码段 | CPL 不变 | 要求 CPL == DPL，且 RPL <= DPL |
| `call far` 到一致代码段 | CPL 不变 | 要求 CPL >= DPL（允许低特权级调用高特权级代码） |
| `syscall` 指令 | **CPL 变为 0** | 硬件自动加载 MSR_STAR 中预设的内核 CS |
| `sysret` 指令 | **CPL 变为 3** | 硬件自动加载 MSR_STAR 中预设的用户 CS |
| 中断/异常门 | CPL 变为目标 DPL | 通过 IDT 中的门描述符 |

> Linux 不使用 `call far` 调用门做系统调用，而是用 `syscall`/`sysret` 快速路径。一致代码段（Conforming Code）在 Linux 中也不使用。

### 4.3 CPL、RPL、DPL 三者的关系

```bash
┌─────────────────────────────────────────────────┐
│                  CPL (Current Privilege Level)    │
│  当前正在执行的代码的特权级                          │
│  存储在 CS 寄存器的低 2 位 (CS.RPL)                 │
│  ring 0 = 最高权限, ring 3 = 最低权限               │
├─────────────────────────────────────────────────┤
│                  RPL (Requested Privilege Level)  │
│  段选择子中的请求特权级                             │
│  表示"我想以什么身份访问这个段"                      │
│  可以被软件设置（但 max(CPL,RPL) 决定实际权限）      │
├─────────────────────────────────────────────────┤
│                  DPL (Descriptor Privilege Level) │
│  目标段描述符中的特权级                             │
│  表示"访问这个段需要至少什么权限"                    │
│  由操作系统在 GDT 中设置，用户态无法修改              │
└─────────────────────────────────────────────────┘
权限检查公式（访问数据段或非一致代码段）：
  max(CPL, RPL) <= DPL  → 通过
  max(CPL, RPL) >  DPL  → #GP 异常
```

**RPL 存在的意义**：防止低特权级代码"借用"高特权级的选择子。例如：

```asm
; 内核代码（ring 0）想代表用户态（ring 3）访问一个段
; 如果直接用内核选择子（RPL=0），会以内核身份访问
; 如果用 ARPL 指令把选择子 RPL 设为 3，就会以用户身份检查权限
mov ax, 0x2B        ; 用户数据段，但 RPL=3 → 只能访问用户数据
mov ax, 0x18        ; 内核数据段，RPL=0 → 内核身份，可以访问
; 如果内核故意设 RPL=3 再去访问内核数据段：
; max(CPL=0, RPL=3) = 3 > DPL=0 → #GP!
```

## 五、段寄存器与特权级切换的完整交互

### 5.1 syscall 如何瞬间从 ring 3 跳到 ring 0

`syscall` 指令之所以能"合法地"切换特权级，是因为它不走正常的段加载权限检查路径。CPU 硬件直接根据 **MSR_STAR** 中的预设值写入 CS 和 SS，不检查 CPL vs DPL。

```asm
; syscall 指令的段切换硬件操作
CS.Selector := MSR_STAR[47:32]       ; = 0x10 (内核代码段选择子)
CS.Base     := 0                     ; 长模式强制为 0
CS.Limit    := 0xFFFFFFFF            ; 长模式忽略
CS.Type     := 11 (Execute/Read)
CS.DPL      := 0                     ; ← CPL 变成 ring 0
CS.P        := 1
CS.L        := 1 (64-bit)
CS.D        := 0
SS.Selector := MSR_STAR[47:32] + 8   ; = 0x18 (内核数据段选择子)
SS.Base     := 0
SS.DPL      := 0
; 注意：DS/ES 没有被 syscall 切换！
; 内核入口代码必须手动做这件事
```

**MSR_STAR 的设置**（开机时执行一次）：

```c
// MSR_STAR 的布局：
// [63:48] = 用户态 sysret 时用的 CS/SS 基值
// [47:32] = 内核态 syscall 时用的 CS/SS 基值
// 实际设置：
// __USER_CS = 0x30 (用户代码段 GDT 索引 6)
// __KERNEL_CS = 0x10 (内核代码段 GDT 索引 2)
// 
// syscall 时: CS = 0x10 (内核), SS = 0x10 + 8 = 0x18 (内核)
// sysret 时: CS = 0x30 | 3 = 0x33 (用户), SS = 0x30 + 8 | 3 = 0x2B (用户)
wrmsrl(MSR_STAR,  ((u64)__USER_CS   << 48) |
                  ((u64)__KERNEL_CS << 32));
```

### 5.2 内核入口代码手动完成剩余的段切换

```asm
; entry_SYSCALL_64 的关键代码（简化）
SYSCALL_ENTRY:
    swapgs                  ; GS 从用户 TLS → 内核 per-CPU
    ; ★ 切换栈指针（此时 SS 已经是内核段，但 RSP 还是用户栈指针）
    movq    %rsp, PER_CPU_VAR(rsp_scratch)   ; 暂存用户 RSP
    movq    PER_CPU_VAR(cpu_current_top_of_stack), %rsp  ; RSP → 内核栈
    ; ★ 手动加载内核数据段到 DS/ES
    movl    $__KERNEL_DS, %eax   ; __KERNEL_DS = 0x18
    movl    %eax, %ds
    movl    %eax, %es
    ; 构建 pt_regs，保存用户寄存器现场
    pushq   $__USER_DS           ; 用户 SS
    pushq   PER_CPU_VAR(rsp_scratch)  ; 用户 RSP
    pushq   %r11                 ; 用户 RFLAGS
    pushq   $__USER_CS           ; 用户 CS
    pushq   %rcx                 ; 用户 RIP
    ... 保存其余寄存器 ...
    ; 检查是否需要切页表（KPTI）
    SWITCH_TO_KERNEL_CR3 scratch_reg=%rax
    ; 开始执行系统调用处理
    call    *sys_call_table(, %rax, 8)
```

**为什么 DS/ES 必须手动切换？**

`syscall` 硬件只切换 CS 和 SS，不切换 DS/ES。如果内核代码继续用用户态的 DS（DPL=3），当它访问内核数据时会触发页表 U/S 位检查失败（内核页 U/S=0，用户态段 CPL=3 无法访问）。所以必须手动加载内核 DS/ES。

### 5.3 sysret 返回用户态的段切换

```asm
; 返回路径的关键代码（简化）
sysret_path:
    ; 恢复用户数据段
    movl    $__USER_DS, %eax    ; __USER_DS = 0x2B
    movl    %eax, %ds
    movl    %eax, %es
    ; 切回用户页表（KPTI）
    SWITCH_TO_USER_CR3
    ; 从 pt_regs 恢复用户寄存器
    popq    %rcx    ; 用户 RIP
    popq    %r11    ; 用户 RFLAGS
    ... 其余寄存器 ...
    swapgs              ; GS 从内核 per-CPU → 用户 TLS
    ; sysretq: 硬件自动切换 CS 和 SS 回用户态
    sysretq
    ; 之后 CS=0x33, SS=0x2B, CPL=3
```

## 六、用户态能"看到"内核内存吗？双重保护机制

### 6.1 第一层：段级别保护

```plantuml
@startuml
skinparam shadowing false
start
:用户态代码 (CS=0x33, CPL=3);
if (尝试加载内核数据段 DS=0x18?) then (是)
  :CPU 检查: max(CPL=3, RPL=0) = 3 > DPL=0;
  :触发 #GP(0) 异常;
  :进程收到 SIGSEGV;
  stop
else (否)
endif
:正常执行用户代码;
:访问用户地址 0x7fff...;
:CPU 查页表: U/S=1 (User) → 允许;
stop
@enduml
```

**但仅靠段级别保护不够**。在长模式下，大部分数据访问不经过段检查（因为 DS 基址和界限被忽略），真正拦住用户态的是第二层。

### 6.2 第二层：页表级别保护（真正的防线）

```bash
页表条目 (PTE) 的关键位：
Bit 2  U/S (User/Supervisor)
       0 = Supervisor-only（只有 ring 0/1/2 能访问）
       1 = User（ring 3 也能访问）
内核地址映射 (0xFFFF800000000000 以上):
  所有 PTE 的 U/S 位 = 0
用户地址映射 (0x00007FFFFFFFFFFF 以下):
  所有 PTE 的 U/S 位 = 1
当用户态 CPL=3 访问 U/S=0 的页面时:
  → 页错误 (Page Fault, #PF)
  → do_page_fault() → force_sig(SIGSEGV)
```

### 6.3 KPTI 的第三层：物理隔离页表

Meltdown 漏洞之后，KPTI 把保护提升到了物理层面：

```bash
无 KPTI：                         有 KPTI：
┌─────────────────────┐          ┌──────────────────┐  ┌──────────────────┐
│ 进程页表（唯一一份）    │          │ 用户态页表         │  │ 内核态页表         │
│                     │          │                  │  │                  │
│ 用户空间映射          │          │ 用户空间映射       │  │ 用户空间映射       │
│ 内核空间映射（U/S=0） │          │ trampoline 极少   │  │ 完整内核映射       │
│                     │          │ **无内核映射**     │  │                  │
└─────────────────────┘          └──────────────────┘  └──────────────────┘
                                        ↑                    ↑
                                   用户态用             内核态用
                                   CR3 指向              CR3 指向
即使 CPU 推测执行"看到"了内核地址：
  → 用户态页表中根本没有那个地址的映射
  → 推测执行拿不到数据
  → Meltdown 被物理阻断
```

### 6.4 三层保护对比

| 保护层 | 机制 | 保护范围 | 可绕过吗 |
|--------|------|---------|:---:|
| **段 DPL** | 加载段寄存器时检查 CPL vs DPL | 阻止加载内核段选择子 | Meltdown 推测执行可绕过 |
| **页表 U/S 位** | 每次内存访问时检查 CPL vs PTE.U/S | 阻止访问内核物理页 | Meltdown 推测执行可绕过 |
| **KPTI 页表隔离** | 用户态页表中根本没有内核映射 | 物理上阻断所有内核地址 | **不可绕过** |

## 七、Linux 内核对 GDT 的运行时管理

### 7.1 per-CPU 的 GDT

每个 CPU 核心有自己的一份 GDT（虽然内容基本相同）。这是因为：

- GDTR 寄存器是 per-CPU 的
- TSS（Task State Segment）是 per-CPU 的，必须放在各自的 GDT 中
- TLS（线程局部存储）描述符可能每个线程不同

```c
// arch/x86/include/asm/desc.h
struct gdt_page {
    struct desc_struct gdt[GDT_ENTRIES];  // 默认 32 个条目
} __aligned(PAGE_SIZE);
// 每个 CPU 的 GDT：
DECLARE_PER_CPU_PAGE_ALIGNED(struct gdt_page, gdt_page);
```

### 7.2 GDT 的初始化时机

```bash
系统启动时的 GDT 初始化流程：
start_kernel()
  → setup_arch()
    → init_per_cpu_var(gdt_page)      // 初始化模板
      设置 GDT[0..9] 的基本描述符
    → load_per_cpu_segments()         // 加载到当前 CPU
CPU 热插拔或 secondary CPU 启动：
  start_secondary()
    → cpu_init()
      → load_gdt(&get_cpu_gdt_rw(cpu))  // 加载该 CPU 的 GDT
      → load_segments()                 // 加载 CS/DS/ES/SS/FS/GS
```

### 7.3 动态修改 GDT —— TLS 描述符

当线程设置 TLS（Thread Local Storage）时，内核会修改 GDT 中的条目：

```c
// arch/x86/kernel/tls.c (简化)
int do_set_thread_area(struct task_struct *p, int idx,
                       struct user_desc __user *u_info, int can_allocate) {
    // 在 GDT 中找到一个空闲条目，设置 TLS 描述符
    // GDT[6] 之后的条目可以用于 TLS
    // 用户态通过 FS/GS 段寄存器访问 TLS
}
// 用户态接口：
// arch_prctl(ARCH_SET_FS, base_addr)  — 设置 FS 基址
// arch_prctl(ARCH_SET_GS, base_addr)  — 设置 GS 基址
```

`wrfsbase`/`wrgsbase` 指令（需要 CR4.FSGSBASE=1）可以直接设置 FS/GS 的基址寄存器，绕过 GDT——这是更高效的 TLS 访问方式。

## 八、段管理与系统调用的关系

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<u>> #E3F2FD
  BorderColor<<u>> #1976D2
  BackgroundColor<<k>> #FFCDD2
  BorderColor<<k>> #C62828
  BackgroundColor<<hw>> #FFF9C4
  BorderColor<<hw>> #F9A825
}
rectangle "GDT (全局描述符表)" <<hw>> {
  rectangle "GDT[2]: 内核代码段 DPL=0" <<k>>
  rectangle "GDT[3]: 内核数据段 DPL=0" <<k>>
  rectangle "GDT[5]: 用户数据段 DPL=3" <<u>>
  rectangle "GDT[6]: 用户代码段 DPL=3" <<u>>
}
rectangle "MSR 寄存器" <<hw>> {
  rectangle "MSR_STAR\n[63:48]=0x30 (用户CS基值)\n[47:32]=0x10 (内核CS基值)" <<hw>>
  rectangle "MSR_LSTAR\n= entry_SYSCALL_64 地址" <<hw>>
}
rectangle "syscall 指令执行" <<hw>> {
  rectangle "CS = MSR_STAR[47:32] = 0x10" <<k>>
  rectangle "SS = MSR_STAR[47:32]+8 = 0x18" <<k>>
}
rectangle "sysret 指令执行" <<hw>> {
  rectangle "CS = MSR_STAR[63:48]|3 = 0x33" <<u>>
  rectangle "SS = MSR_STAR[63:48]+8|3 = 0x2B" <<u>>
}
@enduml
```

**关键点**：

1. GDT 是静态配置的，开机时写好就不再变化（除了 TLS 条目）。
2. `syscall`/`sysret` 通过 MSR_STAR 知道该切到哪个段，不需要查 GDT。
3. 段选择子的值（0x10/0x18/0x33/0x2B）是 ABI 级别的常量，永远不会变。
4. 段权限的"门"只在 GDT 描述符的 DPL 字段中——操作系统设置一次，CPU 硬件执行检查。

## 九、观测与调试

### 9.1 查看当前段寄存器

**方法一：gdb 完整寄存器快照（最真实）**

在 `main()` 返回前打一个断点（`break main`，`run` 到 `return 0`），执行 `info registers`：

```bash
$ gdb ./find
(gdb) break find.cpp:9           # return 0 那行
(gdb) run
Breakpoint 1, main () at find.cpp:9
9           return 0;
(gdb) info registers
rax             0x400612          4195858        ; main() 的返回值（0 不在这里——见下）
rbx             0x0               0
rcx             0x100             256
rdx             0x7fffffffc9f8    140737488341496  ; argv 指针
rsi             0x7fffffffc9e8    140737488341480  ; argc
rdi             0x1               1
rbp             0x7fffffffc900    0x7fffffffc900
rsp             0x7fffffffc900    0x7fffffffc900  ; 栈顶（RBP=RSP，无栈帧）
r8              0x7fff74ede80     ...
r9              0x0
r10             0x7ffffffbbce0
r11             0x7fff715ef30
r12             0x400540          4195648
r13             0x7fffffffc9e0
r14             0x0
r15             0x0
rip             0x400616          0x400616 <main()+4>  ; PIE 二进制基址 0x400000
eflags          0x246             [ PF ZF IF ]         ; PF=1 上一条指令结果为偶、ZF=1、IF=1 中断开
cs              0x33              51                   ; ★ 用户代码段（GDT[6], RPL=3）
ss              0x2b              43                   ; ★ 用户数据/栈段（GDT[5], RPL=3）
ds              0x0               0                    ; 长模式忽略基址/界限
es              0x0               0
fs              0x0               0                    ; 选择子为 0，但 fs_base 有值
gs              0x0               0
fs_base         0x7ffff7dd4740    140737353959232      ; ★ TLS 基址（arch_prctl/ARCH_SET_FS 设置）
gs_base         0x0               0                    ; 内核用 swapgs 切换
```

**这份快照与本文理论预测的对应关系：**

| 寄存器 | 实测值 | 本文预测 | 解读 |
|--------|-------|---------|------|
| `CS` | `0x33` | `0x33`（GDT[6]） | 用户态代码段，RPL=3 |
| `SS` | `0x2b` | `0x2B`（GDT[5]） | 用户态栈段，RPL=3 |
| `DS/ES/FS/GS` | `0x0` | 被忽略 | 选择子 0 = 指向 GDT[0]（NULL 描述符），长模式不检查基址/界限 |
| `fs_base` | `0x7ffff7dd4740` | 进程 TLS 区域 | 这是动态链接器 glibc 为主线程设置的 TLS 基址，访问 `fs:[0]` 实际访问这里 |
| `gs_base` | `0x0` | 0 | 用户态 GS 未设置，内核会通过 `swapgs` 切到 per-CPU 基址 |
| `RSP` | `0x7fffffffc900` | 用户栈顶 | 典型 48 位虚拟地址高位，栈向低地址增长 |
| `RIP` | `0x400616` | PIE 二进制 | 非 PIE 二进制默认加载到 `0x400000` 段，PIE 则在更高的随机地址 |
| `eflags` | `0x246` = `0b 0010 0100 0110` | — | bit 1=TF(陷阱)、bit 9=IF(中断开)、bit 11=ZF(零标志=1，因 `return 0` 准备写 0)、bit 14=嵌套任务，bit 6/3/0 是固定 1 |

> **关键观察**：`DS/ES/FS/GS` 选择子是 `0x0`（指向 GDT[0] NULL 描述符）但程序仍能正常运行——这就是长模式"扁平化"的体现：硬件不读这些段选择子里的基址/界限，所以选 NULL 也无所谓；CPU 内部只关心 `CS.Selector.RPL` 决定 CPL，以及 `fs_base/gs_base` 这两个独立的 MSR。

**方法二：单独看段寄存器（精简版）**

```bash
(gdb) info registers cs ds es fs gs ss
cs             0x33     51
ds             0x0      0
es             0x0      0
fs             0x0      0
gs             0x0      0
ss             0x2b     43
```

**方法三：看 fs/gs 的隐藏基址寄存器**

```bash
(gdb) info registers fs_base gs_base
fs_base         0x7ffff7dd4740
gs_base         0x0
```

> 默认 gdb 不会自动显示 `fs_base/gs_base`，需要用 `info registers` 完整输出（见方法一），或显式 `print $fs_base`。这是排查线程局部存储（TLS）问题时的关键寄存器。

**方法四：通过 `/proc` 查看（需要在内核中打印）**

```c
// 在内核模块或 eBPF 中读 task_struct->thread.fsbase/gsbase
pr_info("FS base = 0x%lx\n", current->thread.fsbase);
```

用户态无法直接读自己/他人的 `fs_base`（`arch_prctl(ARCH_GET_FS, ...)` 只能读自己的，且需要 root 或合适权限）。

### 9.2 查看 GDT 内容

```bash
# 通过 debugfs 查看（需要 root）
sudo cat /sys/kernel/debug/x86/gdt | head -20
# 或者用 rdmsr 读 GDTR
sudo rdmsr 0xC0000081   # MSR_STAR
```

### 9.3 验证段权限

```c
// 测试：用户态尝试加载内核段选择子
// 预期：收到 SIGSEGV
#include <stdio.h>
int main() {
    unsigned short kernel_ds = 0x18;  // 内核数据段
    // 下面的内联汇编会触发 #GP(0)
    // 内核将其转为 SIGSEGV
    __asm__ volatile (
        "movw %0, %%ds\n\t"
        :
        : "r"(kernel_ds)
        :
    );
    printf("如果看到这行，说明段保护失效了\n");  // 永远到不了这里
    return 0;
}
```

### 9.4 查看 MSR 寄存器

```bash
sudo rdmsr 0xC0000082  # MSR_LSTAR — syscall 入口地址
sudo rdmsr 0xC0000081  # MSR_STAR — 段选择子配置
sudo rdmsr 0xC0000084  # MSR_FMASK — 标志清除掩码
```

## 十、一句话总结

> **x86-64 的段管理废掉了地址翻译（基址和界限强制平坦），但完整保留了权限管理。GDT 是段管理的"配置中心"，每个段描述符 8 字节，其中 DPL 字段决定该段的最小访问特权级。CPU 在加载段寄存器时执行 `max(CPL, RPL) <= DPL` 检查，用户态 CPL=3 永远无法加载 DPL=0 的内核段。`syscall` 指令绕过正常段加载检查，通过 MSR_STAR 直接写入预设的内核 CS/SS（0x10/0x18），实现 CPL 从 3 到 0 的合法跳转。页表 U/S 位和 KPTI 页表隔离构成第二、第三层保护。这三层共同保证：即使用户态知道内核地址，也无法读写内核内存——段选择子加载不了、页表 U/S 位拦着、KPTI 下页表里根本没有映射。**

## 十一、与仓库其他文档的关系

- **[syscall-details.md](/concepts/process/syscall-details.md)** — 系统调用深入细节，本文是其中段管理部分的独立深化
- **[syscall.md](/concepts/process/syscall.md)** — 系统调用基础过程与开销分析
- **[interrupts.md](/concepts/process/interrupts.md)** — 中断处理，另一种进入内核态的方式（通过 IDT 门描述符，不同于 syscall）
- **[context-switch.md](/concepts/process/context-switch.md)** — 上下文切换时也涉及段寄存器的保存/恢复
- **[../elf/memory-layout.md](/concepts/elf/memory-layout.md)** — 用户态/内核态地址空间布局，段权限保护了这些地址的隔离
- **[../cache/tlb.md](/concepts/cache/tlb.md)** — TLB 与页表 U/S 位的关系，KPTI 通过 PCID 优化页表切换开销

---

# 十二、分页机制补充：32位两级分页与64位四级分页

前面 §六 提到页表 U/S 位是"双重保护"的关键防线。本节从寻址角度完整展开两级与四级分页的转换流程，理解"线性地址如何最终落到物理内存"。

### 12.1 三类核心地址定义

为避免概念混淆，统一全文地址术语，x86 架构始终存在三层地址转换关系：

- **逻辑地址（虚拟地址）**：CPU 指令直接使用的地址，程序视角的地址，由「段选择子 + 偏移量」组成。
- **线性地址**：段机制转换后的中间地址。无分段/无分页时等于物理地址；开启分页后需经过页机制二次转换。
- **物理地址**：内存硬件的实际地址，最终用于访问内存单元。

### 12.2 32位保护模式分页机制

分页机制是虚拟内存的核心，将**连续的线性地址**映射为**离散的物理地址**，实现内存按需分配、进程地址空间隔离、内存换页。

**分页基础参数：**

- 默认页大小：**4KB**（标准分页），支持 4MB 大页。
- 两级页表结构：页目录表（PDT）+ 页表（PT）。
- 控制寄存器 CR3：存储**页目录物理地址**，每个进程拥有独立 CR3，实现进程地址空间切换。

**线性地址拆分规则（32位）：**

32 位线性地址划分为三段：

- 第 22~31 位（10位）：页目录索引（PDX），查询页目录项。
- 第 12~21 位（10位）：页表索引（PTX），查询页表项。
- 第 0~11 位（12位）：页内偏移（OFFSET），页内精准寻址。

**两级分页转换完整流程：**

1. CPU 从 CR3 寄存器获取当前进程页目录的**物理起始地址**。
2. 使用线性地址高 10 位（PDX），在页目录中找到对应的**页目录项（PDE）**。
3. 校验 PDE 有效位，读取页表的物理起始地址。
4. 使用线性地址中间 10 位（PTX），在页表中找到对应的**页表项（PTE）**。
5. 校验 PTE 有效位，读取物理页框起始地址。
6. **物理地址 = 物理页框地址 + 页内偏移**。

**页表项核心属性：**

PDE/PTE 包含权限与状态位，用于内存管理：Present（存在位）、RW（读写位）、US（用户/内核位）、PWT、PCD、A（访问位）、D（脏位）等，实现内核态与用户态内存权限隔离、换页统计。

### 12.3 64位长模式四级分页机制

64位长模式采用**四级页表嵌套**，从高到低依次为：PML4（页四级目录）、PDPT（页三级目录）、PDT（页二级目录）、PT（页一级表）。CR3 寄存器存储 **PML4 物理地址**，每个进程独立 CR3。

**48位线性地址拆分规则：**

64 位虚拟地址仅有效低 48 位，划分为 5 个部分，每部分 9 位（除偏移）：

- 第 39~47 位（9位）：PML4 索引
- 第 30~38 位（9位）：PDPT 索引
- 第 21~29 位（9位）：PDT 索引
- 第 12~20 位（9位）：PT 索引
- 第 0~11 位（12位）：页内偏移（4KB 页固定）

**四级分页转换流程：**

1. 通过 CR3 获取当前进程 PML4 表的物理基地址。
2. 用 48 位地址最高 9 位索引 PML4，获取 PDPT 物理地址。
3. 用次 9 位索引 PDPT，获取 PDT 物理地址。
4. 用第三段 9 位索引 PDT，获取 PT 物理地址。
5. 用第四段 9 位索引 PT，获取物理页框基地址。
6. 物理页框基地址 + 12 位页内偏移 = 最终物理地址。

**大页支持：**

64 位分页支持 2MB、1GB 超大页，可跳过下级页表查询，减少页表内存开销、提升寻址效率，广泛用于内核大段内存映射。

### 12.4 三模式寻址总对比

| 对比维度 | 实模式（16位） | 32位保护模式 | 64位长模式 |
|---|:---:|:---:|:---:|
| 寻址位数 | 20位物理地址 | 32位线性/物理地址 | 48位有效虚拟地址 |
| 最大寻址空间 | 1MB | 4GB | 256TB（虚拟地址） |
| 段机制 | 段基址直接存储，无描述符 | 完整段描述符，段参与地址计算 | 段基址置0，仅保留权限校验 |
| 分页机制 | 无分页 | 两级分页（PDT+PT） | 四级分页（PML4+PDPT+PDT+PT） |
| 内存保护 | 无 | 有特权级、内存隔离 | 强化权限隔离，更安全 |
| 运行场景 | BIOS、引导阶段 | 32位操作系统、老旧程序 | 现代64位操作系统 |

---

# 十三、指令寻址实例：`MOV EAX, [0x7FFF12345678]` 在64位长模式下的完整寻址过程

前面各章从"模式"和"机制"视角讲了段管理与分页的原理。本章换一个视角——**从一条具体的汇编指令出发**，跟踪 CPU 硬件在每一拍做什么、每一步经过哪些硬件单元，把前面分散的概念（分段、分页、TLB、缓存）串成一条完整的流水线。

> **选择的指令**：`MOV EAX, [0x7FFF12345678]`——将内存地址 `0x7FFF12345678` 处的 4 字节数据加载到 32 位寄存器 EAX 中。选择这条指令的原因是：地址 `0x7FFF...` 属于典型的用户态地址空间（bit 47 = 0），使用绝对直接寻址（moffs），寻址路径经过完整的段→页→缓存→内存流程，适合作为"标本"逐级拆解。

## 13.1 指令总览：这条指令到底在做什么

```bash
汇编:    MOV EAX, [0x7FFF12345678]
语义:    EAX := Mem[0x7FFF12345678]    // 从内存地址读 4 字节，放入 EAX
宽度:    32 位（EAX），不影响 RAX 高 32 位（高 32 位清零）
```

**机器码编码（64位模式）**：

这是 `MOV r32, moffs32` 指令。在 64 位模式下，操作码为 `A1`，带 `REX.W=0` 前缀时使用 **32 位立即数偏移**，CPU 会将它**零扩展**（不是符号扩展，因为 moffs 语义不同）到 64 位。

```bash
REX.W=0  (可选)
Opcode   0xA1          ← MOV EAX, moffs
Offset   78 56 34 12   ← 低 32 位：0x12345678（小端序）
         FF 7F 00 00   ← 高 32 位：0x00007FFF（小端序）
─────────────────────────────────
完整 64 位偏移：0x00007FFF12345678
```

> **注意**：`moffs` 地址是 **0 扩展**到 64 位的（和 RIP-relative 不同，后者用符号扩展）。因此 `A1` 后面跟 8 字节地址时，`0x7FFF12345678` 的编码需要 64 位完整地址。

指令的关键特征：**地址是编码在指令里的绝对数值，不需要任何寄存器参与计算**——这和 `mov eax, [rbx]`（寄存器间接寻址）或 `mov eax, [rbx+rcx*4+8]`（SIB 复杂寻址）完全不同。

## 13.2 步骤一：指令取指（Instruction Fetch）

```bash
CPU 前端（Front End）
  │
  ├─ RIP 指向下一条指令
  ├─ 分支预测器检查是否有分支
  ├─ 指令预取器从 L1i（指令缓存）取指令字节
  │    └─ 缓存命中：~4-5 拍
  │    └─ 缓存 miss：→ L2 → L3 → 内存，可能几十~几百拍
  └─ 取指结果：opcode 0xA1 + 8 字节立即数，共 9 字节
```

Intel/AMD 现代 CPU 的指令预取器一次能取 16~32 字节（一个取指窗口），9 字节的指令通常在一个取指窗口内完成。

## 13.3 步骤二：指令解码（Instruction Decode）

```bash
CPU 前端 → 解码器
  │
  ├─ 识别 Opcode 0xA1 → MOV rAX, moffs
  │    └─ 如果是 REX.W + A1 → MOV RAX, moffs64（64位操作数）
  │    └─ 如果是 A1（无 REX.W）→ MOV EAX, moffs32（32位操作数，高32清零）
  │
  ├─ 立即数字段 → 有效地址 = 0x00007FFF12345678
  │
  └─ 解码产物：一条微操作（μop）
       ├─ 操作类型：Load（内存读）
       ├─ 数据宽度：4 字节（32位）
       ├─ 目标寄存器：EAX
       └─ 源地址：线性地址 = 0x7FFF12345678（后续经段机制确认）
```

解码结果被送入 **微操作队列（μop Queue）**，等待发射到执行单元。

## 13.4 步骤三：地址生成单元计算有效地址

解码后的 μop 进入 **AGU（Address Generation Unit，地址生成单元）**：

```bash
AGU 计算:
  ┌─────────────────────────────────────────────────────────┐
  │ 寻址方式: moffs（绝对直接地址）                            │
  │                                                         │
  │ 参与计算的组件:                                           │
  │   Base Register: 无（moffs 直接使用立即数）                │
  │   Index Register: 无                                     │
  │   Scale: 无                                              │
  │   Displacement: 0x00007FFF12345678 = 立即数本身           │
  │                                                         │
  │ 有效地址 = Immediate Offset = 0x00007FFF12345678          │
  └─────────────────────────────────────────────────────────┘
```

**为什么 moffs 在 64 位代码中很少见？** 因为 RIP-relative 寻址（`MOV EAX, [RIP+offset]`）生成的代码更短（只需要 32 位符号扩展偏移，不需要完整的 64 位地址），且天然支持位置无关代码（PIC/PIE）。`moffs` 主要是 MOV 到/从 AL/AX/EAX/RAX 的优化路径，编译器很少使用。

## 13.5 步骤四：段机制翻译（Segment Translation）→ 线性地址

有了有效地址后，AGU 将结果送入**段翻译单元**。这是 MMU 的第一级处理——这部分就是本文 §一~§四拆解的段管理机制在实际指令上的应用：

```bash
┌──────────────────────────────────────────────────────────┐
│            x86-64 长模式下的段翻译（DS 段）                  │
│                                                          │
│  输入：                                                   │
│    段选择子 = DS（默认数据段）                              │
│    有效地址 = 0x00007FFF12345678                           │
│                                                          │
│  硬件查 DS 的"隐藏部分"（Descriptor Cache，段寄存器影射）：    │
│    ┌─────────────────────────────────────┐               │
│    │ Base   = 0          ← 长模式强制为 0  │               │
│    │ Limit  = 0xFFFFFFFF ← 长模式不做界限检查│               │
│    │ DPL    = 3          ← 用户态数据段     │               │
│    │ Type   = Read/Write ← 可读写数据段     │               │
│    └─────────────────────────────────────┘               │
│                                                          │
│  输出：                                                   │
│    线性地址 = Base + 有效地址 = 0 + 0x7FFF12345678         │
│            = 0x00007FFF12345678  ← 后续分页的输入           │
│                                                          │
│  同时检查：                                                │
│    ✓ Base = 0 → 线性地址 = 有效地址（"平坦模型"）            │
│    ✓ Limit 忽略（长模式不检查 DS 界限）                     │
│    ✓ DPL = 3, CPL = 3（用户态运行）→ 允许                   │
│    ✓ Type 含 Read → 可以读 → 允许                          │
└──────────────────────────────────────────────────────────┘
```

关键点：**长模式下，DS/ES/SS 的段基址被硬件强制为 0，段翻译实际上是"透传"——线性地址 = 有效地址**。但 CPU 仍然在加载段寄存器时检查 DPL（见本文 §四），只是地址计算这一步被"旁路"了。

**FS/GS 的特殊性**：只有 FS 和 GS 在长模式下可以有非零基址（通过 `wrfsbase`/`wrgsbase` 指令或 MSR 设置），用于线程局部存储（TLS）。本例用的是 DS，基址 = 0。

## 13.6 步骤五：规范地址检查（Canonical Address Check）

线性地址出来后，MMU 做的第一件事是**规范地址检查**——验证地址是否在合法范围：

```bash
┌──────────────────────────────────────────────────────────┐
│  64 位虚拟地址空间 ≠ 全部 64 位都能用                        │
│                                                          │
│  x86-64 当前实现只使用低 48 位（虚拟）或 57 位（5 级分页）     │
│  规则：bits 63:48 必须全部等于 bit 47 的值（符号扩展）         │
│                                                          │
│  0x00007FFF12345678 的检查：                               │
│    bit 47 = 0                                             │
│    bits 63:48 = 0x0000                                    │
│    0x0000 = 全 0 ✓                                        │
│    → 规范地址，通过检查                                     │
│                                                          │
│  如果地址是 0xFFFF800000000000 开头：                        │
│    bit 47 = 1                                             │
│    bits 63:48 = 0xFFFF ← 必须全 1                          │
│    0xFFFF = 全 1 ✓                                        │
│    → 规范地址（内核空间），通过检查                           │
│                                                          │
│  非法地址例：0x0000800000000000                             │
│    bit 47 = 1，但 bits 63:48 = 0x0000 ≠ 全 1               │
│    → **非规范地址 → #GP(0) 异常！**                         │
└──────────────────────────────────────────────────────────┘
```

```bash
64 位虚拟地址空间（规范地址分界）：
非规范（hole）         非规范（hole）
  ←─────────────────→  ←─────────────────→
  0x00008000_00000000 ~ 0xFFFF7FFF_FFFFFFFF
        ↕                       ↕
  无法访问！（硬件直接拒绝）
0x00000000_00000000    0x00007FFF_FFFFFFFF    ← 用户空间（128TB）
│                       │                      bit 47=0，合法
│─────── 用户态地址 ←──────│
0xFFFF8000_00000000    0xFFFFFFFF_FFFFFFFF    ← 内核空间（128TB）
│                       │                      bit 47=1，合法
│─────── 内核态地址 ←──────│
```

> **这条指令的地址 `0x7FFF12345678` 落在用户空间（bit 47 = 0），CPL = 3 时允许访问，但如果页表 U/S 位不允许也会触发页错误——这就是 §六 讨论的双重保护机制。**

## 13.7 步骤六：TLB 查找 —— 地址翻译的第一步

在真正走页表之前，CPU 先查 **TLB（Translation Lookaside Buffer）**——页表翻译结果的专用缓存。

```bash
┌──────────────────────────────────────────────────────────┐
│  TLB 查找过程                                              │
│                                                          │
│  输入：虚拟地址 0x00007FFF12345678                          │
│                                                          │
│  拆分（假设 4KB 页）：                                      │
│    VPN（虚拟页号） = bits[47:12] = 0x7FFF12345             │
│    Offset          = bits[11:0]  = 0x678                 │
│                                                          │
│  Step 1: 查 L1 dTLB                                      │
│    ┌──────────────────────────────────────────┐          │
│    │ 条目数：64 条（典型值）                      │          │
│    │ 相联度：4 路组相联或全相联（因型号而异）       │          │
│    │ 覆盖范围：64×4KB = 256KB（极小！）          │          │
│    │                                          │          │
│    │ 索引方法：用 VPN 的低位选 set，用完整 VPN    │          │
│    │          + ASID/PCID 做 tag 比较           │          │
│    │                                          │          │
│    │ 命中 → 直接拿到 PFN，跳过整个 page walk     │          │
│    │       → 跳转到 13.8（缓存查找）             │          │
│    │ miss → 继续查 L2 STLB                      │          │
│    └──────────────────────────────────────────┘          │
│                                                          │
│  Step 2: 查 L2 STLB（大 TLB）                             │
│    ┌──────────────────────────────────────────┐          │
│    │ 条目数：1536~2048 条（因型号而异）           │          │
│    │ 覆盖范围：~6~8MB（4KB 页下）               │          │
│    │                                          │          │
│    │ 命中 → PFN，代价几拍（比 L1 dTLB 慢但        │          │
│    │         远小于 page walk）                 │          │
│    │       → 跳转到 13.8（缓存查找）             │          │
│    │ miss → 触发 **Page Walk**，进入 13.7.1    │          │
│    └──────────────────────────────────────────┘          │
└──────────────────────────────────────────────────────────┘
```

> **TLB hit vs miss 的代价差**：TLB 命中的翻译几乎免费（与负载流水线并行，~1 拍有效延迟），TLB miss 的 page walk 要 4 次访存、几十到上百拍。这也是 [../cache/tlb.md](/concepts/cache/tlb.md) 里"D=256 断崖"的根源——大步长导致页工作集超出 TLB 容量，每次访存前都挂着整趟 page walk。

### 13.7.1 步骤六（续）：TLB Miss → 四级页表 Page Walk

TLB 全部 miss 后，**MMU 硬件**启动 page walk，从 CR3 出发逐级走四级页表——这就是 §十二.3 所描述的四级分页机制在实际硬件上的走法：

```bash
┌───────────────────────────────────────────────────────────┐
│              48 位虚拟地址的五部分拆分                        │
│                                                           │
│  [47:39]    [38:30]    [29:21]    [20:12]    [11:0]      │
│   PML4      PDPT       PD         PT         Offset       │
│   (9 bits)  (9 bits)   (9 bits)   (9 bits)   (12 bits)   │
│                                                           │
│  每级 9 位 = 512 个表项，每表恰好 4KB = 一物理页               │
│  每表项 8 字节（64 位），512 项 × 8B = 4096B                 │
└───────────────────────────────────────────────────────────┘
```

**四级 page walk 逐级走法（以 0x00007FFF12345678 为例）**：

```bash
第 0 级: CR3 寄存器
  │   存放当前进程 PML4 表的物理基地址（物理页框号，低 12 位为 0）
  │
  ▼
第 1 级: PML4（Page Map Level 4，页映射四级表）
  │
  │   索引 = (0x7FFF12345678 >> 39) & 0x1FF
  │        = 0xFF  （十进制 255，PML4 的最后一个槽位）
  │
  │   PML4 表 = 物理内存首址（来自 CR3）+ 索引 × 8 = PML4_Base + 2040
  │   读取 8 字节 PML4E（PML4 Entry）
  │
  │   检查:
  │     ✓ P（Present, bit 0）= 1 → 页表存在
  │     ✓ R/W（bit 1）= 1 → 下级可写（数据访问需要）
  │     ✓ U/S（bit 2）= 1 → 用户态可访问（CPL=3 时需要）
  │     ✓ NX（bit 63）暂不检查（数据读，非执行）
  │
  │   PML4E[51:12] = 下一级 PDPT 表的物理基地址
  │
  ▼
第 2 级: PDPT（Page Directory Pointer Table，页目录指针表）
  │
  │   索引 = (0x7FFF12345678 >> 30) & 0x1FF
  │        = 0x1FF （十进制 511，PDPT 的最后一个槽位）
  │
  │   PDPT 表 = PDPT_Base + 511 × 8 = PDPT_Base + 4088
  │   读取 8 字节 PDPTE
  │
  │   检查 P、R/W、U/S（同 PML4）
  │
  │   ┌── 如果 PDPTE[7]（PS 位）= 1: **1GB 大页命中！**
  │   │   跳过 PD 和 PT，直接:
  │   │     PFN = PDPTE[51:30]
  │   │     offset = VA[29:0]（30 位偏移）
  │   │     → 物理地址 = PFN << 30 | offset[29:0]
  │   │     → page walk 提前终止，跳到 13.8
  │   │
  │   └── PS = 0: 继续下一级
  │
  │   PDPTE[51:12] = 下一级 PD 表的物理基地址
  │
  ▼
第 3 级: PD（Page Directory，页目录表）
  │
  │   索引 = (0x7FFF12345678 >> 21) & 0x1FF
  │        ≈ 0x191 （十进制 ~401）
  │
  │   PD 表 = PD_Base + 401 × 8 = PD_Base + 3208
  │   读取 8 字节 PDE
  │
  │   检查 P、R/W、U/S
  │
  │   ┌── 如果 PDE[7]（PS 位）= 1: **2MB 大页命中！**
  │   │   跳过 PT，直接:
  │   │     PFN = PDE[51:21]
  │   │     offset = VA[20:0]（21 位偏移）
  │   │     → 物理地址 = PFN << 21 | offset[20:0]
  │   │     → page walk 提前终止，跳到 13.8
  │   │
  │   └── PS = 0: 继续最后一级
  │
  │   PDE[51:12] = 下一级 PT 表的物理基地址
  │
  ▼
第 4 级: PT（Page Table，页表）★ 最后一级
  │
  │   索引 = (0x7FFF12345678 >> 12) & 0x1FF
  │        = 0x145 （十进制 325）
  │
  │   PT 表 = PT_Base + 325 × 8 = PT_Base + 2600
  │   读取 8 字节 PTE（Page Table Entry）
  │
  │   检查:
  │     ✓ P（Present, bit 0）= 1 → 页面在物理内存中
  │       如果 P = 0 → **Page Fault（#PF 异常）**
  │         ├─ 如果是已映射但未分配 → 惰性分配（minor fault）
  │         ├─ 如果是已被换出到 swap → 从磁盘换入（major fault）
  │         └─ 如果是 SIGSEGV 的地址 → 杀掉进程
  │     ✓ R/W（bit 1）= 1 → 允许写（读操作也需要检查，但更宽松）
  │     ✓ U/S（bit 2）= 1 → 用户态可访问（CPL=3）
  │     ✓ NX 对数据读无关
  │     ✓ A（Accessed, bit 5）：硬件自动置 1（若未设置则写入）
  │     ✓ D（Dirty, bit 6）：本次为读，不检查
  │
  │   PTE[51:12] = 目标物理页框号（PFN）
  │
  ▼
最终物理地址 = (PFN << 12) | (VA & 0xFFF)
            = (PTE[51:12] << 12) | 0x678
```

**page walk 的四次访存（每次都可能穿过多级缓存）**：

```bash
Page Walk 访存路径：
① 读 PML4E ──→ L1d/L2/L3/DRAM（用 PML4_Base 物理地址）
② 读 PDPTE ──→ L1d/L2/L3/DRAM（用 PDPT_Base 物理地址）
③ 读 PDE   ──→ L1d/L2/L3/DRAM（用 PD_Base 物理地址）
④ 读 PTE   ──→ L1d/L2/L3/DRAM（用 PT_Base 物理地址）
优化机制:
  ├─ Page Walk Cache（PWC）：CPU 内部专门缓存中间级
  │   表项（PML4E/PDPTE/PDE），避免为常见地址范围反复走前几级
  │
  ├─ PTE 缓存行效应：一条 64B cache line 装 8 个 PTE（每个 8B）
  │   → 访问一个 PTE 时，相邻 7 个页的 PTE 也被拉进 L1d
  │   → 遍历连续内存时 walk 代价极低
  │
  └─ 热页表：内核频繁使用的地址范围，其各级页表项常驻 L1/L2
```

### 13.7.2 页表项（PTE）关键 bit 位检查的总流程

```bash
MMU 逐级检查（以 4KB 页的最后一级 PTE 为例，前几级同理）：
PTE 的内容（64 位）:
┌──┬────────────────────────────────┬──┬──┬──┬──┬──┬──┬──┬──┐
│63│      PFN 物理页号 [51:12]       │..│8 │7 │6 │5 │4 │3 │...│
│  │        (40 bits)               │  │  │  │  │  │  │  │  │
├──┼────────────────────────────────┼──┼──┼──┼──┼──┼──┼──┼──┤
│NX│                                  │G │PS│D │A │PCDPWT│RW│P │
└──┴──────────────────────────────────┴──┴──┴──┴──┴──┴──┴──┴──┘
每步检查（顺序可能因微架构而异，但语义等价）：
① P = 1?       → 否 → #PF Page Fault
② 如果是 CPL=3: U/S = 1? → 否 → #PF（用户态无法访问内核页）
③ 如果是写操作: R/W = 1? → 否 → #PF（只读页不可写）
                  // 读操作在 U/S=1 且 R/W 可以为 0（COW 等场景本身是读允许的）
④ 保留位必须为 0 → 否则 #PF
⑤ A bit 未置 → MMU 原子地写回 1（可能触发额外的微操作）
⑥ 翻译完成: PFN 存入 TLB，地址翻译结束
```

## 13.8 步骤七：物理地址 → 缓存层次查找

拿到物理地址后，CPU 将其送入**数据缓存层次**：

```bash
物理地址（Physical Address）拆分（以典型 L1d 为例）:
       Tag              Set Index     Block Offset
   [51:12/...]       [中间若干位]     [低 6 位]
   （高位 + ASID）   （选哪一组/路）   （cache line 内偏移）
    1. L1 Data Cache（~32KB, 8 路组相联, 64B line）
       │
       ├─ 命中（~4-5 拍延迟）
       │    └─ 读取 4 字节（对齐/非对齐）
       │       → 数据放入 EAX
       │       → 寻址流程结束 ★
       │
       └─ Miss → 继续 L2
    2. L2 Cache（~256KB~1MB, 各型号不同）
       │
       ├─ 命中（~12 拍延迟）
       │    └─ 整条 cache line（64B）加载到 L1d
       │       └─ 从 L1d 读取 4 字节 → EAX
       │
       └─ Miss → 继续 L3 / DRAM
    3. L3 Cache（LLC, ~几MB~几十MB, 所有核共享）
       │
       ├─ 命中（~40-50 拍延迟）
       │    └─ line 逐级填充 L2 → L1d
       │       └─ 读取 4 字节 → EAX
       │
       └─ Miss → 访问 DRAM
    4. DRAM（主内存）
       │
       └─ ~100-300 拍延迟（受内存频率、时序、NUMA 节点影响）
          └─ line 逐级填充 L3 → L2 → L1d
             └─ 读取 4 字节 → EAX
```

## 13.9 步骤八：数据回填 → 寄存器写入

```bash
L1d 命中后:
  │
  ├─ 4 字节数据通过内部数据总线传入整数执行单元
  │
  ├─ 写入 EAX（RAX 高 32 位清零）
  │    └─ 物理寄存器文件（PRF）重命名映射：架构寄存器 EAX ←→ 物理寄存器 #N
  │
  └─ μop 完成，ROB（重排序缓冲）提交，指令退役
```

## 13.10 完整寻址流程图

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<if>> #FFF9C4
  BorderColor<<if>>     #F9A825
  BackgroundColor<<done>> #C8E6C9
  BorderColor<<done>>    #388E3C
  BackgroundColor<<dead>> #FFCDD2
  BorderColor<<dead>>    #C62828
}
rectangle "指令取指\n(RIP → L1i → 解码)" as FETCH
rectangle "有效地址\n= 0x7FFF12345678\n(moffs 直接立即数)" as EA
rectangle "段翻译\nDS.Base=0\n线性地址 = 0x7FFF12345678" as SEG
rectangle "规范地址检查\nbit 47=0, bits[63:48]=0\n→ ✓ 合法" as CANON
rectangle "L1 dTLB hit?" <<if>> as TLB1
  rectangle "L2 STLB\nhit?" <<if>> as TLB2
rectangle "page walk (MMU)\nCR3→PML4→PDPT→PD→PT→PFN\n× 4 次访存" as WALK
rectangle "PFN + offset = 物理地址" as PA
rectangle "检查 P/U/S/RW\n权限位" as PERM
rectangle "L1d hit?" <<if>> as L1D
rectangle "L2 hit?" <<if>> as L2
rectangle "L3 hit?" <<if>> as L3
rectangle "DRAM\n~100-300 拍" as DRAM
rectangle "4 字节数据 → EAX\n指令完成 ★" <<done>> as DONE
rectangle "#GP 异常\n非规范地址" <<dead>> as GP
rectangle "#PF 异常\n页不存在/权限不足" <<dead>> as PF
FETCH -down-> EA
EA -down-> SEG
SEG -down-> CANON
CANON -down-> TLB1
TLB1 --> DONE : hit (~1拍)
TLB1 -down-> TLB2 : miss
TLB2 --> DONE : hit (~几拍)
TLB2 -down-> WALK : miss
WALK -down-> PERM
PERM --> PF : 权限失败
PERM -down-> PA
PA -down-> L1D
L1D --> DONE : hit (~4-5拍)
L1D -down-> L2 : miss
L2 --> DONE : hit (~12拍)
L2 -down-> L3 : miss
L3 --> DONE : hit (~40-50拍)
L3 -down-> DRAM : miss
CANON --> GP : bit47≠bits[63:48]
@enduml
```

## 13.11 常见异常路径

在实际执行中，这条看似简单的指令可能在多个环节触发异常：

| 阶段 | 异常类型 | 触发条件 | 内核处理 |
|------|---------|---------|---------|
| 规范地址检查 | **#GP(0)** | bit 47 != bits[63:48] | 内核发 SIGSEGV |
| 段权限检查 | **#GP(0)** | CPL > DS.DPL | 内核发 SIGSEGV |
| Page Walk 中某级 P=0 | **#PF (Page Fault)** | 页表项不存在 | `do_page_fault()` → 惰性分配 / swap-in / SIGSEGV |
| Page Walk 中 U/S=0 且 CPL=3 | **#PF** | 用户态访问内核页 | `force_sig(SIGSEGV)` |
| 最终 PTE 的 A/D 位更新 | 无异常，但触发额外 μop | A=0 时硬件写回 | 硬件自动完成 |
| 跨页边界（本例不适用） | 可能两次 TLB 查找 | 如果 [addr, addr+3] 跨 4KB 边界 | 重新拆分地址 |

> **Page Fault 的三种命运**：

> - **Minor fault**：页表已建、物理页未分配 → 内核分配一页，填充 PTE，iret 回用户态重试指令 → 透明恢复

> - **Major fault**：页被换出到 swap → 内核从磁盘读回，几百微秒~毫秒级

> - **Invalid fault**：地址完全无效（没映射过）→ SIGSEGV，进程终止

## 13.12 四种寻址模式在 64 位长模式下的对比

这条 `moffs` 指令只是 x86-64 多种寻址模式的一种。将其与其他常见模式并排，有助于理解何时用哪种：

| 寻址模式 | 汇编示例 | 地址计算 | 编码长度 | 用途 |
|---------|---------|---------|:---:|------|
| **立即直接 (moffs)** | `MOV EAX, [0x7FFF12345678]` | 立即数直接 = 地址 | 9 字节 | AL/AX/EAX/RAX 特殊路径，少用 |
| **寄存器间接** | `MOV EAX, [RBX]` | RBX | 2-3 字节 | 基址在寄存器中 |
| **基址+偏移** | `MOV EAX, [RBX+8]` | RBX + 8 | 3-4 字节 | 结构体成员访问 |
| **基址+索引+比例** | `MOV EAX, [RBX+RCX*4]` | RBX + RCX×4 | 3-4 字节 | 数组索引 `a[i]` |
| **基址+索引+比例+偏移** | `MOV EAX, [RBX+RCX*4+16]` | RBX + RCX×4 + 16 | 4-7 字节 | `struct.a[i]` 复杂嵌套 |
| **RIP-relative** | `MOV EAX, [RIP+0x1234]` | RIP + 0x1234 | 6-7 字节 | 全局变量、PIC/PIE、位置无关代码 |

> **RIP-relative 是 64 位长模式的新增能力**——32 位模式下 EIP 不能作为基址寄存器。64 位下 `[RIP+disp32]` 是实现"位置无关代码"（PIE）的关键：代码被加载到任意地址，只要数据和代码的相对偏移不变，寻址就始终正确。编译器大量使用这种模式替代绝对地址。

## 13.13 性能透视：这条指令从发射到退役需要多少拍？

逐环节估算（现代 Intel/AMD x86-64，典型场景）：

| 环节 | 乐观（全缓存命中）| 悲观（全部 miss 到内存）|
|------|:---:|:---:|
| 取指 (L1i) | 4-5 拍 | ~200 拍（L1i miss → DRAM）|
| 解码 (1 μop) | 1-2 拍 | 1-2 拍 |
| 地址生成 (AGU) | 1 拍 | 1 拍 |
| 段翻译 | 0 拍（旁路）| 0 拍 |
| 规范地址检查 | 0 拍（与上并行）| 0 拍 |
| TLB 查找 | 1 拍（L1 dTLB hit）| ~80 拍（TLB miss + 4×page walk 未命中缓存）|
| 物理地址 → L1d | 4-5 拍 | ~200 拍（L3 miss → DRAM）|
| 数据写入 EAX | 1 拍 | 1 拍 |
| **总计** | **~12-15 拍** | **~500+ 拍** |

> **差了 40 倍以上。** 这就是为什么"缓存/TLB 友好"不是空洞的口号——同样的指令、同样的语义，在两种极端情况下的执行时间差了 40 倍。

## 13.14 与本文各章的关系

- **§一~§四（段管理核心）**——§13.5 段翻译的完整理论基础
- **§六（双重保护机制）**——§13.7 页表 U/S 位检查就是第二层防线在指令级的体现
- **§十二（分页机制补充）**——§13.7.1 四级 page walk 在具体地址上的逐级走法
- **[../cache/tlb.md](/concepts/cache/tlb.md)**——TLB 和页表深度展开版，含 TLB thrashing 实验
- **[../cache/cache-organization.md](/concepts/cache/cache-organization.md)**——§13.8 的物理地址缓存查找完整展开版
- **[syscall-details.md](/concepts/process/syscall-details.md)**——§13.11 的 page fault 处理也涉及内核入口，#PF 走的是和 syscall 类似的 IDT 门路径
- **[x86-64-registers.md](/concepts/process/x86-64-registers.md)**——本章涉及的 CR3/EFER/段寄存器详细定义

