﻿# 线程内核栈的快照 —— 多次切换中内核栈的"冻结点"分析

> [context-switch.md](/concepts/process/context-switch.md) §5.4 讲了 `switch_to()` 里 **SP 一换就变成另一个 task** 的魔法，但只聚焦于切换那一瞬间。本篇换个视角：**以一条线程的整个生命周期为纵轴，跟踪它在多次切换中内核栈的"快照"变化**——每次被冻结在栈上的内容是什么、为什么是这个结构、从低地址端 `thread_info` 到高地址端 `pt_regs`（RSP0 一侧）之间夹着几层调用帧、以及这些快照如何让线程"下次还能接着跑"。注意：这些快照**无法用普通工具实时捕获**（内核栈对用户态不可见，且被切走时 CPU 已不在该线程上），但理解它们对于理解调度、抢占、信号投递的"为什么能恢复"至关重要。

## 前置概念

在进入具体分析之前，先把本文反复出现的几个核心概念逐一讲清楚。如果你已经熟悉这些，可以直接跳到 §〇。

### thread_info（线程信息块）

**位置**：内核栈的**低地址端**（栈从高地址向低地址生长，所以低地址端是"栈的最远点"）。

**内容**：存的是线程的"控制面板"——标志位和计数，不是寄存器现场：

| 字段 | 含义 | 典型用法 |
|------|------|---------|
| `flags` | 位掩码标志位 | `TIF_SIGPENDING`（有信号待处理）、`TIF_NEED_RESCHED`（需要重新调度）——返回用户态前逐项检查 |
| `preempt_count` | 抢占计数器 | 非 0 时禁止内核抢占（持锁、关抢占等场景）；为 0 且 `TIF_NEED_RESCHED` 置位时触发调度 |
| `cpu` | 最后运行的 CPU 编号 | 用于负载均衡、per-CPU 缓存亲和性判断 |

> **实现说明**：在旧版 Linux 上，`thread_info` 确实放在内核栈低地址端——`task_struct` 分配时顺带分配一块 16KB 的内核栈，`thread_info` 占据栈的最低一段空间，通过 `RSP & ~(THREAD_SIZE-1)` 就能定位到它。**但现代 x86-64（Linux 4.9+）已经把 `thread_info` 从栈上搬走了**——直接嵌在 `task_struct` 里（`task_struct.thread_info`），内核栈的分配也变成了独立的 `alloc_thread_stack_node()`（通过 `vmalloc` 或 SLUB cache），不再和 `thread_info` 共用同一片物理页。

> >

> > 本文沿用"thread_info 在栈底"的**概念模型**画图——因为无论它物理位置在哪，从线程生命周期的视角看：内核栈分配完就在那，线程创建时 `thread_info` 字段被初始化，之后每次调度回来都不变。把它画在栈图里只是为了让"控制信息（低地址）→ 执行轨迹（中间）→ 用户态出口（高地址）"这个三层结构一目了然。

### pt_regs（用户态寄存器快照）

**位置**：内核栈的**高地址端**——紧挨着 RSP0（初始栈指针）的下方。这是进入内核态时**最先压入栈的数据**。

**内容**：用户态被打断那一刻的 CPU 寄存器完整现场（x86-64 约 168 bytes）：

```bash
struct pt_regs {
    unsigned long r15, r14, r13, r12, rbp, rbx;   // 通用寄存器
    unsigned long r11, r10, r9, r8;                 // 通用寄存器
    unsigned long rax, rcx, rdx, rsi, rdi;          // 通用寄存器 + 系统调用号
    unsigned long orig_rax;                         // 原始系统调用号
    unsigned long rip;                              // 被打断的用户态指令地址
    unsigned long cs;                               // 用户态代码段
    unsigned long rflags;                           // 标志寄存器
    unsigned long rsp;                              // 用户态栈指针
    unsigned long ss;                               // 用户态栈段
};
```

**两种构造方式**（详细对比见 §一 → 快照③）：

| 方式 | 谁来压栈 | 触发场景 |
|------|---------|---------|
| **硬件自动压栈** | CPU 硬件（中断/异常） | 中断、缺页异常等：CPU 自动 push SS/RSP/RFLAGS/CS/RIP |
| **软件手工构造** | `entry_SYSCALL_64` 逐个 `pushq` | 系统调用：`SYSCALL` 指令不压栈，软件模拟中断的效果来统一布局 |

> **关键认知**：`pt_regs` 是内核态返回用户态的唯一"出口"——`sysretq`（系统调用）或 `iretq`（中断）从 `pt_regs` 恢复所有寄存器。如果你在内核态修改了 `pt_regs.rip`，线程就会跳到另一个地址去执行（信号机制正是利用这一点，见快照⑦）。

### RSP0（初始栈指针）

