第 12 章

虚拟内存:内存的幻觉

虚拟内存:内存的幻觉

你的笔记本装了 16 GB 内存,却可以同时运行几十个程序——Chrome 吃了 3 GB,VS Code 占了 1 GB,还有 Steam、Slack、各种后台进程,加起来早就超过 16 GB 了。它们为什么没有互相踩踏?为什么一个程序的 bug 不会覆盖另一个程序的数据?

答案是虚拟内存(Virtual Memory)——操作系统和 CPU 合谋制造的内存幻觉。

Level 1:建立直觉

每个程序都相信自己独占内存

当你运行一个程序,它看到的内存地址是 0x0000 到 0x7FFF...(大约 128TB 的地址空间,虽然实际物理内存只有 16GB)。这个程序认为它独享这整块内存。

实际上,多个程序同时运行,每个都有自己的"虚拟地址空间":

程序 A(Chrome)的视角:
┌────────────────────────────┐ 0x7FFF_FFFF_FFFF
│ 很多空的地址空间             │
│         ...                │
├────────────────────────────┤
│ Chrome 的栈                │
├────────────────────────────┤
│ Chrome 的堆                │
├────────────────────────────┤
│ Chrome 的代码、数据          │
└────────────────────────────┘ 0x0000_0000_0000

程序 B(VS Code)的视角:
┌────────────────────────────┐ 0x7FFF_FFFF_FFFF
│ 很多空的地址空间             │
│         ...                │
├────────────────────────────┤
│ VS Code 的栈               │
├────────────────────────────┤
│ VS Code 的堆               │
├────────────────────────────┤
│ VS Code 的代码、数据         │
└────────────────────────────┘ 0x0000_0000_0000

两个程序都以为自己在用同样的地址(比如 0x1000),但这是虚拟地址——操作系统把它们映射到物理内存的不同位置:

虚拟地址        物理地址
Chrome  0x1000  →  物理 0x4000_0000
VS Code 0x1000  →  物理 0x8000_0000

这叫地址隔离:两个程序永远不会看到对方的数据,即使用了相同的虚拟地址。

分页:虚拟内存的实现机制

虚拟内存通过**分页(Paging)**实现:把虚拟地址空间和物理内存都切成固定大小的"页(Page)":

x86-64 标准页大小:4 KB(4096 字节)
大页(Huge Page):2 MB 或 1 GB

虚拟地址空间:被切成 4KB 的虚拟页
物理内存:被切成 4KB 的物理页框(Frame)

操作系统维护一张页表(Page Table),记录每个虚拟页映射到哪个物理页框:

虚拟页号  →  物理页框号   | 标志位
0x10      →  0x3000     | 存在,可读可写
0x11      →  0x5100     | 存在,可读可写
0x12      →  磁盘位置     | 不在内存(已换出到磁盘)
0x13      →  0x7200     | 存在,只读

当程序访问虚拟地址时,CPU 的内存管理单元(MMU)自动把虚拟地址翻译成物理地址:

虚拟地址 → [MMU + 页表查询] → 物理地址

虚拟内存的三大好处

好处一:内存隔离 每个进程有独立的页表,一个进程绝对无法访问另一个进程的内存(除非显式共享)。这是现代操作系统安全性的基础。

好处二:内存超量使用(Overcommit) 操作系统可以把一些不常用的页面"换出"到磁盘(Swap),腾出物理内存给活跃的程序。这允许程序的总虚拟内存超过物理内存。

好处三:内存映射文件 可以直接把文件"映射"到虚拟地址空间,访问文件就像访问内存一样,操作系统按需加载页面。

Level 2:原理剖析

多级页表:解决页表太大的问题

如果直接用一张大表映射所有虚拟地址,需要多大?

64位地址空间:2^64 字节
页大小:4KB = 2^12 字节
总页数:2^64 / 2^12 = 2^52 个页
每个页表项:8字节
总页表大小:2^52 × 8 = 32 PB(32 拍字节)

32 PB 的页表!每个进程都要这么大一张表,完全不可行。

解决方案:多级页表(Multi-Level Page Table)

x86-64 使用四级页表(4-level paging):

虚拟地址(48位有效位):
┌──────┬──────┬──────┬──────┬──────────────┐
│ PGD  │ PUD  │ PMD  │ PTE  │  Page Offset │
│ 9位  │ 9位  │ 9位  │ 9位  │    12位      │
└──────┴──────┴──────┴──────┴──────────────┘
  L4表   L3表   L2表   L1表   页内偏移

PGD: Page Global Directory (L4)
PUD: Page Upper Directory (L3)
PMD: Page Middle Directory (L2)
PTE: Page Table Entry (L1)

翻译过程:

  1. 从 CR3 寄存器取 PGD 基地址
  2. 用虚拟地址的最高 9 位(PGD 索引)在 PGD 中找到 PUD 的地址
  3. 用下 9 位在 PUD 中找到 PMD 的地址
  4. 用下 9 位在 PMD 中找到 PTE 的地址
  5. 用下 9 位在 PTE 中找到物理页框号
  6. 物理页框号 + 页内偏移(12位)= 物理地址

多级页表的节省:大多数虚拟地址是未使用的,对应的页表项不需要存在。只为实际用到的部分分配页表,空间大大节省。

TLB:地址翻译的缓存

多级页表翻译需要 4 次内存访问(每级一次),这开销太大了。

解决方案:TLB(Translation Lookaside Buffer,翻译后备缓冲区)——专门缓存最近使用的"虚拟→物理"地址映射。

TLB 命中(Hit):直接用缓存的映射,0 额外内存访问
TLB 未命中(Miss):走四级页表,4 次内存访问(但结果被缓存)

