**前言:**很多同学学习虚拟内存时容易混淆两套模型:教学用的单级页表简化模型、Linux x86_64真实四级多级页表。尤其是 mmap、VMA、PTE、present位、缺页、段错误之间的关系,很容易越看越乱。本文把两套模型放在一起对照,从基础共识、时序流程、对比表格、树形示例一次性梳理清楚,同时解答高频疑问。
一、基础共识
先分清两个核心角色,一个硬件、一个内核软件,这是所有理解的根基:
PTE(页表项,硬件结构,给MMU使用) 核心字段:present位、物理页号PFN、权限位。
- present=1:MMU可以直接完成虚拟地址→物理地址翻译;
- present=0:硬件触发缺页异常。
VMA(vm_area_struct,内核软件结构) 内核用链表管理,描述一段连续的虚拟地址区间:记录起止地址、访问权限、映射类型(匿名映射 / 文件mmap / 代码段/数据段)、后备存储。
✅ 最重要判定规则(单级、多级通用): present=0 不等于非法地址,要看地址是否落在VMA中:
- 地址落在VMA内:地址合法 → 触发缺页中断,内核负责加载数据、填充PTE;
- 地址不在任何VMA内:地址非法 → 直接抛出段错误 SIGSEGV。
mmap 是系统调用:核心作用是新增一条VMA;默认懒加载,不会提前分配物理页,不会把PTE的present置1。
二、模型一:单级页表
核心特点
页表是一张固定长度的大数组,虚拟页号直接作为数组下标。进程创建时,整张页表数组一次性全部分配完毕。 虚拟页编号 → 数组下标 → 拿到PTE。 👉 重点:不存在缺少PTE的情况,所有虚拟页都预先拥有对应的PTE位置。
时序流程
进程刚创建时 整张页表数组已经存在,所有PTE就位,初始状态全部 present=0; 内核只预先创建基础VMA:代码段、数据段、栈;其余虚拟地址区间没有VMA,访问会段错误。
调用mmap(文件映射 / 匿名映射) 不会新增、创建PTE(PTE位置本来就已经存在); 仅仅新增一条VMA,标记一段连续虚拟地址合法,登记映射类型、后备存储信息; 该区间对应的PTE依旧保持 present=0。
第一次访问mmap对应的虚拟地址 MMU查询PTE发现 present=0 → 硬件触发缺页中断; 内核拿到虚拟地址,查找VMA确认地址合法,判断映射类型:
- 匿名映射:分配清零的物理页,后备存储为Swap;
- 文件映射:读取磁盘文件到Page Cache物理页,后备存储为原文件; 原地修改已经存在的PTE:present=1,填入物理页号。 后续访问:MMU直接完成地址翻译,不再触发缺页。
内存回收、Swap换出 匿名页:内存紧张时,页数据写入Swap分区,PTE置 present=0; 文件共享页:干净页可以直接丢弃;脏页刷回磁盘文件后,PTE置 present=0; 再次访问该地址,重新走一遍缺页流程。
✅ 单级页表一句话总结: PTE位置出厂全部配齐;VMA是虚拟地址使用权许可证。mmap就是申请一块虚拟地址的许可证,PTE本身不动,等到访问触发缺页再填充物理页信息。
❌ **缺点:**地址空间一大,页表数组体积巨大,严重浪费内存,真实操作系统不会采用。
单级页表示例表(VP0~VP7)
| 虚拟页号 VP | PTE(present / 物理页号) | 是否落在VMA(合法地址) | 访问结果 |
|---|---|---|---|
| VP0 | present=1,PFN=5 | ✅ 是(代码段VMA) | 正常翻译 |
| VP1 | present=1,PFN=7 | ✅ 是(代码段VMA) | 正常翻译 |
| VP2 | present=0 | ✅ 是(mmap新增VMA,文件映射) | 缺页中断,读文件进page cache |
| VP3 | present=0 | ✅ 是(mmap新增VMA,文件映射) | 缺页中断,读文件进page cache |
| VP4 | present=0 | ✅ 是(匿名VMA,堆) | 缺页中断,分配清零物理页 |
| VP5 | present=0 | ❌ 否 | 段错误 SIGSEGV |
| VP6 | present=0 | ❌ 否 | 段错误 SIGSEGV |
| VP7 | present=0 | ❌ 否 | 段错误 SIGSEGV |
单级模型关键:所有VP天生就有PTE;mmap只是把VP标记为落在VMA中;present=0只是没有绑定物理页,合法性由VMA说了算。
三、模型二:多级页表
核心特点
页表是一棵树,CR3寄存器指向根页表PML4;各级页表节点、PTE都不是预先分配,按需懒创建,没有固定大数组。 虚拟地址拆分为多段索引:PML4索引 → PDPT索引 → PD索引 → PT索引 → 页内偏移 只有最底层PT页表里面存放PTE;前面几级都是目录项,用来指向下一级页表。 👉 重点:不存在的分支 = 没有下级页表、自然也没有PTE。
时序流程
进程刚创建时 仅仅分配顶级根页表PML4; 绝大多数虚拟地址对应的下级页表(PDPT/PD/PT)、PTE根本不存在; 同样预先创建基础VMA:代码段、数据段、栈。 很多虚拟地址:连PTE都不存在,而不是PTE present=0。
调用mmap 依旧只做一件事:新增一条VMA,标记一段虚拟地址合法,记录映射类型; 不会分配下级页表,不会创建PTE;这条地址对应的页树分支仍然是空的。
第一次访问mmap对应的虚拟地址 MMU沿着多级索引遍历页表树,走到某一级发现下级页表不存在 → 触发缺页中断; 内核处理步骤:
- ① 查询VMA,确认地址合法,区分缺页类型(文件缺页 / 匿名缺页);
- ② 按需分配路径上缺失的下级页表节点;
- ③ 分配物理页(匿名页清零 / 文件读取到page cache);
- ④ 新建PTE,present=1,填入物理页号,挂载到页表树中。 后续访问:顺着页树查到PTE,直接地址翻译。
内存回收、Swap换出 逻辑和单级模型大体一致:页面被回收后,PTE可以清空,present置0; 极端场景:地址长时间不访问,内核甚至可以释放闲置的下级页表节点,节约内存。
✅ 多级页表一句话总结: 只有页表树根PML4一开始存在;PTE和下级页表节点都是缺页的时候现场新建。VMA依旧是地址许可证,mmap只负责拿到许可证。
✅ **优点:**稀疏结构,只分配用到的分支,极大节省内存,现代操作系统标准方案。
多级页表树形演示
初始状态:进程刚创建,仅根PML4存在
PML4(根,CR3指向它)
├─ 项0:指向PDPT0 → ❌ PDPT0 不存在(没分配)
├─ 项1:指向PDPT1 → ❌ PDPT1 不存在(没分配)
└─ 项2:指向PDPT2 → ✅ PDPT2 存在(代码段提前建好)
└─ PDPT2
├─ 项0:指向PD0 → ✅ PD0存在
│ └─ PD0
│ ├─ 项0:指向PT0 → ✅ PT0存在
│ │ └─ PT0(底层页表,存放PTE)
│ │ ├─ PTE0:present=1,有物理页号(代码页)
│ │ └─ PTE1:present=1,有物理页号(代码页)
│ └─ 其他PT指针:❌ PT不存在
└─ 其他PD指针:❌ PD不存在
执行mmap之后:树结构完全不变,仅仅新增VMA
PML4(根)
├─ 项0:指向PDPT0 → ❌ PDPT0【仍然不存在】 ← mmap不会创建页表节点
├─ 项1:指向PDPT1 → ❌ PDPT1 不存在
└─ 项2:...(代码段分支不变)
第一次访问mmap地址,触发缺页,内核补齐整条分支:
PML4(根)
├─ 项0:指向PDPT0 → ✅ 分配创建 PDPT0
│ └─ PDPT0
│ ├─ 项5:指向PD5 → ✅ 分配创建 PD5
│ │ └─ PD5
│ │ ├─ 项2:指向PT2 → ✅ 分配创建 PT2
│ │ │ └─ PT2
│ │ │ └─ PTE:✅ 新建PTE,present=1,填入page cache物理页号
│ │ └─ 其余PT指针:❌ PT不存在
│ └─ 其余PD指针:❌ PD不存在
├─ 项1:指向PDPT1 → ❌ PDPT1 不存在
└─ 项2:...(代码段分支不变)
四、两套模型核心对比汇总表
| 项目 | 单级页表 | Linux多级页表 |
|---|---|---|
| PTE是否预先全部存在 | ✅ 全部存在,固定数组 | ❌ 按需懒创建,大部分虚拟地址没有PTE |
| mmap做什么 | 新增VMA,PTE本来就存在,维持present=0 | 新增VMA,此时无PTE、无下级页表 |
| present=0含义 | PTE存在,但没有绑定物理页 | 两种:①有PTE但present=0;②连PTE都不存在 |
| 缺页时动作 | 修改已存在PTE,置present=1、填物理页号 | 先分配缺失的页表节点,新建PTE |
| 非法地址(无VMA)访问 | 段错误 | 段错误 |
| 内存开销 | 巨大,不实用 | 稀疏,节省内存 |
| VMA作用 | 标记地址合法,记录缺页规则 | 和单级完全一致 |
五、高频经典问题答疑
Q1:单级页表每个虚拟页都有PTE,为什么还要区分合法/非法地址?
PTE只是硬件地址翻译记录,不负责管理这块虚拟地址有没有分配给进程,分配权限由内核软件结构VMA管理。 present=0 只代表:虚拟页没有绑定物理内存,无法区分两种场景:
- 地址已经分配给进程(有VMA):合法 → 缺页中断,内核分配/加载物理页;
- 地址从未分配给进程(无VMA):非法 → 段错误。
进程创建之初只有少量VMA(代码段、数据段、栈);brk/malloc、mmap 的本质,就是动态新增VMA,把原本非法的虚拟地址区间变成合法地址。
类比:虚拟地址空间是一本笔记本,单级页表就是本子上每一行都预先印好格子(PTE);VMA是内核用笔圈出来,告诉进程:只有圈出来的行你才可以使用。没有圈的行,格子虽然存在,但属于未分配区域,访问直接报错。
一句话:PTE存在 ≠ 地址合法;地址合法与否唯一判断依据:是否存在VMA。
Q2:为什么有Swap还会出现OOM?
Swap仅仅是内存的后备存储,不是无限兜底:Swap空间耗尽、内核关闭Swap、进程命中内存资源上限,依旧会触发OOM。页面换入换出本身存在性能开销,不能无限回收。
Q3:mmap是不是直接映射磁盘?
两套模型规则一致:CPU只能访问物理内存。文件mmap的后备存储是磁盘;只有缺页的时候才会把文件数据读到Page Cache物理页,用户虚拟地址最终映射到这块物理页,不是直接访问磁盘。
Q4:用户空间和内核空间页表关系?
每个进程CR3指向独立的页表;页表分为用户区域(进程间互相独立)、内核区域(所有进程共享同一份映射);依靠PTE中的U/S权限位,限制用户态代码无法访问内核映射。
Q5:MAP_POPULATE是什么?
属于mmap的特例,不属于默认懒加载行为:调用mmap阶段主动提前加载页面、填充PTE,预先规避后续访问的缺页中断。
六、总结
- VMA是内核软件层的地址许可证,这套规则在单级、多级模型下完全统一;有没有VMA,区分合法缺页和段错误;
- PTE是硬件MMU翻译用的记录,两套模型最大差异在于PTE和页表节点的创建时机:
单级:一次性全部建好PTE;缺页只是修改已有PTE;
Linux多级:树根常驻,其余节点、PTE缺页时现场创建;
- mmap默认只新增VMA,懒加载,不会提前分配物理页、不会提前创建PTE;
- present=0 含义在两套模型下不一样,不要混淆!