线程内核栈的快照 —— 多次切换中内核栈的"冻结点"分析
context-switch §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):
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都在栈上原样保留,因为栈指针恢复后一切照旧
内核栈总览
低地址 内核栈 (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 从上次停下的位置继续执行。

内核栈的布局规则(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:

关键点:
copy_thread()做了两件事:- 在
thread_struct.sp里写了一个指向栈中间某个位置的 SP(大致在 ret_from_fork 返回地址附近) - 在栈上伪造了一份
pt_regs,其中rip指向线程入口函数
- 在
这意味着:当 T 第一次被调度时,
switch_to()加载T->thread.sp→ 栈恢复 →ret→ 跳到ret_from_fork→ 从伪造的pt_regs恢复寄存器 →sysretq跳到线程入口函数线程从来没有真正"陷入"过内核——它的内核栈入口是伪造的(详见 thread-startup)
快照 ②:线程在用户态跑着 —— 内核栈几乎为空
T 被调度后开始执行用户态代码。此时它不在内核态,内核栈上只有:

关键点:
pt_regs已经从栈上弹出(上一次返回用户态时sysretq前就弹掉了)- 调用帧也早就出栈了
- 栈上只剩下
thread_info,其余空间要么是上次使用留下的垃圾数据,要么是零 - 内核栈指针 RSP = RSP0(高地址端)——栈是空的,RSP 在初始位置
快照 ③:线程陷入内核(系统调用 / 中断)—— pt_regs 再次出现
T 调用了 read() 或收到时钟中断:
→ 系统调用路径:

进出系统调用:硬件/软件分工
系统调用和中断的一个关键区别: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:

关键点(这就是"冻结点"的全部秘密):
- 调用链冻住了:从
entry_SYSCALL_64到switch_to()的每一帧都在栈上原样保留 switch_to()压栈的 6 个 callee-saved 寄存器(rbp/rbx/r12-r15)也存在栈上——恢复时要从栈上pop回来thread_struct.sp保存了切换瞬间的 RSP 值(大致指向switch_to压栈 rbp 之后的位置)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 被放回就绪队列。当调度器再次选中它:

关键点:
- 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),需要先执行信号 handler:

关键点:
setup_rt_frame()在内核态修改了pt_regs(这是内核态的特权操作——改自己内核栈上的数据),把rip从"原本的用户代码地址"改成"handler 地址"- 修改后的
pt_regs被sysretq恢复时,线程就跳到了 handler 而不是原代码 - 原来的
pt_regs内容并没有丢——它们被写到了用户栈上的sigframe里(详见 signal-multithread §二),等rt_sigreturn时再恢复
二、全景图:内核栈"冻住什么、不冻什么"
汇总上面 7 个快照,画出内核栈在整个线程生命周期中的变化全景:

什么东西冻住了(切换时保留):
| 冻住的内容 | 存在哪里 | 谁恢复它 |
|---|---|---|
| 用户态寄存器现场(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 §5.5) |
| 页表/CR3 | 存在 mm_struct,切线程不换、切进程才换 |
三、"冻结点"的精确位置:switch_to() 栈帧解剖
在所有快照中,快照④(冻结点)是最重要的。它在栈上的精确结构如下(x86-64):
高地址(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 被恢复时,过程正好反过来:
mov T->thread.sp → RSP→ RSP 指向[rbp]那个位置pop r15 → pop r14 → ... → pop rbp→ 6 个寄存器恢复ret→ 弹出返回地址,跳到context_switch()中switch_to()之后的位置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 的切换。

一个线程的内核栈在切换中就像一块"化石":当线程被切走时,
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 §5.4 ——
switch_to()汇编级 SP 切换魔法 - thread-startup —— 新线程内核栈如何被
copy_thread伪造(快照①的细节) - syscall-details —— 系统调用陷入/退出时 pt_regs 的完整机制
- signal-multithread —— 信号如何改写 pt_regs(快照⑦的细节)
- interrupts —— 中断处理中的内核栈与 IST 独立栈
- task-struct ——
thread_struct与task_struct的关系
总结
线程能在多次切换间“下次还能接着跑”,靠的是内核栈这块“化石”:概念上从低地址端的 thread_info(标志/计数,现代 x86-64 已移入 task_struct)到高地址端 RSP0 下的 pt_regs(用户态完整寄存器现场,是返回用户态的唯一出口,信号正是靠改写它跳转),中间夹着该线程陷入内核后走过的每一层函数调用帧;切走时 switch_to() 把 RSP 存进 task_struct.thread.sp、压入 rbp/rbx/r12–r15 共 6 个 callee-saved 寄存器,栈上一切原样冻结,恢复时从 thread.sp 载入 RSP、逐寄存器 pop、ret 回到 context_switch() 再逐层返回到 sysretq——全程没有魔法,就是栈的 push/pop 加一根 SP 的切换;这些快照对用户态不可见,实战中最常用 echo t > /proc/sysrq-trigger + dmesg 间接印证阻塞线程的冻结点。