你的程序使用虚拟地址访问内存,但 DRAM 只认物理地址。每次内存访问都需要一次地址翻译——从虚拟地址到物理地址。如果每次翻译都要查页表(存在内存中),那每次内存访问实际上需要两次内存访问:一次查页表,一次访问数据。
TLB(Translation Lookaside Buffer)是地址翻译的缓存,将翻译结果保存在 CPU 内部,避免每次都查页表。TLB 未命中的代价可能比缓存未命中更高——因为页表遍历需要多次内存访问。
一、虚拟内存与页表
1.1 为什么需要虚拟内存?
- 隔离:每个进程有独立的地址空间,互不干扰
- 简化编程:程序员使用连续的虚拟地址,无需关心物理内存的碎片
- 按需分配:虚拟地址空间可以大于物理内存,通过换页实现
- 保护:页表项包含权限位,实现读/写/执行保护
1.2 页式内存管理
虚拟地址空间被划分为固定大小的页(Page),物理内存被划分为同样大小的页帧(Page Frame)。页表记录虚拟页到物理页帧的映射。
虚拟地址:[页号][页内偏移] ↓页表查找 → 物理页帧号 ↓物理地址:[物理页帧号][页内偏移]1.3 页大小
| 页大小 | 适用场景 | Linux 支持 |
|---|---|---|
| 4 KB | 默认页大小 | 默认 |
| 2 MB | Huge Pages | 透明大页 / hugetlbfs |
| 1 GB | Giant Pages | hugetlbfs |
| 16 KB | ARM 默认 | ARM 配置 |
| 64 KB | PowerPC | PPC 配置 |
二、多级页表
2.1 为什么需要多级页表?
以 x86-64 为例:虚拟地址 48 位,页大小 4KB(12 位偏移),页号 36 位。如果用单级页表:
- 页表项数 = 2^36 = 64G 项
- 每项 8 字节 → 页表大小 = 512 GB!
这显然不可行。多级页表通过分级索引解决了这个问题——只有实际使用的虚拟地址范围才需要分配页表页。
2.2 x86-64 的 4 级页表
地址分解:9 + 9 + 9 + 9 + 12 = 48 位
每一级页表有 512 个条目(2^9),每个条目 8 字节,恰好占一个 4KB 页。
2.3 页表遍历的过程
一次地址翻译需要 4 次内存访问(4 级页表):
1. 读取 CR3 寄存器 → PML4 表基址2. 用 PML4 索引查 PML4 表 → PDPT 表基址 (内存访问 1)3. 用 PDPT 索引查 PDPT 表 → PD 表基址 (内存访问 2)4. 用 PD 索引查 PD 表 → PT 表基址 (内存访问 3)5. 用 PT 索引查 PT 表 → 物理页帧号 (内存访问 4)6. 物理页帧号 + 偏移 → 物理地址如果每次内存访问都要 4 次页表查找,性能将灾难性下降。这就是 TLB 存在的意义。
三、TLB 的结构与工作原理
3.1 TLB 是什么?
TLB 是页表条目的缓存——缓存虚拟页号到物理页帧号的映射。TLB 命中时,地址翻译只需 1 个周期;TLB 未命中时,需要遍历页表(4 次内存访问)。
3.2 现代 CPU 的 TLB 配置
| CPU | L1 ITLB | L1 DTLB | L2 STLB | L2 STLB 相联度 |
|---|---|---|---|---|
| Intel Skylake | 64 项 4-way | 64 项 4-way | 1536 项 12-way | 12 |
| AMD Zen 4 | 64 项 | 64 项 | 2048 项 | 8 |
| Apple M1 | ~128 项 | ~128 项 | ~3072 项 | — |
3.3 TLB 的覆盖范围
TLB 能覆盖的内存大小 = TLB 项数 × 页大小
| 配置 | TLB 项数 | 页大小 | 覆盖范围 |
|---|---|---|---|
| L1 DTLB + 4KB 页 | 64 | 4 KB | 256 KB |
| L2 STLB + 4KB 页 | 1536 | 4 KB | 6 MB |
| L1 DTLB + 2MB 页 | 64 | 2 MB | 128 MB |
| L2 STLB + 2MB 页 | 1536 | 2 MB | 3 GB |
使用 2MB Huge Pages 后,L2 STLB 的覆盖范围从 6MB 增长到 3GB——500 倍的提升!这就是为什么数据库和大数据应用强烈推荐使用 Huge Pages。
3.4 TLB 未命中的代价
| 场景 | TLB 命中 | TLB 未命中(L2 命中) | TLB 未命中(页表遍历) |
|---|---|---|---|
| 延迟 | 1 周期 | 5-10 周期 | 100-200 周期 |
| 相对代价 | 1x | 5-10x | 100-200x |
四、Huge Pages
4.1 Huge Pages 的原理
Huge Pages 使用更大的页大小(2MB 或 1GB),减少页表级数和 TLB 压力:
4.2 Huge Pages 的性能影响
| 应用 | 4KB 页性能 | 2MB Huge Pages 性能 | 提升 |
|---|---|---|---|
| PostgreSQL | 基准 | +10-20% | TLB 压力减少 |
| Redis | 基准 | +5-15% | 大内存分配 |
| Java G1 GC | 基准 | +5-10% | 堆内存大 |
| 向量搜索 | 基准 | +15-30% | 大数组遍历 |
4.3 Linux 的 Huge Pages 配置
# 方式 1:透明大页(Transparent Huge Pages, THP)# 自动将 4KB 页合并为 2MB 大页cat /sys/kernel/mm/transparent_hugepage/enabled# [always] madvise never
# 启用 THPecho always > /sys/kernel/mm/transparent_hugepage/enabled
# 方式 2:hugetlbfs(预留大页)# 预留 1024 个 2MB 大页 = 2GBecho 1024 > /proc/sys/vm/nr_hugepages
# 查看大页状态cat /proc/meminfo | grep Huge# HugePages_Total: 1024# HugePages_Free: 512# Hugepagesize: 2048 kB4.4 Huge Pages 的注意事项
Huge Pages 的潜在问题:
内存浪费:2MB 页即使只使用 1 字节也占 2MB,内部碎片
预留困难:hugetlbfs 需要预先预留,系统启动后可能无法获取连续物理内存
THP 的延迟:khugepaged 线程合并页时可能导致延迟尖刺
NUMA 影响:大页的物理内存可能分布在远端 NUMA 节点
五、TLB 刷新
5.1 什么时候需要刷新 TLB?
| 事件 | 刷新范围 | 频率 |
|---|---|---|
| 上下文切换 | 全局刷新(或使用 PCID) | 每次切换 |
| 页表修改 | 单项或全局刷新 | mmap/munmap |
| 内存保护修改 | 单项刷新 | mprotect |
| 进程迁移 | 全局刷新 | NUMA 迁移 |
5.2 PCID:减少上下文切换的 TLB 刷新
x86 的 PCID(Process-Context Identifier)允许 TLB 中同时存在多个进程的映射:
# 查看 CPU 是否支持 PCIDcat /proc/cpuinfo | grep pcid
# Linux 4.14+ 默认启用 PCID(如果硬件支持)# 效果:上下文切换时不再刷新整个 TLB5.3 TLB 刷新的性能影响
// 测量 TLB 刷新的影响#include <stdio.h>#include <time.h>#include <sys/mman.h>
#define SIZE (256 * 1024 * 1024) // 256MB
int main() { char *pages[65536]; // 65536 个 4KB 页
// 分配并访问所有页(填充 TLB) for (int i = 0; i < 65536; i++) { pages[i] = mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0); pages[i][0] = 1; // 触发页表填充 }
// 测量访问时间(TLB 热状态) clock_t start = clock(); for (int i = 0; i < 65536; i++) { pages[i][0]++; } clock_t end = clock(); printf("TLB 热状态: %.3f 秒\n", (double)(end - start) / CLOCKS_PER_SEC);
// 触发 TLB 刷新(修改页保护) for (int i = 0; i < 65536; i++) { mprotect(pages[i], 4096, PROT_READ); }
// 测量访问时间(TLB 冷状态) start = clock(); for (int i = 0; i < 65536; i++) { pages[i][0]++; // TLB 未命中 } end = clock(); printf("TLB 冷状态: %.3f 秒\n", (double)(end - start) / CLOCKS_PER_SEC);
return 0;}六、TLB 与缓存的关系
6.1 地址翻译在缓存访问之前
虚拟地址 → TLB 查找 → 物理地址 → 缓存查找 → 数据TLB 未命中会阻塞后续的缓存访问——即使数据在缓存中,也无法获取,因为不知道物理地址。
6.2 VIPT(Virtual Index Physical Tag)
L1 缓存使用虚拟索引 + 物理标签的设计,允许 TLB 查找和缓存索引并行进行:
虚拟地址:[索引位][标签位][偏移] ↓ ↓ 缓存索引(虚拟) TLB 查找 → 物理标签 ↓ ↓ 缓存行查找 ←──── 标签比较这要求 L1 缓存的大小 ≤ 页大小 × 相联度。例如 4KB 页 × 8-way = 32KB——这正是 L1 D-Cache 的典型大小。
七、动手实验
7.1 实验 1:测量 TLB 大小
#include <stdio.h>#include <stdlib.h>#include <time.h>
int main() { printf("步长(页数) 每次访问延迟(ns)\n"); for (int stride_pages = 1; stride_pages <= 4096; stride_pages *= 2) { int total_pages = stride_pages * 64; int *pages = malloc(total_pages * sizeof(int)); for (int i = 0; i < total_pages; i++) pages[i] = (i + stride_pages) % total_pages;
clock_t start = clock(); int idx = 0; for (int i = 0; i < 10000000; i++) idx = pages[idx]; clock_t end = clock();
double ns = (double)(end - start) / CLOCKS_PER_SEC * 1e9 / 10000000; printf("%8d %.1f\n", stride_pages, ns); free(pages); } return 0;}// 延迟在 TLB 容量边界处跳跃7.2 实验 2:Huge Pages 的效果
# 使用 perf 测量 TLB 未命中perf stat -e dTLB-loads,dTLB-load-misses,iTLB-loads,iTLB-load-misses ./your_program
# 对比 4KB 页和 2MB 大页# 4KB 页./your_program # 默认
# 2MB 大页(需要程序支持)HUGETLB_MORECORE=yes ./your_program7.3 实验 3:查看 TLB 信息
# 查看 TLB 配置lscpu | grep -i tlb
# 查看页大小getconf PAGESIZE# 4096
# 查看大页信息cat /proc/meminfo | grep -i huge
# 查看进程的页表大小cat /proc/$(pidof your_program)/status | grep VmPTE八、虚拟地址翻译的完整过程
8.1 x86-64 虚拟地址分解
一个 64 位虚拟地址(实际使用 48 位或 57 位)的分解如下:
4 级页表(48 位虚拟地址):┌────────┬────────┬────────┬────────┬──────────────┐│ 保留 │ PML4 │ PDPT │ PD │ PT │ 页内偏移 ││ 16位 │ 9位 │ 9位 │ 9位 │ 9位 │ 12位 │└────────┴────────┴────────┴────────┴──────────────┘ 63-48 47-39 38-30 29-21 20-12 11-0
5 级页表(57 位虚拟地址,LA57):┌────────┬────────┬────────┬────────┬────────┬──────────────┐│ 保留 │ PML5 │ PML4 │ PDPT │ PD │ PT │ 偏移 ││ 7位 │ 9位 │ 9位 │ 9位 │ 9位 │ 9位 │ 12位 │└────────┴────────┴────────┴────────┴────────┴──────────────┘ 63-57 56-48 47-39 38-30 29-21 20-12 11-08.2 完整翻译流程示例
假设虚拟地址为 0x7FF8A3B2C100,4KB 页,4 级页表:
8.3 页表项(PTE)的结构
每个页表项 64 位,包含物理页帧号和权限/属性位:
| 位域 | 名称 | 含义 |
|---|---|---|
| 0 | P(Present) | 页是否存在 |
| 1 | R/W | 读/写权限 |
| 2 | U/S | 用户/超级用户 |
| 5 | A(Accessed) | 是否被访问过 |
| 6 | D(Dirty) | 是否被写入过 |
| 7 | PS(Page Size) | 是否为大页 |
| 8 | G(Global) | 全局页(不随 CR3 刷新) |
| 12-51 | 物理页帧号 | 40 位物理地址 |
| 63 | XD(Execute Disable) | 禁止执行 |
A 位(Accessed)和 D 位(Dirty)由硬件自动设置。操作系统利用 A 位进行页面替换决策(类似 LRU),利用 D 位决定换出时是否需要写回磁盘。
九、5 级页表(LA57)
9.1 为什么需要 5 级页表
4 级页表支持 48 位虚拟地址 = 256 TB 虚拟地址空间。对于大多数应用足够,但以下场景需要更大空间:
- 大型数据库(如 Oracle、SAP HANA)需要映射数十 TB 内存
- 多个虚拟机共享物理内存
- 内存映射文件(如大型数据集)
- Intel Optane 持久内存
5 级页表将虚拟地址扩展到 57 位 = 128 PB。
9.2 4 级 vs 5 级页表对比
| 维度 | 4 级页表 | 5 级页表 |
|---|---|---|
| 虚拟地址位数 | 48 | 57 |
| 虚拟地址空间 | 256 TB | 128 PB |
| 页表遍历次数 | 4 次 | 5 次 |
| TLB 未命中代价 | 更低 | 更高(多一次内存访问) |
| 硬件支持 | 所有 x86-64 CPU | Ice Lake+ |
| Linux 支持 | 4.0+ | 4.14+(需启用 CONFIG_X86_5LEVEL) |
十、TLB 未命中的处理
10.1 硬件页表遍历器(Page Table Walker)
现代 CPU 内置了硬件页表遍历器,TLB 未命中时自动遍历页表:
10.2 TLB 未命中的性能影响量化
// 测量 TLB 未命中的影响#include <stdio.h>#include <stdlib.h>#include <time.h>
#define SIZE_MB 256#define PAGE_SIZE 4096
int main() { int pages = SIZE_MB * 1024 / PAGE_SIZE; char *buf = malloc(SIZE_MB * 1024 * 1024);
// 顺序访问(TLB 友好) clock_t start = clock(); long long sum = 0; for (int i = 0; i < SIZE_MB * 1024 * 1024; i += PAGE_SIZE) { sum += buf[i]; // 每页只访问 1 字节 } clock_t end = clock(); printf("顺序访问(每页1字节): %.3f 秒\n", (double)(end - start) / CLOCKS_PER_SEC);
// 随机跳页访问(TLB 不友好) int *order = malloc(pages * sizeof(int)); for (int i = 0; i < pages; i++) order[i] = i; // shuffle order... start = clock(); sum = 0; for (int i = 0; i < pages; i++) { sum += buf[order[i] * PAGE_SIZE]; // 随机页访问 } end = clock(); printf("随机页访问: %.3f 秒\n", (double)(end - start) / CLOCKS_PER_SEC);
free(buf); free(order); return 0;}10.3 Huge Pages 的配置实战
# ===== 透明大页(THP)=====# 查看当前状态cat /sys/kernel/mm/transparent_hugepage/enabled# always [madvise] never
# 启用 THPecho always | sudo tee /sys/kernel/mm/transparent_hugepage/enabled
# 在代码中使用 madvise 请求大页# madvise(ptr, size, MADV_HUGEPAGE);
# ===== 静态大页(hugetlbfs)=====# 预留 1024 个 2MB 大页 = 2GBecho 1024 | sudo tee /proc/sys/vm/nr_hugepages
# 查看大页状态cat /proc/meminfo | grep Huge# HugePages_Total: 1024# HugePages_Free: 1024# HugePages_Rsvd: 0# Hugepagesize: 2048 kB
# 挂载 hugetlbfssudo mount -t hugetlbfs nodev /mnt/hugepages
# ===== 1GB 大页 =====# 预留 4 个 1GB 大页(需要启动参数)# 在 /etc/default/grub 添加:# default_hugepagesz=1G hugepagesz=1G hugepages=4sudo update-grub && sudo reboot
# 查看可用大页大小cat /proc/meminfo | grep Hugepages| 大页类型 | 配置方式 | 适用场景 | 缺点 |
|---|---|---|---|
| 透明大页(THP) | echo always / madvise | 通用应用 | khugepaged 延迟尖刺 |
| 2MB 静态大页 | nr_hugepages | 数据库、Java | 需要预留,内部碎片 |
| 1GB 大页 | 启动参数 | 超大内存数据库 | 需要重启,严重碎片 |
透明大页(THP)在数据库场景中可能导致严重的延迟问题。Oracle 和 MongoDB 官方文档都建议禁用 THP,改用静态大页。原因是 khugepaged 线程在合并 4KB 页为 2MB 页时需要获取锁,可能导致延迟尖刺。
十一、小结
上一章理解了SIMD 与向量化计算。
| 概念 | 要点 | 对软件的影响 |
|---|---|---|
| 4 级页表 | x86-64 的地址翻译 | 每次翻译 4 次内存访问 |
| TLB | 页表条目的缓存 | 命中 1 周期,未命中 100+ 周期 |
| TLB 覆盖范围 | TLB 项数 × 页大小 | 大页显著增加覆盖范围 |
| Huge Pages | 2MB/1GB 大页 | TLB 压力减少,数据库/大数据受益 |
| PCID | 减少上下文切换 TLB 刷新 | 多进程场景性能提升 |
| VIPT | L1 缓存与 TLB 并行 | L1 大小受页大小限制 |
下一步:NUMA 架构——多插槽系统的内存拓扑如何影响性能?跨 NUMA 节点访问的延迟惩罚有多大?
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






