---
title: Linux 上下文切换：context_switch、switch_mm 与 switch_to
url: https://doc.liz6.com/linux-kernel/01-process-management/03-context-switch
locale: zh
area: linux-kernel
tags:
- context-switch
- Linux kernel
- scheduler
- TLB
- PCID
- FPU
- performance
- linux-kernel
- 进程管理
date: 2026-08-11
modified: 2026-08-11
description: 沿当前 Linux 调度路径解释 context_switch 如何切换 active_mm、CR3/PCID、内核栈、callee-saved 寄存器与 FPU 状态，并区分上下文切换成本、调度延迟和 TLB shootdown。
---

# Linux 上下文切换

调度器回答“下一个运行谁”，上下文切换负责把 CPU 从 `prev` 交给 `next`。这不只是保存一组通用寄存器，还可能涉及：

- 用户地址空间与 CR3/PCID；
- 内核栈和 callee-saved 寄存器；
- FPU/SIMD/扩展状态；
- perf、ptrace、I/O bitmap、TLS 等架构状态；
- RCU、membarrier、rseq 和调度器锁的收尾。

“上下文切换耗时”“任务从唤醒到运行的延迟”和“缓存/TLB 冷却造成的后续性能损失”是三项不同指标，不能用一个固定的微秒数概括。

## 1. 调用路径

典型路径：

```text
schedule()
  └─ __schedule()
       ├─ pick_next_task()
       └─ context_switch(rq, prev, next, rf)
            ├─ prepare_task_switch()
            ├─ 切换或借用 mm
            ├─ prepare_lock_switch()
            ├─ switch_to(prev, next, prev)
            └─ finish_task_switch(prev)
```

`context_switch()` 定义在 `kernel/sched/core.c`。下面的代码只保留控制流，**是带注释的简化版，不是可以与任意内核版本逐行对应的源码**：

```c
prepare_task_switch(rq, prev, next);
arch_start_context_switch(prev);

if (!next->mm) {                 // 切到内核线程
    enter_lazy_tlb(prev->active_mm, next);
    next->active_mm = prev->active_mm;
    // 根据 prev 是否为用户任务维护 active_mm 引用
} else {                         // 切到用户任务
    membarrier_switch_mm(rq, prev->active_mm, next->mm);
    switch_mm_irqs_off(prev->active_mm, next->mm, next);
    // 从内核线程切回用户任务时延迟释放 borrowed active_mm
}

prepare_lock_switch(rq, next, rf);
switch_to(prev, next, prev);
return finish_task_switch(prev);
```

应始终对照目标版本的[当前 `context_switch()` 源码](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/kernel/sched/core.c)，因为 mm 引用管理和同步步骤会变化。

## 2. `mm` 与 `active_mm`

用户任务拥有 `mm`；纯内核线程没有用户地址空间，因此 `mm == NULL`。但 CPU 仍必须装载一套页表，所以内核线程会临时借用前一个用户任务的 `active_mm`。

| prev → next | 地址空间动作 |
|---|---|
| 用户 → 用户 | 调用 `switch_mm_irqs_off()`，必要时切换 CR3/ASID |
| 用户 → 内核线程 | 进入 lazy TLB，内核线程借用 `prev->active_mm` |
| 内核线程 → 内核线程 | 继续 lazy TLB，并转移 borrowed `active_mm` |
| 内核线程 → 用户 | 切到 `next->mm`，随后释放旧的 borrowed `active_mm` |

“内核线程借用用户页表”不表示它可以访问任意用户地址。内核态执行仍受页表、KPTI、SMEP/SMAP 和显式用户访问 API 等约束。

## 3. `switch_mm_irqs_off()`、CR3 与 PCID

在 x86-64 上，地址空间切换最终围绕 CR3 和 TLB 状态展开。PCID 为 TLB entry 加上地址空间标签，使切换 CR3 时不必无条件丢弃所有非 global entry。

但“有 PCID 就永不 flush”也不正确。以下情况仍可能要求失效：

- ASID/PCID 被回收并重新分配；
- 页表内容发生变化；
- KPTI 需要维护用户/内核 CR3 组合；
- `mm` 的 TLB generation 表明当前 CPU 落后；
- 安全修复或架构约束要求更强的失效操作。

Linux 在每 CPU 的 `cpu_tlbstate` 中跟踪当前 mm、ASID 和 TLB generation。具体实现位于 [`arch/x86/mm/tlb.c`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/mm/tlb.c)。

## 4. `switch_to()` 真正保存什么

x86-64 的 `switch_to` 宏调用 `__switch_to_asm(prev, next)`。汇编入口主要做三件事：

1. 将 `rbp`、`rbx`、`r12`–`r15` 等 callee-saved 寄存器压入 `prev` 的内核栈。
2. 把当前 `rsp` 写入 `prev->thread.sp`，再载入 `next->thread.sp`。
3. 从 `next` 的内核栈恢复寄存器，然后跳转到 C 函数 `__switch_to()` 完成架构状态切换。

```asm
# arch/x86/entry/entry_64.S（简化）
pushq %rbp
pushq %rbx
pushq %r12
pushq %r13
pushq %r14
pushq %r15

movq %rsp, TASK_threadsp(%rdi)
movq TASK_threadsp(%rsi), %rsp

popq %r15
popq %r14
popq %r13
popq %r12
popq %rbx
popq %rbp
jmp __switch_to
```