**定义**：内核栈的"栈底"——即**高地址端的起始位置**。每个线程的内核栈在分配时，`task_struct` 里记录了它的起始地址（RSP0）。栈为空时，RSP = RSP0。

**角色**：RSP0 是内核栈的参照系——`pt_regs` 紧贴 RSP0 下方，函数调用帧从 RSP0 方向向低地址方向一层层压入。

### 调用帧（Call Frame / Stack Frame）

**定义**：每次函数调用（`call` 指令）都会在栈上压入至少**返回地址**（`rip` 的下一条），加上被调用函数可能压入的 `rbp`（形成帧指针链）和分配的局部变量。这一整块就叫一个"调用帧"。

**为什么重要**：内核栈上的调用帧链就是线程在内核态的"执行轨迹"——从 `entry_SYSCALL_64` → `do_syscall_64` → `sys_read` → `vfs_read` → ... 每一层调用都在栈上留了帧。当线程被切走时，这些帧原样冻住；恢复时原样解冻，逐层 `ret` 返回——线程根本不知道中间发生过切换。

### thread_struct.sp（冻结点的入口指针）

**位置**：存在 `task_struct.thread.sp` 里，不在栈上。

**含义**：上下文切换时（`switch_to()` 宏），当前线程会把**自己的 RSP** 存入 `prev->thread.sp`，然后把 `next->thread.sp` 加载到 CPU 的 RSP 寄存器。这就是切换的核心——不是"保存所有东西然后恢复"，而是**换了一根栈指针**。

**为什么叫"冻结点入口" **：

- 当线程被切走：`thread.sp` 指向栈上 `switch_to()` push 的 6 个 callee-saved 寄存器（`rbp/rbx/r12-r15`）的位置
- 当线程被调度回来：`mov next->thread.sp → RSP`，然后 `pop` 这 6 个寄存器、`ret` 回到 `context_switch()`——线程从冻结点"解冻"
- 中间夹着的所有调用帧和 `pt_regs` 都在栈上原样保留，因为栈指针恢复后一切照旧

### 内核栈总览

```bash
低地址                    内核栈 (16KB, 4页)                    高地址
├────────────────────────────────────────────────────────────────┤
│ thread_info  │  调用帧链（从高到低依次压入）  │  pt_regs │ RSP0 │
│ flags/       │                               │  168B    │(初始 │
│ preempt/     │  entry → ... → schedule        │ 寄存器快照 │ 栈顶)│
│ cpu          │                                │          │      │
├────────────────────────────────────────────────────────────────┤
                ← 栈生长方向 (RSP 从 RSP0 向低地址递减) ←
```

> **一句话**：`thread_info` 是控制面板（低地址），`pt_regs` 是用户态出口（高地址），调用帧链是内核态执行轨迹（中间）——三者合起来就是线程被冻结时的完整快照。

---

## 〇、先破题：内核栈不是"一块内存"，而是线程的"执行轨迹化石"

每个线程有两个栈：

- **用户栈**：在用户态跑函数调用时用的，`mmap` 分配，约 8MB
- **内核栈**：进入内核态时用的，`task_struct` 创建时分配，x86-64 上默认 16KB（4 页）

关键区别：**用户栈上的内容一直在变，而内核栈在"线程没在内核态"时是冻住的**——它保存了线程上一次离开内核态时的完整轨迹。当线程再次被调度回来，内核栈上的东西原样未动，CPU 从上次停下的位置继续执行。

```plantuml
@startuml
skinparam shadowing false
skinparam nodesep 1
skinparam ranksep 1
skinparam rectangle {
  BackgroundColor<<th>> #C8E6C9
  BorderColor<<th>> #388E3C
  BackgroundColor<<ks>> #E3F2FD
  BorderColor<<ks>> #1976D2
}
rectangle "thread_info (低地址端)\nflags / preempt_count / cpu" <<th>> as TI
rectangle "schedule() → context_switch()\n→ switch_to()" <<调用帧 1>> as F1 #FFF9C4
rectangle "__schedule() 的局部变量" <<调用帧 2>> as F2 #FFF9C4
rectangle "do_signal() / sys_read() 等" <<调用帧 N>> as F3 #FFF9C4
rectangle "pt_regs (靠近 RSP0 一侧)\n15个通用寄存器\nrip / rsp / cs / rflags / ss" <<ks>> as PT
rectangle "(高地址端, 初始栈指针)" <<RSP0>> as TOP #FFFFFF
TI -[hidden]down- F1
F1 -[hidden]down- F2
F2 -[hidden]down- F3
F3 -[hidden]down- PT
PT -[hidden]down- TOP
@enduml
```

**内核栈的布局规则**（x86 栈从高地址向低地址生长）：

