1. context_switch到底在交接什么东西
1.1 __schedule最后一步:把CPU从prev交到next手里
如果你把__schedule比作一次交接仪式,那context_switch就是真正把接力棒递出去的那一下。前面pick_next_task选好了next,更新了各种统计和调度类回调,这些都是在“纸面”上做的准备工作;到了context_switch,才轮到动真格:把CPU的地址空间、内核栈、寄存器现场,全部从prev换到next。
这个函数在内核里的位置很关键,我直接贴简化后的主流程,代码来自Linux 6.x:
static __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引入了PCID(Process Context ID),TLB条目可以打上进程的标签,写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时不刷新TLB,CPU的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_to:C层恢复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里唤醒抢占、负载均衡这些分支展开聊聊。