TLB 与内存映射
TLB(Translation Lookaside Buffer)是 CPU 内部的高速缓存,缓存最近使用的页表项——没有它,每次内存访问都要查 4 次页表,性能直接崩盘
查一次地址要访存 4 次?
四级页表查询走完需要:
- 读取 PML4 页表项 → 1 次内存访问
- 读取 PDP 页表项 → 1 次内存访问
- 读取 PD 页表项 → 1 次内存访问
- 读取 PT 页表项 → 1 次内存访问
- 访问目标数据 → 1 次内存访问
总共 5 次内存访问 才能读一个数据!比单级页表(2 次访问)慢太多了。
TLB(Translation Lookaside Buffer) 就是来解决这个问题的——它把最近用过的虚拟页→物理页框映射保存在 CPU 内部的高速缓存中。
🏫 类比:你的微信通讯录 要联系隔壁宿舍的同学,你不用去查全校花名册(四级页表)——你直接打开微信通讯录(TLB)找到他就行。
查通讯录 = 1 秒钟。查全校花名册 = 10 分钟(还要跑教务处)。
TLB 的工作原理
虚拟地址 → 检查 TLB
│
├── TLB 命中(Hit)→ 直接拿到物理页框号 → 访问内存
│
└── TLB 未命中(Miss)→ 查四级页表
│
├── 更新 TLB(加入新的映射)
│
└── 访问物理内存
TLB 命中率通常超过 99%——这就是为什么多级页表没拖垮系统性能。
TLB 结构
┌────────┬────────┬──────────┐
│ 虚拟页号│ 物理页框│ 标志位 │
├────────┼────────┼──────────┤
│ 0x12345│ 0x9ABCD│ Valid=1 │
│ 0x67890│ 0x24680│ Valid=1 │
│ ... │ ... │ ... │
│ ... │ ... │ ... │
└────────┴────────┴──────────┘
64-512 个条目(现代 CPU 有数百个)
每个条目存储:
• 虚拟页号(VPN)
• 物理页框号(PPN)
• 有效位
• 保护位(r/w/x)
• ASID(地址空间标识符)
• ... 其他标志
💡 现代 CPU 有独立的指令 TLB(iTLB)和数据 TLB(dTLB)——指令和数据分开缓存,避免竞争。还分 L1 TLB 和 L2 TLB(容量更大但稍慢)。
TLB 未命中的处理
硬件处理(x86)
x86 的 TLB 未命中由 MMU 硬件自动处理——它遍历页表,找到物理地址,填充 TLB,整个过程不需要操作系统介入。
CPU → TLB 未命中 → 硬件自动遍历页表 → 更新 TLB → 重试
↑
不需操作系统参与
软件处理(一些 RISC 架构)
MIPS 等架构的 TLB 未命中触发异常,操作系统负责查页表并填充 TLB。
CPU → TLB 未命中 → 触发 TLB 异常 → OS 查页表 → 写 TLB → 返回重试
↑
操作系统参与(更灵活但更慢)
TLB 刷新与 ASID
上下文切换时 TLB 怎么办?
切换进程时,新进程的页表完全不同——TLB 中的旧映射必须失效。
进程切换 → 切换 CR3(页表基址)
│
├── 方案 A:**全部刷新**(x86 传统做法)
│ 把所有 TLB 条目标记为无效
│ 缺点:切换后大量 TLB 未命中
│
└── 方案 B:**ASID(地址空间标识符)**
每个条目标记所属的进程 ID
切换后 TLB 保留,只匹配当前 ASID
TLB 条目 = VPN + ASID → PPN
不同进程可以用相同 VPN 但不同 ASID
; 普通 CR3 切换(全部刷新 TLB)
mov cr3, rax ; TLB 全部失效
; 使用 INVPCID 指令选择性刷新(现代 x86)
invpcid eax, 0 ; 刷新指定进程的 TLB
invpcid eax, 1 ; 刷新指定页
invpcid eax, 2 ; 刷新所有
💡 ASID 显著减少了上下文切换后的 TLB 未命中——Linux 内核在 2010 年后支持 ASID,切换进程时不再需要全部刷新 TLB。
TLB 一致性
当操作系统修改了页表(比如把某个页换出),必须确保 TLB 中的旧映射也失效:
// 修改页表后,刷新对应的 TLB 条目
void unmap_page(struct mm_struct *mm, unsigned long addr) {
// 1. 清除页表项
pte_clear(&mm->page_table[addr >> PAGE_SHIFT]);
// 2. 刷新 TLB 中对应的条目
flush_tlb_page(addr);
// x86: invlpg 指令刷新单页
// 或: MOV CR3 刷新全部(一杆子打死)
}
TLB 性能的定量分析
TLB 对内存访问性能的影响有多大?来算一笔账:
假设:
- TLB 命中率:99%
- TLB 命中耗时:0.5 纳秒(~1 个 CPU 周期)
- TLB 未命中耗时:50 纳秒(需要查 4 级页表)
- 每次内存访问耗时(数据读取):平均 100 纳秒
有 TLB 时有效访存耗时:
= TLB 命中率 × TLB 命中时间 + TLB 未命中率 × TLB未命中时间
= 99% × 0.5ns + 1% × 50ns
= 0.495ns + 0.5ns
≈ 1ns
没有 TLB 时:
每次访问都要查 4 级页表
= 50ns(查页表)+ 100ns(读数据)
= 150ns → 慢了 150 倍!
结论:TLB 的 99% 命中率让虚拟内存的性能损失从 150× 降到了几乎可以忽略。
这就是为什么 TLB 是 CPU 内部最关键的加速结构之一——没有它,虚拟内存带来的性能开销是不可接受的。
大页与 TLB 覆盖率
TLB 覆盖范围 = TLB 条目数 × 页大小
4KB 页: 64 个条目 → 覆盖 256KB → ❌ 太小了
2MB 页: 64 个条目 → 覆盖 128MB → ✅ 足够了
1GB 页: 64 个条目 → 覆盖 64GB → ✅ 适合数据库
这就是为什么大页(Huge Pages)能提升性能——TLB 可以覆盖更大的内存区域,减少未命中。
# 启用透明大页(Linux)
echo always > /sys/kernel/mm/transparent_hugepage/enabled
# 查看进程的大页使用
$ cat /proc/1234/smaps | grep -i huge
AnonHugePages: 2048 kB
数据库场景:一个 PostgreSQL 实例可能需要几十 GB 内存。大页让 TLB 覆盖从 256KB → 128MB,TPC-H 基准测试能快 10-20%。
内存映射 I/O(mmap)
TLB 还有一个重要用途:内存映射 I/O——把文件直接映射到进程的地址空间,读文件就像读内存一样。
#include <sys/mman.h>
#include <fcntl.h>
int fd = open("bigfile.dat", O_RDONLY);
size_t len = 1024 * 1024 * 1024; // 1GB
// 把 1GB 文件映射到内存
void* addr = mmap(NULL, len, PROT_READ, MAP_PRIVATE, fd, 0);
// 现在可以像访问数组一样访问文件
char c = ((char*)addr)[42]; // 读文件第 42 个字节
// ← 这里会缺页 → OS 从磁盘读入 → TLB 缓存映射 → 下次更快
munmap(addr, len);
close(fd);
mmap 的工作原理:
mmap()在进程的虚拟地址空间中保留一段区域,但不分配物理页- 首次访问时触发缺页异常 → OS 从文件读取数据到物理内存 → 创建页表项
- 页表项被缓存到 TLB → 后续访问像普通内存一样快
💡 内存映射文件比
read()/write()更快的原因是:它绕过了内核缓冲区的复制(不需要从内核缓冲区拷贝到用户缓冲区),并且利用分页机制按需加载。
小结
| 概念 | 要点 |
|---|---|
| TLB | CPU 内部的页表项缓存,加速地址翻译 |
| 命中率 | >99%,没有 TLB 多级页表不可用 |
| ASID | 区分不同进程的 TLB 条目,减少刷新 |
| 大页 | 更大的页 → 更高的 TLB 覆盖率 |
| mmap | 利用分页和 TLB 实现高效的文件访问 |
| TLB 一致性 | 修改页表后要刷新对应的 TLB 条目 |
为什么先学这个? TLB 是理解内存性能的关键——为什么大页快、为什么进程切换慢、为什么 mmap 高效。接下来转向文件系统:文件系统接口与实现。