卷 III · 幻境CH 10深度 10/24

虚拟内存:每个人都从 0 开始

你的程序里出现的每一个地址,都是编的。这不是比喻——是字面意义上的编造。这一卷要拆的是三重假象里最彻底的一重:你这辈子一个真实的物理地址都没碰过,而且永远也碰不到。

虚拟地址地址空间vma段与权限

一个能自己验证的实验

先做件事。写这个程序:

#include <stdio.h>
int global = 42;

int main(void) {
  int local = 1;
  printf("代码在  %p\n", (void *)main);
  printf("全局在  %p\n", (void *)&global);
  printf("栈上在  %p\n", (void *)&local);
  return 0;
}

编译时关掉地址随机化(gcc -no-pie),跑两遍:

$ ./a.out
代码在  0x401136
全局在  0x404010
栈上在  0x7ffd4a2b1c34

$ ./a.out
代码在  0x401136
全局在  0x404010
栈上在  0x7ffd4a2b1c34

一模一样。同时跑两个实例,还是一模一样。让你同事在他机器上跑,仍然一模一样。

可这两个进程当然不可能共用同一块内存——它们各自改自己的 global,互不影响。

◆ 所以这些地址是什么

它们是虚拟地址:一个只在你这个进程内部有意义的编号。

每个进程都有一张自己的翻译表(页表)。你说 0x404010,MMU 查你的表,翻成物理地址 0x1a7010;我说 0x404010,MMU 查我的表,翻成 0x3f2010同样的数字,不同的答案。

这就是为什么每个进程都能「从 0 开始」——每个人都有一张自己的地图,而地图上的坐标只对自己有效。

为什么非得这样?三个理由

这一层翻译显然要花钱(下一章会算),那它买到了什么?三样东西,每一样单拿出来都值这个价:

1. 隔离

你的页表里压根没有指向我内存的条目。所以你不是「不被允许」访问我的内存,而是根本无法表达那个地址——你能写出的任何虚拟地址,要么翻译到你自己的内存,要么翻译失败。

这比「检查一下有没有权限」强得多。没有检查可以被绕过,因为压根没有检查——那条路不存在。

2. 重定位

如果程序用真物理地址,那编译时就得知道自己会被装在内存的哪一段。可这怎么可能知道?机器上还有别的程序,谁先谁后、各占多少,编译时完全无法预测。

有了虚拟地址,每个程序都可以假装自己被装在同一个地方。编译器写死 0x400000,实际装在哪由内核决定,翻译表兜着。

3. 超额分配

虚拟地址空间是 256 TB(48 位可用),而你的机器可能只有 16 GB 内存。

这不矛盾,因为虚拟地址不必都对应真实内存。绝大部分地址空间是空的(没有页表项),有的对应磁盘上的文件,有的暂时被换到 swap 去了。「有地址」和「有内存」是两件事——这个区分是第 12 章和第 15 章的全部基础。

那张所有人都一样的地图

▶ 动手 · 地址空间地图

下面这张图是每一个 Linux 进程的地址空间布局。点每一段看它的范围、权限和来源。特别注意开头那个「空洞」和最后那个「内核空间」——它们解释了两个很常见的现象。

几个值得停一下的地方

开头那 4 MB 为什么空着

不是浪费,是故意的。这块地址不填任何页表项,于是任何落在这里的访问都会立刻失败。

好处是它顺手接住了一整类 bug:

Node *p = NULL;
printf("%d\n", p->value);      // 访问 0x0 + offsetof(value) = 0x8
                               // 仍然落在空洞里 → 段错误

如果只有 0x0 这一个地址不可用,那 p->value 这种小偏移的野指针就会溜进去,改掉某块合法内存,然后在一个完全无关的地方以一种完全无法追查的方式炸掉。留一大片空洞,就是让这类 bug 尽可能早、尽可能响地崩掉。

堆向上长,栈向下长

它们相向而行,中间隔着一大片空洞。这个布局有个很实际的理由:两边都不知道自己最终要用多少

如果给堆划一个固定区间、给栈划另一个,那么无论怎么划都会有程序不够用。让它们从两端往中间长,就能动态地共享那片空洞——谁需要谁用。

栈用完了余地就是 stack overflow。不过实际上你很少撞到堆——线程栈通常有个固定上限(默认 8 MB,ulimit -s 可看),撞到上限就先崩了。

内核在每个进程的地址空间里

这个事实常常让人意外:你的地址空间里有内核,就在高地址那一半,映射的是同一份物理内存(全机器共享)。

