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

从页表到TLB:内存映射的完整生命周期与内核管理实战

从页表到TLB:内存映射的完整生命周期与内核管理实战 ★ FEATURED ARTICLE
很多人学内核内存管理喜欢死记页表结构、VMA链表、各种 flags结果一遇到实际调优或者排查问题就懵了为什么一个地址昨天还能访问今天就成了 bad page为什么 munmap 之后程序没有立刻崩溃为什么 vm 内核线程切换的时候 TLB 不会出错这些问题光看数据结构是找不到答案的。页表、VMA、TLB 这些东西是静态的、某一瞬间的快照而真正要理解的问题是动态的生命周期一条虚拟地址 - 物理地址的映射关系是什么时候、被谁、用什么方式建立起来的在它存活期间它会被哪些机制修改、加强、削弱最终又是在什么时机、通过什么路径被拆掉的这篇文章就是沿着这条时间线写的。不打算事无巨细把每个内存管理的结构体都列一遍而是从一个映射从生到死的视角把内核里那些参与它的建立、维护、销毁的代码路径串起来。适合正在看内核内存管理源码的人、写驱动需要理解 ioremap/vmalloc 行为的人以及被线上诡异内存问题折磨过的性能调优选手。1. 映射不是一锤子买卖先建立生命周期这个分析框架1.1 一次地址转换在硬件眼中的样子要谈生命周期得先明确活着到底是什么意思。CPU 要访问一个虚拟地址硬件拿到的是虚拟地址得通过页表把它翻译成物理地址。现代 64 位 CPU 都采用多级页表比如 x86-64 的四级页表或者 ARM64 的四级/五级页表。逻辑上都一样虚拟地址被拆成几个段每一段作为索引逐级查下去最后一级页表项PTE里才存放物理页帧号和相关权限位。硬件具体怎么做属于体系结构的事。x86 查页表要依赖 CR3 寄存器指向当前进程的顶级页表PGD然后硬件自动遍历四级。ARM64 更干脆用 TTBR0 和 TTBR1 分别指向用户态和内核态的顶级页表。但关键是CPU 不关心这个页表项是谁、在什么时候写入的它只认 present 位。所以对内核来说生命周期管理的本质就是在合适的时机让某条 PTE 从不存在变成存在再在合适的时机让它从存在变成不存在并且保证这中间硬件看到的状态始终一致。这里就埋下了一个常见的理解误区大多数人以为用户态调用 mmap内核立刻就会建立页表项。实际上完全不是这样。mmap 只是在一个进程的虚拟地址空间里做规划建立起一块 VMA虚拟内存区域登记一下这片地址将来要映射什么、什么权限。至于真正的 PTE要等你真的去读写这片地址触发缺页异常之后内核才根据 VMA 的信息现场接生。这就是为什么映射的生命周期起点至少分成VMA 注册和PTE 落地两个阶段两件事可以隔得非常远。1.2 页表项身上的状态位生与死的判定依据页表项虽然是一条映射但它远比虚拟地址物理地址两个字段复杂。一个典型的 PTE 里有 present、rw、user、accessed、dirty、protnone、softdirty 这些标志位。这些标志位不是摆设它们就是生命周期在不同阶段的体检指标。判断一个映射是否活着第一眼要看 present 位。pte_present() 返回 1说明硬件可以拿它直接做地址转换。如果 present 为 0后面还有几种可能如果整个 PTE 全是 0pte_none说明这块区域从未被建立过映射访问它属于非法访问进程会收到 SIGSEGV。但有一种例外——PTE 被做成了特殊 swap 项也就是页面被换出到磁盘了PTE 里保存的不是物理地址而是换出位置和 swap 类型。如果 present 为 0 但软件位记录着一些状态比如典型的写保护但已被访问过的标记通常就是写时复制COW或者 KSM 合并后留下的痕迹它说明这条映射还活着只是被限制成只读一旦被写还会由缺页异常来升级。再看 accessed 位和 dirty 位。硬件在每次访问页表项时会自动把 accessed 置 1在写操作时会把 dirty 也置 1。内核内存回收模块的时钟算法比如 active/inactive 链表扫描就是靠定期地读一读然后再手动清掉 accessed 位来探测哪些页面最近被用过。dirty 位对文件映射尤其重要——如果文件页被写过dirty 位标记了这个页面和磁盘上的原始文件内容已经不一致回写线程才知道要把它刷盘。这些标志位的置位和清理本身就是映射生命周期里最频繁的常规维护动作。所以你看一个 PTE 不是死的它身上就像带着一份病历内核通过这些位判断一个映射当前处于哪个阶段、下一步该怎么处理。理解了这一点再去看缺页异常、回收、迁移的代码很多逻辑就顺理成章了。2. 映射的诞生谁在什么时机创建了页表项2.1 进程地址空间的映射mmap 与延迟分配用户态程序最常见的映射来源就是 mmap 系列调用。调用 mmap 后内核做的主要事情是do_mmap()找一个空闲的、大小合适的虚拟地址区间然后创建一个 VMA 挂到进程的 mm 结构体链表/红黑树上并建立 VMA 之间的合并规则。这一步只是把一个地址区间画了出来页表页没有分配PTE 更是零。真正让映射落地的是第一次访问。当 CPU 执行 load/store 指令访问这块地址时因为页表里还没有对应的条目硬件无法完成地址翻译就会触发缺页异常。x86 上这个异常会顺着 IDT 进入do_page_faultARM64 则是do_page_fault入口再统一收敛到handle_mm_fault()。handle_mm_fault要做的事一大半都是在回答一个问题这个 VMA 当初是怎么设置的现在应该给我什么样的物理页面匿名私有映射进程内部使用不映射文件。缺页时直接通过伙伴系统分配一个物理页然后建立 PTE。第一次读的时候这个页面是全零的所以叫匿名零页。写过之后它才是普通数据页。文件映射比如把可执行文件或者某个数据文件通过 mmap 映射进来缺页时要从 page cache 里找这个文件对应的页面。如果 page cache 里没有就得从磁盘读入major fault如果已经有了直接复用minor fault。这里有个细节文件映射缺页时PTE 的建立要同时把该页的引用计数加上否则页面被回收或换出时映射就悬挂了。私有可写映射的第一次写如果 VMA 是可写但私有的mmap 之后第一次写也会缺页。内核这时候要从匿名内存中分配新页并且把原来的文件页/共享页做拷贝建立新的 PTE再把旧的写保护解除。这就是写时复制COW的完整流程。每次缺页会分配新页面、建立新 PTE但你感受不到因为用户态程序只是正常读写了一下。然而这些操作代价并不低尤其分配页、清页、建立 TLB 条目都是实打实的开销。所以不少高性能程序喜欢提前madvise或者用MAP_POPULATE预先把页表建好这就是在主动调控映射生命周期的出生时间把成本从运行热点挪到初始化阶段。2.2 内核地址空间的映射直接映射区、vmalloc 与固定映射区内核本身的地址空间也存在一堆映射生命周期。最简单的是直接映射区线性映射在 x86-64 上一段虚拟地址和物理地址之间保持固定的偏移PAGE_OFFSET比如物理地址 X 直接对应虚拟地址 X PAGE_OFFSET。这段映射在启动早期整个内存初始化时就已经建好绝大多数情况下它的生命周期跟系统运行时间一样长——你几乎不会单独拆除某一条直接映射的 PTE。但要注意直接映射不是永远不可变的典型例子是内存热插拔一块物理内存要被移除时内核会先拆除这段物理地址对应的直接映射 PTE并且做 TLB 刷写然后才能把内存 offline 掉。反过来vmalloc 区就是典型的短命映射。vmalloc 分配虚拟地址连续、物理地址不一定连续的内存它在分配时需要临时建立一条映射释放时又要把这条映射拆掉。跟直接映射的固定偏移不同vmalloc 的 PTE 是每次分配时现场填写的因此同一物理地址在不同时间的虚拟地址可能是完全不同的每次模块加载、卸载、驱动注册都会带来一批映射的出生和死亡。也正因如此模块里如果有用全局指针保存 vmalloc 返回地址的习惯内核日志里会反复出现 vmalloc 区间失效的 fault本质就是生命周期管理跟上了使用没跟上。固定映射区fixmap则是一段在编译期就确定的虚拟地址通常用于内核启动早期、ioremap 之前的临时映射以及在中断上下文里需要快速访问的映射。它的生命周期表现为复用同一块虚拟地址在不同时刻可以映射不同的物理页比如kmap_local和kmap_atomic使用临时映射槽位用完就解除来得快去得也快。所以如果某个中断处理函数里使用 kmap 拿到的虚拟地址却在下半部才去访问就可能互相覆盖——这就是生命周期错配造成的典型 bug。2.3 缺页异常是绝大多数映射的接生婆缺页异常看起来是个异常实际上它是映射出生和升级的主路径。一次缺页可以分成几类地址根本不存在的映射没建SEGV、映射存在但页面不在内存的需要从磁盘/swap 读回来、映射存在但权限不满足的比如写一个只读页。拿写时复制来举例你会发现缺页处理像一次精密的多方协作。假设父进程 fork 出一个子进程子进程的匿名页 PTE 被全部改成只读并标记为这个页面即将被复制实际是让父子两个进程共享同一物理页但谁也别想写。一旦子进程尝试写缺页异常会查出这是 COW 缺页分配新物理页把原来共享页的内容拷贝过来更新子进程的 PTE 指向新页、恢复可写同时把原来共享页的引用计数减掉。如果这时父进程还在用这个共享页它的 PTE 则再保持只读。等父进程也去写又触发一次 COW这时候两个进程才终于拥有各自的物理页。这个过程中同一个物理地址作为共享映射只存活了一小段时间随后就被拆分成两条独立的 映射生命周期。缺页异常里还有一组重要流程是 mmap_sem现在是 mmap 读锁和页表锁的配合。建立 PTE 不是一行代码写进去就完事要先通过pte_alloc逐级分配目录页再拿锁、填 PTE、刷新 TLB。如果一个线程在建立映射的同时另一个线程在 fork 之后等待锁的顺序一旦乱了死锁就来了。这也是为什么很多 Memory Model 相关的 bug 不好复现因为它们都出现在生命周期最活跃的转换点。3. 映射的活跃期引用、更新、迁移与失效的管理机制3.1 TLB映射的高速缓存与它的失效规则建立一条 PTE 之后CPU 不会每次都去内存里慢慢查四级页表那样太慢。第一次翻译完成后结果会被缓存在 TLB 里。TLB 本质上是映射这份映射的缓存——它缓存的是虚拟地址到物理地址的瞬时对应关系。所以映射生命周期的维护一大半是在维护 TLB 的一致性。什么时候 TLB 对应条目会失效很自然地想到PTE 被修改、被清除的时候。但这里有个麻烦TLB 是分布式的一个系统里有多个 CPU 核心每个核都有自己独立的 TLB。如果一个 CPU 改了某个进程的 PTE必须让其他 CPU 上缓存的旧条目也失效否则别的核还是按照老地址翻译。这个过程叫 TLB shootdownTLB 降级。内核通过 IPI 中断通知所有相关 CPU 执行flush_tlb_page、flush_tlb_range或更暴力的flush_tlb_all。另一个关键是进程切换。不同进程的页表完全不同切换时理论上 TLB 要全部作废。现代 CPU 都有 ASID / PCID给每个进程分配一个地址空间标识符这样 TLB 里可以同时保留多个进程的条目切换进程时不用全部刷掉只需要用 ASID 区分。这直接优化了上下文切换的开销。内核线程比较特殊它没有自己的用户空间地址运行时会借用上一个进程的active_mm这时的 TLB 条目自然得小心处理——借用期间 TLB 里的用户空间条目不能复用必须保证不会乱串到别的进程。TLB 的生命周期问题最典型的发生在 vmalloc 区。因为 vmalloc 区的映射是动态建立、动态销毁的内核使用了一套范围刷新的机制分配新 vmalloc 映射后会 flush TLB释放 vmalloc 映射后也要 flush TLB。如果你写过一个驱动反复创建销毁大块 vmalloc 内存性能下降得厉害别急着怪分配器先看看是否每次都在大量刷新 TLB。实际中vm_unmap_aliases()这类函数会做延迟合并刷新就是在攒一批失效条目后一次性刷掉本质上是在批量处理 TLB 的生命周期。3.2 写保护、写时复制与页表项的运行时更新实际运行中PTE 的更新不止发生在缺页新建。很多机制会让一个已经活着的映射被临时改写保护位、权限位。最常见的是这些mprotect用户程序主动改变 VMA 的权限比如从 RX 改成 RW。内核需要找到该地址范围内的所有 PTE修改权限位同时刷新 TLB。mremap / madviseVMA 可能被移动、合并、做更细致的部分失效。比如MADV_DONTNEED会把一段范围内的 PTE 清掉物理页引用减掉相当于让这段映射提前死亡。fork 写时复制上面说了父进程 PTE 被改成只读并置 COW 标记。KSM内核同页合并。多个进程映射了相同内容的匿名页KSM 通过扫描找出这些页面把它们合并成同一个物理页然后将相关 PTE 改为只读。此时两个进程虽然看到的是同一个物理页但谁也不能写一写就触发 COW把页面拆开。这类运行时改 PTE操作有一个共同危险修改 PTE 和正在并发访问同一 PTE 的硬件/软件路径可能冲突。比如内核刚把旧物理页的引用计数减掉另一核却还在通过旧 PTE 访问访问完成后又会把 accessed/dirty 位写回——这里需要原子操作或者锁保护。实际中页表项的更新一般使用 CPU 提供的原子比较交换指令如 x86 的cmpxchg和内存屏障配合确保硬件侧的访问要么看到旧值要么看到新值不会出现中间状态。3.3 页迁移、KSM 与反合并页表项如何在运行时被改写内存管理是个动态系统物理页有时也需要搬家。典型是内存碎片整理compaction和内存热迁移。当内核决定要把某个页迁移到另一处物理内存时它不能光改 allocator 里的记录还必须更新所有指向这个页的 PTE。这里就轮到**反向映射rmap**上场了。正向映射是从虚拟地址找物理地址通过页表就能完成反向映射则是从物理页找到有哪些 PTE 指向它。匿名页的反向映射通过 anon_vma 结构维护文件页则挂在 address_space 的 i_mmap 树里。页面迁移时内核沿着 rmap 条目遍历对每个映射该页的 PTE改写为指向新的物理页更新引用计数再刷新对应的 TLB。如果一个页面被映射了几十次这个过程就要走几十遍 PTE 更新。这也是为什么页面迁移的开销跟映射数量和系统进程数相关。KSM 的反合并也是如此。如果某个进程突然要写一个被 KSM 合并的页缺页处理发现 PTE 是只读的而且对应物理页的引用计数大于 1就知道必须拆开分配新页、拷贝内容、更新 PTE。这个过程同样涉及反向映射的遍历需要在多个进程间协调。所以活跃期的映射不是静止的告示牌而是一个参与者。它可能被临时降级为只读可能被迁移到新的物理地址可能在回收时被暂时清空。维护这些状态转换的正确性才是内核内存管理复杂度的真正来源。4. 映射的消亡解除、回收与反向映射的最后一公里4.1 munmap 与 exit页表项和页表页如何被拆掉有生就有死映射的消亡一般发生在三种场景用户进程主动munmap、进程退出、内核内存回收强制摘除 PTE。munmap的入口是do_munmap()它先把对应 VMA 从进程地址空间摘除然后调用unmap_vmas()/zap_page_range()把这段范围内的 PTE 逐个清除同时检查每个页面是否还有别的映射。这个过程中页表项清掉、物理页引用计数减少但物理页不一定立刻释放——如果还有别的进程映射它比如共享内存它继续活在其他人的 PTE 里。zap_page_range这个名字很形象zap 就是消灭。它会遍历指定地址范围内的 PTE执行 pte_clear然后根据是匿名页还是文件页调用对应的释放/放回 page cache 的逻辑。之后还有一段tlb_finish_mmu负责批处理 TLB 失效。所以 munmap 不是瞬间完成的它是个多阶段的拆迁协议。进程退出时更彻底。所有 VMA 都会被拆除页表页从叶子到根逐级释放。顶级页表pgd是最后释放的。每释放一级页表页那一片地址的所有映射也就连根拔起了。这里有个常被忽略的性能点如果进程的虚拟地址空间巨大比如数据库常见几十上百 GB 的共享内存映射exit 时要走完整个拆页表的过程这个开销可能比进程生命周期内的运行花销还大。于是就有了exit_mmap的批量处理技巧先用unmap_vmas清 PTE再集中调用free_pgtables释放页表页中间穿插 TLB 批量 flush目的就是尽量减少一次清理过程中的同步开销。4.2 反向映射在消亡中的作用前面提到反向映射它在映射消亡时同样关键。回收线程要回收一个物理页但不知道有哪些 PTE 引用了它碰到匿名页就沿着 anon_vma 找 VMA再找 PTE碰到文件页就沿着 address_space 的 i_mmap 树找各个进程映射了同一文件的位置。只有当最后一个映射被摘除物理页才能真正回到伙伴系统。这里有个非常微妙的点如果没有反向映射回收就得遍历所有进程的所有页表去查找那是 O(N*M) 的灾难。反向映射把查找变成了从物理页出发精确找到相关 VMA再把查找范围缩小到适当的 PTE 区间。这个机制直接影响内存回收的性能所以消亡这件事不只关乎相应当前进程还牵扯整个系统内存压力的处理效率。反向映射的一个经典理解方式把页表想象成每个人手机里的通讯录反向映射则是一个谁存了我的号码的登记簿。回收、迁移时需要的就是后者。每当新增一条映射内核要在登记簿上添一笔拆除映射也要在登记簿上删一笔——这个过程也是映射生命周期的一部分只是大多数讲解页表结构的资料不会强调。4.3 页表页本身的回收页表页page table page也是内存里的页也占用物理内存。一个进程如果大量产生缺页、创建 VMA页表页会不断分配munmap 或 exit 后这些页表页要尽量释放回去。但页表页的释放有个讲究。free_pgtables并不会把所有页表页全部立刻释放——它会尝试把相邻、可以合并的 VMA 清空后整段释放页表层级减少页表页的碎片化。尤其在使用大页THP场景下如果 VMA 在 hugepage 边界上释放逻辑还需要判断是 partial split 还是 full unmap。一个处理不当会让内存碎片更严重影响后续大页分配。另一个容易被忽略的点页表页本身是不允许直接换出的。不像数据页可以被 swap 到磁盘页表页一旦缺失CPU 无法完成任何地址翻译因此页表页的生命周期就是从分配到释放没有中间休眠状态。为了让页表页能被回收内核会优先保证页表分配的高效并配合 TLB 批量操作。一旦页表页分配的路径上出现内存不足缺页、mmap 都会退回失败——进程报 OOM 往往不是物理内存全用光而是页表页分配不出这说明映射管理的骨架资源已经见底。5. 生命周期视角下的疑难杂症与实战排查经验5.1 为什么 munmap 之后访问不一定会立刻崩溃这个问题经常在讲页表时被人问到。程序 munmap 了一段地址随后又访问这段地址为什么有时候会立刻 SIGSEGV有时候却慢几拍才出问题甚至有时似乎还能读到数据答案就在 TLB 生命周期里。PTE 被清除后如果本 CPU 的 TLB 里还残留旧的映射条目那么后续访问该地址时硬件可能直接命中 TLB顺利完成地址转换压根不会触发缺页。也就是说逻辑上映射已经死了硬件还给它续命了一段。当然现代 CPU 对 TLB 失效有严格约束当对应 PCID 相关的 TLB 条目失效或者上下文切换后残留条目会被清掉最终还是会缺页。但在失效之前这段僵尸期是真实存在的。x86 的全局页Global Page如果被标记甚至会在上下文切换后仍然保留这也是某些复杂调试问题里出现灵异现象的原因。实际排查时如果你看到某段已 munmap 的内存还能访问最该查的不是 VMA而是该地址是否落在另一个 VMA 里很多程序误以为 munmap 了实际上只拆了一部分。该地址对应的 PTE 是不是真的被 zap 掉了用/proc/pid/pagemap看。是不是还挂在某个共享/映射设备文件上引用关系没断干净。5.2 追踪映射生命周期的主要调试手段想观察一个映射从生到死最直接的是看/proc/pid/smaps。它会汇总进程所有 VMA 的映射情况、RSS、PSS、swap 用量、页表大小等。配合/proc/pid/pagemap的 PFN 和 present 位能拿到很细粒度的这一页当前映射状态。不过得注意权限读 pagemap 需要相应的能力CAP_SYS_ADMIN不然只能读个寂寞。再深一层可以用 ftrace/kprobes 去挂载关键函数。handle_mm_fault是映射诞生的核心入口zap_page_range是消亡的关键路径unmap_vmas、tlb_finish_mmu则能反映 TLB 批处理。随便找一个你关心的函数挂上 kprobe 打印调用栈、进程 PID、地址范围就能把一次重映射的前后因果链看清。还有一个实用技巧当怀疑某个地址被反复映射/解除时用内核的CONFIG_DEBUG_VM打开各种 mm 调用的断言检查。它能捕捉到页表项状态不一致、引用计数错乱之类的问题尤其适合在写驱动或测试新内核版本时提前暴露 bug。毕竟生命周期里最难的往往不是正常路径而是异常路径上的状态校验。5.3 实际排查案例一个 vmalloc 映射生命周期错乱的问题之前在一次模块开发里遇到过非常典型的生命周期错乱。在中断上下文里驱动申请了一块 DMA 缓冲区用dma_alloc_coherent返回的虚拟地址在中断处理里偶尔访问结果偶发 page fault。查了很久发现驱动在free_irq之前就已经调用了dma_free_coherent把映射拆了可中断里还有最后一次延迟访问没处理完。这在生命周期里的名字就叫做映射死亡时间早于最后一次访问时间。解决思路也很明确把释放动作推迟到真正确认下一次中断永远不会再来的时机或者在释放前做一次内存屏障并清理相关状态。很多内核模块的稳定性问题说到底都是生命周期管理的问题——你觉得你在写内存访问其实你在偷偷地跟映射的生死搏斗。后来我把这类先申请、后释放、中间确保不再触碰的顺序整理成一套检查习惯确实少踩了不少坑。一些零零碎碎但很重要的体会最后分享一点个人心得。分析内核地址映射如果只看结构体和宏定义会觉得特别散PGD、PMD、PTE、VMA、vmalloc、kmap、rmap……各管一摊想串起来很难。但如果你在脑子里建立起一个出生 - 活跃 - 消亡的时间轴很多代码就能归位到时间轴的某个节点上。写驱动的时候也一样申请一块内核内存映射要清楚它是什么类型的映射——是线性映射里的物理页还是 vmalloc 区的临时映射还是 ioremap 的 I/O 映射不同类型的映射生命周期管理方式差别巨大对应的释放函数和 TLB 刷新策略也完全不同。搞混一次可能只是偶发的故障但如果你在内核里常年跟这些打交道这种混淆早晚会让你在半夜抱着串口日志发呆。如果你还想往下深挖建议自己做个实验写一个小模块在模块初始化时打印某个内核符号的虚拟地址和物理地址然后用debugfs或者kprobe在模块卸载时跟踪对应 PTE 的消失过程。动手做一遍比看十篇源码分析文章都有用。
阅读完成 · 觉得有帮助?
咨询建站