很多人问我Linux 进程上下文切换内核到底在忙啥说真的这玩意确实是面试必问线上出问题十有八九也和它沾边但很多人背了无数遍流程真到啃源码、查 bug 的时候还是两眼一抹黑。一、内核切换的指挥官 context_switch进程切换说白了就两件事。第一件把新进程的内存映射给换上说白了就是换页表。第二件把内核栈和 CPU 寄存器的状态全给换成新进程的换寄存器状态 内核栈指针。就这两件事看着简单我估摸着有80%的人不懂。在Linux内核里这俩活儿全塞进了context_switch这个函数。用户空间那部分靠switch_mm内核栈和寄存器靠switch_to。我第一次看这段代码的时候脑子里全是问号为啥要分这么细为啥判断 next-mm 是不是空后来才反应过来——内核线程和普通用户进程待遇不一样内核线程没有用户地址空间啊它的 mm_struct *mm 就是 NULL。那内核线程跑的时候用谁的页表用前一个进程的 active_mm其实就是借用。/* * context_switch - 切换到新进程的内存地址空间和寄存器状态 */ static __always_inline struct rq * context_switch(struct rq *rq, struct task_struct *prev, struct task_struct *next, struct rq_flags *rf) { /* 进程切换前的准备工作架构相关的前置处理、调度器状态更新全在这 */ prepare_task_switch(rq, prev, next); /* * 半虚拟化相关的钩子ARM上基本是空实现不用太纠结 * x86上主要是把页表重载和切换后端合并成一个hypercall */ arch_start_context_switch(prev); /* * 这里是整个函数的核心分支别死记硬背就记4种场景 * 内核线程 - 内核线程 | 用户进程 - 内核线程 * 内核线程 - 用户进程 | 用户进程 - 用户进程 */ if (!next-mm) { // 要切到的是内核线程它没有自己的用户地址空间 enter_lazy_tlb(prev-active_mm, next); // 进入懒TLB模式ARM里默认是空实现 next-active_mm prev-active_mm; // 内核线程直接蹭前一个进程的active_mm if (prev-mm) // 前一个是用户进程要给内存描述符的引用计数1 mmgrab(prev-active_mm); else // 前一个也是内核线程把它的active_mm清掉防止野指针 prev-active_mm NULL; } else { // 要切到的是用户进程有独立的mm_struct membarrier_switch_mm(rq, prev-active_mm, next-mm); // 内存屏障保证membarrier系统调用的时序正确 /* * sys_membarrier要求在设置rq-curr和返回用户态之间必须有一个smp_mb() * 下面的switch_mm_irqs_off会提供这个屏障如果前后进程mm相同finish_task_switch里的mmdrop也会提供 */ // 核心切换用户地址空间ARM没实现专属的switch_mm_irqs_off最终调用的就是switch_mm switch_mm_irqs_off(prev-active_mm, next-mm, next); if (!prev-mm) { // 前一个是内核线程记录之前的active_mm后面要释放引用 /* 这个mm的引用释放会在finish_task_switch()里通过mmdrop()完成 */ rq-prev_mm prev-active_mm; prev-active_mm NULL; } } rq-clock_update_flags ~(RQCF_ACT_SKIP|RQCF_REQ_SKIP); prepare_lock_switch(rq, next, rf); /* 前面换完了内存地址空间这里才是真正切换寄存器状态和内核栈 */ switch_to(prev, next, prev); barrier(); // 编译器屏障防止乱序保证switch_to之后的代码一定在切换完成后执行 // 收尾工作释放前一个进程的mm引用处理调度器的剩余状态 return finish_task_switch(prev); }是不是看着分支有点绕没事我当年也绕了好久。内核线程永远跑在内核态根本用不到用户地址空间所以内核线程没有自己的mm所以它借用上一个进程的active_mm。从用户进程切到内核线程时要mmgrab增加引用计数反过来从内核线程切到用户进程时就把prev_mm记下来后面finish_task_switch里再mmdrop。要是前一个也是内核线程一定要把它的active_mm清掉不然引用计数乱了内存泄漏都是轻的严重的直接 panic。我以前写用户态代码的时候从来没想过内核线程这么“寄生”。现在想想调度器得时刻记得谁在用哪套页表这活儿真不轻松。二、内存管理机制switch_mm用户进程切换内存空间主要靠switch_mm或者switch_mm_irqs_offx86有专门实现ARM暂时没。ARM上这本质就是把新进程的页表基址写进TTBR0寄存器。每个进程都有自己一套完整的虚拟地址空间但物理内存是共享的。PGD页全局目录是最高层下面是PTE最终指向物理页。不同进程的PGD不一样所以地址空间天然隔离。ARMv7里switch_mm长这样我只把关键部分贴了出来static inline void switch_mm(struct mm_struct *prev, struct mm_struct *next, struct task_struct *tsk) { #ifdef CONFIG_MMU unsigned int cpu smp_processor_id(); if (cache_ops_need_broadcast() !cpumask_empty(mm_cpumask(next)) !cpumask_test_cpu(cpu, mm_cpumask(next))) __flush_icache_all(); /* 刷新CPU Core所有I-cache */ if (!cpumask_test_and_set_cpu(cpu, mm_cpumask(next)) || prev ! next) { check_and_switch_context(next, tsk); if (cache_is_vivt()) cpumask_clear_cpu(cpu, mm_cpumask(prev)); } #endif }先处理I-cache。ARM是哈佛架构I-cache和D-cache分开。当进程第一次被调度到某个核上得把I-cache全刷掉不然可能执行到旧指令。我以前在多核调试的时候遇到过诡异的“代码没变但行为变了”的bug现在回想可能就是I-cache没刷干净。2.1 别让 I-Cache 拖后腿在 ARM 世界里switch_mm 的核心任务就是改写 TTBR0Translation Table Base Register 0。这玩意儿就像是内存管理单元MMU的“导航仪”指定了页全局目录PGD在哪儿。但是直接改 TTBR0 会出事吗其实还有个更头疼的问题——缓存。为啥切换进程还要刷新 I-Cache指令缓存呢原因很简单哈佛结构。在 ARM SMP 架构里L1 缓存分成了独立的指令缓存I-Cache和数据缓存D-Cache。如果一个进程从 CPU A 迁移到了 CPU BCPU B 的 I-Cache 里可能还留着旧数据这能行吗不用担心switch_mm 里有个骚操作static inline void switch_mm(struct mm_struct *prev, struct mm_struct *next, struct task_struct *tsk) { #ifdef CONFIG_MMU unsigned int cpu smp_processor_id(); /* * 如果 next 进程刚迁移到这个 CPU并且硬件需要广播 I-Cache 失效 * 就把整个 I-Cache 炸掉。 */ if (cache_ops_need_broadcast() !cpumask_empty(mm_cpumask(next)) !cpumask_test_cpu(cpu, mm_cpumask(next))) _flush_icache_all(); // 这条指令会通过 CP15 的 c7 寄存器干掉 I-Cache /* 把当前 CPU 设置到 next 的 cpumask 里表示这个进程现在也在这个核上跑过 */ if (!cpumask_test_and_set_cpu(cpu, mm_cpumask(next)) || prev ! next) { check_and_switch_context(next, tsk); // 核心处理 ASID 切 TTBR0 if (cache_is_vivt()) cpumask_clear_cpu(cpu, mm_cpumask(prev)); } #endif }_flush_icache_all 在 ARMv7 SMP 上实际执行的是#define __flush_cache_all_v7_smp() asm(mcr p15, 0, %0, c7, c1, 0 :: r(0))MCR p15, 0, r0, c7, c1, 0→ CP15 协处理器的 c7, c1, 0 组合就是 ICIALLUISInner Shareable 指令缓存全局失效。这就像给 CPU 下达了一个死命令“把你的指令缓存全给我倒了重新来”第一次看到这操作的时候我觉得这也太暴力了吧但没办法不暴力就会跑飞。2.2 ASID 和 TLB为啥 ARM 只给了 8 位TLB快表大家都知道它是为了加速地址转换的。但以前的 ARM 架构有个大毛病进程一切换TLB 就得全清。这就好比每次换台电视都要把之前的记忆全抹掉重新搜索信号那速度能快吗为了解决这个问题ARM 引入了ASIDAddress Space ID。ASID 只有 8 位这意味着最多只能有 256 个独立的 ID。它是怎么玩的呢它把 TLB 分成了两类Global属于内核的谁来都不变nG位为0。Process-specific属于用户的靠 ASID 来区分。这样一来进程 A 和进程 B 的 TLB 项因为 ASID 不同根本不会打架。切换进程时只要换个 ASID旧的 TLB 项依然有效这设计真的绝了。不过8 位 ID 总有发完的时候。这时候就得触发ASID 溢出机制重新分配 ID 并刷新 TLB。这算是这个机制唯一的“阿喀琉斯之踵”吧。来看这个切换的核心逻辑void check_and_switch_context(struct mm_struct *mm, struct task_struct *tsk) { unsigned int cpu smp_processor_id(); u64 asid; /* * 因为更新页表基址和 ASID 没法做到原子操作 * 所以咱们得先切到一个临时的保留状态防止在这个间隙出岔子 [cite: 612, 622, 626]。 */ cpu_set_reserved_ttbr0(); asid atomic64_read(mm-context.id); /* 拿出现在进程的 ASID [cite: 613, 627] */ /* 如果号没溢出万事大吉走快车道直接切 [cite: 614, 628, 630] */ if (!((asid ^ atomic64_read(asid_generation)) ASID_BITS) atomic64_xchg(per_cpu(active_asids, cpu), asid)) goto switch_mm_fastpath; /* 号不够用了那就得加锁重分配还得刷一遍 TLB 和分支预测器 [cite: 615, 631, 642, 643] */ raw_spin_lock_irqsave(cpu_asid_lock, flags); asid new_context(mm, cpu); atomic64_set(mm-context.id, asid); local_flush_bp_all(); local_flush_tlb_all(); raw_spin_unlock_irqrestore(cpu_asid_lock, flags); switch_mm_fastpath: cpu_switch_mm(mm-pgd, mm); /* 真正去改页表基址寄存器 TTBR0 [cite: 649] */ }local_flush_tlb_all 在 ARMv7 里实际是这条协处理器指令static inline void local_flush_tlb_all(void) { const int zero 0; const unsigned int __tlb_flag __cpu_tlb_flags; if (tlb_flag(TLB_WB)) dsb(nshst); __local_flush_tlb_all(); tlb_op(TLB_V7_UIS_FULL, c8, c7, 0, zero); if (tlb_flag(TLB_BARRIER)) { dsb(nsh); isb(); } }展开 tlb_op mcr p15, 0, r0, c8, c7, 0CRnc8, opc10, CRmc7, opc20→ 查 ARM 手册就知道这是 TLBIALL统一 TLB 无效化。也就是说一旦 ASID 用光所有进程的 TLB 条目一起陪葬——性能会抖一下但至少不会错。往期文章推荐图解 TCP/IP协议看图秒懂图解 C/C 多线程看图秒懂爆肝整理嵌入式开发必知10种调试手段【图解】SSH安全外壳协议工作原理【就业避坑】C 就业前景全解析为什么劝退声不断大厂核心岗仍刚需 C【大厂标准】Linux C/C 后端开发系统学习路线【音视频】音视频流媒体高级开发核心学习路径【Qt进阶】C Qt 桌面 嵌入式开发一条龙学习攻略【内核底层】Linux 内核硬核修炼指南【面试冲刺】C/C 高频八股面试题 1000 题三【项目实战】手撕线程池C 程序员的能力试金石三、 switch_to前面都在说用户空间地址切换但真正让 CPU 执行流的“灵魂”转移的是 switch_to。先复习下switch_to的宏定义很多人搞不懂为啥要传三个参数prev、next、prev。#define switch_to(prev,next,last) \ do{ \ __complete_pending_tlbi(); \ last __switch_to(prev,task_thread_info(prev), task_thread_info(next)); \ } while (0)这里的核心是__switch_to纯汇编实现也是整个进程切换的最终落脚点。它把前一个进程prev的 CPU 寄存器状态保存起来然后把下一个进程next之前保存的寄存器状态恢复出来包括栈指针 sp 和程序计数器 pc。等 pc 寄存器被换成 next 进程的值CPU 接下来就会执行 next 进程的指令进程切换就彻底完成了。ENTRY(__switch_to) UNWIND(.fnstart ) UNWIND(.cantunwind ) 入参 R0prev进程的task_struct R1prev进程的thread_info R2next进程的thread_info add ip, r1, #TI_CPU_SAVE ip寄存器 prev进程thread_info里cpu_context的地址 把prev进程的r4-sl、fp、sp、lr寄存器保存到prev的cpu_context里 ARM( stmia ip!, {r4 - sl, fp, sp, lr} ) THUMB( stmia ip!, {r4 - sl, fp} ) THUMB( str sp, [ip], #4 ) THUMB( str lr, [ip], #4 ) 把next进程的TLS线程本地存储指针读到r4、r5里 ldr r4, [r2, #TI_TP_VALUE] ldr r5, [r2, #TI_TP_VALUE 4] #ifdef CONFIG_CPU_USE_DOMAINS mrc p15, 0, r6, c3, c0, 0 读域访问控制寄存器DACR str r6, [r1, #TI_CPU_DOMAIN] 把旧的DACR保存到prev的thread_info里 ldr r6, [r2, #TI_CPU_DOMAIN] 从next的thread_info里读新的DACR #endif switch_tls r1, r4, r5, r3, r7 切换TLS线程本地存储 #if defined(CONFIG_STACKPROTECTOR) !defined(CONFIG_SMP) ldr r7, [r2, #TI_TASK] ldr r8, __stack_chk_guard .if (TSK_STACK_CANARY IMM12_MASK) add r7, r7, #TSK_STACK_CANARY ~IMM12_MASK .endif ldr r7, [r7, #TSK_STACK_CANARY IMM12_MASK] #endif #ifdef CONFIG_CPU_USE_DOMAINS mcr p15, 0, r6, c3, c0, 0 把新的DACR写到寄存器里完成域访问权限切换 #endif mov r5, r0 把prev进程的task_struct指针暂存到r5 add r4, r2, #TI_CPU_SAVE r4 next进程thread_info里cpu_context的地址 调用线程切换的通知链内核里其他模块可以通过这个钩子感知进程切换 ldr r0, thread_notify_head mov r1, #THREAD_NOTIFY_SWITCH bl atomic_notifier_call_chain #if defined(CONFIG_STACKPROTECTOR) !defined(CONFIG_SMP) str r7, [r8] 更新栈溢出保护的canary值 #endif THUMB( mov ip, r4 ) mov r0, r5 把prev进程的task_struct指针放到r0作为函数返回值 核心从next的cpu_context里把之前保存的寄存器全恢复出来 包括r4-sl、fp、sp、pcsp恢复了内核栈就切到next进程了 pc被赋值成next进程之前保存的lrCPU就会跳转到next进程的执行流 ARM( ldmia r4, {r4 - sl, fp, sp, pc} ) THUMB( ldmia ip!, {r4 - sl, fp} ) THUMB( ldr sp, [ip], #4 ) THUMB( ldr pc, [ip] ) UNWIND(.fnend ) ENDPROC(__switch_to)看到没整个进程切换的最后一步就靠stmia和ldmia这两条批量内存指令。stmia把当前进程的寄存器全存到内存里。最骚的就是最后一条 ldmia r4, {..., pc}。sp 被换成了 next 的内核栈pc 被换成了 next 上一次被切换出去时保存的返回地址。于是 CPU 就神奇地开始在 next 的上下文里跑起来了。我第一次看这段汇编时盯着 pc 的变化愣了三分钟——这不就是传说中的“魂穿”吗写到这里又是几千字了。说实话ARM 的进程切换比 x86 清爽不少至少没那么多段寄存器要折腾但 ASID 只有 8 位这件事每次想到都让我觉得有点像“当年设计寄存器的大佬喝了假酒”。不过话说回来256 个 ASID 在嵌入式场景也够用——毕竟谁没事在一颗 Cortex-A 上跑几百个进程啊你要真跑那么多了那刷 TLB 的代价你就自己受着吧反正 Linux 内核已经给了你选择要么忍受 TLB miss 多一点要么偶尔来一次全局冲刷。最后送大家一句我踩坑总结的真理调试进程切换 crash第一眼看 mm-active_mm 是不是 NULL第二眼看 TTBR0 寄没寄。别问我怎么知道的问就是又写Bug了。