虚拟内存:从一条访存指令理解页表、缺页与 TLB

本页目录

程序成功映射了一大片内存,RSS 却没有同步增长;第一次写入时又发生缺页。沿一条 value = *p 的访存指令,可以把合法地址、页表翻译、实际内容这三层分开。

下面用 x86-64、4 KiB 基础页手算地址,再跟随一页数据经历首次使用、fork 和解除映射。其他架构的页大小和权限编码可能不同。

要解释的现象阅读位置
TLB miss 是否一定要进内核?第 1 节:访存路径
多级页表怎样节省空间?第 2~3 节:地址计算与权限
fork 后为何读到相同内容,却能分别修改?第 4 节:COW 与实验
解除映射为何还要通知其他 CPU?第 5~8 节:TLB、大页与观测

1. 一条 load 指令为什么有时不进入内核,有时会缺页

假设程序执行 value = *p。CPU 得到的是虚拟地址及读取操作,MMU 需要确认映射和访问权限。通常先利用 TLB 中缓存的翻译;命中且权限满足时,不需要为这次访问从头读取整棵页表。TLB 中没有合适记录,硬件才尝试 page walk,页表缓存和普通 CPU cache 又可能加速这个过程。

一次地址访问:TLB 命中与页表遍历是两条路径 虚拟地址 + 访问类型 读 / 写 / 取指 TLB 查询 地址空间标签与权限 访问目标物理地址 命中且权限允许 硬件页表遍历 缺失翻译时进行 有效叶子项 填充翻译后继续访问 page fault 缺映射或权限不符 TLB miss 不等于 page fault;page fault 也不等于必须从磁盘读取。

如果遍历获得有效映射,TLB miss 可以完全由硬件解决。若映射缺失或权限不符,CPU 产生异常,由内核查看软件侧的地址范围和访问语义:这可能是合法的首次分配、换入、写时复制,也可能是程序访问了不属于自己的地址。处理成功后通常重试原指令;无法满足的用户访问才按情况产生 SIGSEGV、SIGBUS 等结果。

把“缺页”放回一个具体场景: 文件内容已经被其他进程读入页缓存,本进程首次访问时仍可能缺页,因为它尚未安装对应 PTE。这次可以复用已有内容,无需重新读盘。

要确认的事情应看哪一层
这个地址允许读取吗?VMA 的范围与访问语义
CPU 能直接完成地址翻译吗?当前页表和 TLB 状态
需要重新从后端取内容吗?页缓存、交换后备等驻留状态

所以,已有 VMA、已经有 PTE、数据已经驻留,不能互相替代。

2. 为什么页表是多级结构:先算一笔空间账

48 位地址、4 KiB 页意味着 2^48 / 2^12 = 2^36 个虚拟页。若每页固定保留一个 8 字节项,单个线性页表就需要 2^39 字节,也就是 512 GiB。绝大多数进程只用到其中极少区域,为所有空洞提前分配页表非常浪费。

多级页表让一个上层空项代表整个未映射范围,只有实际需要的分支才分配下层表页。对 x86-64 的 4 KiB 表页,512 个 8 字节条目恰好装满一页,所以每级索引用 9 位。4 级索引共 36 位,加上 12 位页内偏移就是 48 位。

配置Linux 层次与索引最后的页内偏移
四级 x86-64PGD [47:39],P4D 折叠,PUD [38:30],PMD [29:21],PTE [20:12][11:0]
五级 x86-64PGD [56:48],P4D [47:39],其后与四级相同[11:0]

“折叠”意味着 Linux 通用代码仍可按五级接口书写,目标配置不需要的那层不对应独立硬件表。四级模式的 PGD 对应硬件 PML4,五级时 PGD 对应 PML5,名字不能机械地永远绑定为同一个硬件层。见 页表层次。

手算一个地址如何定位到物理页

构造地址,使四级索引依次为 1、2、3、4,页内偏移为 0x567。CR3 提供顶层表的物理基址和相关控制信息。硬件读取顶层第 1 项,取得下一张表的基址;再取第 2、第 3、第 4 项。每一步条目的地址都是“该级表基址 + 索引 × 8”,⁠不是把四个索引相加后乘页大小。

