在上一篇里,父子进程打印同一个全局变量的地址,得到的是一模一样的数字,值却不一样:
child : &g=0x10239c000 g=99
parent: &g=0x10239c000 g=42同一个地址,两个值。这说明程序里的”地址”根本不是内存芯片上的位置,而是一个要经过翻译的名字。这篇讲这套翻译机制怎么设计、为什么设计成这样,以及它顺手带来的一堆能力:进程隔离、按需加载、fork 不复制、mmap 读文件、malloc 能从系统要内存。
C 例子在 macOS(Apple Silicon,Apple clang 21)上运行,这台机器的页大小是 16 KiB;x86-64 Linux 默认是 4 KiB,下文提到时会分开说。
为什么不直接用物理地址
最朴素的做法:程序里的地址就是物理内存地址。早期机器和今天很多单片机就是这样。它在三个地方坏掉。
第一,隔离。任何程序都能写任何地址,一个野指针就能改掉别的程序甚至内核的数据。
第二,重定位。编译器生成的代码里写死了地址(编译链接与 Python 扩展 里链接器做的就是填这些地址)。两个程序都想从地址 0x400000 开始,物理上只能有一个在那里。
第三,容量。所有进程想要的内存加起来常常比物理内存大,而其中很多根本没被用到:一个程序 malloc 了 1 GiB 却只写了开头一点,或者一个 200 MB 的可执行文件只跑了其中几个函数。
解决办法是在 CPU 和内存之间加一层间接:程序发出的是虚拟地址(VA),硬件里的 MMU 把它翻译成物理地址(PA)再去访问内存。每个进程有自己的一张翻译表,于是:两个进程的同一个 VA 可以映射到不同 PA(隔离、重定位);一个 VA 可以暂时不映射到任何 PA,等真用到时再分配(容量)。
翻译的粒度是页(page):虚拟地址空间和物理内存都切成固定大小的块,翻译只记录”第几个虚拟页对应第几个物理页(页框)“,页内偏移原样保留。逐字节记录映射不可能,页是让翻译表小到可行的折中。
printf("page size = %ld\n", sysconf(_SC_PAGESIZE));page size = 16384页表:一个数组,然后是一棵树
有了页,翻译表最自然的形状就是一个数组:下标是虚拟页号(VPN),元素是页表项(PTE),里面存物理页号(PPN)和几个标志位:
- V(valid):这个映射存不存在。
- R/W/X:可读、可写、可执行。写一个只读页会引发故障。
- U(user):用户态能不能访问。内核的页不带 U,用户代码碰它就触发故障,这就是 进程异常与信号 里”直接 call 内核地址会被杀”的原因。
- A/D:硬件在访问/写入时自动置位,内核据此判断哪些页常用、哪些页被改过需要写回。
一次访存的翻译是:VA 切成 VPN 和偏移,用 VPN 查表得到 PTE,检查权限,PA = PPN 拼上偏移。
问题出在数组的大小。x86-64 实际用 48 位虚拟地址,4 KiB 的页需要 12 位偏移,剩 36 位 VPN,也就是 2³⁶ 个 PTE,每个 8 字节:
print('flat table, 48-bit VA, 4 KiB pages:', (2**36)*8 // 2**30, 'GiB')
print('flat table, Sv39:', (2**27)*8 // 2**20, 'MiB')flat table, 48-bit VA, 4 KiB pages: 512 GiB
flat table, Sv39: 1024 MiB每个进程一张 512 GiB 的表,显然不行。但地址空间绝大部分是空的:典型进程只有底部的代码和堆、顶部的栈有映射,中间是几十 TB 的空洞。数组为这些空洞也付了钱。
既然大部分是空的,就把数组改成一棵树,空的子树干脆不建。这就是多级页表:把 VPN 再切成几段,第一段索引顶层表,顶层的 PTE 指向下一级表,依此类推,最后一级才指向真正的物理页。一整段空洞在顶层表里只占一个 V=0 的表项。
xv6 用的 RISC-V Sv39 是最好的教学例子:39 位 VA = 3 段 9 位 VPN + 12 位偏移。每级表正好 2⁹ = 512 个 8 字节 PTE,即 4 KiB,恰好一页。手算一个地址:
va = 0x0000_7f3a_1234_5678 & ((1<<39)-1)
off = va & 0xfff
vpn = [(va >> (12 + 9*i)) & 0x1ff for i in range(3)]
print(f'va = {va:#x}')
print(f'L2 idx = {vpn[2]}, L1 idx = {vpn[1]}, L0 idx = {vpn[0]}, offset = {off:#x}')va = 0x3a12345678
L2 idx = 232, L1 idx = 145, L0 idx = 325, offset = 0x678硬件拿 satp 寄存器里的顶层表地址,取第 232 项,得到二级表地址,取第 145 项,得到三级表,取第 325 项,得到物理页号,拼上 0x678。xv6 的 walk() 函数用软件把这三步写了一遍,内核建映射时用它。x86-64 是同样的思路,四级、每级 9 位。
树的代价是:一次访存要先访问 3~4 次内存才能拿到 PTE。内存访问本来就是 内存层次与缓存 里最怕的事,翻译再乘以 4,没人受得了。
多级页表相比单级(平坦)页表,主要省在哪里?
平坦页表必须为每个虚拟页都准备一项,哪怕 99% 是空洞。多级页表里,一片空洞只在上层表里占一个无效项,下面整棵子树都不存在。
多级页表恰恰让每次翻译多走几次内存,这正是需要 TLB 的原因。PTE 大小和层数无关。大页是另一项优化,不是多级结构带来的。
一个进程的地址空间长什么样
有了页表,内核可以把每个进程的虚拟地址空间按同一张蓝图摆放,从低地址往高地址大致是:
- 代码段(text):可执行文件里的机器指令,映射为只读、可执行。
- 数据段与 BSS:已初始化的全局变量(从文件读入)和未初始化的全局变量(按需清零),可读写、不可执行。
- 堆:
malloc管理的区域,从数据段之后往高地址长。 - mmap 区:共享库、
mmap出来的文件和匿名内存,位置由内核挑。 - 栈:从高地址往低地址长,下面通常留一个不映射的保护页,栈溢出一碰到它就产生故障、变成 SIGSEGV,而不是悄悄写坏别的数据。
- 内核:在大多数系统上,每个进程地址空间的最高一段映射着内核,PTE 不带 U 位,用户态碰不到,陷入内核时却不用换页表就能访问。xv6 的做法不同:内核有自己独立的页表,所以
uservec要切satp,它只把一页 trampoline 代码同时映射在用户和内核两边的最高地址处,作为切换时的落脚点。
每一段的权限都写在 PTE 里,“数据不可执行”(NX/XN 位)就是硬件对 机器级程序 里缓冲区溢出攻击的一种回应:攻击者塞进栈里的字节即使被跳到,CPU 也拒绝把它当指令执行。另一个回应是 ASLR(地址空间布局随机化):每次运行把栈、堆、共享库放到随机的基址,攻击者事先不知道该跳到哪。这也是为什么本系列里每次运行打印的地址都不一样。
蓝图之外的地址一律没有映射。地址 0 附近的一大段特意不映射,所以空指针解引用一定触发故障,而不是读到某个碰巧在那里的数据。
程序解引用空指针 *(int*)0 = 1; 为什么几乎总是立刻崩溃,而不是悄悄写坏某处数据?
页表里没有这段地址的有效映射,MMU 无法翻译就产生故障;内核在记录里也查不到这段合法区域,于是把故障转成信号。
硬件只认页表,不特殊对待地址 0。虚拟地址 0 和物理地址 0 毫无关系,翻译根本没发生。C 编译器不会插入运行时空指针检查。
TLB:把翻译结果缓存起来
解法和 CPU cache 如出一辙:程序有局部性,刚翻译过的页马上还会用,那就把”VPN → PTE”的结果缓存在 MMU 里一个很小、很快的全相联或组相联结构里,叫 TLB(translation lookaside buffer)。TLB 命中时翻译几乎免费,和访问 L1 cache 并行完成;未命中时才去走页表(x86 和 ARM 由硬件走,有些架构交给软件)。
TLB 很小,一级 TLB 通常几十项。它能覆盖的内存(TLB reach)= 项数 × 页大小:
print('TLB reach, 64 entries x 4 KiB =', 64*4, 'KiB; x 2 MiB =', 64*2, 'MiB')TLB reach, 64 entries x 4 KiB = 256 KiB; x 2 MiB = 128 MiB256 KiB 对于一个几十 GB 的模型权重、或者在大数组里随机跳跃的哈希表来说太小,TLB 会频繁未命中。这就是大页(huge page)存在的理由:x86-64 可以让二级表直接指向一个 2 MiB 的物理块(或三级表指向 1 GiB),少走一级、一项覆盖 512 倍的内存。Linux 的 Transparent Huge Pages、数据库和推理引擎里对大页的配置,都是在压 TLB 未命中。Apple Silicon 选 16 KiB 基础页也有同样的考虑。
TLB 还带来一个一致性问题:内核改了页表,TLB 里可能还缓存着旧翻译。所以内核改映射后必须显式刷 TLB(RISC-V 的 sfence.vma,x86 的 invlpg);多核上要通知其它核也刷(TLB shootdown),这是 munmap 在多线程程序里偏贵的原因。上下文切换换页表时,旧进程的 TLB 项也全部作废,除非硬件给每项打上地址空间标签(ASID/PCID)。
一个程序在 64 GiB 的数组里做随机下标的读,profile 发现大量时间花在 TLB 未命中上。下面哪个改动最直接针对这个问题?
大页让每个 TLB 项覆盖 2 MiB 而不是 4 KiB,TLB reach 扩大 512 倍,随机访问更容易命中。
数组减半后仍然远超 TLB reach,未命中率几乎不变。物理内存足够时根本没有换页,加内存无关。多线程会让多个核各自 TLB 未命中,总吞吐也许提高,但每次访问的翻译代价没变。
缺页:故障不是错误
如果 PTE 的 V 位是 0,或者权限不符,MMU 就产生缺页故障。在 进程异常与信号 里讲过,故障的特点是:处理完返回后会重新执行那条指令。这就让内核可以”撒谎”:先答应给你内存,但不真的分配;等你真去碰它,内核在缺页处理程序里分配、建映射,再让那条指令重跑。
内核为每个进程维护一份”这段地址本该是什么”的记录(Linux 叫 VMA,vm_area_struct):[a, b) 是可读写的匿名内存,[c, d) 映射到某个文件的某段,其余都是非法的。缺页时内核查这份记录:
- 地址落在合法区域、只是还没分配:分配一个物理页(匿名内存清零,文件映射就从磁盘读),建 PTE,返回重跑。这叫次缺页(minor fault,不用等磁盘)或主缺页(major fault,要读磁盘)。
- 地址合法但物理页被换出到了磁盘:读回来,返回重跑。
- 地址不在任何合法区域,或者写了只读区域:这是真的错误,内核给进程发 SIGSEGV。
下面的实验映射 256 MiB 匿名内存,然后分两次去写:
size_t n = (size_t)1 << 28;
char *p = mmap(NULL, n, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANON, -1, 0);
printf("after mmap 256MiB : maxrss=%ld MiB\n", rss_mb());
for (size_t i = 0; i < n/2; i += 16384) p[i] = 1; // 每页写一个字节就够触发分配
printf("touch 128MiB : maxrss=%ld MiB\n", rss_mb());
memset(p, 1, n);
printf("touch all 256MiB: maxrss=%ld MiB\n", rss_mb());
printf("minor faults = %ld (256MiB/16KiB = %zu)\n", u.ru_minflt, n/16384);after mmap 256MiB : maxrss=1 MiB
touch 128MiB : maxrss=129 MiB
touch all 256MiB: maxrss=257 MiB
minor faults = 16665 (256MiB/16KiB = 16384)mmap 返回时一页都没分配,常驻内存(RSS)还是 1 MiB。每写一页触发一次缺页,RSS 才涨上去;次缺页的总数正好约等于页数(多出来的两百多次是程序自身启动的缺页)。这就是按需分页(demand paging)。可执行文件也是这样加载的:exec 只建映射,代码页在第一次执行到时才从磁盘读进来。
按需分页让”申请的内存”和”用掉的内存”脱钩。Linux 默认还允许 overcommit:所有进程申请的总量可以超过物理内存加 swap。真到物理页耗尽时,Linux 的 OOM killer 挑一个进程发 SIGKILL。训练任务”没报错突然消失”、dmesg 里出现 Out of memory: Killed process,就是它。
物理内存真的用完时,内核只能把某些页换出(swap out):脏的匿名页写到交换分区,干净的文件页直接丢掉(反正磁盘上有原件),然后把 PTE 的 V 位清掉。下次访问再触发主缺页读回来。选哪一页换出是一个缓存替换问题,理想是换掉”未来最久不会用”的页,实际只能用过去近似未来:用 PTE 里硬件自动置位的 A 位实现 LRU 的近似,经典算法叫 clock:把所有页排成一圈,指针转过去时 A=1 的页清零并放过,A=0 的页说明一圈都没被访问过,就换出它。
换页和 内存层次与缓存 是同一个金字塔的下一层:DRAM 成了磁盘的 cache,只是一次未命中的代价从几十纳秒变成几十微秒到几毫秒。工作集超过物理内存时,进程大部分时间都在等缺页,CPU 利用率暴跌,这叫抖动(thrashing)。服务器和训练机通常宁可关掉 swap、让 OOM killer 果断杀进程,也不要一台机器在抖动里慢慢卡死。
用户程序也能借用这套”故障后重跑”的机制。下面映射一页只读内存,写它时触发故障,信号处理函数用 mprotect 把页改成可写后返回,那条写指令被重新执行,这次就成功了:
static void on_segv(int s, siginfo_t *si, void *_) {
/* 只用异步信号安全的 write,把页改成可写后返回,出错的那条指令会被重跑 */
write(1, "fault caught, unprotecting\n", 27);
mprotect(page, ps, PROT_READ|PROT_WRITE);
}
// main 里:
page = mmap(NULL, ps, PROT_READ, MAP_PRIVATE|MAP_ANON, -1, 0);
sigaction(SIGBUS, &sa, NULL); sigaction(SIGSEGV, &sa, NULL); // macOS 报 SIGBUS,Linux 报 SIGSEGV
printf("read ok: %d\n", page[0]);
page[0] = 7;
printf("write retried, page[0]=%d\n", page[0]);read ok: 0
fault caught, unprotecting
write retried, page[0]=7这正是内核实现 COW 的流程在用户态的缩影。垃圾回收器用它追踪”哪些页被写过”(写屏障),用户态的分布式共享内存系统用它在页被访问时从网络拉数据,调试器和 sanitizer 用不可访问的保护页捉越界。注意平台差异:这种”写只读页”在 macOS 上投递的是 SIGBUS,在 Linux 上是 SIGSEGV,所以示例两个都注册了。
一张页表能做的把戏:COW 与 mmap
页表是内核可以随时改写的间接层,很多看起来昂贵的操作因此变得便宜。
写时复制(copy-on-write, COW)。fork 要给子进程一份父进程地址空间的副本,真复制几个 GB 太慢,而且子进程往往马上 exec,复制的东西全白费。所以 fork 只复制页表,让父子的 PTE 指向同一批物理页,并把这些页都标成只读。谁先写,谁就触发一次权限故障;内核发现这是 COW 页,分配新页、复制内容、把写的那一方的 PTE 改成可写指向新页,返回重跑。开头那个例子里 g 的地址一样、值不一样,就是因为子进程写 g 时触发了 COW,它的那个虚拟页从此指向另一个物理页。实现 COW fork 正是 6.1810 的一个 lab,xv6 原版 fork 是真复制。
COW 的副作用在 Python 里很出名:multiprocessing 用 fork 启动 worker 后,worker 只是读父进程的大对象,内存却也在涨。原因是 CPython 读一个对象也会改它的引用计数,写到了对象头所在的页,触发 COW,这些页就被复制了。
内存映射文件(mmap)。把一个文件的某段映射进地址空间,之后读写内存就是读写文件。缺页时内核从文件读入页(经过页缓存,见 文件系统与 IO),MAP_SHARED 的写会由内核在后台写回文件。好处是:不用 read 把数据复制进用户缓冲区;多个进程映射同一个文件共享同一份物理页;只碰到的页才会被读入。numpy.memmap、safetensors 加载模型权重、各种零拷贝数据加载器都建立在这上面。
共享库是 mmap 的一个特例:每个进程把同一个 libc 文件映射进来,代码页只读且共享,物理内存里只有一份。
父进程 fork() 出子进程,子进程随后只读不写父进程留下的一个大 NumPy 数组。下面哪些说法是对的?
COW 让 fork 只复制页表;物理页共享并标只读,没有写就没有复制。NumPy 数组的数据缓冲区读取时不会被改动,所以能一直共享。
现代内核的 fork 不复制数据页。而 CPython 读一个普通 Python 对象会修改它的引用计数,这是对对象所在页的写入,会触发 COW,所以"读也涨内存"在 Python 对象上确实会发生。
malloc:在页之上再切一层
内核以页为单位给内存,程序却要 24 字节、100 字节这样的小块。malloc 是用户态的分配器,它向内核批发大块(brk/sbrk 扩展堆顶,或者 mmap 一段匿名内存),再零售给程序。
最朴素的设计:每个块前面放一个头,记录大小和是否空闲;所有块首尾相连成一个隐式链表,malloc(n) 从头扫到第一个够大的空闲块(first fit),切出需要的部分;free(p) 标空闲,并和相邻的空闲块合并,防止碎成一地小块。CSAPP 第 9 章的 malloc lab 就是从这里起步。
它在哪里坏掉?扫描是 O(块数),堆大了就慢。改进依次是:
- 显式空闲链表:只把空闲块串起来,分配时只扫空闲块。链表指针就放在空闲块自己的空间里,不占额外内存。
- 分离空闲链表(segregated lists):按大小分成很多档,每档一个链表,
malloc(24)直接去 32 字节档取。真实分配器(glibc ptmalloc、jemalloc、macOS 的 libmalloc)都是这个思路的变种,再加上每线程缓存,避免多线程抢一把锁(见 并发与同步)。 - 大块直接 mmap:超过某个阈值(glibc 默认 128 KiB)的请求直接向内核要整页,
free时直接还给内核。
void *a = malloc(24), *b = malloc(24), *c = malloc(24);
printf("a=%p b=%p c=%p b-a=%ld\n", a, b, c, (char*)b-(char*)a);
free(b); void *d = malloc(24);
printf("d=%p (reuse b? %s)\n", d, d==b?"yes":"no");
void *big = malloc(1<<20);
printf("big=%p\n", big);a=0x1051ad9b0 b=0x1051ad9d0 c=0x1051ad9f0 b-a=32
d=0x1051ad9d0 (reuse b? yes)
big=0xc3a80000024 字节的请求被凑整成 32 字节的槽,三个块紧挨着;free(b) 后同尺寸的请求立刻拿回同一个槽;1 MiB 的大块来自完全不同的一片地址区域。macOS 的具体分档和 glibc 不同,但三种现象在两者上都能看到。
一个常见的困惑是:“我 free 了几个 GB,进程的 RSS 却没降。“原因在于分配器是零售商:从堆中间释放的小块只回到分配器自己的空闲链表,留着下次复用,并不还给内核;brk 扩展的堆只能从顶端收缩,顶端只要有一个还活着的块,下面的空洞就都还不回去。只有走 mmap 的大块会在 free 时立即 munmap。分配器也可以对大片空闲的页调用 madvise(MADV_DONTNEED)(macOS 上是 MADV_FREE),告诉内核”这些页的内容我不要了”,内核回收物理页,虚拟地址仍保留,再次访问时按需分页、拿到清零的新页。glibc 的 malloc_trim、jemalloc 的后台清理线程做的都是这件事。
分配器的两个指标互相拉扯:吞吐(每次 malloc/free 多快)和利用率(申请的总量 / 实际占用的堆)。浪费来自两种碎片:内部碎片是块比请求大(24 凑成 32),外部碎片是空闲内存总量够、但没有一块连续的够大。这和 推理系统 里 PagedAttention 要解决的 KV cache 碎片是同一个问题,那里的答案也是借鉴页表:把 KV cache 切成固定大小的块,用一张表记录逻辑块到物理块的映射。PyTorch 的 CUDA caching allocator 则相当于显存上的分离空闲链表。
用错分配器的后果(悬空指针、重复 free、越界写坏块头)在 C++ 内存与所有权 里讲。
一个长期运行的服务反复分配、释放各种大小的块。监控显示空闲内存总量有 2 GiB,但一次 malloc(512 MiB) 失败了(假设分配器不会为大块单独 mmap)。最可能的原因是?
外部碎片的定义就是空闲总量够但没有一块连续的够大。这正是分配器要做合并、大块要走单独 mmap 的原因。
内部碎片是已分配块里的浪费,不会让空闲内存看起来有 2 GiB 却分不出来。TLB 只是翻译缓存,大小影响速度不影响能否分配。页表能表示整个虚拟地址空间,块大小与层数无关。
记住这三件事
- 程序里的地址是虚拟的,MMU 按页查进程自己的页表翻译成物理地址;页表做成多棵稀疏的树来省空间,再用 TLB 缓存翻译来省时间。
- 缺页是可以修好后重跑的故障,内核借它实现按需分配、换页、COW fork 和 mmap 文件;只有真正非法的地址才变成 SIGSEGV。
- malloc 是页之上的用户态分配器,核心矛盾是速度和碎片;“固定大小的块加一张映射表”这个思路一路用到了 GPU 显存和 KV cache。
延伸阅读
- Bryant & O’Hallaron, Computer Systems: A Programmer’s Perspective, 3rd ed., 第 9 章 Virtual Memory(含 malloc lab)
- Russ Cox, Frans Kaashoek, Robert Morris, xv6 book 第 3 章 Page tables;MIT 6.1810 的 pgtbl 与 cow lab
- Remzi & Andrea Arpaci-Dusseau, OSTEP, Paging、TLBs、Multi-level Paging、Free-Space Management 各章
- Ulrich Drepper, What Every Programmer Should Know About Memory, 第 4 节 Virtual Memory
- Kwon et al., Efficient Memory Management for Large Language Model Serving with PagedAttention, SOSP 2023