这一关回答“上下文切换到底在切换什么”。答案是两样正交的东西:执行上下文(栈 + 寄存器 + FPU/TLS 这类 CPU 状态)和地址空间(CR3 指向的页表 + TLB)。前者由 switch_to 负责,后者由 switch_mm 负责。线程切换只走前者,进程切换两条都走,切换到内核线程时后者被 lazy TLB 整个跳过——三档代价,一条分界线。
本关的四个支点:__switch_to_asm(汇编最小集)→ __switch_to(C 语言扩展状态)→ context_switch 的 mm 分支(lazy TLB)→ switch_mm_irqs_off 的 ASID/PCID 快路径。M01 的 copy_thread/ret_from_fork 和 M04 的 TLB 章节,在这里会合。
__switch_to_asm(arch/x86/entry/entry_64.S)是 x86-64 上下文切换里唯一的汇编部分,它只做三件事:保存被调用者保存寄存器、换栈、跳到 C 函数 __switch_to。源码注释写明,push 的顺序必须与 inactive_task_frame(arch/x86/include/asm/switch_to.h)一致。
具体动作:pushq %rbp, %rbx, %r12–%r15;movq %rsp, TASK_threadsp(%rdi) 存旧栈顶,movq TASK_threadsp(%rsi), %rsp 切到新栈(参数约定 %rdi=prev、%rsi=next);中间按 CPU 特性做 FILL_RETURN_BUFFER(RSB 清洗,防推测执行攻击);最后 popq 恢复、jmp __switch_to。
为什么只存 callee-saved?因为 switch_to 在 C 语义上就是一次普通函数调用——调用者保存寄存器按 C ABI 由调用方在需要时保存,切换点“看起来”就是 prev 调用了一个函数、从 next 返回,编译器已经保证了调用者保存寄存器的安全。全量保存是重复劳动,还破坏“切换 = 函数调用”的心智模型。
新任务的第一次切换是个特例:copy_thread 把新任务的内核栈布置成“看起来像刚从 __switch_to_asm 返回”的样子,返回地址指向 ret_from_fork_asm——于是新任务第一次被切进来时,落点是 ret_from_fork_asm → ret_from_fork,而不是某个旧的调用点(M01 埋的伏笔在这里兑现)。
栈换过来之后,剩下的都是 C 代码能表达的“扩展 CPU 状态”:FPU、TLS、FS/GS、PKRU、TSS.sp0 等。汇编做最小集、C 做扩展集,是可维护性的分工——FPU 这类逻辑用汇编写是灾难。
__switch_to(arch/x86/kernel/process_64.c)的顺序值得逐行记住:switch_fpu 保存 FPU;save_fsgs(prev) 必须在 load_TLS(next) 之前——注释原话,%fs/%gs 可能被 load_TLS 清掉;arch_end_context_switch 收尾 paravirt 懒模式;DS/ES 条件重装(注释:CS/SS 不用管,它们是 pt_regs 的一部分);x86_fsgsbase_load、x86_pkru_load;raw_cpu_write(current_task, next_p) 换当前任务;update_task_stack 重载 TSS.sp0;switch_to_extra;最后 return prev_p。
关键细节:TSS.sp0 指向的是该任务的内核栈顶——下一次从用户态进内核(中断/异常/系统调用)时,CPU 用它找内核栈。换任务不换 sp0,下次进内核就跑到别人的栈上。
还有个藏着的 workaround:X86_BUG_SYSRET_SS_ATTRS——AMD 的 SYSRET 不更新 SS 描述符缓存,__switch_to 里强制把 SS 换成 __KERNEL_DS,保证后续 SYSRET 安全。上下文切换里藏着给系统调用返回路径擦的屁股(M08 会展开 SYSRET)。
context_switch(kernel/sched/core.c)的注释开宗明义:context_switch - switch to the new MM and the new thread's register state——先换 MM,再换寄存器状态,顺序固定。
分支一:next->mm == NULL(切换到内核线程)→ enter_lazy_tlb(prev->active_mm, next),next->active_mm = prev->active_mm:借上一个进程的地址空间,根本不去碰 CR3。
分支二:next->mm != NULL → switch_mm_irqs_off(prev->active_mm, next->mm, next);如果 prev 也是内核线程(prev->mm == NULL),把 prev->active_mm 记到 rq->prev_mm,留给 finish_task_switch 做 mmdrop。
最后 switch_to(prev, next, prev),注释:Here we just switch the register state and the stack. 三档代价就此定格:内核线程切换(只换栈+寄存器)< 同进程线程切换(mm 相同,switch_mm 快路径直接返回)< 跨进程切换(+ CR3/TLB)。
switch_mm(arch/x86/mm/tlb.c,经 switch_mm_irqs_off)是“换地址空间”的唯一入口,核心矛盾是 TLB:CR3 一换,旧 TLB 条目要么失效、要么靠 PCID 区分。
快路径:prev == next 且不在 lazy 状态 → 同一地址空间(同一进程的线程之间切换),直接返回,CR3 不写、TLB 不刷。
慢路径:choose_new_asid() 给 next_mm 挑 ASID → load_new_mm_cr3() 写 CR3。ASID 仍有效 → build_cr3_noflush():只写 CR3,靠 PCID 标签区分新旧条目,TLB 零刷新;ASID 过期 → 走 flush 路径。
lazy TLB(arch/x86/include/asm/tlbflush.h):enter_lazy_tlb 是“下一个是内核线程”的提示,把 tlb_state.is_lazy 置位,后续判断可以跳过不必要的 CR3 切换。注释原话:这是在赌“省掉的 CR3 load”收益大于管理开销。
PCID(x86 对 ASID 的叫法)给每个 TLB 条目打上地址空间标签,写 CR3 换 PCID 时旧条目不必失效——切换代价从“全刷 TLB + 全部重填”降到“一次 CR3 写”。
工程细节:每 CPU 只有 TLB_NR_DYN_ASIDS = 6 个动态 ASID 轮转(注释:6 个够用,且 tlb_state 能塞进两条 cache line);ASID 0 保留,内核用 kPCID = ASID+1;KPTI 开启时用户 PCID 在内核 PCID 基础上偏移 2048——4K ASID 被用户/内核对半分,各 2K。
耗尽时:6 个 ASID 轮完 → 走 flush 路径,全刷 TLB。所以“活跃进程数远超 6 且频繁切换”是 PCID 收益衰减的场景;nopcid 启动参数可以关掉它,对比 TLB miss 率能看到代价差。
注意当前 master 还另有 global ASID 与 INVLPGB(广播 TLB 失效)支持——6-ASID 轮转是默认快路径,不是唯一机制。写代码时别把它当成全部真相。
为什么汇编只存 callee-saved 寄存器,而不是全量寄存器?
因为 switch_to 在 C 语义上是一次普通函数调用,C ABI 已规定调用者保存寄存器由调用方在需要时保存。切换点“看起来”就是 prev 调用了一个函数、从 next 返回——编译器保证了调用者保存寄存器的安全。全量保存是重复劳动,还破坏“切换 = 函数调用”的心智模型。
为什么线程切换不换 CR3,进程切换要换?
因为 CR3 属于 mm(地址空间),不属于 task。同一 mm 的线程共享页表,换 CR3 除了多一次写 + 可能的 TLB 刷新,语义上什么都没改变。context_switch 里 !next->mm / switch_mm_irqs_off / switch_to 的三段式,就是“地址空间”与“执行流”正交的直接代码形态。
为什么用 PCID 而不是每次切换都全刷 TLB?
TLB 是 CPU 最热的结构之一,全刷后访存都要重走页表(4 级页表最坏 4 次内存访问打底),工作集越大、刷完越疼。PCID 让最多 6 个地址空间的 TLB 条目同时驻留,切换只是换标签——把“换地址空间”从 O(TLB 大小) 的重填变成 O(1) 的 CR3 写。6 这个数是“够用”和“tlb_state 塞进两条 cache line”的折中。
copy_thread 布置新任务栈帧、ret_from_fork_asm 是第一次切换的落点;内核线程 mm == NULL 借 active_mm,正是本关 lazy TLB 的由来。task_struct 与内核栈的分配——每次切换换的那个“栈”,本体是 M03 分配出来的内核栈。出题原则:10 题中记忆性题目不超过 3 道,其余为应用/推理/权衡类——场景预测、设计 trade-off 比较、从代码路径找问题、解释“为什么这样设计而不是那样”。难度递进,最后两道需要想一会儿。
三件事:保存被调用者保存寄存器(pushq %rbp, %rbx, %r12–%r15)、换栈(%rsp 存到 TASK_threadsp(%rdi),从 TASK_threadsp(%rsi) 取新栈)、jmp __switch_to。push 顺序必须与 inactive_task_frame 的字段顺序一致——这是源码注释的原话要求。
因为 switch_to 在 C 语义上是一次函数调用,C ABI 规定调用者保存寄存器由调用方负责——编译器在切换点周围已经保证了它们的安全。改成全量保存:语义上没错,但每次切换多存/多取一倍寄存器,纯属浪费;更糟的是破坏“切换 = 函数调用”的心智模型,后人再也看不出为什么这里能省。
注释原话:%fs/%gs 可能被 load_TLS() 清掉,所以必须先保存旧的、再装新的——顺序反了会丢 prev 的 FS/GS。CS/SS 不用管,因为它们是 pt_regs 的一部分,在中断/系统调用入口进内核时由硬件+入口汇编保存、返回用户态时恢复,不属于“任务私有 CPU 状态”。
因为 copy_thread 把新任务的内核栈伪装成“刚从 __switch_to_asm 返回”的样子:栈顶布置好 callee-saved 寄存器,返回地址填成 ret_from_fork_asm。第一次切换切到这个栈、__switch_to 返回时,CPU 取到的返回地址就是 ret_from_fork_asm——新任务“出生”时并没有调用过任何函数,所以只能伪造一个落点。
内核线程(next->mm == NULL):enter_lazy_tlb(prev->active_mm, next),借 prev 的地址空间。不调 switch_mm 是因为根本没有新的地址空间可换——内核线程不用用户地址空间,调了也是白写一次 CR3 + 白刷 TLB。用户进程:switch_mm_irqs_off(prev->active_mm, next->mm, next),正经换页表。
直接返回,CR3 不写、TLB 不刷。对应同一进程的线程之间切换——mm 相同,页表都没变,换 CR3 纯属浪费。这是“线程切换比进程切换便宜”在代码里最硬的一行。
显著上升。正常有 PCID 时,两个进程的 ASID 都有效,switch_mm 走 build_cr3_noflush()——只写 CR3 换标签,TLB 条目保留,ping-pong 回来时旧条目大概率还在。nopcid 关掉 PCID 后,每次 switch_mm 都全刷 TLB,回来重填——miss 率直接反映“每次切换清空全部 TLB”的代价。
6 个动态 ASID 轮完 → 新的 choose_new_asid 拿不到有效 ASID → 走 flush 路径,全刷 TLB。PCID 不是没用:任意时刻最热的 6 个地址空间仍然享受零刷新切换——时间局部性让它依然赚。只是“100 个进程均匀对打”这种最坏场景下,收益会被 flush 摊薄。6 是“够用”和“tlb_state 塞进两条 cache line”的工程折中。
因为 KPTI 下用户和内核各用独立的页表(防 Meltdown),4K ASID 被对半分:内核用低 2K,用户用高 2K(偏移 2048)。偏移的目的是让用户地址空间的 TLB 条目和内核的永远不重名——否则用户态的条目可能 alias 到内核条目,KPTI 的隔离就破了。
得到:省一次 jmp __switch_to 的调用开销——几个 cycle,可忽略。失去:(1) 可维护性——FPU/TLS/PKRU 全是条件编译+特性判断的 C 逻辑,汇编里写是灾难;(2) 复用——switch_fpu、load_TLS 等是 arch 通用 helpers,搬进汇编等于复制一份;(3) 灵活性——新增一个要切换的状态(比如未来的 APX 寄存器),C 里加一行,汇编里要重排栈帧。结论:汇编只做“不得不汇编”的事(换栈),其余全给 C——这是内核里反复出现的分工原则。
__switch_to_asm、ret_from_fork_asm,汇编最小集__switch_to,C 语言扩展状态switch_mm_irqs_off、choose_new_asid,地址空间切换switch_to 宏、inactive_task_frame三档代价:一条分界线
========================
schedule()
|
context_switch() ← "先换 MM,再换寄存器"
|
+-- next->mm == NULL --------> enter_lazy_tlb() [内核线程]
| 借 prev->active_mm,CR3 不动 代价 ≈ 换栈+寄存器
|
+-- next->mm != NULL, == prev->mm -> switch_mm 快路径 [同进程线程]
| prev == next,直接返回 代价 ≈ 换栈+寄存器
|
+-- next->mm != NULL, != prev->mm -> 写 CR3 [跨进程]
可能刷 TLB 代价 = 换栈+寄存器+CR3+TLB
|
v
switch_to(prev, next) ← 只换栈+寄存器,与 mm 无关
记住这张图,就记住了 7.3 的全部:switch_to 永远只换执行上下文,switch_mm 只在跨地址空间时发生。
PCID 快路径:switch_mm_irqs_off
================================
prev_mm == next_mm 且非 lazy
→ 直接返回(线程切换,零代价)
choose_new_asid():
ASID 仍有效 → build_cr3_noflush()
只写 CR3,换 PCID 标签
TLB 零刷新 ← 最热路径
ASID 过期/耗尽 → flush 路径
全刷 TLB,重填
每 CPU 6 个动态 ASID 轮转(TLB_NR_DYN_ASIDS)
KPTI:用户 PCID = 内核 PCID + 2048(4K 对半分)
PCID 的本质是给 TLB 条目加“地址空间”这一维标签——切换从“清空重填”降级为“换标签”。
__switch_to_asm 的栈切换(%rdi=prev, %rsi=next) ================================================ pushq %rbp, %rbx, %r12-%r15 ← 存 callee-saved(顺序须配 inactive_task_frame) movq %rsp, TASK_threadsp(%rdi) ← 旧栈顶存档 movq TASK_threadsp(%rsi), %rsp ← 切到新栈(从这里起,用的就是 next 的栈) [FILL_RETURN_BUFFER] ← RSB 清洗(防推测执行) popq %r15..%rbp ← 从新栈恢复 jmp __switch_to ← C 语言接管(FPU/TLS/FS-GS/PKRU...)
换栈是原子性的分界点:上一行还在 prev 的栈上,下一行已经是 next 的栈——中间没有任何“中间状态”。
在页面里每题写下你的答案(关键词即可),卡住的题点一下“卡住了”标记,
点“生成作答汇总”后复制、贴到聊天里——对错由我来判定,我逐题批改并据此
调整下一关的深浅;准备好了说“下一关”。
点下面按钮生成汇总文本,复制后贴到「内核二十关」侧聊里,对错由我判定。