引言

当你在Linux系统中执行top命令时,看到的VIRT和RES两列数字之间往往存在巨大差异。一个简单的Chrome浏览器进程可能显示VIRT为10GB,而RES仅有200MB。这种差异的背后,是操作系统在硬件支持下构建的一套精巧机制——虚拟内存管理。

这套机制不仅仅是"用磁盘扩展内存"那么简单。它涉及硬件级的多级页表结构、专用缓存(TLB)、复杂的缺页中断处理、进程间内存隔离,以及现代处理器提供的内存保护能力。理解这些机制,对于排查性能问题、优化内存密集型应用、以及编写安全代码都有直接帮助。

历史起源:Atlas计算机与虚拟内存的诞生

虚拟内存的概念并非与生俱来。在20世纪50年代,计算机的内存容量极其有限,程序员必须手动管理程序与数据在主存和磁鼓之间的交换。这个过程被称为"overlay"(覆盖)技术,需要程序员精确计算每段代码和数据何时加载、何时卸载。

1957年至1962年间,曼彻斯特大学的Tom Kilburn领导团队开发了Atlas计算机。Atlas的核心创新是"一级存储"(one-level storage)概念——让程序员看到的是一个统一的、大容量的地址空间,而系统自动在快速的核心存储器和慢速的磁鼓之间移动数据[1]。

Atlas使用了512字的页面大小,每个页面有一个Page Address Register(PAR)。当程序访问一个虚拟地址时,硬件会比较请求的页面号与所有PAR的内容。如果匹配成功,直接访问核心存储器;如果不匹配,触发"页面错误"中断,操作系统负责从磁鼓加载所需页面[2]。

这套系统还实现了第一个页面置换算法——一个"学习程序",试图预测哪些页面在未来最不可能被使用。虽然这个算法对于没有明显循环模式的程序效果不佳,但它奠定了后续LRU(Least Recently Used)算法研究的基础。

1965年,MIT的Multics系统正式使用了"虚拟内存"这个术语。1970年代,IBM System/370和DEC VAX等系统广泛采用虚拟内存技术,使其成为现代计算机系统的标准配置。

x86_64页表架构:四级与五级页表

为什么需要多级页表

假设一个64位系统使用4KB页面,虚拟地址空间为64TB(实际上x86_64目前只使用48位虚拟地址)。如果采用单级页表,每个页表项8字节,那么一个进程的页表需要:

$$\frac{64 \times 10^{12}}{4 \times 10^3} \times 8 = 128 \text{MB}$$

128MB仅仅用于存储一个进程的页表!而且大部分进程只使用地址空间的一小部分,这造成了巨大的空间浪费。

多级页表通过按需分配解决这个问题。Linux x86_64采用四级页表结构:

  • PGD(Page Global Directory):页全局目录
  • PUD(Page Upper Directory):页上级目录
  • PMD(Page Middle Directory):页中间目录
  • PTE(Page Table Entry):页表项

每级页表占用虚拟地址的9位(可索引512个表项),加上12位页面偏移,共使用48位虚拟地址[3]。

虚拟地址格式(48位):
+--------+--------+--------+--------+------------+
| PGD(9) | PUD(9) | PMD(9) | PTE(9) | Offset(12) |
+--------+--------+--------+--------+------------+

当进程只使用地址空间的一小部分时,大部分中间级页表根本不需要分配。只有当程序实际访问某个地址范围时,系统才会创建对应的页表结构。

五级页表的引入

随着内存容量的增长,Linux 4.14引入了五级页表支持。五级页表增加了P4D(Page 4th Level Directory),将虚拟地址空间从256TB扩展到128PB:

虚拟地址格式(57位):
+------+--------+--------+--------+--------+------------+
|P4D(9)| PGD(9) | PUD(9) | PMD(9) | PTE(9) | Offset(12) |
+------+--------+--------+--------+--------+------------+

五级页表目前主要用于服务器和数据中心场景,普通桌面系统仍默认使用四级页表。可以通过cat /proc/cpuinfo | grep la57检查CPU是否支持五级页表。

页表遍历:一次地址转换的开销