假设最后叶子项指向物理页基址 0x12345000,目标物理地址就是 0x12345567。页内偏移来自虚拟地址低 12 位;PTE 的低位则是标志,并不保存这段偏移。

indices = [1, 2, 3, 4]
shifts = [39, 30, 21, 12]
va = sum(index << shift for index, shift in zip(indices, shifts)) | 0x567
assert [(va >> shift) & 0x1ff for shift in shifts] == indices
physical_base = 0x12345000  # 假设遍历得到的结果,不读取实际页表
physical = physical_base | (va & 0xfff)
assert physical == 0x12345567
assert (1 << 48) // 4096 * 8 == 512 * (1 << 30)
print(f"virtual={va:#x}, physical={physical:#x}")

下面把地址拆成索引和偏移,直接改变叶子大小,观察遍历在哪一层终止。图中另选 0x40000000 作为对齐的数据基址,计算规则与上面的手算相同。

b 是叶子覆盖范围的偏移位数:本图分别取 12、21、30。

正在呈现知识画面
地址里的哪些位,在这一层被使用?

多级索引逐层选择表项;物理地址由叶子页基址加页内偏移得到。大页在较高层结束遍历,更多低位直接成为偏移。

地址宽度:能力、启用状态与实际分配分开看

x86-64 地址还必须满足当前模式的 canonical 规则:未使用的高位按有效最高地址位扩展。五级带来更大的虚拟空间,并不要求进程立即分配更多物理内存。CPU 暴露 LA57 只表明能力,还需内核配置和运行时模式;Linux 也出于应用兼容性考虑,通常不会默认把普通 mmap 放到 47 位窗口以外。见 五级分页说明。

3. 权限存在于映射路径中,不只是最后一项的一个开关

先用常见 x86 叶子项区分几类信息:

信息作用读位定义时的边界
Present参与映射有效性判断非 present 可表达多种软件状态,不只 swap
R/W、U/S写权限与用户访问控制有效权限还受上层条目和架构状态约束
NX不可执行语义常见 x86 编码在 bit 63,bit 11 不是 NX
Accessed、Dirty提供使用与写入线索属于映射级信息,不能单独证明内容可恢复

大页叶子与普通 PTE 对某些位的解释不同,物理地址位宽也需按 CPU 能力处理。

例如上层拒绝用户访问,即使叶子项 U/S 允许,读取也不能成功。再如某个 PTE 的 Dirty=0,并不足以证明匿名内容已有有效后备副本:硬件访问线索和数据的恢复来源是不同问题。

内核维护的 VMA 权限描述“程序允许怎样使用这个范围”,硬件页表权限描述“当前这次访问能否直接完成”。二者有意不总相同,这正是下一节 COW 能工作的基础。查具体位定义可定位目标内核 arch/x86/include/asm/pgtable_types.h,不要把教学简图当作可直接移植的位掩码。

4. 沿一页数据走完 demand paging 与 COW

程序映射一段私有匿名可读写空间时,内核首先登记地址范围及语义。通常不必马上为每个地址分配数据页。第一次读取可能使用共享零页等优化;第一次实际写入则需要能够保存本进程数据的页,并保证新分配的用户数据内容不会泄露之前使用者的信息。

接着调用 fork。父子需要看到 fork 时相同的私有内存内容,但如果子进程很快 exec,立即复制所有数据页会白做大量工作。典型 COW 做法是让两边暂时共享物理页,把相关硬件映射设为写保护,同时保留两边 VMA 的可写语义。真正的写入才迫使内核判断是否需要分离。

私有匿名页:从承诺地址到共享,再到独立写入 mmap 建立可读写范围 VMA 允许写入,尚可没有对应的驻留数据页 1 首次写入触发缺页 分配并清零物理页 P,建立可写映射 2 fork 复制地址空间视图 典型 COW:父子映射共享 P,硬件写权限收紧 3 子进程尝试写入 VMA 允许写,但 PTE 写保护,内核处理 COW 4 建立子进程独立可写页 P′ 更新映射和翻译缓存,再重试那条写指令 5 若旧页已可独占,内核也可能复用;可见性语义不要求每次都物理复制。

