虚拟内存:每个人都从 0 开始
你的程序里出现的每一个地址,都是编的。这不是比喻——是字面意义上的编造。这一卷要拆的是三重假象里最彻底的一重:你这辈子一个真实的物理地址都没碰过,而且永远也碰不到。
一个能自己验证的实验
先做件事。写这个程序:
#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 章会亲手验证这一点)。
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 位的地址是怎么被切成五段、走完四级、最后落到一个物理页上的。那台引擎你可以亲手输地址进去。