| 本篇位置 | 虚拟化内存的第一篇。先看清"地址空间"这个东西长什么样,第 10 篇起讲它怎么实现 |
| 运行环境 | 容器里的 Linux,Python 3 + gcc。关地址随机化的演示要加 --privileged |
一、先看一眼真的地址空间
第 1 篇有个演示:两个进程在同一个地址 0xffffa4479d40 上存着不同的值。当时的结论是——那不是物理地址,那是个只在进程内部有效的编号。
这一篇把这个编号的全集摊开看。它有个名字:地址空间(address space)。
/proc/self/maps 会把当前进程的地址空间原样打印出来:
# maps.py
import os, sys
def show(tag):
print(f"\n===== {tag} =====")
print(f"{'起止地址':<30} {'权限':<6} {'大小':>9} 说明")
for line in open("/proc/self/maps"):
f = line.split()
rng, perm, path = f[0], f[1], (f[5] if len(f) > 5 else "")
lo, hi = (int(x, 16) for x in rng.split("-"))
size = hi - lo
if size < 64 * 1024 and "stack" not in path and "heap" not in path:
continue
note = {"[heap]": "← 堆:malloc 要来的地", "[stack]": "← 栈:函数调用和局部变量"}.get(path, path)
print(f"{rng:<30} {perm:<6} {size//1024:>7} KB {note}")
show("刚启动")
big = [bytearray(1 << 20) for _ in range(64)] # 要 64 MB
show("申请了 64 MB 之后")
===== 刚启动 =====
起止地址 权限 大小 说明
00400000-009cd000 r-xp 5940 KB /usr/bin/python3.13
009e0000-00a6d000 rw-p 564 KB /usr/bin/python3.13
240c0000-24187000 rw-p 796 KB ← 堆:malloc 要来的地
ffff98224000-ffff98486000 rw-p 2440 KB
ffff984e0000-ffff9867c000 r-xp 1648 KB /usr/lib/aarch64-linux-gnu/libc.so.6
ffff98730000-ffff987be000 r-xp 568 KB /usr/lib/aarch64-linux-gnu/libm.so.6
ffffdb1ad000-ffffdb1ce000 rw-p 132 KB ← 栈:函数调用和局部变量
从上往下读这张表,你能看见一个进程的全部家当:
00400000开头、权限r-xp:Python 解释器的机器码。r-x是可读可执行但不可写——代码不该被改。- 紧跟着的
rw-p:这个程序的全局变量。可读可写,不可执行。 [heap]:堆。malloc从这儿要地。- 中间那些
libc.so.6、libm.so.6:动态库,映射进来的。注意它们也是r-x。 [stack]:栈。函数调用、返回地址、局部变量。
⚠️ 注意权限那一列:每一段要么可写、要么可执行,没有一段两者都占。 这条规则叫 W^X(Write XOR Execute)。它挡住了一整类攻击——往数据区写入一段机器码,再跳过去执行。这是硬件强制的,第 30 篇再展开。
打个比方
这一部分(第 8 到 15 篇)会一直用同一个比方,先在这里立起来:
一家酒店,同时住着好几个旅行团。每个团拿到的房号都是从 101 开始编的。
甲团的"101 房"和乙团的"101 房",在两个团的行程单上写的是同一个号,实际却是两个完全不同的房间。前台有一本对照表,把"甲团的 101"翻译成"实际的 507 房"。
- 团员们手上的房号 = 虚拟地址
- 实际的房间 = 物理地址
- 前台那本对照表 = 页表(第 12 篇)
⭐ 这一篇看的是行程单本身——每个团拿到的那张纸上都印了什么。对照表怎么建、怎么查得快,是后面几篇的事。
二、堆往上长,栈往下长
上面那张表里有件事值得单独证明:堆和栈是相向而行的。
用 C 看最直接——Python 的对象都在堆上,看不见栈:
#include <stdio.h>
#include <stdlib.h>
void down(int n) {
char local[4096]; /* 每层在栈上占 4 KB */
local[0] = (char)n;
if (n % 4 == 0)
printf(" 第 %2d 层:局部变量在 %p\n", n, (void *)local);
if (n > 0) down(n - 1);
}
int main(void) {
printf("堆:连续 malloc 四次,每次 1 KB\n");
for (int i = 0; i < 4; i++)
printf(" 第 %d 块在 %p\n", i, malloc(1024));
printf("\n栈:递归 16 层,每层占 4 KB\n");
down(16);
return 0;
}
堆:连续 malloc 四次,每次 1 KB
第 0 块在 0xaaaaaaac22b0
第 1 块在 0xaaaaaaac26c0
第 2 块在 0xaaaaaaac2ad0
第 3 块在 0xaaaaaaac2ee0
栈:递归 16 层,每层占 4 KB
第 16 层:局部变量在 0xffffffffebe0
第 12 层:局部变量在 0xffffffffab60
第 8 层:局部变量在 0xffffffff6ae0
第 4 层:局部变量在 0xffffffff2a60
第 0 层:局部变量在 0xfffffffee9e0
堆的地址在变大(...22b0 → ...2ee0),栈的地址在变小(...ebe0 → ...e9e0)。
为什么这么设计?因为你事先不知道哪个会长得更多。
一个程序可能递归很深、堆上没什么东西;另一个可能递归很浅、堆上放着几个 G。把它们放在两端相向生长,中间那片空地谁需要谁用——不用提前分家。
⚠️ 当然,它们撞上了就出事。栈撞到堆叫栈溢出,Linux 会在栈下面留一个不可访问的保护页,撞上就 SIGSEGV。
三、地址是随机的——直到你把它关掉
同一个程序跑三次,看栈的位置:
# aslr.py
import os
box = [0]
for line in open("/proc/self/maps"):
if "[stack]" in line:
print(f"栈在 {line.split()[0]} 一个对象在 {hex(id(box))}")
栈在 fffff1ea0000-fffff1ec1000 一个对象在 0xffff94bc9c80
栈在 ffffff295000-ffffff2b6000 一个对象在 0xffffb3579c80
栈在 ffffdfb08000-ffffdfb29000 一个对象在 0xffffa0899c80
三次全不一样。 这是 ASLR(地址空间布局随机化):每次启动,内核把栈、堆、动态库都放到随机的位置。
它挡的是这样一类攻击:攻击者要往某个已知地址跳过去,或者要覆盖某个已知地址的数据。地址每次都变,他就得先猜。
关掉它,同一个程序三次跑出一模一样的地址:
for i in 1 2 3; do setarch -R python3 aslr.py; done
栈在 fffffffdf000-1000000000000 一个对象在 0xfffff7a09c80
栈在 fffffffdf000-1000000000000 一个对象在 0xfffff7a09c80
栈在 fffffffdf000-1000000000000 一个对象在 0xfffff7a09c80
一位不差。 这说明前面那些随机是内核主动加的——地址空间的布局本身完全是确定的,随机化是一层额外的防御。
⭐ 这也顺便回答了第 1 篇那个演示为什么成立:地址是内核发的。内核想让它随机就随机,想让它固定就固定。物理内存对此毫不知情。
(本课后面凡是要看确定地址的演示,都会用 setarch -R。它需要 --privileged。)
四、⭐ 一件反直觉的事:申请 64 MB,堆没涨
回到第一节那个演示的第二部分。申请 64 MB 之后:
===== 申请了 64 MB 之后 =====
240c0000-24187000 rw-p 796 KB ← 堆:malloc 要来的地
ffff941e4000-ffff98486000 rw-p 68232 KB
[heap] 还是 796 KB,一个字节没涨。 涨的是另一段——一块 68 MB 的匿名区域。
原因是 glibc 的 malloc 有两条路:
- 小块(默认小于 128 KB):从
[heap]里切。堆不够了就用brk把它的上边界往上推。 - 大块:直接
mmap一段独立的区域,不走堆。
为什么要分两条路?因为堆只能从一头伸缩。如果一块 64 MB 落在堆中间,它释放之后堆的上边界也降不下来——那 64 MB 就一直占着。单独 mmap 的大块可以随时单独还回去。
⚠️ 所以以后看到"程序内存涨了",先分清是哪一种。[heap] 不涨不代表没申请内存,很多监控只看 RSS 也看不出区别。
五、⭐ 这一层到底买到了什么
摊开看完,把这层"中间人"的收益列一下——第 1 篇说过每一层都要付两笔钱,这里先看收益:
一、隔离。 进程 A 拿到的地址在 A 的对照表里,翻译不到 B 的物理内存上。A 想读 B 的数据,连"表达"这个意图的办法都没有——它能说出的每一个地址,都只会落在自己那份表里。这比"检查一下有没有权限"强得多:不是被拒绝,是根本无法表达。
二、每个程序都能假设自己从 0 开始。 编译器生成代码时不用知道这个程序将来会被装到内存的哪个位置。没有这一层,每个程序都得能被重定位到任意地址——这正是虚拟内存出现之前,人们真的要处理的问题。
三、可以撒更多的谎。 一旦地址是假的,你就能玩很多花样:
- 两个进程的地址指向同一块物理内存 → 共享库只需要在内存里存一份(上面那张表里的
libc.so.6,机器上所有进程共用同一份物理页)。 - 地址有效但暂时没有对应的物理内存 → 换出到磁盘(第 15 篇)。
- 复制时先不真复制,等谁写了再说 → 写时复制,第 3 篇
fork那笔账就是这么省下来的。
回到酒店:给每个团发一张自己的房号表,这件事本身就打开了后面所有的可能——两个团可以被安排进同一间会议室(共享),某个团的行李可以被暂时搬到地下仓库而他们的房号不变(换出)。这些都建立在"团员手上的号不是真房号"之上。
六、代价与取舍
它花了什么: 每一次内存访问都要查那本对照表。这是这门课里最贵的一笔中间层开销,接下来的第 12 到 14 篇,有一大半篇幅是在想办法把它压下去。
它放弃了什么: 地址不再对应物理位置,于是很多事变得难做——DMA 要用物理地址、大页要连续的物理内存、性能分析要知道数据落在哪个 NUMA 节点上。这些都要额外的接口把那层谎再掀开一角。
七、小结
- 地址空间是一个进程能用的全部地址的集合,
/proc/self/maps能原样打印出来:代码段(r-x)、全局变量(rw-)、堆、动态库、栈。 - ⚠️ W^X:没有一段既可写又可执行。这挡住了"写入机器码再跳过去执行"这一整类攻击。
- 堆往上长,栈往下长,中间留空地——因为你事先不知道哪个会长得更多。实测:堆地址递增,栈地址递减。
- ASLR 让每次启动的布局都不同(三次跑出三个地址);
setarch -R关掉它,三次一模一样。说明布局本身是确定的,随机是内核主动加的。 - ⭐ 申请 64 MB,
[heap]一个字节没涨——大块走mmap而不走堆,因为堆只能从一头伸缩。 - 这一层买到了三样东西:隔离(A 连表达"读 B"的办法都没有)、每个程序都能假设自己从 0 开始、以及可以继续撒谎的空间(共享库、换出、写时复制都建在它上面)。
思考题
- 第一节那张表里,
libc.so.6的权限是r-xp。机器上每个进程都映射了它。这些进程的物理内存里,libc 的代码存了几份? 为什么可以这样? - 如果栈往上长、堆也往上长(都从低地址开始),会有什么问题?操作系统还能怎么补救?
- ASLR 每次随机的位数是有限的(Linux 上栈通常随机 30 位以内)。攻击者能不能靠反复尝试猜出来?什么样的程序特别容易被猜中?(提示:想想一个崩溃后会自动重启、而且每次重启地址都重新随机的服务,和一个
fork出子进程处理请求的服务,哪个更危险。) - 酒店那个比方里,“两个团被安排进同一间会议室"对应共享库。那写时复制对应比方里的什么?