通用寄存器并不是全部复制进一个 `thread_struct` 数组。callee-saved 状态主要保存在内核栈的 `inactive_task_frame` 中，`thread.sp` 让内核能够找到该帧。

### “switch_to 返回到了谁”

执行 `switch_to(prev, next, prev)` 后，CPU 已经使用 `next` 的内核栈和任务上下文。代码从 `next` 上次被切出时留下的调用链继续执行。

因此更准确的说法是：`switch_to` **会返回，但返回发生在切入任务的上下文里**；第三个参数接收真正被切出的任务，供 `finish_task_switch()` 做清理。简单说“它从 C 视角不返回”会掩盖这个双重返回语义。

## 5. 现代 x86 FPU 状态切换

现代 Linux 不再把常规 FPU 上下文描述成“每次切换只设置 TS，等新任务第一次执行浮点指令触发 `#NM` 再保存旧状态”的经典 lazy-FPU 模型。

当前调度路径中的 `switch_fpu()` 会在需要时：

1. 把旧任务的 FPU 寄存器保存到 fpstate；
2. 设置 `TIF_NEED_FPU_LOAD`；
3. 在返回用户态或内核需要使用该状态前恢复寄存器。

如果任务回到仍持有其寄存器状态的同一 CPU，内核可以避免不必要的恢复。XFD/`#NM` 仍可用于按需启用 AMX 等动态扩展状态，但不能把它泛化为所有 FPU/SIMD 状态的调度策略。

参考当前 [`switch_fpu()`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/include/asm/fpu/sched.h) 与 [x86 FPU 文档](https://docs.kernel.org/arch/x86/xstate.html)。

## 6. TLB shootdown 不是普通 context switch 的固定步骤

`munmap()`、权限变化或页表回收等操作修改了某个 `mm` 的页表后，其他正在或曾经运行该 `mm` 的 CPU 可能仍缓存旧 TLB entry。内核需要让相关 CPU 执行失效操作，必要时通过 IPI 协调。

```text
修改页表
  ├─ 本 CPU 失效对应 entry/range
  └─ 向持有该 mm 旧 TLB 状态的远端 CPU 发起 shootdown
       └─ 等待必要的完成确认
```

这与“调度器把 CPU 从 A 切到 B”相关但不等价：普通上下文切换可能只装载已有 ASID，而一次页表更新也可能在没有任务切换时触发 shootdown。

## 7. 怎样测量

先明确要测哪一项。

### 7.1 统计切换数量

```bash
perf stat -e context-switches,cpu-migrations,page-faults -- ./workload

grep -E 'voluntary_ctxt_switches|nonvoluntary_ctxt_switches' \
  /proc/<pid>/status
```

数量高不自动等于性能差。I/O 型程序会产生大量主动切换；问题取决于延迟目标、CPU 利用率和缓存影响。

### 7.2 观察调度事件

```bash
perf sched record -- ./workload
perf sched timehist
perf sched latency
```

`perf sched latency` 更接近任务等待调度的延迟，而不是单条 `switch_to` 汇编花费的周期数。

### 7.3 测量 wakeup-to-run 延迟

```bash
bpftrace -e '
tracepoint:sched:sched_wakeup {
    @wake[args->pid] = nsecs;
}
tracepoint:sched:sched_switch /@wake[args->next_pid]/ {
    @wake_to_run_us = hist((nsecs - @wake[args->next_pid]) / 1000);
    delete(@wake[args->next_pid]);
}'
```

这个直方图测的是“任务被唤醒到真正获得 CPU”的时间，包含 run queue 等待，不应标成纯 context-switch cost。

### 7.4 微基准

```bash
perf bench sched pipe -l 100000
```

它测量两个任务通过 pipe 往返的综合成本，包含 syscall、唤醒、调度与切换。报告结果时至少注明：CPU、内核版本、mitigation、CPU affinity、频率策略、是否跨 NUMA，以及线程还是进程。

## 8. 常见误区

- **“一次 context switch 固定 1–5 μs”**：不同测量定义和硬件无法直接比较。
- **“线程切换不碰地址空间”**：同一 `mm` 通常避免 CR3 切换，但仍有调度、栈、寄存器和可能的 FPU 成本。
- **“PCID 消除了 TLB flush”**：它减少无条件 flush，不消除页表失效和 ASID 回收。
- **“所有 FPU 状态都等首次使用再恢复”**：这是过时的泛化；现代实现使用 eager save 与 return-to-user restore，并对动态扩展另行处理。
- **“TLB shootdown 就是 context switch”**：前者是页表一致性机制，后者是任务执行权转移。
- **“`finish_task_switch` tracepoint 间隔就是切换耗时”**：它通常混入任务运行时间或排队时间，必须先定义测量边界。

## 9. 上游资料

- [Linux `context_switch()`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/kernel/sched/core.c)
- [x86 `__switch_to_asm()`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/entry/entry_64.S)
- [x86 TLB implementation](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/mm/tlb.c)
- [x86 FPU scheduler hooks](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/include/asm/fpu/sched.h)
- [Scheduler tracing documentation](https://docs.kernel.org/trace/ftrace.html)
- [perf sched documentation](https://man7.org/linux/man-pages/man1/perf-sched.1.html)

阅读内核文章时，最可靠的习惯是把“稳定概念”“某版本的实现”和“测量结果”分别标注。上下文切换长期存在，但它周围的安全加固、FPU 策略、TLB 管理和调度器收尾并不静止。
