1. context_switch到底在交接什么东西1.1 __schedule最后一步把CPU从prev交到next手里如果你把__schedule比作一次交接仪式那context_switch就是真正把接力棒递出去的那一下。前面pick_next_task选好了next更新了各种统计和调度类回调这些都是在“纸面”上做的准备工作到了context_switch才轮到动真格把CPU的地址空间、内核栈、寄存器现场全部从prev换到next。这个函数在内核里的位置很关键我直接贴简化后的主流程代码来自Linux 6.xstatic __always_inline void context_switch(struct rq *rq, struct task_struct *prev, struct task_struct *next, struct rq_flags *rf) { prepare_task_switch(rq, prev, next); arch_start_context_switch(prev); /* * 如果 next 是内核线程mm 为 NULL * 不切换页表只借用 prev 的 active_mm。 */ if (!next-mm) { // to kernel enter_lazy_tlb(prev-active_mm, next); next-active_mm prev-active_mm; } else { // to user membarrier_switch_mm(rq, prev-active_mm, next-mm); smp_mb__after_spinlock(); switch_mm_irqs_off(prev-active_mm, next-mm, next); } /* 切换内核栈和寄存器上下文 */ switch_to(prev, next, prev); barrier(); /* 切回来之后才做收尾 */ finish_task_switch(prev); }这段代码分成两截switch_to之前是“切出去”的准备switch_to之后是“切回来”的收尾。问题是switch_to(prev, next, prev)这个调用不会像普通函数那样原路返回——它会把CPU的执行流彻底交给next等prev再次被调度回来时才会返回到switch_to下一行。这就是内核调度最反直觉的地方也是初学者最容易卡住的地方。1.2 先切mm再切栈这个顺序是有讲究的注意代码里是先调用switch_mm_irqs_off切地址空间再调用switch_to切内核栈。为什么不反过来因为切栈本质上就是改rsp而改rsp之后执行的下一条指令必须来自新任务的内核栈上的返回地址。如果把栈先切了但页表还是旧任务的那新栈所在的内存页在当前页表里可能根本没有映射CPU直接取指失败。反过来先切mm影响不大内核地址空间在所有进程的页表里都是一份相同的映射切换页表并不会让当前正在执行的context_switch代码失效。只要内核映射还在代码就能继续跑。这个顺序在代码注释里其实没有大篇幅强调但实际调优和理解时非常关键。尤其是开启了CONFIG_VMAP_STACK之后内核栈是vmalloc出来的新任务的内核栈虚拟地址和当前任务的内核栈虚拟地址很可能落在同一段地址范围但物理页完全不同。如果先切栈再切mm那切换后第一条pop指令访问的新栈页在旧页表里映射的可能是别人的物理页后果不堪设想。所以先切mm让新栈在当前页表中可见是唯一安全的选择。2. 地址空间切换一次CR3写入背后的门道2.1 为什么用户进程必须换页表内核线程却能蹭别人的每个用户进程都有自己独立的虚拟地址空间这个地址空间的核心就是mm_struct里那颗pgd页表。CPU要访问内存必须通过CR3寄存器找到当前页表。换进程不换CR3那A进程就能读到B进程的地址空间用户态数据全部裸奔这显然不行。但内核线程特殊它们没有用户态next-mm NULL根本不关心用户地址空间长什么样。内核线程在内核态运行访问的是内核映射而内核映射在所有页表里都一样。所以调度器让内核线程直接借用上一个用户进程的active_mm不开新页表不写CR3省掉一次昂贵的TLB刷新。用个生活化的比喻用户进程是带着自家钥匙进门的业主内核线程是物业维修工进哪家都不需要换钥匙因为门锁是通用的但维修工自己没房子只能借业主的房子干活。active_mm这个字段的存在就是为了这个“蹭”的动作。真正的mm是进程自己的地址空间active_mm是CPU当前实际在用的地址空间。用户进程active_mm mm内核线程只有active_mm没有自己的mm。2.2 写CR3不只是写个寄存器PCID、ASID与TLB开销切换到用户进程时switch_mm_irqs_off最终会走到load_new_mm_cr3把新进程的pgd写进CR3。但简单写一次CR3背后有个大坑CR3变了CPU的TLB页表缓存会大量失效后续每次内存访问都可能重新查页表性能掉得厉害。早期CPU没有PCID时每次写CR3等于整个TLB推倒重来这是进程切换开销的大头。后来x86引入了PCIDProcess Context IDTLB条目可以打上进程的标签写CR3时带上新的PCID旧PCID对应的TLB条目还能留着继续用。Linux在x86上把这套机制叫ASID代码里随处可见loaded_mm_asid。static inline void load_new_mm_cr3(pgd_t *pgdir, u16 new_asid, bool need_flush) { /* 实际代码还要处理 KPTI 的 user/kernel 两套 CR3这里是简化示意 */ unsigned long new_mm_cr3 build_cr3(pgdir, new_asid); if (need_flush || this_cpu_read(cpu_tlbstate.loaded_mm_asid) ! new_asid) { /* 需要让该 CPU 上残留的旧 TLB 条目失效 */ } this_cpu_write(cpu_tlbstate.loaded_mm, next_mm); this_cpu_write(cpu_tlbstate.loaded_mm_asid, new_asid); write_cr3(new_mm_cr3); }这个逻辑很好理解如果新旧ASID不同说明换了一个全新的地址空间TLB里旧ASID的条目虽然还在但不可能被新ASID命中了等于隐式隔离如果ASID相同那是同一个地址空间下做的小切换比如从内核线程回到用户进程反而要考虑是否刷新。几种情况的差异我整理了一下场景CR3是否需要写TLB影响实际代价无PCID切换用户进程必须写基本全刷高有PCID切换用户进程必须写换ASID旧条目按ASID隔离较低切换到内核线程不写无几乎为零开启KPTI进入/退出内核每次都要写依赖PCID明显增加2.3 active_mm和lazy TLB内核线程不切mm的代价与收益前面提到内核线程借用active_mm不写CR3。这个机制在内核里有个专门名字lazy TLB。enter_lazy_tlb(prev-active_mm, next)就是在告诉CPU接下来一段时间内页表还是prev那套但你别急着把TLB全刷了因为内核线程不会去写用户页面。为什么可以这么懒因为内核线程跑在内核态访问的都是内核映射如果它真的去访问用户地址比如某些驱动做copy_from_user内核会通过access_ok这类检查拦住不允许内核线程直接碰用户数据。既然碰不到用户地址那TLB里的用户条目留着也无所谓反而省掉了刷新开销。但天下没有免费的午餐。内核线程借用了prev的active_mm意味着prev的mm_struct引用计数不能随便释放。所以context_switch里面对“从内核线程切回用户进程”的情况做了一个特殊动作if (!prev-mm) { // 上一个任务是内核线程 rq-prev_mm prev-active_mm; // 记录借来的 mm prev-active_mm NULL; }这个rq-prev_mm会在finish_task_switch里被mmdrop处理掉。也就是说当一个内核线程把CPU交还给用户进程时它借用的那个地址空间终于可以还回去了引用计数减一归零就释放页表。这一套引用管理是调度器里最容易写错的地方稍微漏一个分支就是内存泄漏或use-after-free。2.4 VMAP_STACK为何逼着代码走同步刷新switch_mm_irqs_off里有一段非常容易被忽略的代码if (IS_ENABLED(CONFIG_VMAP_STACK)) load_new_mm_cr3(next-pgd, 0, true); else load_new_mm_cr3(next-pgd, 0, false);区别就在最后一个参数need_flush。为什么开了CONFIG_VMAP_STACK就要强制同步刷新这得从vmalloc栈的特殊性说起。传统内核栈分配在直接映射区虚拟地址到物理地址的映射是固定的、全局的所有进程的页表里这段映射都一样TLB条目也基本是全局的切换页表影响不大。但CONFIG_VMAP_STACK把内核栈挪到了vmalloc区域每个任务的内核栈虚拟地址可能是相同的物理页却各不相同。这种情况下如果切换mm时不刷新TLBCPU的TLB里可能残留着上一个任务栈的映射。等会儿switch_to把rsp切到新任务栈时访问的虚拟地址可能和旧任务栈的虚拟地址相同但TLB给出的还是旧物理页新任务栈上的数据全部错乱轻则栈数据被踩重则直接panic。所以开了VMAP_STACK必须在切换mm时同步把TLB刷掉确保新栈映射生效。这属于那种“不读代码永远不知道为什么要这么做”的细节。很多人调内核栈溢出问题最后发现罪魁祸首是VMAP_STACK下的TLB残留其实根子就在这里。3. 内核栈切换switch_to里那几行汇编是理解调度的钥匙3.1 每个任务独立内核栈这不是洁癖用户态每个进程有自己的用户栈这个大家都熟。但很多人没意识到每个任务还额外拥有一块独立的内核栈用于内核态函数调用链、局部变量、中断现场保存等。为什么必须独立因为内核态是整个系统共享的如果所有任务用同一个内核栈那A任务在内核里跑了一半被调度器切走B任务进来直接在同一个栈上继续压栈A的返回地址、局部变量全被覆盖等A回来时栈已经面目全非。内核栈的大小是固定的x86_64上默认THREAD_SIZE通常是16KB开了某些配置会更大一点。16KB听起来小但内核栈只保存内核态运行时的调用链和局部变量不存用户数据正常情况下完全够用。真正翻车的情况基本都是驱动里写了超大局部数组或者递归调用没控制住把栈压爆了。这里也有个常见误区有人问为什么内核栈不能像用户栈一样动态增长。原因很简单内核栈必须能快速分配而且要在中断、异常等场景下立即可用不可能像用户栈那样走缺页异常慢慢扩。更重要的是内核栈所在的内存页往往还要承担thread_info等元数据的存放必须固定大小、固定位置。3.2 __switch_to_asm压栈、换rsp、弹栈的三板斧switch_to在x86_64上是个宏直接调汇编函数__switch_to_asm。我贴一段简化后的汇编每行都值得仔细看SYM_FUNC_START(__switch_to_asm) /* 保存上一个任务的被调用者保存寄存器 */ pushq %rbp pushq %rbx pushq %r12 pushq %r13 pushq %r14 pushq %r15 pushq %rax /* 关键操作换栈指针 */ movq %rsp, TASK_threadsp(%rdi) # 当前 rsp 保存到 prev-thread.sp movq TASK_threadsp(%rsi), %rsp # next-thread.sp 加载到 rsp /* 从 next 的内核栈上恢复它上次保存的寄存器 */ popq %r15 popq %r14 popq %r13 popq %r12 popq %rbx popq %rbp popq %rax /* 进入 C 代码完成剩余切换 */ jmp __switch_to SYM_FUNC_END(__switch_to_asm)这段汇编做的事情并不复杂把prev的“现场”压到prev的内核栈上把rsp换成next的内核栈再弹栈恢复next的“现场”。真正精妙的是它只保存被调用者保存寄存器callee-saved也就是rbx、rbp、r12-r15这些。因为C编译器的调用约定保证了这些寄存器在函数调用过程中必须保持不变而__switch_to_asm正是利用了这个约定把所有跨调度需要保留的寄存器全部堆到栈上。有个细节很多人没注意为什么最后还要push和pop一个%rax一方面__switch_to_asm的返回值要放在rax里——它返回的是prev任务指针这样switch_to(prev, next, prev)宏才能把真正的prev写回去另一方面jmp __switch_to之前栈指针需要保持16字节对齐多压一个寄存器正好凑齐。还要注意最后用的是jmp不是call。因为__switch_to返回时CPU直接从栈上弹出返回地址这个返回地址是next栈上保存的也就是next上次被切走时switch_to后面的那条指令。所以从__switch_to返回后执行流已经“跳”到了next任务的世界里。3.3 __switch_toC层恢复TLS、FPU还要维护current汇编切完rsp剩下的精细工作交给C函数__switch_to__notrace_funcgraph struct task_struct * __switch_to(struct task_struct *prev_p, struct task_struct *next_p) { struct thread_struct *prev prev_p-thread; struct thread_struct *next next_p-thread; struct fpu *prev_fpu prev-fpu; struct fpu *next_fpu next-fpu; int cpu smp_processor_id(); /* 保存/恢复 TLS比如 FS/GS base */ switch_to_extra(prev_p, next_p); /* 懒切换 FPU 状态 */ switch_fpu_prepare(prev_fpu, cpu); switch_fpu_finish(next_fpu, cpu); /* 更新 per-cpu 变量 */ this_cpu_write(current_task, next_p); this_cpu_write(__percpu_offset, __per_cpu_offset(next_p-percpu_offset)); return prev_p; }这里有个容易忽略的点current宏之所以能随时拿到当前任务靠的就是this_cpu_write(current_task, next_p)这一步。切换任务后CPU立刻把per-cpu的current_task更新为next从这一刻起内核里所有current的使用者看到的都是新任务。TLS线程局部存储切换也很重要。x86_64进程切换时FS/GS base寄存器需要指向新任务的TLS段switch_to_extra里做的就是这件事。FPU用的是懒切换机制如果prev在运行期间压根没用过浮点寄存器那就不保存FPU状态只有真正用过才保存可以省掉大量的XMM/YMM寄存器保存开销。更新__percpu_offset也是关键一步。每个任务的per-cpu区域偏移量不同切换任务后访问this_cpu变量时GS base或专用寄存器指向的偏移量必须同步更新否则per-cpu数据全部错乱。3.4 新任务首次被调度ret_from_fork的隐藏入口前面说的都是老任务被切回来、恢复现场的情况。新创建的任务呢它从来没被切换出去过内核栈上哪里来的返回地址答案是copy_thread在新任务的内核栈上伪造了一套完整的“现场”。它会布置好pt_regs把thread.sp指向伪造栈帧并把返回地址设成ret_from_fork的入口。大体逻辑相当于int copy_thread(struct task_struct *p, const struct kernel_clone_args *args) { childregs task_pt_regs(p); /* 填充 pt_regs让新任务从新进程的入口开始执行 */ ... p-thread.sp (unsigned long)childregs; /* 栈顶布好 ret_from_fork 返回地址 */ ... if (args-fn) { /* 内核线程的入口直接指向 fn */ p-thread.sp (unsigned long)args-fn; } }所以当调度器最后一次切到新任务时__switch_to_asm弹栈弹出的寄存器全是copy_thread伪造的“默认值”然后ret到ret_from_fork。ret_from_fork会调用schedule_tail完成调度收尾然后通过syscall_exit_to_user_mode返回用户态新进程这才算真正活了。内核线程的路径更直接它的thread.sp直接指向线程函数入口ret_from_fork里判断是内核线程就跳到fn(arg)执行跑完就do_exit。4. 常见问题与排查技巧实录4.1 current在任意上下文都有效靠的是什么很多人调试内核时都有个疑问为什么在硬中断、软中断、甚至NMI里用current都能拿到当前任务因为current_task是个per-cpu变量它只描述“当前CPU正在运行的任务”。中断发生时CPU并没有切换任务只是在当前任务的内核栈上或者单独的中断栈上嵌套执行了一段中断处理代码current代表的仍然是那个被打断的任务。这个过程不会动current_task所以current一直有效。而真正切换任务时__switch_to第一步就把current_task更新了保证切完之后所有current使用者看到的都是新任务。这里顺便解释一个和内核栈相关的历史包袱很老的内核里current是通过thread_info拿到task指针的而thread_info放在内核栈底部所以只要栈没切current就有效。现代内核x86_64已经改成per-cpu变量方式了但内核栈和任务之间的紧密关系依然没变——栈底仍然存放thread_info。4.2 内核栈溢出症状、检测与定位思路内核栈溢出是驱动开发里很有代表性的问题。症状往往是莫名其妙的panic栈回溯显示一堆看似无关的调用最后发现是某个驱动函数里放了一个几KB的局部数组把栈直接压穿。开启CONFIG_VMAP_STACK之后内核栈底部会有一个guard page溢出时会先踩到这个不可访问的页触发page fault而不是静默破坏邻近内存。配合CONFIG_DEBUG_STACK_USAGE可以在运行时统计每个任务的内核栈最大使用量定位到底谁在吃栈。我调试过一个案例一个网卡驱动在接收路径里声明了char buf[8192]的局部数组x86_64默认内核栈16KB接收函数再带几层调用直接压到栈底。开启CONFIG_DEBUG_STACK_USAGE后通过/proc里的栈使用信息很快定位到是接收路径的栈深度异常。排查手段是先用栈守护页快速复现再用栈使用统计缩小范围最后看反汇编和调用深度确认。4.3 跟踪调度切换的三种实用手段想亲眼看到context_switch的行为有三种比较实用的手段。第一种是ftrace的sched事件最简单echo 1 /sys/kernel/debug/tracing/events/sched/sched_switch/enable cat /sys/kernel/debug/tracing/trace能看到每个CPU上任务的切换记录prev_comm、prev_pid、next_comm、next_pid。第二种是kprobe直接挂在context_switch上观察参数echo p:my_ctx context_switch rq%di prev%si next%dx /sys/kernel/debug/tracing/kprobe_events echo 1 /sys/kernel/debug/tracing/events/kprobes/my_ctx/enablex86_64上前三个参数分别在rdi、rsi、rdx可以从task_struct里读出comm和pid。第三种是bpftrace更适合快速验证kprobe:context_switch { $prev (struct task_struct *)arg1; $next (struct task_struct *)arg2; printf(%s(%d) - %s(%d), next_mm%lx\n, $prev-comm, $prev-pid, $next-comm, $next-pid, $next-mm); }4.4 切换过程里容易忽略的RCU与锁细节finish_task_switch不是简单的“切完收工”它手里还握着两件容易被忽略的事。一件是rq-prev_mm的mmdrop。前面提到内核线程借用active_mm切回用户进程时要归还引用这个归还动作就在这里做。如果这里漏了借用mm的内核线程会把页表引用计数一直抬高内存永远释放不了。另一件是RCU。任务切换意味着当前CPU上运行的进程发生了变化RCU需要记录这个状态变化判断是否可以进入quiescent state。所以__schedule前后会有rcu_note_context_switch之类的调用保证RCU的宽限期判断不会因为调度器把任务藏起来而卡死。还有一个特别容易被新手忽略的问题context_switch执行期间持有rq-lock切换完成后释放。但如果切到的是RT任务或者需要唤醒其他CPU的任务释放锁后可能立即触发抢占。所以finish_task_switch里有一堆preempt count和lockdep的特殊处理搞错了就是死锁或RCU stall。5. 一点个人体会我在读这段代码时有个很深的感受context_switch看起来只有短短几十行但它把CPU最底层的两个执行环境——地址空间和内核栈——换了个底朝天。理解这段逻辑对排查很多内核怪问题都有帮助。比如线上偶发的“任务栈被踩”第一反应不应该是怀疑业务代码而是先确认是否涉及CONFIG_VMAP_STACK下的TLB残留、是否驱动借用了active_mm、rq-prev_mm释放时机对不对。我踩过几次坑之后养成一个习惯遇到调度相关的诡异问题先开CONFIG_DEBUG_VM和CONFIG_DEBUG_STACK_USAGE再挂ftrace观察context_switch参数往往能少走很多弯路。后面如果再写调度器我会接着把__schedule里唤醒抢占、负载均衡这些分支展开聊聊。