审校基线:本页引用的内核符号、行号与源码链接以 Linux 7.3-rc4(torvalds/linux master,2026-09-26 实测)为基准;审校当日 master 的确切 commit 未记录,后续版本可能已有漂移。
进程切换分两步:换栈便宜,换地址空间贵。switch_to 只碰寄存器和栈,switch_mm 才碰 CR3 和 TLB——PCID 的存在,就是为了让第二步从“全刷 TLB”变成“换个标签”。
2026-09-26 · Context Switch, switch_to, switch_mm and PCID

第 7 关 · 上下文切换:switch_to、switch_mm 与 PCID

定位

这一关回答“上下文切换到底在切换什么”。答案是两样正交的东西:执行上下文(栈 + 寄存器 + 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 章节,在这里会合。

核心机制深讲

7.1 __switch_to_asm:汇编只做三件事

__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 埋的伏笔在这里兑现)。

7.2 __switch_to:C 语言接管后的扩展状态

栈换过来之后,剩下的都是 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)。

7.3 context_switch:调度器的两条分支

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)。

7.4 switch_mm:地址空间切换的代价中心

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”收益大于管理开销。

7.5 PCID:把换地址空间从 O(TLB) 降到 O(1)

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”的折中。

与已学模块的关联

自测

出题原则:10 题中记忆性题目不超过 3 道,其余为应用/推理/权衡类——场景预测、设计 trade-off 比较、从代码路径找问题、解释“为什么这样设计而不是那样”。难度递进,最后两道需要想一会儿。

Q1. __switch_to_asm 在汇编里做了哪三件事?push 的寄存器顺序有什么约束?

三件事:保存被调用者保存寄存器(pushq %rbp, %rbx, %r12–%r15)、换栈(%rsp 存到 TASK_threadsp(%rdi),从 TASK_threadsp(%rsi) 取新栈)、jmp __switch_to。push 顺序必须与 inactive_task_frame 的字段顺序一致——这是源码注释的原话要求。

Q2. 为什么 __switch_to_asm 只保存被调用者保存寄存器?如果有人把它改成保存全部寄存器,会有什么后果?

因为 switch_to 在 C 语义上是一次函数调用,C ABI 规定调用者保存寄存器由调用方负责——编译器在切换点周围已经保证了它们的安全。改成全量保存:语义上没错,但每次切换多存/多取一倍寄存器,纯属浪费;更糟的是破坏“切换 = 函数调用”的心智模型,后人再也看不出为什么这里能省。

Q3. __switch_to 里 save_fsgs(prev) 为什么必须在 load_TLS(next) 之前?CS/SS 为什么完全不用管?

注释原话:%fs/%gs 可能被 load_TLS() 清掉,所以必须先保存旧的、再装新的——顺序反了会丢 prev 的 FS/GS。CS/SS 不用管,因为它们是 pt_regs 的一部分,在中断/系统调用入口进内核时由硬件+入口汇编保存、返回用户态时恢复,不属于“任务私有 CPU 状态”。

Q4. 新创建的任务第一次被调度时,为什么落点是 ret_from_fork_asm 而不是某个旧的调用点?

因为 copy_thread 把新任务的内核栈伪装成“刚从 __switch_to_asm 返回”的样子:栈顶布置好 callee-saved 寄存器,返回地址填成 ret_from_fork_asm。第一次切换切到这个栈、__switch_to 返回时,CPU 取到的返回地址就是 ret_from_fork_asm——新任务“出生”时并没有调用过任何函数,所以只能伪造一个落点。

Q5. context_switch 里,next 是内核线程和 next 是用户进程,分别走什么路径?为什么前者连 switch_mm 都不调?

内核线程(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),正经换页表。

Q6. switch_mm_irqs_off 里 prev == next 且不在 lazy 状态时会发生什么?这对应哪种切换场景?

直接返回,CR3 不写、TLB 不刷。对应同一进程的线程之间切换——mm 相同,页表都没变,换 CR3 纯属浪费。这是“线程切换比进程切换便宜”在代码里最硬的一行。

Q7. 两个进程在 8 核机器上 sched_yield 对打(ping-pong 切换),TLB miss 率很高。用 nopcid 启动后,你预期 miss 率怎么变?为什么?

显著上升。正常有 PCID 时,两个进程的 ASID 都有效,switch_mm 走 build_cr3_noflush()——只写 CR3 换标签,TLB 条目保留,ping-pong 回来时旧条目大概率还在。nopcid 关掉 PCID 后,每次 switch_mm 都全刷 TLB,回来重填——miss 率直接反映“每次切换清空全部 TLB”的代价。

Q8. TLB_NR_DYN_ASIDS = 6。如果一台机器上有 100 个活跃进程在频繁切换,会发生什么?PCID 是不是就没用了?

6 个动态 ASID 轮完 → 新的 choose_new_asid 拿不到有效 ASID → 走 flush 路径,全刷 TLB。PCID 不是没用:任意时刻最热的 6 个地址空间仍然享受零刷新切换——时间局部性让它依然赚。只是“100 个进程均匀对打”这种最坏场景下,收益会被 flush 摊薄。6 是“够用”和“tlb_state 塞进两条 cache line”的工程折中。

Q9. KPTI 开启时,用户 PCID 为什么要在内核 PCID 基础上偏移 2048?

因为 KPTI 下用户和内核各用独立的页表(防 Meltdown),4K ASID 被对半分:内核用低 2K,用户用高 2K(偏移 2048)。偏移的目的是让用户地址空间的 TLB 条目和内核的永远不重名——否则用户态的条目可能 alias 到内核条目,KPTI 的隔离就破了。

Q10. 设计题:如果把 __switch_to 的全部工作(FPU/TLS/PKRU/TSS.sp0 等)都搬进 __switch_to_asm,用纯汇编写,你会得到什么、失去什么?

得到:省一次 jmp __switch_to 的调用开销——几个 cycle,可忽略。失去:(1) 可维护性——FPU/TLS/PKRU 全是条件编译+特性判断的 C 逻辑,汇编里写是灾难;(2) 复用——switch_fpu、load_TLS 等是 arch 通用 helpers,搬进汇编等于复制一份;(3) 灵活性——新增一个要切换的状态(比如未来的 APX 寄存器),C 里加一行,汇编里要重排栈帧。结论:汇编只做“不得不汇编”的事(换栈),其余全给 C——这是内核里反复出现的分工原则。

延伸阅读

三档代价:一条分界线
========================
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 的栈——中间没有任何“中间状态”。

在页面里每题写下你的答案(关键词即可),卡住的题点一下“卡住了”标记,
点“生成作答汇总”后复制、贴到聊天里——对错由我来判定,我逐题批改并据此
调整下一关的深浅;准备好了说“下一关”。

作答汇总

点下面按钮生成汇总文本,复制后贴到「内核二十关」侧聊里,对错由我判定。