Appearance
第四章:虚拟内存
不同进程可以使用相同虚拟地址而访问不同物理内容。进程还可以建立大于当前空闲物理内存的虚拟地址范围,只在页面实际使用时分配物理资源。
虚拟地址属于进程地址空间,物理地址标识内存硬件位置;页表规定两者的映射关系与访问权限。
Sv39
xv6 使用 RISC-V Sv39。一个 64 位虚拟地址中,低 39 位参与翻译:最低 12 位是 4 KiB 页内偏移,其上的 27 位分成三个 9 位索引。
text
38 30 29 21 20 12 11 0
+-----------+------------+------------+--------------+
| level 2 | level 1 | level 0 | page offset |
+-----------+------------+------------+--------------+
9 bits 9 bits 9 bits 12 bitssatp 保存最高级页表的物理页号。硬件逐级读取 PTE;非叶 PTE 指向下一层页表,叶 PTE 给出物理页号和 R/W/X/U 权限。xv6 的 walk() 用软件执行同样的三级索引,以便内核建立或查询映射;真正的每次 load/store 翻译由 MMU 完成。
地址翻译
CPU 执行 load/store 或取指时产生虚拟地址。MMU 根据当前地址空间的页表,将虚拟页号翻译成物理页号,并检查用户/内核、读/写/执行等权限。
页表因此同时完成:
- 重定位:程序无需知道自己落在哪些物理页;
- 隔离:不同进程的同名地址映射到不同物理页;
- 共享:需要时也能把多个虚拟页映射到同一物理页。
内核不必拦截每次访存。它在慢速路径上配置页表,硬件在快速路径上逐次强制规则。这是保护既可靠又足够快的原因。
虚拟内存绝不只是“内存不够时用磁盘”。即使完全禁用 swap,进程隔离、共享库、按需装载、COW 与 mmap() 仍依赖它。
多级页表
系统把地址空间切成固定大小的页,常见基础页为 4 KiB。页内偏移直接保留,虚拟页号通过多级页表查到物理页号。多级结构只为用到的地址范围分配下级表,适应巨大而稀疏的 64 位地址空间。
在 xv6 中,mappages() 对每一页调用 walk(..., alloc=1),必要时用 kalloc() 创建中间页表,再写入叶 PTE。uvmcreate() 建立空用户页表,uvmalloc() 增加用户映射,uvmunmap() 删除映射,freewalk() 递归释放只剩页表结构的页面。页表抽象由这几个短函数闭合。
x86-64 扩展
以通常的 x86-64 四级页表为例,48 位 canonical virtual address 可直接拆成:
text
63 48 47 39 38 30 29 21 20 12 11 0
+--------------+----------+----------+----------+----------+----------+
| 符号扩展 | PML4 | PDPT | PD | PT | 页内偏移 |
+--------------+----------+----------+----------+----------+----------+
9 bits 9 bits 9 bits 9 bits 12 bitsCPU 从 CR3 取得 PML4 物理地址,每一级用 9 位索引读取下一张表,最终得到物理页框;启用 LA57 时再增加一级。Linux 对这些字段的体系结构定义可见 pgtable_types.h。
页表项除了物理页号,还包含 present、writable、user、executable、accessed、dirty 等状态。不同架构细节不同,但核心问题相同:页面是否存在,当前访问是否被允许,硬件观察到了哪些访问。
大页以更粗粒度覆盖地址范围,可以减少页表占用和 TLB miss,但会增加内部碎片、缺页成本和回收难度,其收益取决于工作集和访问模式。
TLB
若每次访存都走四五级页表,翻译成本可能超过数据访问本身。TLB 缓存近期虚拟页到物理页的翻译。命中时访存接近普通缓存访问;未命中才由硬件或软件遍历页表。
在 xv6 中,写 satp 切换页表后执行 sfence.vma zero, zero,直接失效当前 hart 的全部相关翻译。这种做法简单但粗粒度。
Linux 扩展
修改页表后,只改内存中的页表项不够,CPU 可能继续使用旧 TLB 项。多核系统还需要通知所有可能运行该地址空间的核心失效翻译,即 TLB shootdown:
text
CPU 0 修改映射
→ 向 CPU 1/2 发送核间中断
→ 各 CPU 失效 TLB 项并确认
→ CPU 0 才能安全回收旧物理页这也是 munmap()、权限变更等操作可能随 CPU 数增加而变贵的原因。PCID/ASID 等地址空间标签允许多个进程的 TLB 项共存,减少上下文切换时的清空。
按需分配
内核为进程维护虚拟内存区域:某段地址可否读写、由匿名内存还是文件作为后备。建立区域通常只是记账,不会立即准备每一页。
第一次访问没有 present 的页时,CPU 触发缺页:
text
访问虚拟地址
→ TLB miss / 页表项不在场
→ 进入内核缺页处理
→ 查虚拟内存区域是否合法
→ 准备页面并填写页表
→ 返回,重试原指令处理结果可能是:匿名页分配并清零、从文件页缓存取页、完成 COW、允许栈增长,或因地址非法发送 SIGSEGV。同一个硬件异常既能实现惰性分配,也能报告 bug。
操作系统通常允许 overcommit,即已承诺的虚拟内存超过可用物理内存,因为部分地址范围不会被实际访问。若物理内存需求集中增长,系统需要执行页面回收和 swap,并可能最终触发 OOM 处理。地址空间容量不代表可用物理资源容量。
当前 xv6 已有一条很小的惰性分配路径:后端 sys_sbrk() 在选择 lazy 模式时只增加 p->sz;用户首次访问空洞页面后,usertrap() 调用 vmfault() 分配并映射清零页。copyin() 和 copyout() 也会调用 vmfault(),否则合法的惰性页面一旦作为系统调用缓冲区就会被误判为坏指针。
这仍不是完整的 Linux 虚拟内存系统。xv6 没有 VMA、文件按需映射、swap 和页面回收,kexec() 装载 ELF segment 也采用立即分配。它恰好把 page fault 从“程序必然出错”改造成了可直接读完的正常机制。
写时复制
当前 xv6 的 uvmcopy() 仍会在 fork() 时为每个已经分配的用户页创建新页并执行 memmove()。紧接着的 exec() 会让这些复制全部浪费。MIT xv6 COW lab 把它改为:父子 PTE 先指向同一物理页,清除 PTE_W,并增加物理页引用计数:
text
fork 后:父虚页 ─┐
├→ 只读物理页
子虚页 ─┘
子写入:触发缺页 → 复制物理页 → 子映射改为可写必须先只读,硬件才有机会在第一次写时通知内核。COW 把创建时成本推迟到写入时,优化常见 fork-then-exec,却可能造成运行中的延迟尖峰和内存峰值。
COW 还用于私有文件映射、虚拟机快照、语言运行时和文件系统。评估这类机制时,需要明确共享的终止条件、首次修改的成本承担者,以及空间不足时的失败位置。
Linux COW
Linux 中,COW 同样不是独立系统调用。写只读 COW 页先产生 #PF,随后进入 handle_mm_fault();写保护分支最终由 do_wp_page() 判断页面能否复用,不能复用时才分配新页并复制。硬件只负责报告写保护错误,“这是 COW”仍由内核的 VMA 和页表状态决定。
Linux 内存映射
mmap() 建立地址范围与后备对象的关系:
- 匿名映射用于堆、大块分配和线程栈;
- 文件私有映射通过 COW 保留修改,不回写文件;
- 文件共享映射让修改进入共享页缓存;
- 共享匿名映射可做进程间通信。
映射大文件不会立刻把它读进 RAM。页面按需进入,I/O 延迟可能出现在任意一条访存指令上。相比显式 read(),mmap() 减少了接口调用和某些复制,却让错误处理、截断、回收和延迟位置更隐蔽。
zero-copy 省略部分数据复制,但仍需处理页面固定、所有权转移、引用计数、DMA 映射和撤销。参与者共享同一缓冲区时,其生命周期协议也更复杂。
Linux 页面回收
空闲 RAM 不能加速程序,所以内核会把它用于文件页缓存和匿名页。内存压力出现时,再决定回收谁:
- 干净文件页可直接丢弃,需要时重读;
- 脏文件页先写回存储;
- 匿名页若要保留,需要 swap 或压缩;
- 正在使用或被固定的页难以回收。
页面回收策略需要估计工作集,即未来较可能再次访问的页面。估计错误会造成 thrashing,使 CPU 时间主要消耗在缺页和 I/O 上,应用有效进展显著下降。此时平均内存用量可能正常,但尾延迟会大幅增加。
NUMA 机器进一步让“有空闲内存”不够:远端节点内存可用但访问更慢。调度线程时需考虑其页面位置,迁移线程或页面都有代价。
Linux 的主要扫描与回收逻辑集中在 mm/vmscan.c。查看 shrink_lruvec() 一类函数可以看到:所谓“回收页面”会按 LRU generation、页类型和脏状态选择 folio,并可能触发 writeback;它不是 CPU 在内存不足时自动淘汰页。
共享硬件效应
页表隔离了可见地址,却没有隔离所有物理效应:
- TLB、末级缓存与内存带宽常被不同进程共享;
- 一个进程的内存压力可能导致全局回收;
- 缺页和 COW 让一次普通访存出现不可预测的长延迟;
- cache 和 page fault 的时间差可能形成侧信道;
- 共享内存本来就主动打破隔离。
虚拟地址空间为访存提供稳定语义,但不能消除共享硬件造成的性能干扰和侧信道。性能分析、安全设计和资源治理仍需考虑这些边界。
实验
先完成 xv6 的 vmprint 实验:打印一条用户虚拟地址的三级页表索引、各级 PTE 和最终物理页。再比较 xv6 uvmcopy() 的逐页复制与 COW lab 的共享只读 PTE。下面的 Linux 工具用于观察同类机制在真实进程中的结果。
bash
# 地址区域与汇总后的实际驻留量
cat /proc/$$/maps
cat /proc/$$/smaps_rollup
# 观察缺页和地址空间峰值
/usr/bin/time -v sh -c 'dd if=/dev/zero of=/dev/null bs=1M count=128'
# NUMA 机器上查看页放置
numactl --hardware可以编写程序 mmap 1 GiB 匿名区域,分别测量“不访问、每 MiB 写一次、每页写一次”时的 RSS 与缺页数,以区分虚拟地址范围、已建立映射的页面和常驻物理页面。
练习
- 对给定 Sv39 虚拟地址,计算三级页表索引和页内偏移。
- xv6 的
walk()完成了哪些工作?真正的硬件地址翻译在哪一步发生? - xv6
uvmcopy()与 COW 版本在 PTE 权限、引用计数和缺页处理上有何差异? - 修改页表后为什么可能需要执行
sfence.vma?旧物理页何时才可回收? - Linux 的 VMA、文件映射和页面回收分别补上了 xv6 的哪些能力?