4、页表层级设计:多级页表原理、x86与ARM页表差异、微内核中的页表管理策略
4.1 多级页表:为什么需要“翻几层”?
好,我们直接切入正题。多级页表,说白了就是解决一个矛盾:虚拟地址空间越来越大,但物理内存有限,页表不能无限膨胀。
你想想看,32位系统下,4GB地址空间,如果只用一级页表,每个进程都要维护一张巨大的线性表。每个页表项4字节,4GB / 4KB × 4B = 4MB。嗯,4MB听起来还行?但如果是64位系统呢?地址空间是2^64,算下来页表大小是天文数字,根本不可能全部放在内存里。
多级页表的思路其实很朴素:按需分配,分层索引。就像查字典,先查部首目录,再查检字表,最后翻到具体页码。每一级只保存下一级的索引,而不是把所有页表项平铺开。
我个人习惯把多级页表理解成“树形结构”。根节点是顶级页表,叶子节点是物理页框。中间每一级都指向下一级的页表页。只有真正用到的虚拟地址范围,才会分配对应的页表页。这样,大部分未使用的地址空间,在页表里就是“空指针”,不占内存。
我在项目中遇到过一个问题:某个嵌入式系统内存只有64MB,但跑的是Linux。启动后光页表就占了十几MB,应用内存捉襟见肘。后来我们改用了2级页表(硬件支持),并裁剪了内核地址空间,才把页表开销压到2MB以内。所以,页表层级不是越多越好,得看硬件和内存容量。
4.2 x86与ARM页表差异:两种哲学
x86和ARM的页表设计,体现了两种不同的硬件哲学。我分别说说。
4.2.1 x86:从32位到64位的演进
x86的页表历史比较长。32位时代是二级页表:页目录(Page Directory)和页表(Page Table)。每个页目录项指向一个页表页,每个页表项指向一个4KB物理页。后来为了支持PAE(物理地址扩展),变成了三级:页目录指针表(PDPT)、页目录、页表。
到了64位(x86-64),地址线只用了48位,但页表变成了四级:
- PML4(Page Map Level 4)—— 顶级
- PDPT(Page Directory Pointer Table)—— 第二级
- PD(Page Directory)—— 第三级
- PT(Page Table)—— 第四级
每个级别9位索引,加上12位页内偏移,正好是48位。嗯,这里要注意:x86的页表项里还包含了访问位、脏位、用户/内核权限位等。这些位是硬件自动维护的,比如访问位会在CPU访问页表时自动置1。
4.2.2 ARM:更灵活的层级与属性
ARM的页表设计更灵活。以ARMv8-A为例,它支持多种页表粒度:4KB、16KB、64KB。页表层级可以是2级、3级或4级,取决于你选择的粒度。
ARM页表的一个特点是:属性字段非常丰富。除了基本的读写权限、用户/内核权限,还有内存类型(Normal、Device、Strongly-Ordered)、共享属性(Inner Shareable、Outer Shareable)、执行权限(XN、PXN)等。这些属性在x86里通常是通过段描述符或MTRR来控制的,ARM直接集成在页表项里。
另一个差异是:ARM的页表基地址寄存器(TTBR0_EL1、TTBR1_EL1)可以分别指向用户空间和内核空间的页表。这样切换进程时,只需要切换TTBR0_EL1,内核空间的页表保持不变。x86则是一个CR3寄存器搞定所有,切换进程时必须刷新整个TLB(除非用了PCID)。
| 特性 | x86-64 | ARMv8-A |
|---|---|---|
| 默认层级 | 4级(PML4→PDPT→PD→PT) | 3级或4级(可配置) |
| 页粒度 | 4KB / 2MB / 1GB | 4KB / 16KB / 64KB |
| 属性字段 | 较少(R/W、U/S、XD、A、D) | 丰富(内存类型、共享、XN/PXN) |
| 用户/内核切换 | CR3切换,TLB全刷 | TTBR0/1分离,内核页表不刷 |
| 硬件页表遍历 | MMU自动完成 | MMU自动完成 |
我个人觉得,ARM的页表设计更“现代”。它把很多内存属性管理下放到了页表层面,减少了对外部配置寄存器的依赖。但代价是页表项更大(8字节 vs x86的8字节,但ARM的页表项结构更复杂)。
4.3 微内核中的页表管理策略:轻量、隔离、高效
微内核的页表管理,和宏内核有本质区别。宏内核里,页表是内核自己维护的,所有进程共享内核页表。微内核呢?内核只做最核心的事——地址空间切换、权限检查、页表分配。具体的页表内容填充,交给用户态的服务进程。
我总结了几条微内核页表管理的核心策略:
4.3.1 最小化内核页表
微内核的页表,内核空间只映射必要的部分:中断向量表、内核栈、IPC缓冲区等。其他所有外设、驱动、文件系统,都运行在用户态,它们的页表由各自的服务进程管理。这样,内核页表非常小,TLB压力也小。
我曾经在seL4上做过实验:内核页表只占4个页表页(16KB),而用户态的服务进程页表加起来有几十MB。但内核切换进程时,只需要刷新TLB中内核空间的部分(因为内核页表不变),用户空间的TLB条目可以保留。这比宏内核每次切换都全刷TLB要高效得多。
4.3.2 能力(Capability)驱动的页表操作
微内核里,用户态进程不能直接修改页表。它必须通过系统调用,向内核请求“映射”、“取消映射”、“修改权限”等操作。内核会检查该进程是否拥有对应的能力(Capability)。
举个例子:进程A想映射一页物理内存到自己的地址空间。它必须持有该物理内存页的“帧能力”(Frame Capability)。如果它没有,内核直接拒绝。这种设计保证了隔离性——一个进程无法访问另一个进程的物理内存,除非显式授权。
4.3.3 延迟分配与写时复制(COW)
微内核里,页表分配也是按需的。进程申请一大块虚拟地址空间,内核不会立刻分配物理页和页表页。只有进程真正访问某个地址时,才会触发缺页异常,内核再分配物理页并建立映射。
写时复制在微内核里也很常见。比如进程A fork出一个子进程B,内核不会复制A的整个地址空间,而是让B共享A的页表,但标记为只读。当B尝试写入时,触发缺页异常,内核再复制物理页并更新页表。这样,fork的开销大大降低。
嗯,这里要注意:微内核的缺页异常处理,比宏内核更复杂。因为缺页异常处理程序本身可能运行在用户态(比如一个分页服务进程)。内核需要把异常信息通过IPC发送给分页服务,分页服务再决定如何响应。这增加了延迟,但换来了灵活性。
4.3.4 页表回收与压缩
微内核系统通常运行在内存受限的环境(比如IoT设备)。页表占用的物理内存不能无限增长。我建议实现一个页表回收机制:当某个进程的页表页长时间未被访问,内核可以将其换出到交换区,或者合并到更高级别的页表(比如将4KB页表合并为2MB大页)。
我在一个微内核项目中,遇到过内存碎片导致页表分配失败的问题。后来我们实现了页表页的“伙伴系统”分配器,并定期压缩页表,才解决了这个问题。
4.4 小结
多级页表是虚拟内存的骨架。x86和ARM的差异,本质上是硬件设计理念的不同:x86偏保守,ARM偏灵活。微内核的页表管理,核心是“最小化内核、能力驱动、按需分配”。
最后,我建议你在设计微内核页表时,先画一张图:虚拟地址如何被分解成各级索引?每一级页表项里有哪些字段?内核和用户态分别管理哪些部分? 画清楚了,代码写起来就顺了。