为什么要这样?为了系统调用不用换页表。你执行 syscall 陷进内核,内核的代码和数据已经在当前页表里映射好了,直接跳过去就行——省掉一次换 CR3、省掉一次 TLB 冲刷(第 9 章那笔昂贵的账)。

那你为什么摸不到它?因为那些页表项的 U 位是 0——只有 ring 0 能访问。硬件替内核挡着(第 11 章会亲手验证这一点)。

✎ 掌故 · Meltdown 与那次昂贵的搬家

2018 年初公开的 Meltdown 漏洞,攻击的正是上面这个设计。

攻击者在用户态读一个内核地址。按理说 U 位检查会挡住它——确实挡住了,那条指令最终会触发异常。但在 CPU 发现这一点之前,乱序执行已经把数据读出来、并用它做了一次后续访存。那次访存改变了缓存状态,而缓存状态是可以通过计时侧信道测出来的。

于是权限检查形同虚设:数据泄露发生在检查生效之前。

修复方案 KPTI 相当粗暴:把内核映射从用户页表里摘出去,只留一小块跳板。既然读不到,就没法泄露了。

代价就是上面那句「省掉一次换 CR3」不成立了——每次系统调用都得换两次页表。这就是第 3 章说的、系统调用在 2018 年之后变贵的原因,也是 PCID 那个特性突然变得至关重要的原因。

这段历史值得记住的地方在于:一个用了三十年、逻辑上完全正确的设计,被 CPU 的一个性能优化从底下掀翻了。「逻辑上正确」和「物理上安全」不是一回事。

ASLR:把这张地图打乱

前面那个实验要求 -no-pie,因为现代系统默认开着 ASLR(地址空间布局随机化)。去掉那个选项再跑,你会看到地址每次都不同。

为什么要随机化?因为很多攻击手法依赖知道某个东西在哪:我要跳到 system() 函数,我得知道它的地址;我要构造 ROP 链,我得知道那些代码片段在哪。

把地址随机化之后,攻击者得先。64 位系统上的熵足够大(通常几十位),暴力猜基本不可行——而且猜错一次就是段错误、进程崩溃,暴露攻击行为。

你可以看到自己进程的真实布局:

$ cat /proc/self/maps
55a8c1e00000-55a8c1e01000 r-xp 00000000 08:01 1049 /usr/bin/cat
55a8c1e01000-55a8c1e02000 r--p 00001000 08:01 1049 /usr/bin/cat
7f2b4c000000-7f2b4c028000 r--p 00000000 08:01 2201 /usr/lib/libc.so.6
7ffd2a1f0000-7ffd2a211000 rw-p 00000000 00:00 0    [stack]
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0 [vsyscall]

每一行就是一个 vma(virtual memory area),内核用一棵红黑树管着它们。第 4 章说的「内核查 vma 链表判断这个地址合不合法」,查的就是这张表。

把每一列读懂:地址范围、权限(r/w/x 加上 p 私有或 s 共享)、文件内偏移、设备号、inode 号、文件路径。没有文件路径的就是匿名映射——堆、栈、以及 mmap 出来的匿名内存。

↑ 回到应用层

「一个 Android App 到底占多少内存?」

这个问题没有单一答案,而 /proc/PID/smaps 解释了为什么。同一个进程有好几个都「正确」的数字:

指标含义什么时候看它
VSZ地址空间总大小几乎没用。它包含大量从未使用的映射
RSS真的在物理内存里的部分有用,但共享库被每个进程重复计算
PSS共享部分按使用者数量均摊后的值★ Android 用它。所有进程的 PSS 加起来,才约等于实际物理内存占用
USS只算私有部分回答「杀掉它能腾出多少」

Zygote 预加载的那些 framework 类(第 6 章)在物理内存里只有一份,被几十个 App 共享。用 RSS 统计,这一份会被算几十遍,加起来远超实际内存;用 PSS,它被均摊。

dumpsys meminfo <package> 打出来的就是 PSS 分解。下次有人问「你的 App 占多少内存」,先反问「你说的是哪个数字」——这四个数字可以差出好几倍。

这一章的一句话

每个进程都以为自己拥有一整条从 0 开始的 256 TB 地址线,而这些地址全是编的——它们经过一张只属于你的翻译表,才落到真的物理内存上。这一层翻译买到了三样东西:无法绕过的隔离、编译期不必知道自己装在哪、以及「有地址」与「有内存」的彻底分离。

下一章,我们把那张翻译表本身打开——一个 48 位的地址是怎么被切成五段、走完四级、最后落到一个物理页上的。那台引擎你可以亲手输地址进去。