- **低地址端**：`thread_info`，存线程的标志位和控制信息（`TIF_SIGPENDING` 等）
- **中间区域**：函数调用帧，从高地址向低地址逐个压入，反映了线程在内核态的调用路径——**这是"冻结点"的核心**
- **高地址端**：`pt_regs` + RSP0。`pt_regs` 是进入内核态时最先压入的寄存器快照，靠近 RSP0（初始栈指针），保存了**用户态被打断时的现场**

> **一句话**：`pt_regs` 告诉你"线程在用户态哪条指令被打断"，调用帧告诉你"线程在内核态正处理什么"，`thread_info` 告诉你"有什么待办事项（信号、调度）"。三者合起来，就是线程被冻结时的完整快照。

## 一、七个典型快照 —— 一个线程从创建到消亡的内核栈全景

下文以一个工作线程 T（`pthread_create` 创建）为例，追踪它经历过的所有典型状态。每个状态画一张简化的内核栈示意图。

### 快照 ①：线程刚创建，尚未运行 —— fork 伪造的"起点"

`pthread_create` → `clone()` → `copy_process()` → `copy_thread()`。在 `copy_thread()` 里，内核为这个从未运行过的新 task 伪造了一份内核栈和 `thread_struct`：

```plantuml
@startuml
skinparam shadowing false
skinparam nodesep 1
skinparam ranksep 1
skinparam rectangle {
  BackgroundColor<<th>> #C8E6C9
  BorderColor<<th>> #388E3C
  BackgroundColor<<fake>> #FFE0B2
  BorderColor<<fake>> #EF6C00
}
rectangle "thread_info" <<th>> as TI
rectangle "ret_from_fork 的返回地址\n(copy_thread 写入)" <<fake>> as RET
rectangle "pt_regs (copy_thread 构造)\nrip = 入口函数地址\nrsp = 用户栈顶\ncs/ss/rflags = 默认值\n其余寄存器 = 0 或继承" <<fake>> as PT
rectangle "RSP0 (高地址端)" as TOP #FFFFFF
TI -[hidden]down- RET
RET -[hidden]down- PT
PT -[hidden]down- TOP
@enduml
```

**关键点**：

- `copy_thread()` 做了两件事：

  1. 在 `thread_struct.sp` 里写了一个**指向栈中间某个位置的 SP**（大致在 ret_from_fork 返回地址附近）
  2. 在栈上伪造了一份 `pt_regs`，其中 `rip` 指向线程入口函数

- 这意味着：**当 T 第一次被调度时**，`switch_to()` 加载 `T->thread.sp` → 栈恢复 → `ret` → 跳到 `ret_from_fork` → 从伪造的 `pt_regs` 恢复寄存器 → `sysretq` 跳到线程入口函数
- 线程**从来没有真正"陷入"过内核**——它的内核栈入口是伪造的（详见 [thread-startup.md](/concepts/process/thread-startup.md)）

### 快照 ②：线程在用户态跑着 —— 内核栈几乎为空

T 被调度后开始执行用户态代码。此时它**不在内核态**，内核栈上只有：

```plantuml
@startuml
skinparam shadowing false
skinparam nodesep 1
skinparam ranksep 1
skinparam rectangle {
  BackgroundColor<<th>> #C8E6C9
  BorderColor<<th>> #388E3C
}
rectangle "thread_info\nflags = 0 (无待办)\npreempt_count = 0\ncpu = 当前核号" <<th>> as TI
rectangle "内核栈剩余空间\n(全为垃圾/零)" as EMPTY
rectangle "RSP0 (高地址端)\nRSP = RSP0, 栈空" as TOP
TI -[hidden]down- EMPTY
EMPTY -[hidden]down- TOP
@enduml
```

**关键点**：

- `pt_regs` 已经从栈上弹出（上一次返回用户态时 `sysretq` 前就弹掉了）
- 调用帧也早就出栈了
- 栈上只剩下 `thread_info`，其余空间要么是上次使用留下的垃圾数据，要么是零
- 内核栈指针 RSP = RSP0（高地址端）——栈是空的，RSP 在初始位置

### 快照 ③：线程陷入内核（系统调用 / 中断）—— pt_regs 再次出现

T 调用了 `read()` 或收到时钟中断：

**→ 系统调用路径：**

```plantuml
@startuml
skinparam shadowing false
skinparam nodesep 1
skinparam ranksep 1
skinparam rectangle {
  BackgroundColor<<th>> #C8E6C9
  BorderColor<<th>> #388E3C
  BackgroundColor<<reg>> #E3F2FD
  BorderColor<<reg>> #1976D2
  BackgroundColor<<call>> #FFF9C4
  BorderColor<<call>> #F9A825
}
rectangle "thread_info" <<th>> as TI
rectangle "sys_read() 的调用帧\n(局部变量、返回地址)" <<call>> as F1
rectangle "vfs_read() 的调用帧" <<call>> as F2
rectangle "do_syscall_64() 的调用帧" <<call>> as F3
rectangle "entry_SYSCALL_64 的调用帧" <<call>> as F4
rectangle "pt_regs (软件构造)\norig_rax = __NR_read\nrip = read() 的下一条指令\nrsp = 用户栈指针\nrdi=fd, rsi=buf, rdx=count\ncs / ss / rflags" <<reg>> as PT
rectangle "RSP0 (高地址端)" as TOP
TI -[hidden]down- F1
F1 -[hidden]down- F2
F2 -[hidden]down- F3
F3 -[hidden]down- F4
F4 -[hidden]down- PT
PT -[hidden]down- TOP
@enduml
```

