从零写OS内核 | 上下文切换——CPU是怎么换场的

调度器决定让进程 A 下 CPU,让进程 B 上 CPU——但 CPU 怎么知道 A 暂停在哪一行代码?B 从哪里继续?进程切换时,寄存器的值是怎么保存和恢复的?

这就是上下文切换(Context Switch)要解决的核心问题:把一个进程的完整执行状态保存起来,把另一个进程的状态恢复出来,让 CPU 看起来好像从未切换过一样。

今天,我们来彻底搞清楚这个机制。


1. 什么是上下文

CPU 在任意时刻的执行状态,由以下部分组成:

Bash
执行上下文(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 用内核栈来保存上下文。每个进程有两把栈:

Bash
每个进程有两把栈:

用户态栈(进程空间,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 的实现原理

Asm
# 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 函数

C
// 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 切换时机:什么时候发生

Bash
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 恢复出来的。

Bash
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 进程切换

进程切换和线程切换的核心区别在于地址空间是否相同

Bash
进程切换(prev->mm != next->mm):
  → 必须切换 CR3(页表基址)
  → TLB 自动失效(或用 invpcid 刷新)
  → 代价较大(大约 100-200 个 CPU 周期)

线程切换(prev->mm == next->mm):
  → 不切换 CR3,共享同一个地址空间
  → TLB 不失效(同一个进程的线程)
  → 只需要切换寄存器(栈、IP、通用寄存器)
  → 代价小(约 20-50 个 CPU 周期)

C
// 调度器对进程和线程的区别处理
context_switch() {
    if (prev->mm != next->mm) {
        // 进程:切换地址空间(慢)
        load_cr3(next->pgd);
        // TLB flush
    }
    // 线程:不需要 load_cr3(快)
    // ...

    // 切换寄存器(都需要的)
    switch_to(prev, next);
}

6. Linux 实践:观察上下文切换

Bash
# 观察系统整体上下文切换次数
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

Bash
# 用 pidstat 看单个进程的上下文切换
pidstat -w 1 2

# 输出:
# UID       PID   cswch/s  mig/s
# root     1234      150     0
# cswch = voluntary context switches(进程主动让出)
# mig = involuntary context switches(被调度器抢走)

Bash
# 用 perf 观察上下文切换热点
perf record -e sched:sched_switch -a sleep 5
perf report

# 观察调度延迟
perf sched record -a sleep 10
perf sched latency

# 显示每个进程的调度延迟(avg, max)
# 延迟大 = 调度不及时,可能影响响应

Bash
# 观察 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)

Asm
# 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)
    ret

7.2 C 语言调度循环

Cpp
// 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 中的栈指针字段

Cpp
// 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 的主要差异

  1. 没有内核栈分离:Linux 每个进程有独立的内核栈,切换时只保存寄存器到栈;wandos 把寄存器保存在 PCB 里,开销略大
  2. 没有 TLB 刷新优化:Linux 用 invpcid 刷新特定地址的 TLB;wandos 切换进程时直接全量 flush
  3. 没有 FPU 状态切换:Linux 在 switch_to 里会保存/恢复 FPU/SSE 寄存器(fxsave/fxrstor),wandos 假设不使用 FPU,省略这步
  4. 没有 Lazy FPU:Linux 用”lazy restore”策略——如果下一个进程不修改 FPU,就不恢复;但如果下一个进程确实用了 FPU,才从内存里恢复。这避免了无意义的 FPU 上下文切换开销。wandos 没有这个优化。

8. 动手环节:实现一个最小上下文切换

今天的目标:在 os-kernel-from-scratch 里实现一个用户态的协程切换器,用 setjmp/longjmp 来理解上下文切换的本质,然后实现一个手动版本的 switch_to

任务 1:setjmp/longjmp 协程

C
// 用 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, cr3invpcid 刷新 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 原版。

  1. 下载 wandos

    Bash
    git clone https://github.com/zhangfuwen/wandos.git
    cd wandos
  2. 找到对应模块:查看 kernel/process/switch.S

  3. 实现作业:根据文中”动手环节”章节的要求,完成代码编写

  4. 提交作业:Fork 仓库,提交你的改动,在 GitHub 上开一个 Pull Request


仓库:https://github.com/golang12306/os-kernel-from-scratch

最后修改: 2024年6月10日

作者

评论

发表评论

您的邮箱地址不会被公开。