当TLB未命中时,MMU(Memory Management Unit)必须执行完整的页表遍历:

  1. 读取CR3寄存器获取PGD物理地址
  2. 根据PGD索引读取PUD物理地址
  3. 根据PUD索引读取PMD物理地址
  4. 根据PMD索引读取PTE物理地址
  5. 根据PTE获取最终物理页帧号,拼接页面偏移得到物理地址

四级页表需要4次内存访问才能完成地址转换。如果每次内存访问需要100纳秒(假设缓存未命中),那么每次地址转换仅页表遍历就需要400纳秒。这对于现代GHz级别的处理器来说是不可接受的延迟。

这就是为什么TLB如此关键。

TLB:地址转换的硬件缓存

TLB的基本原理

TLB(Translation Lookaside Buffer)是MMU中的专用缓存,存储最近使用的虚拟页号到物理页帧号的映射。TLB的典型大小是32-128个表项,采用全相联(fully-associative)结构,可以并行比较所有表项[4]。

一次内存访问的完整流程:

  1. 从虚拟地址提取VPN(Virtual Page Number)
  2. 在TLB中并行查找VPN
  3. 如果命中(TLB hit),直接获取PFN(Physical Frame Number)
  4. 如果未命中(TLB miss),执行页表遍历并更新TLB

TLB的命中率通常在99%以上。这是因为程序具有时间和空间局部性:一旦访问某个页面,很可能在短时间内再次访问(时间局部性),而且很可能访问同一页面的其他位置(空间局部性)。

TLB表项的结构

一个TLB表项包含以下字段:

字段 说明
VPN 虚拟页号
PFN 物理页帧号
Valid 表项是否有效
R/W 读写权限
X/NX 执行/禁止执行
D 脏位(页面被写过)
A 访问位(页面被访问过)
ASID 地址空间标识符

ASID与进程切换

在多进程系统中,不同进程可能使用相同的虚拟地址映射到不同的物理页面。当进程切换时,如果不清空TLB,新进程可能使用旧进程的地址映射,导致严重的安全问题。

早期系统在进程切换时清空整个TLB。但这种方式代价高昂——新进程开始执行时会经历大量TLB未命中。

现代处理器引入了ASID(Address Space ID)机制。每个进程有一个唯一的ASID(通常是8-16位),TLB表项中存储对应的ASID。地址转换时,硬件只会匹配当前进程ASID对应的TLB表项。这样,多个进程的TLB表项可以共存,进程切换时无需清空TLB[5]。

TLB Shootdown:多核同步的开销

在多核系统中,当一个CPU修改了页表(如取消映射某个页面),其他CPU的TLB中可能仍然缓存着旧的映射。此时必须通知其他CPU失效对应的TLB表项,这个过程称为TLB Shootdown。

TLB Shootdown通过处理器间中断(IPI)实现:

  1. CPU A修改页表项
  2. CPU A向其他CPU发送IPI
  3. 其他CPU收到中断,暂停当前执行
  4. 其他CPU执行INVLPG指令失效对应的TLB表项
  5. 其他CPU恢复执行

这个过程的开销可能很高——被中断的CPU需要保存上下文、执行中断处理程序、刷新TLB。在高并发场景下,频繁的TLB Shootdown可能成为性能瓶颈[6]。

缺页中断:按需加载的核心机制

缺页中断的类型

当程序访问的虚拟地址没有有效的页表映射时,CPU触发缺页中断。根据原因不同,缺页中断可以分为几种类型:

1. 页面不存在于物理内存

页面可能在交换分区中,或者属于文件映射但尚未加载。操作系统需要:

  • 从磁盘读取页面内容
  • 更新页表项指向新的物理页面
  • 重新执行导致缺页的指令

2. 页面不存在于地址空间

程序访问了未分配的地址,如空指针解引用。操作系统发送SIGSEGV信号终止进程。

3. 权限违例

程序试图写入只读页面,或执行不可执行的页面。同样发送SIGSEGV信号。

4. Copy-on-Write

这是fork()实现的关键机制,下面详细说明。

Copy-on-Write与fork()的精巧设计

Unix的fork()系统调用创建一个与父进程完全相同的子进程。如果fork()时复制父进程的所有内存页面,开销将非常大——尤其是当子进程立即调用exec()加载新程序时,之前的复制完全是浪费。