**进出系统调用：硬件/软件分工**

系统调用和中断的一个关键区别：**`SYSCALL` 指令本身不压栈**，只把 RIP→RCX、RFLAGS→R11 保存在寄存器里。pt_regs 的整个结构由 `entry_SYSCALL_64` **软件手工构造**——依次 `pushq` 了 SS/RSP/RFLAGS/CS/RIP，模拟了中断硬件自动压栈的效果。这样系统调用和中断共用一个 pt_regs 布局，返回路径可以统一处理。

**进入系统调用（`SYSCALL` 指令 → `entry_SYSCALL_64`）：**

| 步骤 | 谁做的 | 做了什么 |
|------|--------|---------|
| RCX ← RIP | **硬件** `SYSCALL` | 保存用户态返回地址 |
| R11 ← RFLAGS | **硬件** `SYSCALL` | 保存用户态标志寄存器 |
| RIP ← LSTAR MSR | **硬件** `SYSCALL` | 跳转到 `entry_SYSCALL_64` |
| CS ← 内核态段选择子 | **硬件** `SYSCALL` | 切换到 ring0 |
| SWAPGS | **软件** `entry_SYSCALL_64` | 切换到内核 GS 基址 |
| 暂存用户 RSP，切换到内核栈 | **软件** `entry_SYSCALL_64` | `mov %rsp → PER_CPU(rsp_scratch)`，加载内核 RSP |
| pushq $__USER_DS | **软件** `entry_SYSCALL_64` | 构造 pt_regs.ss |
| pushq 用户 RSP | **软件** `entry_SYSCALL_64` | 构造 pt_regs.sp |
| pushq R11 | **软件** `entry_SYSCALL_64` | 构造 pt_regs.flags（R11 即 SYSCALL 时的 RFLAGS）|
| pushq $__USER_CS | **软件** `entry_SYSCALL_64` | 构造 pt_regs.cs |
| pushq RCX | **软件** `entry_SYSCALL_64` | 构造 pt_regs.ip（RCX 即 SYSCALL 时的 RIP）|
| pushq RAX → pt_regs.orig_rax | **软件** `entry_SYSCALL_64` | 记录系统调用号 |
| SAVE_ALL 压入其余 15 个通用寄存器 | **软件** `entry_SYSCALL_64` | 构建完整 pt_regs（~168 bytes）|
| cld | **软件** `entry_SYSCALL_64` | 清除方向标志 |

**退出系统调用（`sysretq` 返回用户态）：**

| 步骤 | 谁做的 | 做了什么 |
|------|--------|---------|
| 逐层返回（pop 调用帧） | **软件** | 从业务逻辑一直回到 `entry_SYSCALL_64` |
| RESTORE_ALL 弹出 15 个通用寄存器 | **软件** `entry_SYSCALL_64` | 从 pt_regs 恢复寄存器 |
| 检查 TIF_SIGPENDING 等标志 | **软件** `exit_to_user_mode_loop` | 决定是否先处理信号/调度 |
| SWAPGS | **软件** | 切回用户 GS 基址 |
| RCX → RIP | **硬件** `sysretq` | 回到用户态下一条指令 |
| R11 → RFLAGS | **硬件** `sysretq` | 恢复用户态 RFLAGS |
| CS/SS → ring3 段选择子 | **硬件** `sysretq` | 切换回用户态特权级 |

> **一句话**：系统调用的 pt_regs 是**纯软件构造**的——`SYSCALL` 不压栈，`entry_SYSCALL_64` 手工 push 模拟了中断硬件自动压栈的效果。

**→ 中断路径（如时钟中断打断了用户态代码）：**

**进入中断：**

| 步骤 | 谁做的 | 做了什么 |
|------|--------|---------|
| 接收中断向量号 | **硬件** CPU + APIC | 中断控制器将向量号发给 CPU |
| 读 IDT[向量号] | **硬件** CPU | 获取中断门描述符（CS:RIP）|
| pushq SS（跨特权级时） | **硬件** CPU | 保存用户栈段 |
| pushq RSP（跨特权级时） | **硬件** CPU | 保存用户栈指针 |
| pushq RFLAGS | **硬件** CPU | 保存被打断时的标志寄存器 |
| pushq CS | **硬件** CPU | 保存被打断时的代码段 |
| pushq RIP | **硬件** CPU | 保存被打断时的指令指针 |
| pushq error_code（如果有） | **硬件** CPU | 部分异常类型会压入错误码 |
| RIP/CS ← IDT 中加载的值 | **硬件** CPU | 跳转到中断处理入口 |
| SWAPGS + push 其余通用寄存器 | **软件** 中断入口 | 构建完整 pt_regs |

