上下文切换的实现 —— schedule() → context_switch():SP 一换,人就变了
前面聊了四种分类,这篇看内核到底怎么切:切换要保存/恢复的"上下文"由哪三块组成、
schedule()如何通过context_switch()完成"换地址空间 + 换寄存器现场"、四大触发场景(时间片耗尽/主动阻塞/被抢占/syscall 返回前)的完整时序,以及两个实现级细节——switch_to()的汇编代码(为什么 SP 一换执行流就变了)和 FPU/SIMD 寄存器的惰性切换(为什么不用每次都存 2KB 状态)。
相关:切换的分类体系见context-switch-types,开销与观测见context-switch-cost,实战案例见context-switch-case;总纲见 context-switch。何时调用
schedule()见 scheduling;被切换的 task 长什么样见 task-struct;硬件寄存器速查见 x86-64-registers。
一、"上下文"到底指什么
切换要保存/恢复的"上下文",就是一个 task 在 CPU 上运行所需的全部易失状态。分三块:
| 上下文 | 具体内容 | 存到哪 | 切线程也要? | 切进程也要? |
|---|---|---|---|---|
| CPU 寄存器现场 | 通用寄存器、栈指针 SP、指令指针 PC、标志位、(浮点/SIMD 状态) | task 的内核栈 / thread_struct | ✅ | ✅ |
| 内核栈 | 每个 task 独立的内核态栈(陷入内核时用) | task_struct 关联 | ✅ | ✅ |
| 地址空间(页表) | mm_struct → 页表根,装入 CR3 寄存器 | mm_struct | ❌(同进程共享 mm) | ✅(不同进程,必须换) |
关键:前两块(寄存器现场 + 内核栈)任何切换都要换,因为每个 task 有自己的执行流和内核栈。第三块(地址空间)只有跨进程才换——这正是"切进程比切线程贵"的根源。同进程的两个线程共享同一个
mm_struct(见 mm-struct),切它们时 CR3 不变、页表不动。
二、内核怎么实现一次切换:schedule() → context_switch()
切换由内核的 schedule() 发起(何时调用见 切换时机篇 §一),核心是 context_switch(),它干两件事:换地址空间 + 换寄存器现场。

switch_mm()——换地址空间:把新 task 的页表根物理地址写入 CR3 寄存器。CR3 一换,CPU 的地址翻译就指向新页表,0x400000这种地址在新进程里翻译到不同的物理页。跨进程才做这步;同进程线程B->mm == A->mm,跳过。switch_to()——换寄存器现场:这是切换最"魔法"的一步。它保存旧 task 的寄存器(尤其栈指针 SP)、加载新 task 的寄存器。当栈指针 SP 被换成 B 的内核栈那一刻,函数返回时就"落到"了 B 的执行流里——同一个switch_to调用,进去时是 A、出来时已经是 B(在 B 上次被切走时的那个switch_to里返回)。- 惰性浮点/SIMD:浮点和 SIMD 寄存器(XMM/YMM/ZMM,状态很大)常用"惰性保存"——不是每次都存,而是等新 task 真正用到浮点指令时才触发保存/恢复,省开销。详见下文 §四。
注意:切换全程在内核态完成。任何一次上下文切换,都是当前 task 先因为某种原因陷入内核(系统调用、中断、异常、时钟 tick),在内核里跑到
schedule(),才可能切到别的 task。用户态代码自己不能直接切换——它得先进内核。
2.1 触发场景一:时间片耗尽(时钟中断→抢占)
最常见的上下文切换触发方式。内核的时钟中断(tick)每 1-10ms 触发一次,检查当前 task 是否用完了时间片:

时间片耗尽切换的要点:
- A 从用户态被打断进入内核(硬件时钟中断自动完成模式切换)
- 在内核态判断时间片用完,调用
schedule() schedule()中做真正的上下文切换(A→B)- B 从内核态返回用户态(B 上次也是被切走时停在内核态的)
- A 的
task_struct状态变为TASK_RUNNING,重新入就绪队列
2.2 触发场景二:主动阻塞(等 IO / 等锁 / sleep)
Task 自己调用阻塞操作,主动让出 CPU:

主动阻塞切换的要点:
- A 自己把自己设为睡眠状态(
TASK_UNINTERRUPTIBLE或TASK_INTERRUPTIBLE) - 调用
schedule()时 A 已不在就绪队列中 - 统计为 voluntary(自愿)上下文切换
- A 在 IO 完成后被中断处理程序唤醒,重新入队
2.3 触发场景三:被高优先级任务抢占
实时任务或刚被唤醒的高优先级任务可以抢占当前正在运行的低优先级任务:

抢占切换的要点:
- 不是 A 主动让出,是 A 被更高优先级的任务挤走
- 统计为 nonvoluntary(非自愿)上下文切换
- 抢占发生在"A 下次进内核时"——在返回用户态前检查
TIF_NEED_RESCHED标志 - 内核态持有自旋锁时不可抢占(
preempt_count > 0),防止死锁
2.4 触发场景四:系统调用返回前发现需要重新调度
系统调用本身不一定导致切换,但在返回用户态前内核会检查是否需要调度:

