从零写OS内核 | 上下文切换——CPU是怎么换场的
调度器决定让进程 A 下 CPU,让进程 B 上 CPU——但 CPU 怎么知道 A 暂停在哪一行代码?B 从哪里继续?进程切换时,寄存器的值是怎么保存和恢复的?
这就是上下文切换(Context Switch)要解决的核心问题:把一个进程的完整执行状态保存起来,把另一个进程的状态恢复出来,让 CPU 看起来好像从未切换过一样。
今天,我们来彻底搞清楚这个机制。
1. 什么是上下文
CPU 在任意时刻的执行状态,由以下部分组成:
执行上下文(Execution Context)的组成:
CPU 寄存器:
- 通用寄存器:rax, rbx, rcx, rdx, rsi, rdi, rbp, rsp, r8-r15
- 指令指针:rip(下一条要执行的指令地址)
- 栈指针:rsp
- 段寄存器:cs, ds, es, fs, gs, ss
- FLAGS:rflags(条件码,如 ZF, SF, OF 等)
控制寄存器:
- cr0(保护模式开关、缓存开关等)
- cr2(Page Fault 地址)
- cr3(页表基址——每个进程不同!)
- cr4(PAE、Page Size Extension 等)
浮点和 SIMD 状态:
- xmm0-xmm15, ymm0-ymm15, zmm0-zmm31(AVX512)
- x87 FPU 寄存器(st0-st7)
- MXCSR(FPU 控制/状态)
特权级别:
- CPL(Current Privilege Level)
- RSP(用户态栈指针,需要在切换时保存)
其他:
- IDTR(中断描述符表位置)
- GDTR(全局描述符表位置)对于进程来说,最重要的是:rip、rsp、通用寄存器。对于线程,还需要保存线程局部存储、信号掩码等。
2. 内核栈和进程切换
Linux 用内核栈来保存上下文。每个进程有两把栈:
每个进程有两把栈:
用户态栈(进程空间,Ring 3):
→ 程序执行 main() / 函数调用 / 局部变量时用
→ 切换到内核时,硬件自动把 SS/RSP/RFLAGS/CS/RIP 压到内核栈
内核栈(内核空间,Ring 0):
→ 系统调用、中断、异常处理时用
→ 进程切换时,CPU 寄存器保存在这里
→ 大小固定(通常是 8KB 或 16KB,对齐到页面)
┌──────────────────────────────────────┐
│ 内核栈(Ring 0) │
│ │
│ ... │
│ SS(用户栈选择子) │ ← 中断入口时自动压入
│ RSP(用户栈指针) │
│ RFLAGS │
│ CS(用户代码段选择子) │
│ RIP(用户下一条指令) │
│ error code / vector │
│ ... │
│ callee-saved registers │ ← switch_to 保存
│ ... │
│ task_struct *current │ ← 调度器用
└──────────────────────────────────────┘3. switch_to:进程切换的核心
switch_to 是 Linux 上下文切换的核心汇编函数。它保存在当前进程的寄存器,从下一个进程恢复寄存器。
3.1 switch_to 的实现原理
# x86_64 switch_to 宏(简化版)
# 源码:arch/x86/include/asm/switch_to.h
.macro SWITCH_TO prev, next, saved_reg
# 保存 prev 的 callee-saved 寄存器
pushf # 保存 RFLAGS
pushq %rbp
pushq %r12
pushq %r13
pushq %r14
pushq %r15
pushq %rbx
# 把 prev 的栈指针保存到 prev->thread.sp
movq %rsp, (prev + THREAD_SP)($1)
# 把 next 的栈指针恢复到 RSP(切换到 next 的栈!)
movq (next + THREAD_SP)($2), %rsp
# 恢复 next 的 callee-saved 寄存器
popq %rbx
popq %r15
popq %r14
popq %r13
popq %r12
popq %rbp
popf
# 此时 CPU 在 next 的栈上运行
# next 的 rip 已经在栈上(上次切换时保存的)
# ret 指令会弹出这个 rip,next 从断点继续执行
.endm关键点:栈指针切换 = 进程切换。因为所有寄存器都存在栈上,一旦 rsp 指向了另一个进程的内核栈,CPU 就已经在运行另一个进程了。
3.2 context_switch 函数
// Linux context_switch(简化版)
// 源码:kernel/sched/core.c
static __always_inline void
context_switch(struct rq *rq, struct task_struct *prev,
struct task_struct *next) {
// Step 1: 切换地址空间(页表)
if (next->mm != prev->mm) {
// 加载 next 的页表(CR3)
switch_mm(prev->mm, next->mm);
} else {
// 同一地址空间(线程),只切换栈
}
// Step 2: 切换内核栈
// 这行汇编做了真正的寄存器保存/恢复
switch_to(prev, next, prev->thread.sp);
// 当 switch_to 返回时,CPU 已经在 next 的上下文里
// prev 和 next 的角色互换(下一个调度周期里,prev 会再次被切换回来)
}3.3 切换时机:什么时候发生
context_switch 被调用的时机:
schedule()
↓
pick_next_task() 选出了 next
↓
context_switch(rq, prev, next)
↓
switch_to(prev, next) ← 真正的寄存器切换
↓
next 进程从 switch_to 返回,继续执行注意:switch_to 返回后,代码继续在 prev 的栈上执行——但这个 prev 已经是”上一个调度周期里的 next”了。这就是为什么 switch_to 用__asm__ __volatile__来阻止编译器优化。
4. 用户态上下文切换:fork 的 return
除了内核里的上下文切换,还有一种更常见的”切换”——用户态的函数返回。fork 在子进程中返回 0,就是靠 switch_to 把子进程的 rax = 0 恢复出来的。
fork() 的返回路径(子进程视角):
main() {
pid = fork(); ← 调用 libc fork()
↓
syscall(SYS_fork) → do_fork() → copy_process()
↓
子进程被 schedule() 调度到
↓
switch_to(父进程, 子进程) ← 切换寄存器
↓
子进程恢复执行(从它上次被中断的地方)
↓
在子进程的栈上,rax = 0(copy_process 里设置的)
↓
ret 指令从 syscall 返回
↓
libc 读到 rax = 0 → return 0(子进程)
}所以 fork 的返回值不需要任何特殊处理——它就是寄存器恢复的自然结果。
5. 线程切换 vs 进程切换
进程切换和线程切换的核心区别在于地址空间是否相同:
进程切换(prev->mm != next->mm):
→ 必须切换 CR3(页表基址)
→ TLB 自动失效(或用 invpcid 刷新)
→ 代价较大(大约 100-200 个 CPU 周期)
线程切换(prev->mm == next->mm):
→ 不切换 CR3,共享同一个地址空间
→ TLB 不失效(同一个进程的线程)
→ 只需要切换寄存器(栈、IP、通用寄存器)
→ 代价小(约 20-50 个 CPU 周期)// 调度器对进程和线程的区别处理
context_switch() {
if (prev->mm != next->mm) {
// 进程:切换地址空间(慢)
load_cr3(next->pgd);
// TLB flush
}
// 线程:不需要 load_cr3(快)
// ...
// 切换寄存器(都需要的)
switch_to(prev, next);
}6. Linux 实践:观察上下文切换
# 观察系统整体上下文切换次数
vmstat 1
# 每秒上下文切换次数(cs 列)
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
0 0 0 655360 12340 234567 0 0 0 0 34 120 5 2 93 0 0
# cs = context switches per second# 用 pidstat 看单个进程的上下文切换
pidstat -w 1 2
# 输出:
# UID PID cswch/s mig/s
# root 1234 150 0
# cswch = voluntary context switches(进程主动让出)
# mig = involuntary context switches(被调度器抢走)# 用 perf 观察上下文切换热点
perf record -e sched:sched_switch -a sleep 5
perf report
# 观察调度延迟
perf sched record -a sleep 10
perf sched latency
# 显示每个进程的调度延迟(avg, max)
# 延迟大 = 调度不及时,可能影响响应# 观察 CPU 在不同进程间的迁移
cat /proc/$$/status | grep Ctx
# 用 strace 观察系统调用导致的切换
strace -f -e trace=read,write ls /tmp
# -f 同时追踪 fork 出来的子进程7. 从零实现:switch_to 的简化版
⚠️ wandos 当前状态:kernel/process/ 目录有 context switch 代码。以下分析其实现。
7.1 寄存器保存(kernel/process/switch.S)
# wandos 上下文切换汇编
# 源码:kernel/process/switch.S
.section .text
.global switch_to
.type switch_to, @function
# 参数:rdi = prev PCB*, rsi = next PCB*
switch_to:
# 保存 callee-saved 寄存器
pushq %rbp
pushq %r12
pushq %r13
pushq %r14
pushq %r15
pushq %rbx
# 保存当前栈指针到 prev->sp
movq %rsp, (%rdi)
# 恢复下一个进程的栈指针
movq (%rsi), %rsp
# 恢复下一个进程的寄存器
popq %rbx
popq %r15
popq %r14
popq %r13
popq %r12
popq %rbp
# 返回(弹出之前保存的 rip)
ret7.2 C 语言调度循环
// wandos 调度器中的上下文切换调用
// 源码:kernel/process/scheduler.cpp
void schedule(void) {
struct process_control_block *prev = current;
struct process_control_block *next = pick_next_process();
if (next == prev) return; // 同一进程,不需要切换
current = next; // 更新当前进程指针
// 汇编切换函数(rdi=prev, rsi=next)
switch_to(prev, next); // ← 关键:寄存器切换
// 切换返回后,继续执行新进程
}7.3 PCB 中的栈指针字段
// PCB 结构中与切换相关的字段
struct process_control_block {
uint32_t pid;
// ...
// 内核栈指针(在 switch_to 里保存/恢复)
uint64_t kernel_sp;
// 通用寄存器快照(switch_to 之外保留完整上下文)
uint64_t rax, rbx, rcx, rdx;
uint64_t rsi, rdi, rbp;
uint64_t r8, r9, r10, r11, r12, r13, r14, r15;
uint64_t rip; // 下一条指令地址(恢复时用)
uint64_t rflags; // FLAGS 寄存器
// 页表基址(CR3)
void *page_table;
};7.4 wandos 和 Linux 的主要差异
- 没有内核栈分离:Linux 每个进程有独立的内核栈,切换时只保存寄存器到栈;wandos 把寄存器保存在 PCB 里,开销略大
- 没有 TLB 刷新优化:Linux 用
invpcid刷新特定地址的 TLB;wandos 切换进程时直接全量 flush - 没有 FPU 状态切换:Linux 在
switch_to里会保存/恢复 FPU/SSE 寄存器(fxsave/fxrstor),wandos 假设不使用 FPU,省略这步 - 没有 Lazy FPU:Linux 用”lazy restore”策略——如果下一个进程不修改 FPU,就不恢复;但如果下一个进程确实用了 FPU,才从内存里恢复。这避免了无意义的 FPU 上下文切换开销。wandos 没有这个优化。
8. 动手环节:实现一个最小上下文切换
今天的目标:在 os-kernel-from-scratch 里实现一个用户态的协程切换器,用 setjmp/longjmp 来理解上下文切换的本质,然后实现一个手动版本的 switch_to。
任务 1:setjmp/longjmp 协程
// 用 setjmp/longjmp 实现两个协程的切换
jmp_buf env1, env2;
void coro1(void) {
printf("A1n");
longjmp(env2, 1); // 切换到 coro2
printf("A2n");
}
void coro2(void) {
printf("B1n");
longjmp(env1, 1); // 切换到 coro1
printf("B2n");
}
int main() {
if (setjmp(env1) == 0) coro1(); // 首次设置,返回 0
if (setjmp(env2) == 0) coro2(); // 首次设置,返回 0
// 循环切换...
}任务 2:实现手动 switch_to
用 struct context { uint64_t regs[8]; jmp_buf jb; } 实现两个进程的切换,用 sigsetjmp/siglongjmp 保存和恢复完整上下文(包括信号掩码)。
验收标准
- 两个协程各执行 5 次后退出(不无限循环)
- 用
make编译无警告 setjmp被调用至少 2 次,longjmp被调用至少 2 次
9. 踩坑与注意事项
坑 1:switch_to 用错参数顺序
switch_to(prev, next) 里,prev 和 next 的顺序很重要——ret 弹出的 rip 是上一个进程被切换时保存的。所以如果顺序写反了,ret 会弹出一个错误的地址,程序跳到未知位置(几乎必然崩溃)。检查方法:next->rip 必须指向一个有效的、准备恢复的指令地址。
坑 2:FPU 状态没保存导致计算错误
如果进程 A 用 SSE 指令计算了一个浮点结果,切换到进程 B 后,进程 B 也用 SSE——如果 switch_to 没有 fxsave/fxrstor,A 的 SSE 寄存器值可能被 B 覆盖,导致 A 的计算出错。解决:每个进程第一次用 FPU 时触发 #NM(设备不可用)异常,在 handler 里 fxsave 到进程的结构体里,下次切换时 fxrstor。
坑 3:内核栈溢出
switch_to 需要在栈上保存所有寄存器(大约 8-16 个 8 字节)。如果内核栈只剩 100 字节,而你的 switch_to 需要保存 128 字节,就会栈溢出。Linux 的内核栈固定 8KB 或 16KB,对齐到页面,可以避免这个问题。调试方法:在 switch_to 前后打印 rsp 的值,看是否在合理范围内。
坑 4:CR3 切换后 TLB 不失效
切换到新进程后,如果新进程的页表和旧进程有重叠的虚拟地址,但物理地址不同,TLB 里还缓存着旧映射。访问这些地址会读到错误的数据。解决:切换 CR3 后执行 mov cr3, cr3 或 invpcid 刷新 TLB。Linux 在 switch_mm 里做这件事。
写在最后
上下文切换是操作系统最底层的工作之一——它本质上就是两件事:保存状态、恢复状态。寄存器存在栈上,栈指针切换了,CPU 就换了一个进程。页表切换了,地址空间就隔离了。
理解上下文切换,你才算真正理解为什么一个进程崩溃不会影响其他进程(每个进程有独立的寄存器状态和地址空间),以及为什么调度器是操作系统最核心的组件之一(它决定什么时候切换、切换到谁)。
下篇预告:VFS——虚拟文件系统,Linux 的一切皆文件是如何实现的。
相关阅读
- Linux Kernel Source:
arch/x86/include/asm/switch_to.h(switch_to 宏) - Linux Kernel Source:
kernel/sched/core.c(context_switch 函数) - Intel SDM Volume 3: Chapter 7(任务切换)
- wandos:
kernel/process/switch.S - wandos:
kernel/process/scheduler.cpp - wandos:
kernel/process/process.cpp - 本文 Demo: https://github.com/golang12306/os-kernel-from-scratch (demos/context_switch/)
- https://github.com/zhangfuwen/wandos — Linux 内核教程
- 下一篇:《从零写OS内核 | VFS——虚拟文件系统,Linux的一切皆文件是如何实现的》
动手环节
想深入理解本文内容?动手实践是最好的方式:
今天的目标:下载 wandos 代码仓库,理解 switch.S 的实现,对比 Linux 原版。
-
下载 wandos:
git clone https://github.com/zhangfuwen/wandos.git cd wandos -
找到对应模块:查看
kernel/process/switch.S -
实现作业:根据文中”动手环节”章节的要求,完成代码编写
-
提交作业:Fork 仓库,提交你的改动,在 GitHub 上开一个 Pull Request
评论