**退出中断（`iretq` 返回用户态）：**

| 步骤 | 谁做的 | 做了什么 |
|------|--------|---------|
| 恢复通用寄存器 | **软件** 中断出口 | 从 pt_regs 弹出 |
| SWAPGS | **软件** | 切回用户 GS 基址 |
| popq RIP | **硬件** `iretq` | 回到被打断的指令 |
| popq CS | **硬件** `iretq` | 恢复代码段 |
| popq RFLAGS | **硬件** `iretq` | 恢复标志寄存器 |
| popq RSP（跨特权级时） | **硬件** `iretq` | 恢复用户栈指针 |
| popq SS（跨特权级时） | **硬件** `iretq` | 恢复用户栈段 |

> **一句话**：中断的 pt_regs 顶部 5 个字段（SS/RSP/RFLAGS/CS/RIP）由**硬件自动压栈**，其余由软件 push。退出时 `iretq` 自动从栈上 pop 出这 5 个字段。

**关键点**：

- 此时 T 正在内核态执行，**还没有被切走**
- 栈上是完整的调用轨迹：`entry → 中断/syscall 处理 → 具体业务逻辑`
- `pt_regs` 还在靠近 RSP0 的高地址侧，记录了被打断时的用户态现场
- 如果后面一切顺利（syscall 完成、直接返回用户态），这些调用帧会逐层弹出，`pt_regs` 被恢复，`sysretq`（系统调用）或 `iretq`（中断）回到用户态——**不会发生上下文切换**

### 快照 ④：线程在 schedule() 中被切走 —— 冻结点

这是最关键的快照。T 在 `read()` 执行中发现数据未就绪，调用 `schedule()` 让出 CPU：

```plantuml
@startuml
skinparam shadowing false
skinparam nodesep 1
skinparam ranksep 1
skinparam rectangle {
  BackgroundColor<<th>> #C8E6C9
  BorderColor<<th>> #388E3C
  BackgroundColor<<reg>> #E3F2FD
  BorderColor<<reg>> #1976D2
  BackgroundColor<<call>> #FFF9C4
  BorderColor<<call>> #F9A825
  BackgroundColor<<frozen>> #FFCDD2
  BorderColor<<frozen>> #C62828
}
rectangle "thread_info\nflags = 0" <<th>> as TI
rectangle "sys_read() 帧" <<call>> as F1
rectangle "vfs_read() 帧" <<call>> as F2
rectangle "__schedule() 帧\n(局部变量、prev/next 指针)" <<frozen>> as F3
rectangle "context_switch() 帧\n(这就是线程的'冻结点')" <<frozen>> as F4
rectangle "★ switch_to() push 的 6 个寄存器 ★\nrbp / rbx / r12 / r13 / r14 / r15\n(这些值现在在栈上冻着)" <<frozen>> as F5
rectangle "pt_regs (仍在 RSP0 一侧)\n记录 read() 被打断时的\n用户态现场" <<reg>> as PT
rectangle "RSP0 (高地址端)" as TOP
TI -[hidden]down- F1
F1 -[hidden]down- F2
F2 -[hidden]down- F3
F3 -[hidden]down- F4
F4 -[hidden]down- F5
F5 -[hidden]down- PT
PT -[hidden]down- TOP
note right of F4
  thread_struct.sp = 当前 RSP
  (指向 F5 中 rbp push 之后的位置)
end note
@enduml
```

**关键点（这就是"冻结点"的全部秘密）**：

1. **调用链冻住了**：从 `entry_SYSCALL_64` 到 `switch_to()` 的每一帧都在栈上原样保留
2. **`switch_to()` 压栈的 6 个 callee-saved 寄存器**（rbp/rbx/r12-r15）也存在栈上——恢复时要从栈上 `pop` 回来
3. **`thread_struct.sp`** 保存了切换瞬间的 RSP 值（大致指向 `switch_to` 压栈 rbp 之后的位置）
4. **`pt_regs`** 仍然靠近 RSP0 的高地址侧，记录了用户态被打断时的完整寄存器现场——这意味着：当 T 被调回来、从 `switch_to` 恢复后，最终可以通过 `pt_regs` 回到用户态的 `read()` 调用之后