Copy-on-Write(COW)的解决方案是:

  1. fork()时,子进程复制父进程的页表,但所有页面都标记为只读
  2. 两个进程共享同一组物理页面
  3. 当任一进程试图写入某个页面时,触发缺页中断
  4. 操作系统检测到这是COW页面,复制该页面给触发写入的进程
  5. 更新页表,两个进程各自拥有独立的可写页面

这种设计使得fork()非常高效——只复制页表结构(几KB到几MB),而不复制实际数据(可能几GB)[7]。

Linux内核中,页表项的COW标志通过PTE的R/W位和PMD的软脏位(soft-dirty)协同实现。当检测到写入只读页面时,内核判断是否需要执行COW复制。

mmap:文件与内存的统一抽象

mmap的工作原理

mmap()系统调用将文件或设备映射到进程的虚拟地址空间。映射后,访问这段内存就像访问普通数组一样,操作系统负责处理与磁盘的同步。

mmap的核心优势是避免了read()/write()系统调用的数据拷贝:

传统read():
磁盘 -> 内核缓冲区 -> 用户缓冲区

mmap:
磁盘 -> 内核页缓存 -> 用户虚拟地址空间(直接映射,无额外拷贝)

mmap的实现依赖缺页中断:

  1. mmap()调用时,内核只在进程的VMA(Virtual Memory Area)链表中记录映射信息
  2. 当程序访问映射区域时,触发缺页中断
  3. 缺页处理程序检查VMA,发现这是文件映射
  4. 从页缓存查找或从磁盘读取数据
  5. 更新页表,将虚拟页面映射到页缓存中的物理页面[8]

匿名映射与文件映射

mmap支持两种映射类型:

匿名映射(Anonymous Mapping)

  • 不关联任何文件,用于分配大块内存
  • 由内核的零页面初始化
  • 进程退出或munmap()时内存被回收

文件映射(File-backed Mapping)

  • 关联一个文件描述符
  • 修改会写回文件(取决于映射模式)
  • 多个进程映射同一文件可以共享内存

共享库就是通过文件映射实现的。当多个进程加载同一个共享库时,库的代码段在物理内存中只有一份副本,所有进程共享,节省大量内存。

大页技术:减少TLB压力

问题的根源

标准4KB页面在现代系统中面临一个根本性问题:TLB覆盖范围太小。

假设一个系统有64个数据TLB表项和64个指令TLB表项,使用4KB页面:

$$\text{TLB覆盖范围} = 64 \times 2 \times 4\text{KB} = 512\text{KB}$$

一个需要访问1GB数据的应用程序,如果访问模式是随机的,TLB命中率将接近于零。每次内存访问都需要页表遍历,性能急剧下降。

大页的解决方案

x86_64支持两种大页尺寸:

  • 2MB大页:使用PMD直接映射,跳过PTE级别
  • 1GB大页:使用PUD直接映射,跳过PMD和PTE两级

使用2MB大页,同样的TLB表项可以覆盖:

$$\text{TLB覆盖范围} = 64 \times 2 \times 2\text{MB} = 256\text{MB}$$

覆盖率提升了512倍!

大页的实现方式:

1. 静态大页(HugeTLB) 需要预先在启动时预留大页,通过/proc/sys/vm/nr_hugepages配置。数据库系统常用这种方式分配大块内存。

2. 透明大页(Transparent Huge Pages, THP) 内核自动将连续的4KB页面合并为2MB大页。对应用透明,但可能增加内存碎片和延迟抖动[9]。

大页的权衡

大页并非万能药:

  • 内存浪费:一个2MB页面即使只使用1字节也占用完整2MB
  • 碎片化:长时间运行的系统难以找到物理连续的大块内存
  • 迁移困难:大页难以在NUMA节点间迁移

因此,大页最适合工作集明确、访问模式相对稳定的场景,如数据库、科学计算等。

内存保护:从权限位到NX位

页表权限位

每个PTE都包含权限控制位,控制CPU如何访问该页面:

名称 作用
R/W 读写位 0=只读,1=可读写
U/S 用户/特权位 0=内核态,1=用户态
NX 不执行位 1=禁止执行

这些权限位是内存隔离的基础。用户程序不能访问内核页面(U/S=0),代码段通常不可写(R/W=0),数据段不可执行(NX=1)。

NX位与安全

NX(No-eXecute)位是AMD在AMD64架构中引入的特性(Intel称为XD,eXecute Disable)。在此之前,x86架构没有硬件级的执行权限控制——任何可读的页面都可以执行代码。