2.5 四大场景汇总
| 场景 | 触发方式 | 统计类型 | 典型原因 |
|---|---|---|---|
| 时间片耗尽 | 时钟中断→schedule() | nonvoluntary | CPU 密集型任务超时 |
| 主动阻塞 | 自己调 schedule()(等 IO/锁/sleep) | voluntary | IO 等待、锁争用、主动 sleep |
| 被高优先级抢占 | 唤醒高优先任务→设 TIF_NEED_RESCHED→下次进内核时切 | nonvoluntary | 实时任务唤醒、优先级更高的任务就绪 |
| syscall 返回前发现需调度 | syscall 完成→检查 need_resched→切走 | nonvoluntary | syscall 执行期间有事件唤醒其他任务 |
三、switch_to() 的汇编级实现:SP 一换,人就变了
switch_to() 是上下文切换中最核心的步骤,它负责保存旧 task 的寄存器现场并加载新 task 的现场。下面是 x86-64 上的简化实现:
; arch/x86/entry/entry_64.S (简化版)
; 调用约定: switch_to(prev, next)
; prev 在 rdi, next 在 rsi
switch_to:
; ===== 第一步:保存 prev 的寄存器现场 =====
pushq %rbp
pushq %rbx
pushq %r12
pushq %r13
pushq %r14
pushq %r15
; 把当前的栈指针(RSP)保存到 prev->thread.sp
; prev 的 task_struct 地址在 rdi 中
movq %rsp, THREAD_SP(%rdi) ; prev->thread.sp = RSP
; ===== 第二步:加载 next 的寄存器现场 =====
; 把 next 的栈指针加载到 RSP
movq THREAD_SP(%rsi), %rsp ; RSP = next->thread.sp
; ★★★ 从这一行开始,执行流已经"变成"了 next ★★★
; 因为 RSP 指向了 next 的内核栈,
; 下面的 pop 操作读的是 next 之前保存的寄存器值
; 从 next 的内核栈恢复寄存器
popq %r15
popq %r14
popq %r13
popq %r12
popq %rbx
popq %rbp
ret ; 返回到 next 上次被切走时的调用点为什么 SP 一换就"变成"了另一个 task?
switch_to() 是一个对称的代码——prev 和 next 执行的是完全相同的指令序列,但一个在"保存"、一个在"恢复"。关键在 movq %rsp, THREAD_SP(%rdi) 和 movq THREAD_SP(%rsi), %rsp 之间:
prev 的视角:
push/push... → 把自己的寄存器压到自己的内核栈
mov RSP → prev->thread.sp → 记住"我停在这了"
mov next->thread.sp → RSP → 栈换了!
接下来的 pop 恢复的是 next 的寄存器 ← 已经在 next 的执行流了
next 的视角:
上次被切走时,RSP 停在了 popq %r15 那一行
这次恢复后,继续 pop 出自己之前的寄存器
ret 返回到自己上次调 switch_to 之后的代码**这就是上下文切换的"魔法" **:两个 task 各自调用 switch_to(),各自在 movq THREAD_SP(%rsi), %rsp 之后"醒来"——但醒来时看到的是对方的栈和寄存器。整个过程没有魔法函数,只是栈指针的切换。
四、FPU/SIMD 寄存器:为什么要"惰性"切换
x86-64 的浮点和 SIMD 寄存器组非常庞大:
| 寄存器组 | 数量 | 单个大小 | 总大小 |
|---|---|---|---|
| x87 FPU | 8 个 | 80 bits | 80 bytes |
| MMX | 8 个 | 64 bits | 64 bytes (与 x87 共用) |
| XMM (SSE) | 16 个 | 128 bits | 256 bytes |
| YMM (AVX) | 16 个 | 256 bits | 512 bytes |
| ZMM (AVX-512) | 32 个 | 512 bits | 2048 bytes |
如果每次上下文切换都要保存/恢复全部 FPU/SIMD 寄存器,开销将非常大。Linux 使用惰性 FPU 切换:
惰性 FPU 切换的原理:
1. 切换时只设置 CR0.TS(Task Switched)标志,不保存/恢复 FPU 寄存器
2. 新 task 运行时如果尝试执行浮点/SIMD 指令:
→ CPU 发现 CR0.TS=1
→ 触发 #NM (Device Not Available) 异常
→ 异常处理函数中:
a. 保存旧 task 的 FPU 状态(如果之前有人在用)
b. 恢复新 task 的 FPU 状态(如果有保存的副本)
c. 清除 CR0.TS
d. 重新执行浮点指令
3. 如果新 task 根本不执行浮点指令:
→ 永远不会触发 #NM
→ 零开销!
关键统计:
- 大部分 task 在被调度的时间片内不使用浮点/SIMD
- 惰性切换可省掉 256-2048 bytes 的保存/恢复
- 只有当 task 确实用到浮点时才付出代价优化版(Linux 5.x+):现代内核使用 XSAVES/XRSTORS 指令,配合 XFD(eXtended Feature Disable),可以做到更细粒度的惰性切换——只在实际用到的 SIMD 子集上触发保存/恢复。
一句话总结
内核切换一次 task 分两步:
switch_mm()换地址空间(仅跨进程,写 CR3 → TLB 大面积失效)+switch_to()换寄存器现场(把旧 task 的寄存器压栈、把 SP 换成新 task 的内核栈,SP 一换执行流就"变成"了新 task)。切换只能在内核态发生,由schedule()发起,四大触发场景:时间片耗尽(时钟中断)、主动阻塞(等 IO/锁/sleep,自愿切换)、被高优先级抢占(设TIF_NEED_RESCHED,下次进内核时切,非自愿切换)、syscall 返回前发现需要调度。实现细节上,switch_to()是对称代码——prev 和 next 跑同一段指令,靠换 SP 完成交接;FPU/SIMD 状态(最大 2KB)用惰性切换(CR0.TS + #NM 异常),task 不用浮点就零开销,现代内核用 XSAVES/XFD 做更细粒度的惰性。