> **核心认知**：T 此时已不在 CPU 上，但它的内核栈完整保存了"从哪来的、要回哪去"的全部信息。`thread_struct.sp` 是回到这个栈的入口，`pt_regs` 是最终回到用户态的出口。中间夹着的调用帧，就是在内核里尚未完成的工作。

### 快照 ⑤：线程睡眠/等待期间 —— 栈完全冻结

T 的状态是 `TASK_UNINTERRUPTIBLE`（等 IO）或 `TASK_INTERRUPTIBLE`（等信号/锁），任务队列里看不到它。**内核栈的内容和快照④完全一样，没有任何变化。**

因为线程不在运行，没有代码会读写它的内核栈。这份"化石"安静地躺在物理内存中，等待下一次被调度。

### 快照 ⑥：线程被调度回来 —— 从冻结点"解冻"

IO 完成，中断处理程序调 `try_to_wake_up(T)`，T 被放回就绪队列。当调度器再次选中它：

```plantuml
@startuml
skinparam shadowing false
skinparam participantPadding 5
skinparam boxPadding 1
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  ArrowColor                 #37474F
}
participant "调度器" as S
participant "T 的内核栈" as K
participant "T (CPU 上)" as T
S -> S : pick_next_task() 选中 T
S -> K : mov T->thread.sp → RSP
note right #FFCDD2 : RSP 指向 T 上次被切走时\nswitch_to() push rbp 之后的位置
S -> K : pop r15..rbp (恢复被冻住的6个寄存器)
S -> T : ret → 回到 context_switch()\nswitch_to() 调用之后的那一行
note right #C8E6C9 : T 从冻结点"醒来"\n栈上调用帧全部完好
T -> T : __schedule() 剩余代码 → schedule() 返回 → sys_read() 继续执行
T -> T : 逐层返回，弹出调用帧 → 恢复 pt_regs → sysretq → 用户态
@enduml
```

**关键点**：

- T 醒来后做的第一件事是 `pop` 那 6 个寄存器——然后 `ret`——然后发现自己回到了 `context_switch()` 里 `switch_to()` 之后的代码
- 从 T 的视角看，`switch_to()` 就是一个"耗时比较长的函数调用"——调用前在 `context_switch()`，调用后还在 `context_switch()`，中间发生的一切对 T 是透明的
- **栈上的调用帧和 `pt_regs` 都是 T 上次留下的**，所以 T 可以原样复用它们，逐层返回

### 快照 ⑦：线程被信号打断 —— 信号帧插入

T 正在内核态返回用户态的路上（`exit_to_user_mode_loop`），发现了 `TIF_SIGPENDING`（详见 [signal-multithread.md](/concepts/process/signal-multithread.md)），需要先执行信号 handler：

```plantuml
@startuml
skinparam shadowing false
skinparam nodesep 1
skinparam ranksep 1
skinparam rectangle {
  BackgroundColor<<th>> #C8E6C9
  BorderColor<<th>> #388E3C
  BackgroundColor<<reg>> #E3F2FD
  BorderColor<<reg>> #1976D2
  BackgroundColor<<sig>> #FFCDD2
  BorderColor<<sig>> #C62828
}
rectangle "thread_info\nflags = TIF_SIGPENDING" <<th>> as TI
rectangle "syscall/中断的调用帧\n(可能已部分弹出)" as F0 #FFF9C4
rectangle "do_signal() 帧\n(发现信号, 调用 get_signal)" <<sig>> as F1
rectangle "setup_rt_frame() 帧\n修改 pt_regs.rip = handler 地址\n修改 pt_regs.rsp = 新栈顶" <<sig>> as F2
rectangle "pt_regs (已被修改!)\nrip = handler 地址 ← 改写!\nrsp = 用户栈 sigframe ← 改写!\norig_rax = -1 (特殊标记)\n其余寄存器不变" <<reg>> as PT
rectangle "RSP0 (高地址端)" as TOP
TI -[hidden]down- F0
F0 -[hidden]down- F1
F1 -[hidden]down- F2
F2 -[hidden]down- PT
PT -[hidden]down- TOP
@enduml
```

**关键点**：

- `setup_rt_frame()` **在内核态修改了 `pt_regs`**（这是内核态的特权操作——改自己内核栈上的数据），把 `rip` 从"原本的用户代码地址"改成"handler 地址"
- 修改后的 `pt_regs` 被 `sysretq` 恢复时，线程就跳到了 handler 而不是原代码
- **原来的 `pt_regs` 内容并没有丢**——它们被写到了用户栈上的 `sigframe` 里（详见 [signal-multithread.md](/concepts/process/signal-multithread.md) §二），等 `rt_sigreturn` 时再恢复

## 二、全景图：内核栈"冻住什么、不冻什么"

汇总上面 7 个快照，画出内核栈在整个线程生命周期中的变化全景：

