ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

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

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

很多人学内核内存管理,喜欢死记页表结构、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 全是 0(pte_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_fault,ARM64 则是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 shootdown(TLB 降级)。内核通过 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 的权限,比如从 R+X 改成 RW。内核需要找到该地址范围内的所有 PTE,修改权限位,同时刷新 TLB。
  • mremap / madvise:VMA 可能被移动、合并、做更细致的部分失效。比如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,而是:

  1. 该地址是否落在另一个 VMA 里(很多程序误以为 munmap 了,实际上只拆了一部分)。
  2. 该地址对应的 PTE 是不是真的被 zap 掉了,用/proc/pid/pagemap看。
  3. 是不是还挂在某个共享/映射设备文件上,引用关系没断干净。

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 的消失过程。动手做一遍,比看十篇源码分析文章都有用。

返回列表