首页 / 资讯中心 / 文章详情

Linux switch_mm解析:进程切换的地址空间与TLB管理

Linux switch_mm解析:进程切换的地址空间与TLB管理 ★ FEATURED ARTICLE
做系统层开发尤其是内核、嵌入式、性能优化这块早晚要回答一个问题进程切换为什么这么贵。我习惯用一句话切入因为 CPU 要换地址空间。这句话落到代码上核心就是今天要讲的 switch_mm 函数。switch_mm 是 Linux 内核在进程上下文切换时用来切换地址空间的函数。它在调度器里被调用但真实实现分散在 arch 目录下x86、ARM64、RISC-V 各自一套。函数职责不复杂把 CPU 的页表基址从上一个进程的 mm 切到下一个进程的 mm顺便处理 TLB 缓存、ASID/PCID、内核线程借用 active_mm 这些容易出事的边角。搞懂它对内存管理、多核性能、BSP 移植都很有帮助对刚入内核门的朋友来说它是理解“进程上下文”和“地址空间”最好的切入点。接下来我会从调用路径、核心机制、x86 实现、active_mm 的坑、调试方法和跨架构差异几个角度把 switch_mm 讲透。写到最后你会发现这不是一个“改个页表指针”的简单函数而是一整套内存一致性的枢纽。1. switch_mm 在进程切换中的地位1.1 一个 CPU 同一时刻只能拥有一个地址空间每个进程都有自己的虚拟地址空间内核里对应一个 mm_struct。这个结构里不光有代码段、数据段、堆栈区间的映射信息最重要的是根页表 pgd。CPU 里的 MMU 做地址翻译全靠它。问题是 CPU 核心只有一个或一组 MMU同一时刻只能让翻译逻辑指向一个根页表。所以当调度器把 CPU 从进程 A 换给进程 B如果不把根页表从 A 的换成 B 的B 拿自己的虚拟地址去访问时就会被 MMU 按照 A 的页表翻译得到完全错误的物理地址。你可以把页表基址想象成舞台剧的布景。演出顺序是 A 剧、B 剧、C 剧每一场上场前都要把舞台布景换掉否则演员会对着错误的背景演。switch_mm 就是那位“换布景的场务”而且它换布景的同时还得保证观众席上每个位置看到的画面都一致这个“观众席”就是各 CPU 核心的 TLB 缓存。1.2 从 schedule 到 context_switch再到 switch_mm调度器主函数 schedule() 选中 next 任务后会调用 context_switch() 完成真正的硬件级切换。context_switch 里做了两件大事第一是切换地址空间调用 switch_mm_irqs_off第二是保存恢复寄存器与栈指针由汇编函数 switch_to 完成。这里有一个版本相关的细节老内核里这个入口叫 switch_mm新内核里它变成了一个包壳实际实现是 switch_mm_irqs_off。所以你在源码里搜 switch_mm 会看到两个符号别懵跟着 irqs_off 走就对了。调用点长什么样我简化一下逻辑static void context_switch(...) { if (!next-mm) { enter_lazy_tlb(prev-active_mm, next); next-active_mm prev-active_mm; if (prev-mm) mmgrab(prev-active_mm); switch_mm_irqs_off(prev-active_mm, next-active_mm, next); } else { switch_mm_irqs_off(prev-active_mm, next-mm, next); } }注意第一个参数不是 prev-mm而是 prev-active_mm。这是新手最常看漏的地方。prev 如果是普通用户态进程prev-mm 和 prev-active_mm 指向同一个结构但 prev 如果是内核线程它的 mm 是 NULLCPU 实际还在使用它借来的 active_mm。不把这个活跃 mm 传进去switch_mm 就没有页表可以比较、也会在 TLB 判断上犯糊涂。2. 核心机制拆解mm_struct、TLB 与 ASID2.1 mm_struct 是进程的“虚拟地址空间档案”要读懂 switch_mm先认识 mm_struct。它包含进程完整的地址空间描述最关键的几个字段pgd顶层页表指针也是最终要写进 CR3 或 TTBR0 的值mmap用户态虚拟区间链表用于查找 VMAmm_users引用这个地址空间的用户态任务数量进程退出时会减到 0mm_count地址空间对象本身的总引用计数包含 mm_users 那一份基础引用也包含内核线程借用的一份mm_cpumask位图标记哪些 CPU 的 TLB 里可能还缓存着这个 mm 的映射。switch_mm 第一步要确定的就是 next 的根页表是从哪来、和 prev 是否同一个。如果完全相同那连 CR3 都不用重新写只剩一些位图状态需要收尾。这听上去简单但考虑到多级页表、ASID、进程复用真实分支比想象中多。页表为什么要多级因为单级页表要一次性映射 4G 空间会非常大而且绝大多数地址都没用到。多级页表可以做到“按需分配”根目录只有 4KB下面全是稀疏分配的中间层。所以切换页表根非常便宜真正的开销在 TLB 失效上。2.2 TLB 与 ASID为什么不能每次切换都全刷TLB 是 MMU 里的页表缓存把虚拟地址到物理地址的翻译结果缓存下来避免每次访问都去内存里查页表。快是快但它有保存旧进程映射的副作用。切换根页表后TLB 里那些旧进程的条目如果不失效下一次进程访问相同虚拟地址时硬件可能直接命中过期的缓存拿到别的进程的物理页。最保守的做法是每次切换都全刷 TLB。但是全刷的代价非常高因为它会把新进程本来缓存的、还有内核地址空间的条目统统清掉接下来的每次访存都可能要重新查页表。现代处理器用 ASID/PCID 解决这个问题TLB 条目打上地址空间标识硬件在查找时只认当前标识的条目。ARM 把它叫 ASID写进 TTBR0_EL1 的高位或者单独寄存器x86 把它叫 PCID放在 CR3 的低 12 位RISC-V 的 satp 里也有 ASID 域。这样一来切到进程 B 时A 的 TLB 条目可以被保留等切回 A 时还能命中。ASID 数量有限比如 8 位、12 位用完就要回收回收时必须把对应 ASID 的 TLB 条目全刷。switch_mm 里处理 ASID 分配和失效回收的逻辑就是这套机制的核心。2.3 mm_cpumask 与 lazy TLB 的关系每个 CPU 核心都有独立的 TLB。所以“切地址空间”天然是一个 per-CPU 动作。为了知道某个 mm 的 TLB 缓存散落在哪些 CPU 上内核维护了 mm_cpumask 位图。进入 switch_mm 时要把当前 CPU 的位图位置到 next 的 mm_cpumask 中含义是“本 CPU 的 TLB 现在归这个 mm 管”。当多核环境下因为 munmap 或 mprotect 需要使该 mm 的部分 TLB 失效时内核要遍历这个位图给所有相关 CPU 发送 TLB shootdown IPI让它们执行对应的失效指令。lazy TLB 也依赖这个位图。当 CPU 切到内核线程时由于内核线程没有自己的 mm内核会让 CPU 继续保留前一个用户进程的 TLB 条目这种状态叫 lazy TLB。此时 CPU 仍然算作上一个 mm 的“缓存宿主”位图里的位不能清掉直到真正切换到一个不同的用户 mm。理解了这个位图后面看 x86 的实现就能顺下来。3. x86 平台 switch_mm 的实现细节3.1 switch_mm_irqs_off 才是真正干活的函数x86 上switch_mm 的历史包袱比较重新内核把主体逻辑放在 arch/x86/mm/tlb.c 的 switch_mm_irqs_off 里。为什么名字里带 irqs_off因为函数要求中断已经关闭或者函数内部保证不会被中断打断。页表基址寄存器 CR3 的切换和 TLB 失效指令必须在一个“原子窗口”里完成。如果在写 CR3 之后、刷新 TLB 之前发生中断中断处理程序可能唤醒更高优先级任务跑到另一个 cpu或者趁你半切状态访问了不属于当前页表的地址轻则警告、重则连锁崩溃。因此调用点在 context_switch 的关中断临界区内内部也是围绕 CPU 局部状态在操作。x86 还有一个特殊点地址空间隔离机制PTI会强制把用户页表和内核页表的映射拆开导致 TLB 失效清单位更大切换 mm 的代价被放大。社区在 PCID 和 lazy TLB 上做了大量优化就是为了对冲这部分成本。这部分展开又是一整篇文章你只要知道 switch_mm 的性能敏感度极高改动每一行都有人在抠性能就够了。3.2 最小可运行伪代码你需要关注的分支逻辑我不贴完整源码而是给一个高度简化但保留了关键分支的伪代码帮你建立阅读真实代码的索引void switch_mm_irqs_off(struct mm_struct *prev, struct mm_struct *next, struct task_struct *tsk) { unsigned int cpu smp_processor_id(); if (!next) next init_mm; if (next prev) { // 同一个地址空间什么都不用做 return; } if (prev next-pgd prev-pgd) { // 页表根相同但 mm 不同。常见于某些共享页表的场景 // 不需要重新加载 CR3但 ASID/PCID 状态可能需要同步 } // 当前 CPU 的 TLB 马上要包含 next 的映射 cpumask_set_cpu(cpu, mm_cpumask(next)); // 首次遇到这个 next且当前 TLB 里残留了 prev 的映射 // 在支持 PCID 的 CPU 上用 next-context.pcid 构造 CR3 值 // 在不支持 PCID 的 CPU 上直接写 CR3然后全刷 TLB write_cr3(__pa(next-pgd) | next_pcid); flush_tlb_mm(next); }看到这里你可能会问PCID 都有了为什么还要 flush_tlb_mm因为首次切换到一个新 mm 时它的 PCID 可能刚分配或者刚被回收不能保证 TLB 里没有同 PCID 的旧条目。保守起见第一次加载必须按需失效。后续如果 TLB 里还有这个 PCID 的缓存并且没有非法遗留就可以带 NOFLUSH 标志写 CR3跳过刷新这是 PCID 优化收益的最大来源。3.3 TLB 维护指令与 flush 策略x86 TLB 失效指令族里有 INVLPG单页失效、INVPCID按 PCID 失效、以及全量 reload CR3隐含全刷。switch_mm 里按情况选择对范围内的单个地址失效INVLPG代价最小对某个 PCID/ASID 的全部条目失效INVPCID 或暴力的 CR3 切换全系统全刷让每个 CPU 执行 reload通常由内核函数 flush_tlb_all 驱动。在 switch_mm 路径中最怕的就是不小心执行了全刷。全刷的代价是倍数级的因为其他 CPU 的 TLB 会被清掉之后所有访存都要重新走页表。这也是为什么 switch_mm 对 prev 和 next 的比较这么较真能省一次 flush 就省一次。我实际调性能时观察过一个现象某些高频短生命周期进程的服务器如果 CPU 不支持 PCID或者内核关掉了 PCID 支持用户态换页后 TLB 命中率会明显下降单次上下文的开销肉眼可见地增长。这就是 switch_mm 里每个分支都在“抠”的优化。4. 内核线程、active_mm 与引用计数4.1 内核线程的 mm 为什么是 NULL很多内核线程比如 kworker、kswapd只在内核空间跑代码不访问用户态地址所以它们任务描述符里的 mm 字段是 NULL。这听起来很合理一个没有用户地址空间的进程自然不需要自己的 pgd。但 CPU 执行指令离不开页表。内核态代码、数据、模块、栈都要通过页表翻译物理地址。于是内核设计了一套借用机制当调度器把一个内核线程放到 CPU 上运行时CPU 继续使用上一个用户进程的页表这个被借用的地址空间就叫 active_mm。所以一个任务会有两个概念mm它本身拥有的用户地址空间内核线程为 NULLactive_mm当前 CPU 实际激活的地址空间内核线程借别人的。switch_mm 里第一个参数就是 prev-active_mm第二个参数在普通进程场景下是 next-mm在内核线程场景下是 next-active_mm。抓住这两条线context_switch 的代码就不会绕晕。4.2 active_mm 与 mm_users / mm_count 的区分内核里的引用计数有两个必须分清楚mm_users用户态任务对这个地址空间的引用数。普通进程 fork/exec 会递增exit 时递减减到 0 表示“没有用户进程再拥有它了”可以清空用户页表mm_count这个 mm 结构体本身的引用数。只要还有任何实体持有 mm 对象比如正在借用的内核线程它就不能被 free。上下文切换到内核线程时调用的是 mmgrab(prev-active_mm)也就是递增 mm_count而不是 mmget递增 mm_users。这是语义上刻意设计的内核线程只借用页表不参与用户态所有权。如果这里误用了 mmget会导致用户进程已经退出但 mm_users 迟迟不为 0页表和 mm_struct 泄漏反过来如果漏了 mmgrab用户进程退出时 mm_count 可能提前归零mm_struct 被释放内核线程还在用它系统直接 use-after-free崩溃现场往往特别难看。我把这套机制做个类比mm_users 像租房合同上的租客mm_count 像房产登记簿上的产权人。内核线程是临时借宿的过客不签租约但必须有产权人告诉物业“这人今晚住这里”否则物业会把楼拆了。4.3 lazy TLB 的实际收益有多大理解了 active_mm再看 lazy TLB 就很顺CPU 从用户进程切到内核线程时既然内核线程没有自己的页表CPU 干脆继续用 active_mm 的页表TLB 里所有用户态映射保持原样。这样从内核线程切回另一个用户进程时如果唤起的进程恰好就是之前那个 active_mm 的所有者内存里仍存着它的 TLB完全跳过 flush开销降到最低。进程切换的典型场景是 A 进程 - 内核线程比如处理 IO 完成- B 进程。如果 B 和 A 是同一个进程或者 A 的地址空间被 B 复用lazy TLB 能省下一整条 TLB 重建链即使 B 不是 A也至少省掉了切到内核线程时的那次全刷。内核在进入 lazy mode 时会调用 enter_lazy_tlb()在 x86 上它会让每个 CPU 的 tlb_state 继续指向 active_mm并把当前 CPU 保留在 active_mm 的 mm_cpumask 中。退出 lazy mode 则发生在 switch_mm 真正加载不同 mm 时。这些状态机的严谨程度直接影响着高并发服务器上切换开销的波动。5. 调试、观测与避坑实录5.1 如何观测 switch_mm 的调用与耗时想验证 switch_mm 调了多少次、每次花多长时间最直接的是 kprobe。Linux 提供 /sys/kernel/debug/tracing/kprobe_events可以动态挂到函数入口打印参数# 需要 root且内核开启 kprobe echo p:switch_enter switch_mm_irqs_off prev next tsk /sys/kernel/debug/tracing/kprobe_events echo 1 /sys/kernel/debug/tracing/events/kprobes/switch_enter/enable cat /sys/kernel/debug/tracing/trace生产环境不方便开 debugfs 的话我常用 perf probe 做短时观测sudo perf probe switch_mm_irqs_off prev next tsk sudo perf record -e probe:switch_mm_irqs_off -a -- sleep 10 sudo perf script | head -n 50注意不同内核版本函数签名可能不同perf probe 如果报“找不到符号”先 grep /proc/kallsyms 确认函数名。我在某些老内核上试过那个版本还叫 switch_mm新一点的又变成 switch_mm_irqs_off接口差异不小。如果你只关心调用频率和上下文关系可以直接用调度器 tracepoint sched_switch再和金探测到的 switch_mm 入口做时间对齐能比较完整地还原一次上下文切换里地址空间占用多少时间。这比看墙钟时间准确得多。5.2 常见故障类型与排查思路switch_mm 相关故障往往不是立刻爆的而是延迟释放或早释放造成的“隔代崩溃”。我遇到的典型问题有这几类第一类是 mm 被提前释放。表现为任意进程的 page fault 异常backtrace 指向 __mmdrop 或 __put_task_struct紧接着是 use-after-free。排查时先用 ftrace 打印 mmgrab/mmdrop 调用点看谁少了一次 grab。我的经验是重点查内核线程创建路径上 active_mm 的赋值是否在所有分支都做了 mmgrab。第二类是 mm_cpumask 污染导致的 TLB shootdown 风暴。表现是 /proc/interrupts 里函数调用中断暴涨业务响应变慢。原因是旧的 mm 已经切走但 CPU 位图没有清掉后续该 mm 的 flush 请求会发给所有“可能缓存”的 CPU造成无谓的 IPI。排查时看 /proc/vmstat 里的远程 TLB flush 相关计数器如果每秒增长几万基本可以认定 shootdown 路径有异常。第三类是 PCID/ASID 分配回环导致的偶发失效。表现是间隔性 page fault 或随机访问异常。这种问题最难查一般要配合 CPU errata 文档和内核配置先确认是否启用了 PCID/ASID 优化再对比关闭之后是否复现。很多 BSP 移植时会把这套逻辑改坏。排查时我建议先开 CONFIG_DEBUG_VM再打开对应架构的 TLB flush 调试选项把每次 TLB flush 的调用栈打出来。不要一上来就去看业务代码switch_mm 这种底层接口的问题十个里有八个是状态没同步不是业务 bug。5.3 多核环境下 TLB shootdown 的代价单核 CPU 上 flush TLB 只是清自己的缓存很快。多核系统上涉及跨核当某个 mm 的映射要失效而其他核的 TLB 里可能有它的条目主控核必须通过 IPI 中断通知那些核去执行失效指令这个动作叫 TLB shootdown。switch_mm 主要做的是本地切换和本地 flush但它的决策直接决定后续全局 flush 的次数。比如 CPU 切走时如果还把自己的位图留在 mm_cpumask 里后来该 mm 被 munmap 时这个 CPU 就会平白收到 shootdown IPI。所以新版本的 switch_mm 实现里在结束 lazy TLB 或切到不同 mm 时会把当前 CPU 从旧 mm 的位图清掉降低无效 IPI 频率。性能调优时我习惯看一眼 /proc/interrupts 里与函数调用相关的计数增长曲线和上下文切换曲线对比。如果两者同步暴增说明切换路径触发了大量跨核 flush这时候优先考虑进程亲和性绑定减少进程在 CPU 间迁移效果往往比调内核参数更直接。6. 跨架构差异与移植清单6.1 x86、ARM64、RISC-V 的地址空间切换差异switch_mm 的上层语义是架构无关的但具体实现全在 arch 目录。各平台的关键差别可以放到一张表里对比项x86ARM64RISC-V页表基址装载写 CR3写 TTBR0_EL1写 satp地址空间标识PCIDCR3 低 12 位ASIDTTBR 或 ASID 寄存器SATP 的 ASID 域局部 TLB 失效INVLPG / INVPCIDTLBI 指令族SFENCE.VMAlazy TLB 模式支持有 per-CPU tlb_state支持与 PAN 等特性联动支持程度因实现而异内核/用户页表分离由地址空间隔离机制强制分离TTBR0/TTBR1 天然分离由 S 态和页表标志决定ARM64 里内核空间和用户空间分别用 TTBR1 和 TTBR0切换用户进程通常只需要改 TTBR0因此比 x86 多一层隔离手段RISC-V 的 satp 写入后软件必须执行 SFENCE.VMA否则页表切换不保证生效。这些细节不同但 switch_mm 的整体骨架差别不大先比较 prev/next再维护位图再装载新根页表最后按策略刷 TLB。6.2 移植新架构时的 switch_mm 实现清单如果你要为一个新 CPU 架构移植 Linuxswitch_mm 是实现清单里绕不开的一项。我总结了一个自检列表在 arch_mm_context 里规划 ASID/PCID 的保存字段实现根页表装载封装对应写 CR3/TTBR0/satp 的指令序列实现本地 TLB 失效原语单地址失效、按 ASID 失效、全量失效实现 enter_lazy_tlb明确进入/退出 lazy 的时机在激活新 mm 时更新 mm_cpumask并在切走时清掉当前 CPU 的位特殊处理 init_mm 和内核线程两种 next 场景严格定义调用该函数时的中断/抢占状态最好锁进 context_switch 的临界区。每一项背后都是血泪教训。比如第 5 条很多初版移植只做 set 不做 clear结果快速新建进程时 TLB shootdown 越来越多别人还以为是硬件性能差第 7 条如果没关抢占让迁移发生per-CPU 状态的更新会错位崩溃现场根本无法用常规锁来定位。移植时建议先跑内核自带的 LTP、自旋锁压力和 fork 风暴测试把切换路径压到高频。只有让 switch_mm 在高压下反复执行上百次不崩才能证明地址空间切换逻辑基本可靠。我在实际项目里遇到过最典型的教训某开发板适配时直接复制了另一个架构的 switch_mm以为通用逻辑一样结果忽略了该架构 TLB 失效指令不会自动作用于其他 CPU 的语义导致跨核数据不一致最后查了两天才发现是 shootdown 集合没按架构自定义实现。所以移植 checklist 里一定要有一条确认架构手册里对“TLB 失效操作的作用域”的定义是只清本核还是广播全核别想当然。写到最后还是那句老话内核里越底层的函数越值得抠细节。switch_mm 表面只是换页表基址实际牵扯 TLB、ASID、active_mm、引用计数、mm_cpumask、多核一致性每一步都踩过很多人的坑。我自己调试时只要看到进程切换相关的随机崩溃第一件事永远是先确认 prev-active_mm 跟 next-mm 是不是同一个、位图有没有更新、flush 有没有发对 CPU。这三项排除了再往别处找十有八九能少走弯路。另外有个小技巧网上关于 switch_mm 的老文章普遍讲 switch_mm 这个符号但你去看新内核会发现实际函数是 switch_mm_irqs_off接口和参数都可能变过。所以我建议在源码里 grep 函数名之后先用调用点的 context_switch 整体流程建立上下文再回到实现函数里逐分支看。接口在变但“切地址空间顺带管好 TLB 和 active_mm”这件事的本质一直没变过。
阅读完成 · 觉得有帮助?
咨询建站