```plantuml
@startuml
skinparam shadowing false
skinparam nodesep 5
skinparam ranksep 10
skinparam rectangle {
  BackgroundColor<<empty>> #ECEFF1
  BorderColor<<empty>> #90A4AE
  BackgroundColor<<active>> #C8E6C9
  BorderColor<<active>> #388E3C
  BackgroundColor<<frozen>> #FFCDD2
  BorderColor<<frozen>> #C62828
}
rectangle "线程 T 的内核栈变化时间线" {
  rectangle "① 刚创建\n伪造栈" <<empty>> as S1
  rectangle "② 用户态跑着\n栈近乎空" <<empty>> as S2
  rectangle "③ 陷入内核\npt_regs+调用帧" <<active>> as S3
  rectangle "④ 被切走\n冻结点" <<frozen>> as S4
  rectangle "⑤ 睡眠中\n完全冻结" <<frozen>> as S5
  rectangle "⑥ 被调度回来\n解冻→返回" <<active>> as S6
  rectangle "⑦ 被信号打断\npt_regs改写" <<active>> as S7
  S1 --> S2 : 首次调度
  S2 --> S3 : syscall/中断
  S3 --> S4 : schedule()
  S4 --> S5 : 状态变睡眠
  S5 --> S6 : 被唤醒+调度
  S6 --> S2 : 返回用户态
  S3 --> S7 : 返回前发现信号
  S7 --> S2 : handler+sigreturn
}
note bottom of S2
  状态②/③是最频繁的常态：在用户态跑→进内核→出内核→在用户态跑
  只有③→④时才发生真正的上下文切换
end note
@enduml
```

**什么东西冻住了（切换时保留）**：

| 冻住的内容 | 存在哪里 | 谁恢复它 |
|-----------|---------|---------|
| 用户态寄存器现场（rax/rbx/rcx/rdx/rsi/rdi/r8-r15/rip/rsp/cs/ss/rflags） | 内核栈上的 `pt_regs` | `sysretq` 时从 pt_regs 恢复 |
| 内核态 callee-saved 寄存器（rbx/r12-r15/rbp） | 内核栈上 `switch_to()` 的 push 帧 | `switch_to()` 恢复时 pop |
| 内核态调用链（从 entry 到 schedule 的每一层） | 内核栈上的调用帧 | 逐层 return 时自动出栈 |
| 线程状态标志位（TIF_*、preempt_count） | `thread_info`（低地址端） | 返回用户态前逐项检查 |
| 内核栈指针（RSP） | `thread_struct.sp`（task_struct 内） | `switch_to()` 时 `mov T->thread.sp → RSP` |

**什么东西不冻（切换时交换）**：

| 不冻的内容 | 说明 |
|-----------|------|
| 调用者保存寄存器（rax/rcx/rdx/rsi/rdi/r8-r11） | 这些寄存器在调用约定中由**调用者**负责保存，`switch_to` 不碰它们；但在 `switch_to` 被调用前，它们已经被编译器生成的代码压入了各层调用帧的局部变量区（作为帧的一部分保留） |
| 浮点/SIMD 寄存器（XMM/YMM/ZMM） | 惰性保存——不冻在栈上，存在 `thread_struct.fpu` 区域，只在下次用到时恢复（见 [context-switch.md](/concepts/process/context-switch.md) §5.5） |
| 页表/CR3 | 存在 `mm_struct`，切线程不换、切进程才换 |

## 三、"冻结点"的精确位置：`switch_to()` 栈帧解剖

在所有快照中，快照④（冻结点）是最重要的。它在栈上的精确结构如下（x86-64）：

```bash
高地址（RSP0 一侧，已使用区域）
────────────────────────────────────
pt_regs                            ← 硬件+软件压入，~168 bytes
  .rip = 用户态下一条指令
  .rsp = 用户栈指针
  ... 15 个通用寄存器 ...
────────────────────────────────────
entry_SYSCALL_64 / 中断入口的帧
────────────────────────────────────
do_syscall_64() 的帧
────────────────────────────────────
sys_read() → vfs_read() → ... 的帧
────────────────────────────────────
__schedule() 的帧                  ← 包含 prev/next 等局部变量
────────────────────────────────────
context_switch() 的帧              ← 包含 rdi=prev, rsi=next
────────────────────────────────────
switch_to() push 的:    ← ★ 冻结点 RSP 指向这里 (push rbp 之后)
  [rbp]                             ← T->thread.sp 指向这个位置
  [rbx]                             ← mov %rsp, THREAD_SP(%rdi) 保存的 SP
  [r12]                             ← 就是指向 rbp 被 push 之后、
  [r13]                             ← 其余 5 个寄存器被 push 之前
  [r14]                             ← 的位置
  [r15]
────────────────────────────────────  ← RSP 在 switch_to 执行 push 时指向这里
(以下为未使用空间)
低地址（向 thread_info 方向）
```

