页表凭什么不撑爆内存,CPU 凭什么查得不嫌慢
上一篇我们落到一个结论:你打印出来的地址全是假的,是每个进程私有的、按页翻译的虚拟坐标,CPU 每访问一次内存,背后都要查一次页表,把虚拟页号换成物理页框号。
结论是干净的,但它留下两个我当时没敢碰的疑点:
一,这张表凭什么不把内存吃光? 每个进程一张页表,可虚拟地址空间大得离谱。一张表要记下所有"虚拟页 → 物理页框"的映射,光这张表本身得多大?
二,CPU 凭什么查得不嫌慢? 既然每访问一次内存都得先查一次表,那不就等于每条访存指令都凭空多挂一趟内存访问?这开销不该让程序慢一半吗?
这篇就回答这两个。它们其实是一根链条:第一个问题逼出了"多级页表"这个解法,而多级页表又把第二个问题放大了------于是逼出了 TLB。一环扣一环。
本文所有数字都是真跑的。环境是一台 Linux(aarch64,内核 6.12,页大小 4KB------上一篇 macOS 是 16KB,这次回到教科书最常见的 4KB)。涉及的内核参数我会标命令,复制能复现。
一、先把"一张大平表"这个天真方案算到撑爆
先认真对待最直觉的方案:一张表,从头到尾,第 0 号虚拟页记一条、第 1 号记一条......一直记到最后一页。这种一字排开的表,叫平表(flat / 单级页表)。
它到底要多大?这取决于两个数:有多少个虚拟页、每条记录多大。两个数都能从这台机器上真问出来:
bash
# 虚拟地址用多少位、页表分几级------从内核配置读
zcat /proc/config.gz | grep -E "ARM64_VA_BITS|PGTABLE_LEVELS"
读出来:
ini
CONFIG_ARM64_VA_BITS=48
CONFIG_PGTABLE_LEVELS=4
VA_BITS=48 是说:这台机器的虚拟地址实际用 48 位(不是满 64 位------高位现在用不上,留着也是浪费)。算一下:
ini
虚拟页总数 = 2^48 / 4KB = 2^48 / 2^12 = 2^36 ≈ 687 亿个虚拟页
687 亿个页,平表就得有 687 亿条记录。每条记录至少 8 字节(要存一个物理页框号加一堆标志位)。乘起来:
平表大小 = 2^36 × 8 字节 = 549,755,813,888 字节 = 512 GB
512 GB。 这是一个进程、还没干任何事、光是那张"以防万一全记上"的页表,就要 512GB。机器总共可能就 16GB 内存。开第二个进程再来一张。
一张平表试图覆盖整个 48 位虚拟地址空间:
┌──────────────────────────────────────────────┐
│ 虚拟页 0 → 物理页框 ? │
│ 虚拟页 1 → 物理页框 ? │
│ 虚拟页 2 → 物理页框 ? │
│ ...(中间 687 亿条,绝大多数是空的) │
│ 虚拟页 68719476735→ 物理页框 ? │
└──────────────────────────────────────────────┘
每条 8 字节 × 687 亿条 = 512 GB ------ 一个进程一张,直接劝退
512GB 不是"有点大",是比整机内存大几十倍。而最刺眼的地方在于:这张表绝大部分是空的。
回想上一篇那张 /proc/self/maps------一个刚跑起来的进程,真正映射的内存就是几个互相隔得老远的小块:代码段在 0xaaaab75...、堆在 0xaaaacc8...、库和栈挤在 0xffff... 那头。中间是望不到边的空洞。
也就是说,那 687 亿条记录里,真正有用的可能就几百条,剩下全指向"这里什么都没有"。 为一片几乎全空的地址空间,预先建一张记满 687 亿条的表,这是纯粹的浪费。
平表错就错在:它按"地址空间最大能有多大"来建表,而不是按"你实际用了多少"来建表。
二、多级页表:把表拆成树,只为用到的地方长枝
解法顺着这个浪费往下想就出来了:既然大部分是空的,那就别预先铺满------用到哪一块,才为哪一块建表。
具体做法是把一张大平表,拆成一棵多级的树 。我们这台机器是 4 级(PGTABLE_LEVELS=4)。先不管"4 级"这个数字怎么来的,先看它怎么省。
把 48 位的虚拟页号,从高到低切成几段,每段当一级的"下标":
ini
48 位虚拟地址,切成 4 段下标 + 页内偏移:
[ 9位 ][ 9位 ][ 9位 ][ 9位 ][ 12 位偏移 ]
│ │ │ │ │
第1级 第2级 第3级 第4级 页内第几个字节
下标 下标 下标 下标 (4KB=2^12,正好12位)
查一次地址,就是拿着这几段下标,一级一级往下走:
ini
CPU 手里的虚拟地址
│
▼
┌─────────┐ 第1级下标 ┌─────────┐ 第2级下标 ┌─────────┐ 第3级下标 ┌─────────┐ 第4级下标 物理页框
│ 第1级表 │ ──────────► │ 第2级表 │ ──────────► │ 第3级表 │ ──────────► │ 第4级表 │ ──────────► + 页内偏移
│(每进程1)│ │ │ │ │ │(真正记 │ = 物理地址
└─────────┘ └─────────┘ └─────────┘ │ 页框号) │
└─────────┘
每一级表只有 512 项(9 位下标 = 2^9 = 512),每项指向下一级的一张表。
每级表只有 512 项(9 位下标 = 2^9 = 512),小到一张表正好装进一个 4KB 页(512 项 × 8 字节 = 4096 字节,严丝合缝)。这不是巧合------9 位和 12 位偏移就是为了让每级表恰好一页大而切的。
关键在省 在哪:上层某一项要是没人用,它下面那整棵子树根本不创建。
scss
多级页表:只为用到的路径长出枝叶,其余是空指针
第1级表(1页) 第2级 第3级 第4级
┌────────┐
│ [0] ──┼──► ┌────────┐
│ [1] 空 │ │ [0] 空 │
│ [2] 空 │ │ [3] ──┼──► ┌────────┐ ┌────────┐
│ ... │ │ ...空 │ │ [7] ──┼──►│ 真页框 │ ← 你的代码段
│ [511]空 │ └────────┘ │ ...空 │ └────────┘
└────────┘ └────────┘
│
└─► 511 项里只有几项非空,空的那些下面整棵子树都不存在 ------ 不占一个字节
用 512^4 验一下覆盖范围:512 × 512 × 512 × 512 = 2^36------正好是那 687 亿个虚拟页。4 级树满铺时和平表覆盖的地址空间一模一样,但它从不满铺:只有你真用到的那几条路径上的表才存在,其余分支是空指针,一个字节都不占。
这就是 4 级的来路:48 位地址,扣掉 12 位页内偏移,剩 36 位要分级,每级用 9 位(凑出一页大小的表),36 ÷ 9 = 4 级。数字全都咬死的。
这事能直接量出来。 Linux 在 /proc/self/status 里有一行 VmPTE,就是这个进程当前所有页表占的内存。写个程序申请 1GB、但分两步看------先只申请不碰,再逐页写满:
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
// 从 /proc/self/status 里捞出 VmPTE(页表占的内存)这几行打印出来
static void show(const char *tag){
FILE *f = fopen("/proc/self/status", "r");
char l[256];
printf("--- %s ---\n", tag);
while (fgets(l, sizeof l, f)) {
if (!strncmp(l, "VmPTE", 5) || !strncmp(l, "VmRSS", 5) || !strncmp(l, "VmSize", 6))
fputs(l, stdout);
}
fclose(f);
}
int main(void){
show("启动时");
size_t N = 1L << 30; // 1GB
char *p = malloc(N);
show("malloc 1GB 后(还没碰)"); // 只要了块地址,还没真碰
long page = sysconf(_SC_PAGESIZE);
for (size_t i = 0; i < N; i += page) p[i] = 1; // 逐页写满,真用上
show("写满 1GB 后");
return 0;
}
bash
gcc -O0 ptesize.c -o ptesize && ./ptesize
真跑出来(这里只摘 VmPTE 那行,程序还会顺带打印 VmSize/VmRSS,你能看到申请后 VmSize 立刻涨 1GB,但 VmRSS------真占的物理内存------要写满才涨):
yaml
--- 启动时 --- VmPTE: 44 kB
--- malloc 1GB 后(还没碰) --- VmPTE: 52 kB ← 申请了 1GB,页表几乎没长
--- 写满 1GB 后 --- VmPTE: 2096 kB ← 真用了,页表才长出来
盯着中间那行:申请了整整 1GB,页表只多了 8KB。 因为你只是"要了块地址",还没真碰,多级树用不着长枝叶。直到你逐页写满,第 4 级那些真正记录页框号的表才一张张被建出来,页表涨到 2MB。
页表不是按你"申请了多大"长的,是按你"真碰了多少"长的------这正是多级结构的全部意义。平表那 512GB 的浪费,就这么被省掉了。
三、可代价是:本来查一次,现在查四次
多级页表把空间问题解决得很漂亮,但它顺手把第二个问题------"查表慢不慢"------放大了。
平表查一次只读一次表。多级页表查一次地址,要从第 1 级一路读到第 4 级,读 4 张表。而这 4 张表都在内存里。也就是说:
你程序里每一次访存(读一个变量、取一条指令),为了先把虚拟地址翻译成物理地址,背后要先做 4 次内存访问去走页表。 真正想读的那个数据还没碰呢,光翻译就 5 趟内存起步。
scss
一次 "读 x 的值" 实际发生了什么(没有任何加速时):
想读 x ──► 查第1级表(访存1) ──► 查第2级表(访存2) ──► 查第3级表(访存3)
──► 查第4级表(访存4) ──► 拿到物理地址 ──► 真正读 x(访存5)
5 次内存访问,4 次纯属"为了翻译"。内存访问是 CPU 里最慢的操作之一。
如果每条访存指令都老老实实走这 4 级,程序会慢到没法用。多级页表省了内存,却像是把每次访问的时间乘了 5。
这个账必须有人填。填账的,就是 TLB。
四、TLB:把翻过的页缓存下来,别每次都重走一遍
TLB(Translation Lookaside Buffer,地址翻译缓存)是 CPU 里一块极小、极快的硬件缓存。它就记一件事:最近翻译过的"虚拟页号 → 物理页框号",直接存结果。
道理朴素到近乎废话:程序访问内存是高度扎堆的。 你在一个循环里反复读同一个数组、反复执行同一段指令------这些访问全落在那么几个页里。第一次访问某个页,老老实实走 4 级页表翻译一次,然后把"这个虚拟页 → 那个物理页框"塞进 TLB。下次再碰这个页(马上就会),CPU 先问 TLB:
scss
CPU 要翻译一个虚拟页号
│
▼
┌──────────┐ 命中(hit) 直接拿到物理页框号 ------ 1 步,几乎不要钱
│ TLB │ ──────────►
│ (小而快) │
└──────────┘ 未命中(miss) ──► 老老实实走 4 级页表(5 趟内存) ──► 把结果塞回 TLB
命中,一步到位,4 级页表那 4 趟内存全省了。只有第一次碰一个新页(miss)才付那 4 趟的全价,而且付完就把结果缓存起来,紧接着的成千上万次访问全是白嫖。
因为访问扎堆,命中率高得惊人------通常 99% 以上。于是"每次访问多查 4 次表"这个吓人的开销,被摊薄到几乎为零。多级页表借 TLB 把自己捅的窟窿补上了。
这东西能直接测出来。 思路是:让程序访问的数据总量恒定 (这样 CPU 数据缓存的行为一致,排除掉它的干扰),唯一改变的是这些数据摊在多少个页上。摊在少数几页里,TLB 装得下,次次命中;摊到几千个页上,远超 TLB 能记的条数,几乎次次 miss,每次都得重走页表。
完整代码如下(能直接编译跑,复制即复现):
c
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <unistd.h>
#include <string.h>
// 思路:始终只触碰 LINES 条缓存行(总数据量恒定=都装进缓存,缓存行为一致),
// 但把这些行摆在 stride 字节的间隔上。
// stride 小 -> 都挤在少数几页里 (TLB 装得下,次次命中)
// stride=页大小 -> 每条行落在一个独立页上 -> LINES 个页 -> 远超 TLB -> 几乎次次 miss
// 随机指针追逐(下一跳地址 = 上一次读出的内容)关掉硬件预取,让翻译延迟无处可藏。
#define LINES 2048 // 2048 × 64B = 128KB 真实数据,恒定,稳进缓存
static double chase(char *buf, long stride){
// 1) 造一个 0..LINES-1 的随机排列(Fisher--Yates 洗牌)
long *idx = malloc(LINES * sizeof(long));
for (long i = 0; i < LINES; i++) idx[i] = i;
for (long i = LINES - 1; i > 0; i--) {
long j = rand() % (i + 1);
long t = idx[i]; idx[i] = idx[j]; idx[j] = t;
}
// 2) 按随机顺序把每条行的头 8 字节串成一个环:
// buf[idx[k]*stride] 处存的值 = 下一跳偏移 idx[k+1]*stride
for (long k = 0; k < LINES; k++) {
long cur = idx[k] * stride;
long nxt = idx[(k + 1) % LINES] * stride;
*(long*)(buf + cur) = nxt;
}
// 3) 沿环跑:每次访问的位置都来自上一次读出的值,CPU 无法提前预取
struct timespec a, b;
long cur = idx[0] * stride;
long iters = 30000000;
clock_gettime(CLOCK_MONOTONIC, &a);
for (long i = 0; i < iters; i++) cur = *(long*)(buf + cur);
clock_gettime(CLOCK_MONOTONIC, &b);
volatile long sink = cur; (void)sink; // 防止循环被优化掉
free(idx);
return ((b.tv_sec - a.tv_sec) * 1e9 + (b.tv_nsec - a.tv_nsec)) / iters;
}
int main(void){
long page = sysconf(_SC_PAGESIZE);
long big = (long)LINES * page; // 最大 stride 情形下的占地
char *buf = malloc(big);
memset(buf, 0, big); // 先全部落地,排除缺页干扰
printf("page=%ld, 始终只追逐 %d 条行(=%dKB 真实数据, 恒定)\n",
page, LINES, LINES * 64 / 1024);
long strides[] = {64, 256, 1024, 4096}; // 64B=全挤一起; 4096=每行独占一页
for (int i = 0; i < 4; i++) {
long s = strides[i];
double d = chase(buf, s);
long pages = (LINES * s + page - 1) / page;
printf("stride=%4ldB 跨约 %5ld 页 每次访问 %.2f ns\n", s, pages, d);
}
return 0;
}
bash
gcc -O1 tlb.c -o tlb && ./tlb
有三个让这个测试"干净"的关键点,也是它能把 TLB 单独拎出来的原因:
- 数据总量恒定 (
LINES固定 = 128KB)。四种 stride 下程序碰的数据一样多,数据缓存(L1/L2)的命中行为基本一致------这样测出的差距才能归到 TLB 头上,不混进缓存效应。唯一在变的,只有这 128KB 摊在多少个页上。 - 随机指针追逐(环是洗牌后串的,下一跳地址 = 当前读出的值)。要是改成顺序扫,硬件预取器会提前把后面的页都备好,TLB miss 的代价被藏掉,差距就出不来。
memset先写一遍让所有页落地,否则第一次访问会触发缺页中断(上一篇那个),把缺页开销混进计时。
真跑两遍(数字稳定):
ini
始终只追逐 2048 条行(=128KB 真实数据, 恒定)
stride= 64B 跨约 32 页 每次访问 1.00 ns
stride= 256B 跨约 128 页 每次访问 5.87 ns
stride=1024B 跨约 512 页 每次访问 5.80 ns
stride=4096B 跨约 2048 页 每次访问 8.47 ns
盯着这四行看:数据总量从头到尾都是 128KB,访问次数一样,能进缓存的量也一样。唯一变的,是这 128KB 摊在多少个页上------从 32 个页摊到 2048 个页。
就这一个变量,每次访问从 1ns 慢到 8ns,整整八倍。
这多出来的 7ns,就是"TLB 装不下、被迫重走多级页表"的代价被逼现了形。平时它藏得严严实实------因为你的程序总是扎堆访问那几个页,TLB 次次命中,你根本感觉不到底下有 4 级表要走。只有故意把访问摊开到超过 TLB 容量,才能把这笔平时被白嫖掉的开销,一分不少地拽出来给你看。
五、收尾
把这篇收一下,其实是一根咬合的链条:
- 页表为什么是多级的 :一张大平表要 512GB,荒谬地浪费在一片几乎全空的地址空间上。多级页表把表拆成树,只为你真碰过的内存长出枝叶------申请 1GB 不碰,页表只长 8KB;真写满了才涨到 2MB。表按真实使用量长,而不是按地址空间上限铺。
- 代价是查表变慢:多级把"查一次表"变成"从上到下查 4 次表",每次访存凭空多挂 4 趟内存访问。
- TLB 把这笔账赎回来:它缓存最近翻译过的页,靠程序"访问总是扎堆"这个事实,让 99% 以上的访问一步命中,4 级页表的开销被摊到几乎为零。你平时根本测不到它------因为它一直在替你省;只有把工作集故意摊到超过它的容量,那隐藏的 8 倍开销才会现形。
省空间的代价是费时间,费的时间又被缓存赎了回来。地址翻译这一步,就是在这三者之间反复腾挪算出来的平衡。
回到上一篇那个 0x102838000。现在再看它,它不只是个"按页翻译的虚拟坐标"了------翻译它的那张表是一棵只为你用过的地方长枝的树,而你之所以感觉不到每次访问背后要爬 4 层,是因为有块叫 TLB 的小缓存,早把你刚走过的路记住了。
还剩两个没碰:物理内存真的不够用时,旧页怎么被踢到磁盘上、要用时再换回来(swap);以及上一篇提到的缺页异常,内核接手之后到底一步步做了什么(查 VMA 判断地址合不合法、区分匿名页和文件页、分配物理页填回页表、再让指令重来)。后者是下一篇,我们顺着一次真实的缺页,跟内核走一遍它的处理路径。