这种缺陷被大量利用进行缓冲区溢出攻击。攻击者可以在栈或堆上注入恶意代码,然后通过溢出覆盖返回地址,跳转到注入的代码执行。

NX位使操作系统能够将数据段(栈、堆、全局变量)标记为不可执行。即使攻击者注入了代码,也无法执行[10]。

现代系统实现了W^X(Write XOR Execute)原则:一个页面要么可写,要么可执行,但不能同时具有两种权限。这需要编译器和运行时的配合,如Just-In-Time编译器必须在代码执行前将可写页面改为可执行。

进程地址空间布局

x86_64 Linux内存布局

Linux x86_64的进程地址空间布局如下:

高地址
+------------------+ 0x7FFFFFFFFFFF
|    用户空间      |  (128TB)
+------------------+ 0x7FFFFFFFFFFF
|      栈         |  ↓ 向下增长
+------------------+
|      ...        |
+------------------+
|  内存映射区域    |  mmap()分配
+------------------+
|      ...        |
+------------------+
|      堆         |  ↑ 向上增长
+------------------+
|    BSS段        |  未初始化全局变量
+------------------+
|    数据段        |  已初始化全局变量
+------------------+
|    代码段        |  只读,可执行
+------------------+ 0x400000
|    保留区域      |
+------------------+ 0x0
低地址

这种布局不是固定的。ASLR(Address Space Layout Randomization)会在每次程序运行时随机化各个区域的位置,增加攻击者预测地址的难度[11]。

内核地址空间

内核占据虚拟地址空间的高位部分:

+------------------+ 0xFFFFFFFFFFFFFFFF
|   内核空间       |  (128TB)
+------------------+ 0xFFFF800000000000
|   用户空间       |
+------------------+ 0x0

内核空间包含:

  • 直接映射所有物理内存
  • vmalloc分配区域
  • 内核代码和数据
  • 每CPU变量

内存碎片整理:压缩的艺术

外部碎片问题

随着系统运行,物理内存逐渐碎片化。即使总体有足够的空闲内存,也可能无法找到足够大的连续物理页面来满足大页分配需求。

Linux采用伙伴系统(Buddy System)管理物理页面,可以有效管理碎片,但仍无法完全避免。对于需要连续物理内存的场景(如DMA、大页),碎片化可能成为严重问题[12]。

内存压缩机制

Linux 2.6.35引入了内存压缩(Memory Compaction)机制。其工作原理类似于磁盘碎片整理:

  1. 一个扫描器从区域底部向上扫描,寻找可移动的已分配页面
  2. 另一个扫描器从区域顶部向下扫描,寻找空闲页面
  3. 将可移动页面迁移到顶部的空闲区域
  4. 在底部形成大的连续空闲区域

不是所有页面都可以移动。用户空间页面可以通过修改页表来移动;但内核直接使用的物理页面(通过线性映射访问)无法移动。

可以通过以下方式触发压缩:

  • 写入/proc/sys/vm/compact_memory触发全系统压缩
  • 高阶分配失败时自动触发
  • 后台kcompactd守护进程定期执行

虚拟化环境下的内存虚拟化

影子页表

在虚拟化环境中,客户机操作系统管理自己的页表,将客户机虚拟地址(GVA)转换为客户机物理地址(GPA)。但实际硬件需要将GVA转换为真实物理地址(HPA)。

早期的软件虚拟化方案使用影子页表(Shadow Page Tables):

  • VMM维护一套"影子页表",直接存储GVA->HPA映射
  • 将影子页表的基地址加载到CR3
  • 客户机修改自己的页表时,VMM捕获并同步更新影子页表

影子页表的问题是开销大——每次客户机页表修改都需要VM exit,VMM介入处理。

EPT/NPT:硬件辅助的二级地址转换

现代处理器提供了硬件支持的二级地址转换:

  • Intel EPT(Extended Page Tables)
  • AMD NPT(Nested Page Tables)

以EPT为例:

  1. 客户机页表将GVA转换为GPA(客户机物理地址)
  2. EPT将GPA转换为HPA(真实物理地址)
  3. 硬件自动完成两级转换

当发生EPT violation(类似于页表缺页)时,硬件触发VM exit,由VMM处理[13]。

