ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

深挖context_switch:进程切换中mm与内核栈的完整交接机制

深挖context_switch:进程切换中mm与内核栈的完整交接机制 进程切换这事表面上看就是调度器挑个新任务然后“切换上下文”。可真钻进内核代码你会发现“切换上下文”这四个字背后站着两个完全不同的世界一个是用户地址空间的搬运一个是内核栈的接力。我在读__schedule源码的时候最深的感触就是如果只盯到pick_next_task选出了下一个任务就以为理解了进程切换那等于只看了婚礼请柬没看到婚礼现场。真正决定切换成败的是后面context_switch里对 mm 和内核栈这两个核心资源的一整套交接。这篇文章就是专门拆context_switch的。我会把切换 mm 时为什么要分“真切换”和“惰性切换”为什么内核线程可以没有 mm 却照样跑以及switch_to那几行汇编到底靠什么把执行流从一个进程搬到另一个进程这些问题一个不落讲清楚。适合正在啃调度器源码、做内核驱动开发时遇到current-mm相关疑问、或者准备面试想把这个过程讲透的读者。读完你至少能说得清楚进程切换时CPU 的 CR3 寄存器什么时候必须写、什么时候可以不写内核栈指针是怎么从旧任务跳到新任务的。1. 从__schedule到context_switch一次进程切换的完整起点1.1 调度器主路径__schedule到底做了什么__schedule是所有调度路径的汇聚点不管是主动睡眠、时间片耗尽还是中断返回时发现need_resched最终都会走到这里。函数开头先拿到当前 CPU 的运行队列rq和当前任务prev然后关闭本地中断、给运行队列加锁接着根据prev的状态决定是继续运行还是让它睡眠。关键判断在这里如果prev的状态不是TASK_RUNNING且没有挂起的信号那就把它deactivate_task从运行队列摘出去表示它真的睡了如果有信号挂起就强行把它拉回TASK_RUNNING不让它睡。随后调用pick_next_task从当前 CPU 的运行队列里挑出下一个要跑的任务next。需要注意的是pick_next_task是按照调度类优先级顺序stop、dl、rt、fair、idle挨个询问的不是随便从链表头抓一个。真正触发上下文切换的条件是prev ! next。只有两个任务不同才需要调用context_switch如果选出来还是prev自己那就只是把need_resched清掉然后解锁返回不碰任何切换逻辑。这个判断非常重要因为频繁调度但不切换的场景比如抢占点检查如果也去做 mm 和栈切换性能会直接崩掉。static void __sched notrace __schedule(bool preempt) { struct task_struct *prev, *next; struct rq *rq; int cpu; cpu smp_processor_id(); rq cpu_rq(cpu); prev rq-curr; schedule_debug(prev, preempt); local_irq_disable(); rcu_note_context_switch(preempt); rq_lock(rq, rf); smp_mb__after_spinlock(); if (!preempt prev-state) { if (signal_pending_state(prev-state, prev)) { prev-state TASK_RUNNING; } else { deactivate_task(rq, prev, DEQUEUE_SLEEP | DEQUEUE_NOCLOCK); prev-on_rq 0; } } next pick_next_task(rq, prev, rf); clear_tsk_need_resched(prev); clear_preempt_need_resched(); if (likely(prev ! next)) { rq-nr_switches; rcu_assign_pointer(rq-curr, next); context_switch(rq, prev, next, rf); } else { rq_unlock_irq(rq, rf); } }这里有个细节很多人容易忽略rq-curr是在调用context_switch之前就通过rcu_assign_pointer更新成next了。也就是说运行队列的视角里此刻已经认为next在运行但 CPU 上其实还在执行prev的指令。这种“先改逻辑视图再改物理状态”的做法是为了让运行队列的数据结构在切换过程中保持一致性方便其他 CPU 在锁外看到新任务。1.2 context_switch的前半程prepare_task_switch和prev_mm的交接context_switch的整体结构看起来只有几段但顺序不能乱。它第一件事是调用prepare_task_switch这个函数在 x86 上会做一些准备工作比如调用prepare_arch_switch做硬件级预取同时会触发调度相关的 trace 点、perf 事件、RCU 状态切换等。然后进入核心分支判断next-mm是否为 NULL这决定当前切换是“用户进程到用户进程”还是“内核线程参与切换”。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); if (next-mm) { membarrier_switch_mm(rq, prev-active_mm, next-mm); switch_mm_irqs_off(prev-active_mm, next-mm, next); if (!prev-mm) { rq-prev_mm prev-active_mm; prev-active_mm NULL; } } else { switch_mm_irqs_off(prev-active_mm, next-active_mm, next); } switch_to(prev, next, prev); barrier(); finish_task_switch(prev); }先看next-mm不为空的情况说明下一个任务是普通用户进程它有独立地址空间必须调用switch_mm_irqs_off把页表切过去。如果prev恰好是内核线程prev-mm NULL那prev之前是借用别人地址空间在跑的这个借来的地址空间不能丢要暂存到rq-prev_mm里等切换完成后在finish_task_switch中安全释放引用。同时把prev-active_mm清空表示它不再占用那个地址空间了。next-mm为空的情况则是切到内核线程内核线程不需要自己的地址空间它只需要继续用prev-active_mm来表示当前 CPU 的地址空间没变。这时调用的switch_mm_irqs_off(prev-active_mm, next-active_mm, next)往往不会真正写 CR3因为prev-active_mm和next-active_mm通常指向同一个 mm_struct。这个“借地址空间”的设计是整个进程切换里最精妙、也最容易搞混的地方下一章专门展开。2. 切换mm地址空间是如何“换血”的2.1 mm、active_mm与内核线程的地址空间借用每个用户进程都有一个struct mm_struct里面装着页表根指针pgd、内存映射、虚拟地址范围统计等信息。用户进程的task_struct-mm指向自己的 mm_structtask_struct-active_mm也指向同一个 mm_struct。这一点好理解进程自己拥有地址空间运行时当然就是用它。内核线程就特殊了。内核线程的task_struct-mm是 NULL因为它只跑在内核态不需要访问用户空间也就没必要拥有完整的用户地址空间。但 CPU 不能没有地址空间运行谁都得有一个页表才能取指执行。如果切到内核线程时把 CR3 彻底清空CPU 连内核态的代码都没法跑了。所以内核线程使用active_mm字段指向它“最后借用”的那个 mm_struct。字段用户进程内核线程task_struct-mm指向自己的mm_structNULLtask_struct-active_mm指向自己的mm_struct与mm相同借用当前CPU上一个用户进程的mm_struct切换时需要写CR3吗需要切换到独立页表不需要沿用当前地址空间active_mm的设计本质上就是一种惰性策略既然内核映射在所有进程里都一样那内核线程就没必要为了“自己的地址空间”再维护一套页表直接沿用上一个进程的用户映射但又不碰它的用户态数据。看着同一个页表实际访问只走内核映射部分用户地址空间完全不去碰。这里有一个引用计数问题值得注意。mm_struct有两个引用计数mm_users和mm_count。mm_users表示有多少用户态上下文在使用这个 mm比如进程被 clone 出线程时、某个进程打开了/proc/pid/mem时都会递增。mm_count表示这个 mm_struct 结构体本身被多少地方持有引用其中就包括active_mm的借用。当进程退出时mmput会先减mm_users减到 0 后调用mmdrop减mm_countmm_count减到 0 才真正释放 mm_struct。所以内核线程虽然mm是 NULL但只要它还把active_mm指向别人的地址空间那个mm_struct就不会被释放这是一种非常安全的借约机制。2.2 switch_mm_irqs_offCR3写入与惰性TLBx86 架构下切换页表本质就是写 CR3 寄存器。写一个新 CR3 后CPU 后续取指和访存就会用新页表去翻译整个地址空间瞬间“换血”。但 CR3 不是随便写的因为每次写 CR3 都可能让 TLB 内容失效而 TLB 失效会带来不小的性能损耗。所以内核想尽办法能不写就不写。switch_mm_irqs_off的第一个优化是如果prev next也就是切换前后的地址空间是同一个 mm_struct那就只更新一下mm_cpumask位图直接返回不碰 CR3。这个情况在频繁调度时非常常见比如两个线程属于同一个进程时mm 相同页表根本不需要变。第二个优化就是前面提到的“惰性 TLB”思想。当从用户进程切到内核线程时next-mm为 NULLnext-active_mm就是prev-active_mm两者相同那么switch_mm_irqs_off直接命中prev next的快速路径不写 CR3。这意味着 CPU 的 CR3 仍然指向刚刚那个用户进程的页表但由于当前在内核态而内核映射是全局可见的所以完全没问题。这就是“惰性”的含义地址空间不为内核线程真实切换只是暂时复用。真正需要切 CR3 的是两个用户进程之间切换以及从内核线程切回另一个用户进程且该用户进程与当前借用的 mm 不同。这时要做的不只是写 CR3还要处理 TLB 的同步问题。TLB 的同步策略可以概括为三个层次。第一个层次是刷新整个 TLB代价高一般只在 mm 刚被创建、之前完全没在这个 CPU 上跑过或者 CPU 刚从惰性模式切换回来且之前的 mm 数据已经不可信时才做。第二个层次是局部刷比如flush_tlb_page只刷某个地址对应的条目用于 munmap 等场景。第三个层次是依赖 PCID 或 ASID 避免刷新让不同 mm 的 TLB 条目在硬件上隔离切换时只写 CR3 换标识不刷新就不影响正确性。// x86 下 switch_mm_irqs_off 的核心动作简化 static void switch_mm_irqs_off(struct mm_struct *prev, struct mm_struct *next, struct task_struct *tsk) { if (prev next) { cpumask_set_cpu(smp_processor_id(), mm_cpumask(next)); return; } if (next ! init_mm) cpumask_set_cpu(smp_processor_id(), mm_cpumask(next)); load_new_mm_cr3(next-pgd, new_asid, false); }很多人会问只更新mm_cpumask有什么意义这个位图记录了“哪些 CPU 的 TLB 里可能缓存了这个 mm 的映射”。如果某个 CPU 的位图里没有这个 mm那它不可能有这个 mm 的 TLB 条目也就没必要向它发 IPI 做 TLB shootdown。所以每次切换都要把这个 CPU 加到新 mm 的位图里同时旧 mm 的位图清理会在专门的flush路径里处理。位图本质上是一个“哪些 CPU 持有脏缓存”的签名它直接决定了 TLB 刷新能否精准广播。2.3 KPTI与PCID下的mm切换x86细节现代 x86 内核还有一个额外的复杂度内核页表隔离KPTIKernel Page Table Isolation。开启后每个用户进程实际存在两套页表一套是运行在用户态时的用户页表几乎不映射内核地址一套是陷入内核态时切换到的内核页表完整映射内核地址。普通进程进入系统调用或中断时CR3 还要从用户页表换成内核页表返回用户态时又切回来这就让 mm 切换又多了一层“内核 CR3”的来回。PCIDProcess Context Identifier则给切换带来另一个优化角度。有了 PCIDTLB 条目会带上地址空间标识切换 CR3 时只要换个 PCID旧地址空间的 TLB 条目可以继续留在缓存里不被冲掉等需要时直接复用。不过 PCID 和 KPTI 组合使用时细节非常多CR3 里要用特殊位区分用户和内核页表还要处理invpcid指令的刷条目标。这块我在实际调优中最大的感触就是没有 PCID 时写一次 CR3 等于全量 TLB 失效开销大得肉眼可见开了 PCID 之后切换成本明显下降但代价是调试时更容易被“TLB 里还有旧映射”这种现象坑到。对于大多数看调度代码的人不需要把 CR3 的每一位都背下来但需要记住switch_mm_irqs_off的返回值效果是“让当前 CPU 的地址空间变成新任务的地盘”而且它非常抠门能不动 CR3 就不动能只刷局部就不刷全局。3. 切换内核栈switch_to的寄存器接力3.1 每个进程一个内核栈线程栈和内核栈别混为一谈用户态每个线程有自己独立的用户栈而内核态每个任务同样有自己独立的内核栈。x86_64 上普通进程的内核栈默认是 16KB32 位时代常见 8KB。这块空间独立分配专门用来处理系统调用、中断、异常等内核态执行时的函数调用。它的总量很小所以内核代码里不允许定义大的局部数组也不允许深度递归否则很容易溢出。内核线程也有自己的内核栈它的task_struct同样是完整存在的只是没有用户地址空间。内核线程没有用户栈但内核态的调用全都跑在自己的内核栈上所以它照样能执行schedule、kthread工作函数等。需要注意的是现代内核在 x86_64 上开启了CONFIG_THREAD_INFO_IN_TASKthread_info结构体被内嵌到task_struct里不再像老内核那样放在内核栈底部。这样做的目的是为了减少对栈布局的依赖也让current的获取不必每次通过栈指针去换算。但栈本身依然是每个任务独立的这一点没变。切换内核栈本质上就是让rsp从一个任务的内核栈跳到另一个任务的内核栈同时保证跳完之后寄存器、调用栈状态都严丝合缝。3.2 __switch_to_asmRSP一换整个执行流就换了switch_to是一个宏最终会调用到__switch_to_asm这个汇编函数。它的逻辑非常紧凑把当前任务的 callee-saved 寄存器压栈换 RSP再把新任务之前压栈的寄存器弹出然后跳到 C 函数__switch_to做剩余工作。SYM_FUNC_START(__switch_to_asm) pushq %rbp pushq %rbx pushq %r12 pushq %r13 pushq %r14 pushq %r15 movq %rsp, TASK_threadsp(%rdi) movq TASK_threadsp(%rsi), %rsp popq %r15 popq %r14 popq %r13 popq %r12 popq %rbx popq %rbp jmp __switch_to SYM_FUNC_END(__switch_to_asm)第一次看这段代码的人容易疑惑的点在于为什么压栈之后又能直接弹栈原因在于每个任务被切出去的时候早就在自己的内核栈上压过这一堆寄存器所以栈指针thread.sp恰好指向压栈后的栈区域。等它再次被调度回来从thread.sp恢复 RSP再依次弹出这六个寄存器栈的状态就与上次被切出时完全一致。这里最精妙的是最后一句jmp __switch_to而不是call。因为执行到这一步时rsp已经指向新任务的内核栈了栈顶保存的返回地址是新任务上次被切出时压入的地址也就是新任务在switch_to宏之后的代码位置。jmp __switch_to让新任务的 C 函数随后自然执行__switch_to函数末尾的ret会从新任务的栈上弹出这个返回地址于是执行流就“无缝”落回新任务上次被打断的地方。这就回答了“切换完到底从哪继续跑”的问题从新任务的switch_to调用点继续。3.3 __switch_to与TSS.sp0切完栈还要让硬件认账__switch_to_asm切完 RSP 后__switch_to这个 C 函数还要负责切换各种硬件上下文。它会保存旧任务的线程上下文更新新任务的 FS/GS 基址、调试寄存器、IO 位图还会处理 FPU 状态。不过有个关键点很多人会漏掉切换任务后当前 CPU 的 TSSTask State Segment里记录的sp0必须更新成新任务的内核栈栈顶。为什么必须更新 TSS 的sp0因为当 CPU 从用户态通过 syscall、中断或异常进入内核态时硬件需要从 TSS 中读取一个栈指针作为内核态最初使用的栈顶。如果 TSS 里还残留旧任务的内核栈地址假设旧任务恰好被销毁或者栈内容被覆盖那么下一次中断进去就会踩到无效内存系统直接崩掉。所以每次__switch_to都会把 TSS 的sp0更新为当前任务的栈顶。这里还有一个细节sp0并不是指向内核栈的起点而是指向栈的顶端地址也就是从高地址往下第一个可用的栈位置。内核栈方向是从高地址向低地址增长的所以sp0是栈区间的上限。理解了这一点就能明白为什么内核栈切换和 TSS 更新必须成对出现缺一不可。4. 完整闭环context_switch到finish_task_switch的善后4.1 switch_to返回、last参数与切换到新任务的瞬间switch_to(prev, next, prev)这个宏里第三参数叫last它的存在让很多人困惑。先看宏定义#define switch_to(prev, next, last) \ do { \ ((last) __switch_to_asm((prev), (next))); \ } while (0)__switch_to_asm的返回值其实是通过 RAX 传递的返回的是旧任务指针。也就是说当新任务被切回来继续执行时它从switch_to之后的代码看到的是“上次切走自己的那个任务”的指针。这就是为什么context_switch里switch_to之后要用prev接着调finish_task_switch(prev)这里的prev是“谁把我切走的”而不是宏调用时传进去的静态值。举个例子任务 A 切到 B然后 B 又切到 C最后 C 切回 A。A 恢复执行时RAX 里装的是 C 的指针因为上一次把 A 切走的是 C所以 A 的prev就是 Cfinish_task_switch(prev)用 C 去完成“上一个任务”的善后。这样每个任务在被唤醒后都能知道自己是被谁打断的然后正确地替那个任务清理现场。理解了last参数基本上就理解了三方切换的核心。切换完成的瞬间是很有意思的CPU 的指令流已经跑在next的内核栈上current也已经变成next但函数调用层级看起来好像是一直在context_switch里没出来。这正是内核切换上下文时“逻辑上换了人、执行栈上还是同一条调用链”的奇妙之处。4.2 finish_task_switch为什么必须在rq锁外做mmdropcontext_switch在调用switch_to之前运行队列的锁还握在手上。切换完成后新任务在finish_task_switch里要做一系列善后包括释放 RCU 读锁、执行调度类的post_schedule、处理完全退出的任务TASK_DEAD、更新集群调度状态等。finish_task_switch里有一个对rq-prev_mm的mmdrop操作。还记得前面context_switch里把内核线程prev-active_mm暂存到rq-prev_mm吗就是这个字段在切换后兜底释放。为什么明明可以在锁内就把mmdrop做掉偏要留到锁外因为mmdrop在mm_count减到 0 时会真正释放整个mm_struct包括所有 VMA、页表、甚至可能的文件映射回写这个过程可能很重。如果抱着rq-lock去做这么重的操作所有 CPU 上的任务调度都会被拖慢这是绝对不能接受的。所以调度器采用的是一个非常典型的“延迟清理”模式在锁内只完成轻量逻辑保存指针、清空字段把重活放到锁外、切换完成之后再做。看内核代码的时候这种“锁内少干活、锁外干重活”的设计思路你会反复见到比单纯背源码有用得多。4.3 用户态进程切换时“新进程先在内核态跑一段”的原因很多初学者会疑惑进程切换后新进程是不是立刻回到用户态继续执行其实不是。切换发生在内核态新进程恢复执行后仍然是内核态代码路径在跑等__schedule的调用者比如中断返回路径ret_from_intr或系统调用返回路径最终执行iret或sysret时才回到用户态。这个“先在内核态跑一段”的设计不是偶然的。调度器需要保证新进程在回到用户态之前把内核态的所有状态都整理干净比如解锁运行队列、处理调度通知、完成 TLB/mmu 清理还需要检查是否存在信号要处理。如果切换后立即返回到用户态那这些内核态必备的清理动作就没地方做了。有个容易忽略的细节是__schedule调用结束后不一定会回到原来的调用位置因为switch_to已经把执行流换掉了。比如中断上下文里调用schedule中断返回时的路径执行的是ret_from_intr而不是普通函数return。所以内核里“返回到用户态”的路径通常是一套独立的汇编入口而不是简单的函数返回。5. 排查实践context_switch相关的典型问题5.1 从内核栈溢出到“栈太小”的经典坑内核栈 16KB 听起来不小但内核函数调用路径很长一个文件系统操作可能从 VFS 到具体文件系统再到块设备层几十层函数嵌套每层都要消耗栈空间。如果驱动里在栈上定义一个大数组比如char buf[4096]再加上几层调试打印的缓冲一次就吃掉好几 KB。再加上某些深度嵌套路径比如printk内部、网络协议栈收包路径栈溢出是真实会发生的事。内核提供了几个排查工具。CONFIG_DEBUG_STACK_USAGE会在任务退出时统计栈使用峰值/proc/pid/stack可以查看内核栈回溯。如果想实时看栈水位可以开启CONFIG_STACK_TRACER通过echo func /sys/kernel/debug/tracing/stack_trace_filter对指定函数做栈深度追踪。我实际排查驱动问题时最常用的是dmesg里看到类似kernel stack overflow的日志然后配合objdump反汇编驱动里的热路径函数看它单次调用消耗栈多少。另一个和驱动相关的点是为什么显卡驱动这类大驱动不直接用大内核栈因为内核栈是每个任务独立分配的如果默认栈从 16KB 改成 1MB系统里几万个线程就要多分配几十 GB 内存完全不可行。所以驱动开发的通用做法是不在栈上放大对象用kmalloc或vmalloc堆内存替代递归函数也要加深度限制必要时把任务放到工作队列或专用内核线程里通过换栈避免深调用链把栈耗尽。5.2 内核线程里为什么不能直接copy_from_useractive_mm的边界内核线程的mm是 NULL但它的active_mm指向借来的地址空间。有人会想既然active_mm还在那copy_from_user应该能用吧答案是绝对不能直接用。copy_from_user内部会通过access_ok和might_fault检查当前进程是否有合法的用户地址空间current-mm为 NULL 时整个用户空间的访问路径会被判定为非法。就算你绕过检查手动访问页表里确实映射了某个用户进程的内存你访问了也不代表安全因为你根本不知道该地址属于谁、是不是已经被换走了。正确做法是使用kthread_use_mm临时绑定一个 mm访问完再kthread_unuse_mm解除绑定。这两个函数做的事情本质上就是“临时给内核线程装上一个用户地址空间”它内部会处理引用计数、CPU 位图和 TLB 切换保证借用期间地址空间不会被释放。需要访问用户空间的驱动比如某些直接操作进程内存的调试模块就应该走这条路径而不是手工去抄active_mm。5.3 用ftrace和kprobe观察__schedule和context_switch排查调度相关问题最快的方式是用 ftrace 的调度事件。/sys/kernel/debug/tracing/events/sched/sched_switch这个 tracepoint 会在每次切换时记录prev_comm、prev_pid、next_comm、next_pid以及prev_state字段足以判断切换频率。# 查看调度切换事件 echo 1 /sys/kernel/debug/tracing/events/sched/sched_switch/enable echo 1 /sys/kernel/debug/tracing/tracing_on cat /sys/kernel/debug/tracing/trace如果想深入context_switch内部可以用 kprobe 挂在函数入口上观察参数。比如kprobe:context_switch然后通过trace_printk或perf probe拿到prev和next的地址再解析成task_struct查看mm和active_mm。我用过用户态 kprobe 脚本把每次切换时next-mm是否为 NULL 打出来能非常直观地看到内核线程切换的“惰性 mm”行为。perf probe context_switch prev next perf record -e probe:context_switch -a sleep 1 perf script还需要注意用 ftrace 追踪sched_switch时ftrace 自身也可能触发调度事件造成递归打印所以建议读 trace 前先关闭tracing_on或者用trace-cmd record -e sched_switch sleep 1这种离线记录方式。5.4 频繁切换的“虚假调度”怎么查有些系统性能问题表现为 CPU 使用率高、但业务吞吐低典型原因就是任务切换太频繁。排查时先看/proc/stat里的ctxt字段如果每秒上下文切换数量高到离谱再抓一次perf sched record和perf sched latency看睡眠和唤醒分布。常见元凶包括高频定时器导致hrtick频繁触发、锁竞争导致need_resched被反复置位、用户态线程数量过多且都在忙等。一个经典场景是多个线程通过pthread_spin_lock或sched_yield忙等导致调度器不断把 CPU 切来切去。这种时候__schedule的prev ! next分支会非常频繁地命中但每次切换都是同一批线程之间的来回倒腾实际工作一点没进展。解决思路通常是减少线程数量、用阻塞锁或条件变量替代忙等而不是去调调度器参数。理解context_switch的代价逻辑后你就会明白为什么“切换一次的开销比你想象中大得多”因为它不只是换栈换页表还有 TLB 失效、缓存失效、分支预测失效等一系列连锁反应。6. 实操心得与延伸6.1 这几个符号之间的关系帮你快速定位代码如果你正在读调度代码推荐先抓住一条主线__schedule-pick_next_task-context_switch-switch_mm_irqs_offswitch_to-finish_task_switch。这条路径上的每个函数都对应一类问题选任务看调度类切地址空间看switch_mm_irqs_off切执行流看__switch_to_asm善后清理看finish_task_switch。实际读源码时我习惯先把current-mm和current-active_mm的区别挂在脑子里。遇到一个路径需要访问用户内存第一反应是查current-mm遇到内核线程执行体第一反应是查current-active_mm。这个习惯帮我少踩了不少坑。另一个技巧是用pahole查看task_struct的字段偏移然后配合 kprobe 直接读二进制参数效率比看反汇编快得多。6.2 ARM架构上的对应差异x86 有 CR3ARM 没有 CR3但 ARM 有 TTBR0/TTBR1 和 ASID。ARM 切换地址空间时需要写 TTBR 寄存器并维护 ASIDASID 的作用和 x86 的 PCID 类似都是避免切换时全量刷新 TLB。ARM 的switch_mm实现比 x86 更依赖硬件的 ASID 分配ASID 不够用时还要做全球刷新。如果你要写跨架构调度分析文章建议在 x86 路径理解清楚之后再对着 ARM 的check_and_switch_context过一遍核心逻辑相似但硬件细节差异很大。我最初读 x86 切换代码时总觉得switch_mm_irqs_off里那些位图、PCID 操作没什么大不了。等后来在 ARM 平台调一个 TLB 未刷新的问题翻了很久手册才意识到地址空间切换从来不只是“换页表指针”这么简单它背后是一整套关于缓存有效性的协议。这也是操作系统课程里不太会讲的实战知识。6.3 一个可以自己动手验证的扩展实验如果你想加深理解可以写一个小的内核模块用 kprobe 挂switch_mm_irqs_off统计 one-off 的prev next快速路径命中率。通过/proc/pid/stat里的上下文切换次数做对比你很快会看到同进程多线程场景下快速路径命中率极高而多进程繁忙交互场景下真正写 CR3 的比例会明显上升。这个数据能直接解释为什么“多线程比多进程更高效”——除了共享内存免拷贝切换时地址空间不换也是一个重要原因。更进一步开启CONFIG_PAGE_TABLE_ISOLATION前后各跑一次perf stat测 syscall 基准你还能看到 KPTI 带来的系统调用延迟开销再结合 PCID 无则加冕、有则如虎的对比基本就把 x86 内存管理相关的切换细节串起来了。做内核这块书读百遍不如把关键路径上手拆一遍。__schedule这个函数我前前后后读了不下十遍每读一遍都能发现之前没注意的细节尤其是那些锁内锁外的取舍、位图的维护、惰性切换的边界条件。真正把context_switch搞透之后再回头看进程模型、虚拟内存、甚至驱动开发里的许多问题都会觉得豁然开朗。
返回列表