TLB 大小(典型值):

TLB 命中率通常很高(>99%),因为程序访问的内存地址集中在几个页面上(局部性原理)。

TLB 刷新(TLB Flush):当进程切换或页表修改时,TLB 中的旧映射必须失效。x86 有 INVLPG 指令使单个页的 TLB 条目失效,或 MOV CR3 刷新整个 TLB。频繁刷新 TLB 是进程切换开销的重要来源。

缺页中断:数据不在内存时

当程序访问一个虚拟页,而页表显示它"不在内存"(Present 位为 0),CPU 触发缺页中断(Page Fault),操作系统接管:

  1. 找到该页的数据位置(可能在磁盘的 Swap 分区,或文件系统)
  2. 找一个空闲的物理页框(如果没有,先驱逐一个已有页面到磁盘)
  3. 把数据从磁盘加载到物理页框
  4. 更新页表,标记该页为"存在"
  5. 返回,让程序重新执行刚才触发缺页的指令

缺页的代价:数十毫秒(SSD)到数百毫秒(HDD)——比正常内存访问慢 100 万倍!

这也是为什么内存用满、系统开始大量 Swap 时,电脑会变得极其卡顿——CPU 花了大量时间等磁盘。

写时复制(Copy-on-Write,COW)

一个妙用:fork() 系统调用创建子进程时,不立即复制父进程的所有内存(可能几 GB),而是让父子进程共享同一份物理页面(页表都指向同一个物理页框),标记为只读。

当某个进程(父或子)试图写入共享页面时,才触发缺页中断,操作系统才真正复制那一页,让写操作在新的副本上进行——只在需要时才"真正复制"。

好处:fork() 的开销从"复制整个进程内存"降到接近于零(只复制页表,很小)。对于大量子进程立即调用 exec() 的场景(如 shell 每次执行命令),COW 节省了大量时间和内存。

Level 3 · 规范怎么定义的(资深)

页表与地址翻译的硬件规范

虚拟内存的硬件实现由 CPU 架构手册精确定义。x86-64 的 4 级页表(PML4→PDPT→PD→PT)在 Intel SDM Volume 3A Chapter 4 中有位级定义:CR3 寄存器指向 PML4 表的物理地址,每级页表包含 512 个 8 字节条目,每个条目的位域包括 Present(第 0 位)、Read/Write(第 1 位)、User/Supervisor(第 2 位)、Page Size(第 7 位)、NX(第 63 位)等。5 级页表(LA57,支持 57 位虚拟地址空间)在 Ice Lake 服务器处理器上首次引入。

TLB(Translation Lookaside Buffer)的行为也在架构手册中定义,但其具体实现(容量、组织方式、替换策略)是微架构相关的。Intel 的 TLB 通常分为 L1 ITLB(128 条目)、L1 DTLB(64 条目)和 L2 STLB(1536 条目或更多),并支持 4KB 和 2MB/1GB 大页的独立 TLB 条目。

POSIX 定义了虚拟内存的用户空间接口:mmap()MAP_PRIVATE/MAP_SHARED 语义、mprotect() 的权限位(PROT_READ/PROT_WRITE/PROT_EXEC)、madvise() 的提示(MADV_DONTNEED/MADV_HUGEPAGE 等)。Linux 扩展了 POSIX,添加了 MAP_POPULATE(预填充页表)、MAP_HUGETLB(强制使用大页)等标志。这些接口的语义在 man 2 mmap 中有精确描述。

Level 4 · 边界与陷阱(所有人)

陷阱 1:TLB Miss 的代价远超想象

一次 TLB Miss 需要硬件"页表行走"(page table walk),在 4 级页表下需要 4 次内存访问(每级一次)。如果页表本身不在 Cache 中,每次访问约 100ns,总共 400ns——相当于一次 DRAM 访问的 4 倍。在使用大量小页(4KB)的应用中(如 Java 的大堆、数据库的 buffer pool),TLB Miss 可能占总运行时间的 10-20%。Redis 和 PostgreSQL 的性能调优指南都建议启用 透明大页(Transparent Huge Pages, THP)或显式使用 2MB 大页来减少 TLB 压力。但 THP 的碎片整理(defragmentation)机制有时会导致毫秒级的延迟毛刺,因此 Redis 官方反而建议禁用 THP——这是一个典型的"治一个 bug 引入另一个 bug"的案例。

陷阱 2:写时复制(COW)的隐藏成本

fork() 后,父子进程共享所有物理页,只有当某一方写入时才触发写时复制。这意味着 fork() 本身很快(只复制页表),但之后的第一次写入每一页都会触发缺页中断、分配新物理页、复制 4KB 数据。Redis 在执行 BGSAVE 时会 fork() 子进程做持久化,此时主进程继续处理写请求,每次写入都会触发 COW——如果工作集很大,COW 导致的内存翻倍和缺页中断可能使 Redis 的延迟飙升数倍。这也是 Redis 建议预留 2 倍内存的原因。

陷阱 3:ASLR 并非牢不可破

地址空间布局随机化(ASLR)通过随机化代码段、堆、栈和 mmap 区域的基地址来防止攻击者预测目标地址。但 32 位系统上,虚拟地址空间只有 4GB,随机化的熵值只有约 16 位(65536 种可能)——暴力破解只需几分钟。64 位系统的 ASLR 熵值更高(约 28-40 位),但侧信道攻击(如利用 TLB/Cache 时序差异推断内核页表布局的 KASLR bypass)仍然可以泄露地址信息。2018 年之后,几乎每年都有新的 KASLR 绕过技术被发表。

本章评分
4.6  / 5  (26 评分)

💬 留言讨论