读图:VMA 允许写,为什么还会写保护异常

子进程写入时,内核发现“VMA 允许写、当前共享映射禁止直接写”,于是建立满足私有语义的可写页。若确需复制,子进程的新页接收原内容,再执行原来的修改;父进程继续映射旧页。若旧页已经满足独占条件,则可能只恢复可写权限。具体路径还受大页、固定引用等因素影响,不能从用户看到 COW 语义就反推发生了几次 memcpy。

文件的 MAP_PRIVATE 映射也能产生私有 COW 页:修改后的那部分不写回原文件。MAP_SHARED 则表达共享修改语义,文件页可能进入写回路径。两者都可能有缺页,⁠缺页次数本身不告诉你数据最终属于谁⁠。

下面保留同一页的两条映射,分别尝试“另一方仍在”与“另一方已解除”的写入。比较完整内容和复制次数,而不只看箭头换了颜色。

正在呈现知识画面
改一个字节,为什么会分出一整页?

fork 后的私有页可暂时共享且写保护。写入时若仍被共享,需要保留另一方内容;如果已独占,可能只恢复写权限。

实验:观测隔离结果,不猜测复制次数

下面同时观测首次写入和 fork 后的可见性,只使用 64 个基础页:

#define _GNU_SOURCE
#include <assert.h>
#include <stdio.h>
#include <sys/mman.h>
#include <sys/resource.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void) {
    long page = sysconf(_SC_PAGESIZE);
    assert(page > 0);
    size_t length = 64 * (size_t)page;
    unsigned char *memory = mmap(NULL, length, PROT_READ | PROT_WRITE,
                                MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    assert(memory != MAP_FAILED);
    assert(madvise(memory, length, MADV_NOHUGEPAGE) == 0);
    struct rusage before, after;
    assert(getrusage(RUSAGE_SELF, &before) == 0);
    volatile unsigned char *data = memory;
    for (size_t i = 0; i < length; i += (size_t)page)
        data[i] = 7;
    assert(getrusage(RUSAGE_SELF, &after) == 0);
    printf("首次逐页写入 minor faults 差分: %ld\n",
           after.ru_minflt - before.ru_minflt);
    fflush(stdout);
    pid_t child = fork();
    assert(child >= 0);
    if (child == 0) {
        assert(data[0] == 7);
        data[0] = 9;
        assert(data[0] == 9);
        _exit(0);
    }
    int status;
    assert(waitpid(child, &status, 0) == child);
    assert(WIFEXITED(status) && WEXITSTATUS(status) == 0);
    assert(data[0] == 7);
    assert(munmap(memory, length) == 0);
    puts("子进程私有修改未改变父进程内容:通过");
}

用 cc -std=c11 -Wall -Wextra -Werror vm-demo.c -o vm-demo 编译,不加 -DNDEBUG。minor fault 差分只作为观测,不断言一定等于 64;测量还可能包含其他进程内活动。minor fault 通常无需为本次处理执行后端读入,但并不意味着开销可忽略。实验不读取 PFN,因此验证的是用户可见隔离,而非物理页共享的直接证据。

5. TLB 一致性为什么是内存安全的一部分

假设 CPU 0 执行 munmap,把地址 A 的 PTE 清掉;CPU 1 正在运行同一地址空间的另一个线程,其 TLB 仍缓存 A 到物理页 P 的翻译。普通数据缓存一致性可以传播页表内存的修改,却不会自动让每个 TLB 条目重新查询页表。如果此时把 P 分配给另一个对象,CPU 1 的旧翻译就可能继续读写新对象的数据。

为什么清除 PTE 后不能立刻把旧页交给别的对象 CPU 0:解除映射 页表已不再指向 P CPU 1:旧 TLB 仍可能把地址 A 翻译到 P 物理页 P 此时尚不能安全复用 协调相关 CPU 失效 批量 / IPI / 架构机制 确认旧翻译不可再用 含并发地址空间切换约束 满足其他引用条件 才能回收或重新分配 P 数据缓存一致性不替代 TLB 失效;只需覆盖可能使用旧翻译的相关执行环境。