**当 T 被恢复时**，过程正好反过来：

1. `mov T->thread.sp → RSP` → RSP 指向 `[rbp]` 那个位置
2. `pop r15 → pop r14 → ... → pop rbp` → 6 个寄存器恢复
3. `ret` → 弹出返回地址，跳到 `context_switch()` 中 `switch_to()` 之后的位置
4. `context_switch()` 返回 → `__schedule()` 的剩余代码 → ... → 最终回到 `entry_SYSCALL_64` → 恢复 `pt_regs` → `sysretq` 用户态

## 四、什么情况下能看到这些快照

**正常情况下看不到**——内核栈在用户态不可访问，`/proc/<pid>/task/<tid>/stack` 也只能在线程**正在内核态**时读出当前的调用栈（`save_stack_trace_tsk` 看到的），一旦线程在用户态就用不了了。但以下手段可以间接印证：

| 手段 | 能看到什么 | 限制 |
|------|----------|------|
| `cat /proc/<pid>/task/<tid>/stack` | 线程**正在内核态**时的调用栈快照 | 线程在用户态时为 0；无法看到历史快照 |
| `crash` 工具 + vmcore | **任意时刻**的所有线程内核栈（包括冻结点） | 需要 kdump/vmcore |
| `echo t > /proc/sysrq-trigger` | 所有 CPU 上**当前线程**的内核栈 + 所有**阻塞线程**的内核栈 | dmesg 输出；阻塞线程的栈可以看到 wait 位置 |
| `perf record -e sched:sched_switch -g` | 切换时的调用栈（切走那一刻的栈） | 只能看到切走时的调用路径，看不到完整栈帧 |
| GDB + QEMU 内核调试 | 逐条指令检查内核栈内容 | 需要内核调试环境 |

> **实战中最常用的**：`echo t > /proc/sysrq-trigger` + `dmesg`，对所有非 RUNNING 状态的线程打出的栈（对应快照④/⑤），可以看到它们卡在 `schedule`/`wait_event`/`mutex_lock` 等位置——这就是它们在冻结点被冻住的内核栈。

## 五、总结

**线程内核栈在冻结点的"完整考古"就是**：从低地址端的 `thread_info` 到高地址端（RSP0 一侧）的 `pt_regs`，中间夹着该线程在内核态走过的**每一层函数调用**。冻结点最核心的一层是 `switch_to()` 压入的 6 个 callee-saved 寄存器——`thread_struct.sp` 指向它，恢复时从它开始逐层弹出。整个过程不依赖任何"魔法"，就是栈的 push/pop 和 SP 的切换。

```plantuml
@startuml
skinparam shadowing false
skinparam nodesep 2
skinparam ranksep 2
skinparam rectangle {
  BackgroundColor<<key>> #C8E6C9
  BorderColor<<key>> #388E3C
}
rectangle "thread_info\n(标志位)" as A
rectangle "内核调用帧\n(entry→...→schedule→context_switch→switch_to)" as B
rectangle "switch_to push\nrbp/rbx/r12-r15" <<key>> as C
rectangle "pt_regs\n(用户态现场)" as D
rectangle "= 线程的完整冻结点" <<key>> as E
A -[hidden]down- B
B -[hidden]down- C
C -[hidden]down- D
D -[hidden]down- E
@enduml
```

> **一个线程的内核栈在切换中就像一块"化石"：当线程被切走时，`switch_to()` 把 6 个 callee-saved 寄存器压栈、把 RSP 存入 `thread_struct.sp`——从这一刻起，从低地址端的 `thread_info` 到高地址端（RSP0 一侧）的 `pt_regs`，中间夹着的每一层调用帧都原样冻结；当线程被调度回来时，`switch_to()` 从 `thread_struct.sp` 恢复 RSP、pop 出 6 个寄存器、`ret` 回到 `context_switch()`——线程从冻结点"解冻"，逐层出栈返回到 `sysretq`，最后通过 `pt_regs` 回到用户态被打断的位置，仿佛从未离开过。**

---

延伸阅读：

- [context-switch.md](/concepts/process/context-switch.md) §5.4 —— `switch_to()` 汇编级 SP 切换魔法
- [thread-startup.md](/concepts/process/thread-startup.md) —— 新线程内核栈如何被 `copy_thread` 伪造（快照①的细节）
- [syscall-details.md](/concepts/process/syscall-details.md) —— 系统调用陷入/退出时 pt_regs 的完整机制
- [signal-multithread.md](/concepts/process/signal-multithread.md) —— 信号如何改写 pt_regs（快照⑦的细节）
- [interrupts.md](/concepts/process/interrupts.md) —— 中断处理中的内核栈与 IST 独立栈
- [task-struct.md](/concepts/process/task-struct.md) —— `thread_struct` 与 `task_struct` 的关系