EPT/NPT消除了大部分影子页表的维护开销,使虚拟机的内存性能接近裸机。但二级转换增加了TLB未命中的代价——一次转换可能需要24次内存访问(4级客户机页表 + 4级EPT,每级都需要访问)。

开发者实践指南

理解内存使用指标

Linux提供了多种工具分析进程内存使用:

# 查看进程内存映射
pmap -x <pid>

# 查看详细内存统计
cat /proc/<pid>/status | grep -E 'VmSize|VmRSS|VmData'

# 查看NUMA内存分布
numastat -p <pid>

关键指标解读

  • VIRT:虚拟内存总量,包括未实际分配的地址空间
  • RES/RSS:实际占用的物理内存
  • SHR:共享内存(如共享库、mmap文件)
  • SHR:共享内存

大页使用场景判断

考虑使用大页的情况:

  • 工作集大小接近或超过TLB覆盖范围
  • 访问模式相对稳定
  • 可以接受预分配的内存开销

避免使用大页的情况:

  • 工作集很小(<100MB)
  • 内存稀缺的系统
  • 需要频繁分配释放的场景

内存映射优化

使用mmap时的注意事项:

  1. 映射大小对齐:映射大小最好是页面大小的整数倍
  2. 避免过度映射:大量小映射增加VMA管理开销
  3. 合理使用MAP_POPULATE:可以预加载页面,但会增加启动延迟
  4. 注意信号处理:访问无效映射产生SIGBUS而非SIGSEGV

调试内存问题

# 跟踪缺页中断
perf stat -e faults ./program

# 分析TLB未命中
perf stat -e dTLB-load-misses,iTLB-load-misses ./program

# 查看NUMA平衡效果
numactl --hardware

结语

虚拟内存管理是操作系统最核心的子系统之一。从1962年Atlas计算机的开创性工作,到现代处理器的五级页表和EPT虚拟化;从简单的页表遍历,到TLB、大页、COW等优化技术;这套机制经过了六十年的演进,在复杂度和效率之间找到了精妙的平衡。

理解这些机制有助于我们编写更高效的代码、诊断复杂的性能问题、以及设计更安全的系统。当你下次看到top命令中的内存数据时,希望你能意识到这些数字背后的精巧设计。


参考文献

[1] Kilburn, T., Edwards, D.B.G., Lanigan, M.J., & Sumner, F.H. (1962). One-level storage system. IRE Transactions on Electronic Computers, EC-11(2), 223-235.

[2] IEEE ETHW. Milestones: Atlas Computer and the Invention of Virtual Memory, 1957-1962. https://ethw.org/Milestones:Atlas_Computer_and_the_Invention_of_Virtual_Memory,_1957-1962

[3] Linux Kernel Documentation. x86_64 Memory Layout. https://www.kernel.org/doc/html/latest/arch/x86/x86_64/mm.html

[4] Arpaci-Dusseau, R.H., & Arpaci-Dusseau, A.C. (2018). Operating Systems: Three Easy Pieces. Chapter 19: Paging: Faster Translations (TLBs).

[5] Hennessy, J.L., & Patterson, D.A. (2017). Computer Architecture: A Quantitative Approach (6th ed.). Morgan Kaufmann.

[6] Amit, N., et al. (2017). Don’t shoot down TLB shootdowns! USENIX ATC.

[7] Linux Kernel Documentation. Copy-on-Write Implementation. https://www.kernel.org/doc/html/latest/admin-guide/mm/concepts.html

[8] Biriukov, V. More about mmap() file access. https://biriukov.dev/docs/page-cache/5-more-about-mmap-file-access/

[9] Corbet, J. (2010). Memory compaction. LWN.net. https://lwn.net/Articles/368869/

[10] Wikipedia. NX bit. https://en.wikipedia.org/wiki/NX_bit

[11] Gorman, M. (2004). Understanding the Linux Virtual Memory Manager. Chapter 4: Process Address Space.

[12] High Scalability. (2021). Linux Kernel vs. Memory Fragmentation. https://highscalability.com/linux-kernel-vs-memory-fragmentation-part-i/

[13] VMware. Performance Evaluation of Intel EPT Hardware Assist. https://www.vmware.com/pdf/Perf_ESX_Intel-EPT-eval.pdf