因此,⁠旧物理页再次交给别人之前,危险的旧翻译必须失效。 撤销映射、收紧权限和页面迁移,都要把翻译维护与页的寿命协调起来。

传统 x86 路径常利用 IPI 协调相关 CPU,但无需无差别广播。地址空间使用记录、批量失效、延迟处理和硬件能力可减少工作量;具体函数链随实现变化,安全边界不变。见 TLB 失效接口。

PCID/ASID 为缓存翻译附加地址空间标签,使不同地址空间的结果有机会同时留在 TLB。它减少不必要的清空,却不能让已经过期的映射永久有效;标签重用和页表更新仍需处理失效。频繁 mprotect/munmap 的开销也因地址空间活跃 CPU 数和批量大小而变,不应只按修改了多少个 PTE 估算。

6. 大页的收益从哪里来,成本又由谁支付

先算完整映射 1 GiB 数据所需的叶子项:

映射粒度叶子项数量最底层表的变化
4 KiB 基础页262144 个 PTE仅 PTE 存储就约 2 MiB
2 MiB PMD 大页512 个 PMD 叶子省去对应的底层 PTE 表

一次大页翻译还覆盖更大的虚拟范围,能降低某些工作集的 TLB 压力。收益来自翻译覆盖范围和页表工作量的变化,实际幅度仍与 CPU 和访问模式有关。

但是不同页大小的 TLB 容量与组织未必相同,不能拿同一固定条目数相乘就当作某 CPU 的真实性能。大页也要求对应的连续物理范围;申请、清零、迁移与整理可能将成本集中到一次缺页。只用少量字节的范围又可能造成内部浪费,部分解除映射或权限变化还可能需要拆分。

机制如何获得大页主要取舍
HugeTLB显式池与映射接口,存在预留/实际分配等管理容量管理较明确,但预留不等于无限资源;不能普通 swap
PMD THP缺页尝试或后台合并,按策略决定减少翻译和缺页开销,可能回退基础页或付出整理延迟
mTHP多种尺寸的较大 folio,常仍由 PTE 映射批量处理更多页,减少部分缺页开销;不等于都省掉一级页表

THP 不是简单把“原本已连续的 512 个页改个标志”:后台合并可以分配新大页并搬移内容,缺页路径也可直接尝试大页。mTHP 的 PTE 映射能否获得额外硬件翻译收益依架构而定。

实际尺寸和启用策略应查看各 hugepages-*kB/enabled 等接口,不能仅凭顶层开关判断所有路径;显式 MADV_COLLAPSE 还有独立契约。见 THP 管理文档与 HugeTLB。

7. PTI 为什么要切换页表

传统共享布局可让用户和内核映射同处一套地址翻译结构,正常权限检查阻止用户访问内核页。但 Meltdown 类问题表明,特定 CPU 上被架构权限拒绝的访问仍可能在瞬态执行中留下可观察的侧信道痕迹。PTI 的思路是缩小用户态页表可见的内核映射集合,使大部分敏感地址根本不在那套映射中。

进入内核时切到完整视图,返回用户态时切回精简视图;入口所需的最小区域必须仍可访问。两套视图可以共享用户部分的下层页表,不意味着每个进程的全部用户数据和每一级页表都完整复制两遍。开销包括页表切换和相关翻译维护,PCID 可缓解部分成本;不能固定写成“每个 syscall 恰好两次 TLB miss”。见 x86 PTI 设计。

8. 读观测结果时,把四种数量分开

读监控时,把四笔数量分别记账:

数量典型观测能解释什么
虚拟范围VmSize为程序登记了多少地址空间
驻留数据RSS、smaps当前哪些内容占着内存
页表占用VmPTE翻译结构自身的内存成本
缺页事件fault 计数访问需要内核介入的次数

VmSize 大而 RSS 小,可以来自未触碰的映射。是否实际使用大页应结合 smaps 等字段,不能只看配置开关。

/proc/<pid>/pagemap 是二进制接口,PFN 可因权限限制被隐藏,位解释也应按文档版本读取,不应直接 cat 成文本。下一篇 VMA 与 Maple Tree 继续回答:内核如何知道某个缺页地址合法,以及修改一段权限为何会拆出更多 VMA。