6.1 虚拟地址、页表与 TLB
缓存缓解 CPU 与主存的速度差距。地址翻译处理另一个问题:普通进程通常拥有独立虚拟地址空间,操作系统与 MMU 为它安排映射、保护和共享。相同数字的指针,未必指向同一块物理内存。
cache 回答“这个物理数据离核心多远”,虚拟内存先回答“程序给出的地址指向哪个物理页”。本篇追踪地址翻译;下一篇再讨论按需分页、page fault 与 mmap。
地址相同,不代表物理位置相同
两个进程都可以打印指针 0x7f...。它们各自的页表可以把相同虚拟地址映射到不同物理页,也可以有意映射到同一物理页。
进程的 virtual address space 不是一块预先铺满的物理内存,而是一组映射与空洞。操作系统为映射记录范围、权限和后备对象,MMU 在执行 load/store/fetch 时依据当前地址空间的页表完成翻译。
虚拟内存提供三种关键能力:
- 隔离:用户进程只能访问页表允许的映射与权限;
- 重定位:连续虚拟页可以对应分散物理页,程序无需知道物理布局;
- 共享:不同虚拟范围可以指向相同文件页、共享内存页或写时复制页。
隔离降低了普通野指针破坏其他进程的机会,却不是绝对安全边界的全部。内核漏洞、设备 DMA 配置错误和侧信道仍需其他机制防护。
先观察映射,不猜固定布局
Linux 进程可从 /proc/self/maps 查看当前 virtual memory area(VMA)。下面的 Linux 程序使用 POSIX 接口打印对象地址,并提示本进程的 maps 路径。保存为 show_maps.c,用 cc -std=c17 -Wall -Wextra -Werror show_maps.c -o show_maps 编译。运行后在另一终端执行输出的 cat 命令,再按 Enter 结束:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
static int global_value = 42;
int main(void) {
int stack_value = 10;
int *heap_value = malloc(sizeof *heap_value);
if (heap_value == NULL) {
perror("malloc");
return 1;
}
*heap_value = 20;
printf("pid: %ld\n", (long)getpid());
printf("global object: %p\n", (void *)&global_value);
printf("heap object: %p\n", (void *)heap_value);
printf("stack object: %p\n", (void *)&stack_value);
printf("inspect: cat /proc/%ld/maps\n", (long)getpid());
if (fflush(stdout) == EOF) {
perror("fflush");
free(heap_value);
return 1;
}
int result = 0;
if (getchar() == EOF && ferror(stdin)) {
perror("getchar");
result = 1;
}
free(heap_value);
return result;
}多次运行时,ASLR 往往让 stack、heap、shared library 和 PIE executable 的地址变化;不能预言“两次虚拟地址完全相同”。布局还受 OS、ABI、链接方式、资源限制与运行时分配器影响。
malloc 返回一段满足语言约束的存储。它可能从已有 allocator arena 切分,也可能请求新的映射;“每次 malloc 只登记 VMA,首次写才分配页”不是通用逐次行为。物理页的按需分配要在具体映射与 allocator 状态下观察。
page 把地址分成页号和偏移
设 page size 为 2^p byte,虚拟地址可写为:
virtual address = virtual page number (VPN) | page offset低 p bit 是页内 offset,翻译时原样保留;高位 VPN 通过页表得到 physical frame number(PFN):
physical address = PFN × page_size + offset若 page size 是 4096 byte,p=12。地址 0x...6D00 的 offset 是 0xD00。page size 由体系结构和映射选择,不应在跨平台程序里默认永远为 4096;POSIX 可用 sysconf(_SC_PAGESIZE) 查询常规 page size。
页表条目保存 PFN,也包含 present/valid、read/write、user/supervisor、execute permission、accessed、dirty 等状态。确切 bit 位置属于 ISA 与 paging mode,不能把某张 x86 图套到 RISC-V 或 ARM。
x86-64 四级翻译示例
在使用 48-bit canonical virtual address、4 KiB page 的经典 x86-64 四级模式中,低 48 bit 拆为:
47 39 38 30 29 21 20 12 11 0
+--------------+--------------+--------------+--------------+----------+
| PML4 index | PDPT index | PD index | PT index | offset |
| 9 bit | 9 bit | 9 bit | 9 bit | 12 bit |
+--------------+--------------+--------------+--------------+----------+翻译过程概念上是:
- CR3 指出顶级表的物理基址与相关上下文;
- PML4 entry 指向下一级 page-directory-pointer table;
- PDPT entry 指向 page directory;
- PD entry 指向 page table;
- PT entry 给出 4 KiB physical frame 与权限;
- PFN 和原 offset 合成 physical address。
每一级还要检查 present 和权限;中间级权限也参与最终结果。若 PDPT/PD entry 选择 huge page,walk 会提前结束,并使用更大的页内 offset。
现代 x86-64 还可支持五级 paging,把有效虚拟地址扩展到 57 bit。处理器是否支持、OS 是否启用与用户空间可用范围是不同问题。“64-bit CPU 就有完整 2^64 用户地址”不成立。
RISC-V Sv39、Sv48 与 AArch64 translation tables 使用各自的级数、PTE 格式和地址规则。理解 VPN→多级索引→PFN 的共性后,再查目标 ISA 手册。
多级页表为什么适合稀疏空间
48-bit virtual address、4 KiB page 一共有 2^36 个虚拟页。若每项 8 byte,单个 flat page table 要:
2^36 × 8 byte = 512 GiB这还只是一个地址空间的映射数组,显然不能为每个进程预分配。
多级表把它变成 radix tree。未使用的大片地址范围只需在较高层标记 not present,不分配下面的 page-table page。每个 4 KiB 表页容纳 512 个 8-byte entry,正好由 9-bit index 选择。
代价是稀疏映射仍要为实际 leaf PTE 和通向它们的中间节点付费。按 4 KiB 对齐的连续 100 MiB 映射需要 25,600 个 4 KiB page,对应 leaf PTE 本身约 200 KiB,再加若干级目录页;还要区分映射的数据页数、PTE 字节数和实际页表页数。huge page 可以减少 PTE 数与 TLB 压力,但会增加内部碎片、分配/迁移难度,也可能延长某些内核操作。
页表自身占用物理内存,并可被 CPU cache。一次 page walk 的每一级不是必然访问 DRAM;上层 entry 或整个 walk 可能命中 paging-structure cache 和普通 cache。
用代码拆出四级索引
def split_x86_4k_48(virtual_address: int) -> dict[str, int]:
if not 0 <= virtual_address < (1 << 64):
raise ValueError("address must fit in 64 bits")
low48 = virtual_address & ((1 << 48) - 1)
sign = (low48 >> 47) & 1
expected_high = 0xFFFF if sign else 0x0000
if virtual_address >> 48 != expected_high:
raise ValueError("non-canonical 48-bit virtual address")
return {
"pml4": (virtual_address >> 39) & 0x1FF,
"pdpt": (virtual_address >> 30) & 0x1FF,
"pd": (virtual_address >> 21) & 0x1FF,
"pt": (virtual_address >> 12) & 0x1FF,
"offset": virtual_address & 0xFFF,
}
parts = split_x86_4k_48(0x00007F3A4B5C6D00)
assert parts == {
"pml4": 0xFE,
"pdpt": 0xE9,
"pd": 0x5A,
"pt": 0x1C6,
"offset": 0xD00,
}
kernel_style = 0xFFFF800000001234
assert split_x86_4k_48(kernel_style)["offset"] == 0x234这里验证的是 bit 拆分与 canonical-address 规则,不会读取真实页表。用户进程通常不能直接访问任意物理页表;Linux 提供的 /proc 接口、debugger 或内核工具也会受权限与安全策略限制。
TLB 缓存翻译结果
若每次 load 都重新走四级表,翻译开销会吞掉大量执行时间。translation lookaside buffer(TLB)缓存近期 VPN 到 PFN 及权限的转换。
virtual address
├─ TLB hit -> 检查缓存的权限 -> 得到 PFN 或保护异常
└─ TLB miss -> hardware/software page-table walk
├─ valid mapping -> 填充 TLB,重试访问
└─ 无有效转换/权限失败 -> page faultTLB 命中仍可能因访问权限不符而触发保护异常;TLB miss 本身不表示 page 不在内存。上图针对分页翻译路径,非 canonical 地址等输入还可能按 ISA 产生其他异常,不能一概叫作 page fault。
TLB hit 也不是普遍“恰好 1 cycle”,miss 也不是固定“上百 cycle”。不同层 TLB、page-walk cache、cache 命中与并行 walk 会改变代价。
TLB 通常按 page size 分组或有不同结构。4 KiB page 的一个 entry 覆盖 4 KiB,2 MiB page 的一个 entry 覆盖更大范围,因此 huge page 可以提高 TLB reach:
TLB reach ≈ entry_count × page_size它可能改善大工作集,却不是无条件优化。透明 huge page 的折叠、拆分与 compaction 会带来 latency;数据库或虚拟机也可能选择显式 huge page。应结合工作集与延迟分布选择 THP 策略;always 不是所有工作负载的默认答案,全局配置也会影响同机其他进程。
地址空间切换与 TLB shootdown
切换进程时,旧 TLB entry 不能被当作新地址空间的翻译。简单实现可以 flush;带 ASID/PCID 的体系结构给 entry 标记地址空间,减少不必要清空。global kernel mapping 又有自己的规则。
当 OS 撤销映射、降低权限或改变 PFN 时,其他 CPU 可能仍缓存旧 translation。内核要执行 TLB shootdown:通知相关核心使 entry 失效,并在完成前满足同步要求。频繁 mprotect、munmap 或大规模映射变化可能因此产生跨核开销。
TLB coherence 与 data-cache coherence 是不同问题:前者协调翻译缓存,后者协调同一物理数据 line 的副本。
cache 与翻译谁先发生
很多 L1 cache 采用 virtually indexed, physically tagged(VIPT)等设计,让 page offset 内的 index bit 与 TLB 查询并行,最后用 physical tag 确认。其他层与架构可以不同。
这说明“先完整翻译,再访问每一级 cache”的串行图只是概念模型。硬件会并行和预测以缩短关键路径,但最终必须维护正确的物理身份、权限和 alias 行为。
页权限让错误尽早停在边界
常见映射权限包括 readable、writable、executable 和 user-accessible。OS 可用它们实现:
- code page 通常 executable 而不可写;
- stack/heap 通常 writable 而不可执行;
- copy-on-write page 暂时只读,写入触发 fault,需要时复制,已独占时可直接恢复写权限;
- guard page 保持未映射或设为不可访问,在越界触碰它时触发异常;它不能检测所有越界;
- shared library 的只读 code page 可被多个进程映射。
W^X(writable xor executable)减少同一 page 同时可写可执行的攻击面,这里表示“不可同时可写可执行”,允许两者都不具备,并非逻辑 XOR 真值表。JIT、动态链接和平台策略可安排受控转换;同一物理页的别名映射还要另行约束。NX/execute-never 是硬件能力的一部分,完整安全还需要 ASLR、权限分离、控制流防护等。
页表权限检查发生在每次有效访问的翻译语义中,TLB entry 会缓存相关权限;改变权限后因此需要正确失效旧 entry。
动手追踪一次 load
- 在 48-bit 四级、4 KiB page 模式下拆解虚拟地址
0x0000123456789ABC。 - 计算 1 GiB 连续映射分别使用 4 KiB 与 2 MiB page 时需要多少 leaf entry。
- 解释 flat page table 的 512 GiB 从哪两个指数得来。
- 区分 TLB miss 与 page fault:哪一个可以在已有有效 PTE 时完成?
- 画出修改某个 PTE 后,另一个核心为什么还需要 TLB shootdown。
- 查目标机器实际 page size、支持的 huge page 和地址宽度,不根据“64-bit”猜测。
映射存在不等于数据已经驻留
有效的虚拟范围可以还没有物理匿名页,文件页也可能不在 page cache;只读 page 还可能等待 copy-on-write。下一篇进入按需分页、page fault 与 mmap,解释访问如何让这些承诺变成实际资源。