1.9 虚拟内存
一个 48 位的虚拟地址怎样变成物理地址?一次缺页,内核凭什么知道该分配页、换入页还是发 SIGSEGV?malloc 返回的指针背后是堆、mmap,还是一张尚未分配的空头支票?
手算四级页表的索引与物理地址;解释 TLB、多级页表与大页的取舍;用 mmap/mincore 观察映射;读懂一个带边界标记的空闲链表分配器。
1.8 里,进程访问一个未映射地址会触发缺页异常;内核要么修复,要么发 SIGSEGV。修复的过程就是虚拟内存的核心:查页表、找页、填映射、重试指令。这一章把这条路径算清楚。
虚拟内存常被误解成「内存不够时用磁盘顶替」。那只是它的一个副作用。它真正的三个角色是:缓存(把物理内存当作磁盘上地址空间的缓存)、管理(每个进程一套独立的地址空间,简化链接与加载)、保护(页级权限,进程间隔离)。swap 只是「缓存容量不够时把冷页换出」的策略,和虚拟内存本身不是一回事。
这一章的数字都以 x86-64、4 KiB 基础页、48 位虚拟地址、四级页表为准。ARM64 的页表级数、页大小不同,但「索引—表项—下一级」的结构一样;1.6.7 已经讲过 TLB 与页的基础,这里直接进入地址翻译和分配器。
1.9.1 物理地址、虚拟地址与地址空间
CPU 执行 mov (%rdi), %rax 时,%rdi 里是虚拟地址(virtual address, VA)。地址送到 MMU(memory management unit),MMU 按页表把它翻译成物理地址(physical address, PA),再访问 DRAM。程序看不见 PA,它只看见一条连续的 VA 空间。
虚拟地址空间是进程可见的地址集合。x86-64 在四级页表下是 48 位(用户态低半区 0x0000000000000000–0x00007fffffffffff,内核态高半区 0xffff800000000000–0xffffffffffffffff),启用五级页表后扩展到 57 位。地址空间的大小与物理内存大小无关;一个进程可以保留比物理内存大得多的虚拟区间,只有真正访问的页才占物理页。
48 位地址有一个「规范地址」(canonical address)约束:第 63–48 位必须全部等于第 47 位。0x00007f... 的最高位是 0,高位全 0,合法;0x0000800000000000 第 47 位是 1 但高位是 0,属于非规范地址,访问会立刻触发通用保护故障(#GP),根本走不到页表。
getconf PAGE_SIZE # 4096
grep -m1 'address sizes' /proc/cpuinfo
# address sizes : 46 bits physical, 48 bits virtual
/proc/cpuinfo 这一行说明 CPU 支持多少位物理/虚拟地址。物理地址位宽决定最大内存容量;虚拟地址位宽决定单个进程的地址空间上限。两者不必相等。
五级页表(LA57)把虚拟地址扩到 57 位,页表多一级 PML5,索引仍是 9 位,页内偏移仍是 12 位。用户态可用空间从 128 TiB 扩到 64 PiB,但每次 TLB 未命中多一次访存。是否启用由内核配置和 CPU 支持共同决定,grep la57 /proc/cpuinfo 只能说明硬件支持,不代表内核已经打开。对绝大多数程序,48 位空间已经足够,五级页表更多是给超大内存数据库和稀疏地址空间用的。
1.9.2 虚拟内存作为缓存
把磁盘想象成「下一级存储」,物理内存就是它的缓存,缓存块是页(page),通常是 4 KiB。页表项(page table entry, PTE)记录每一页的状态:
| 位/字段 | 含义 | 作用 |
|---|---|---|
P(present) | 页是否在物理内存 | 0 触发缺页 |
PFN | 物理页框号 | 翻译的高位部分 |
RW | 可写 | 写只读页触发保护故障 |
US | 用户态可访问 | 内核页设为 0 |
A(accessed) | 最近被访问 | 页面回收的参考位 |
D(dirty) | 被写过 | 换出/回写时决定是否要写磁盘 |
NX | 不可执行 | W^X 的落地 |
PS | 大页标志 | 在上级表中表示 2 MiB/1 GiB 页 |
一次内存访问先查 TLB(translation lookaside buffer)。TLB 命中,MMU 直接用缓存的翻译;未命中,硬件(x86)或内核(部分架构)走页表,把结果填回 TLB。页表项 P=0 时,MMU 触发缺页异常,内核的 do_page_fault 接手。
缺页分三种,处理成本差一个数量级:
- 次缺页(minor):页已在物理内存,只是当前进程的页表没建立映射(例如共享库被另一个进程读入、COW 页已存在)。内核填 PTE 即可,微秒级。
- 主缺页(major):页不在内存,需要读磁盘/SSD 或从 swap 换入。毫秒级,是性能分析里的重点。
- 无效缺页:地址不在任何 VMA,或权限不符。内核发
SIGSEGV/SIGBUS,没有「修复」可言。
/usr/bin/time -v 的 minor/major page faults 就是这两类计数。一个启动慢的程序,主缺页多通常意味着它在冷读大量文件页;一个刚 fork 的程序,次缺页多通常是 COW。
页表只负责「VA → PA」和权限。页不在内存时,PTE 的 PFN 字段可以复用为磁盘地址或 swap 槽号,但具体格式由内核决定;对用户态来说,唯一可见的是「访问会触发缺页,内核负责把它弄回来」。文件页的磁盘位置由 address_space 和 inode 的映射维护,匿名页的 swap 位置由 swap 子系统维护。
1.9.3 地址翻译:手算一次四级页表
48 位 VA 被切成五段:四级页表索引各 9 位,页内偏移 12 位。
47 39 38 30 29 21 20 12 11 0
┌──────────┬──────────┬──────────┬──────────┬──────────┐
│ PML4 idx │ PDPT idx │ PD idx │ PT idx │ offset │
│ 9 bits │ 9 bits │ 9 bits │ 9 bits │ 12 bits │
└──────────┴──────────┴──────────┴──────────┴──────────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
PML4 ─────► PDPT ─────► PD ──────► PT ─────► 物理页框
512 项 512 项 512 项 512 项 + offset
每项 8B 每项 8B 每项 8B 每项 8B
读图时注意两件事:每一级表项都指向「下一级表的物理基址」,而不是虚拟地址;最后一级 PTE 的 PFN 与 offset 拼成物理地址。页表本身也在物理内存里,所以内核遍历页表时用的是物理地址(或内核直接映射区里的虚拟地址)。
设 CR3 = 0x1a000,各级表项如下(值为示意,格式为 PFN+标志位)。求 VA 0x00007f3c5d2e8a40 的物理地址。
第一步:拆索引。 按 9/9/9/9/12 位切:
| 字段 | 计算 | 值 |
|---|---|---|
| PML4 索引 | (VA >> 39) & 0x1ff |
254(0xfe) |
| PDPT 索引 | (VA >> 30) & 0x1ff |
241(0xf1) |
| PD 索引 | (VA >> 21) & 0x1ff |
233(0xe9) |
| PT 索引 | (VA >> 12) & 0x1ff |
232(0xe8) |
| 页内偏移 | VA & 0xfff |
0xa40 |
第二步:逐级查表。 每级表项地址 = 表基址 + 索引 × 8:
| 步骤 | 表基址 | 索引 | 表项地址 | 表项值 | 解读 |
|---|---|---|---|---|---|
| PML4 | 0x1a000 |
254 | 0x1a7f0 |
0x1b2067 |
下一级表 0x1b200,P/RW/US/A/D |
| PDPT | 0x1b200 |
241 | 0x1b988 |
0x1c3067 |
下一级表 0x1c300 |
| PD | 0x1c300 |
233 | 0x1ca48 |
0x1d4067 |
下一级表 0x1d400 |
| PT | 0x1d400 |
232 | 0x1db40 |
0x2e8a0067 |
物理页框 0x2e8a0 |
第三步:拼物理地址。 PTE 的低 12 位是标志,高位是 PFN:
PA = (0x2e8a0067 & ~0xfff) | 0xa40 = 0x2e8a0000 + 0xa40 = 0x2e8a0a40。
验证:0x2e8a0067 的低位 0x67 = 0b1100111,对应 P=1、RW=1、US=1、A=1、D=1;没有 PS 位,说明最后一级是普通 4 KiB 页。若 PS=1 出现在 PD 项,就是 2 MiB 大页,低 21 位直接作为页内偏移,不再有 PT 级。
这个例子说明页表遍历的成本:一次 TLB 未命中要读 4 个表项,每个表项都可能是一次内存访问(实际上会被缓存)。这就是 TLB 存在的理由——把「4 次额外访存」压成「0 次」。
TLB 命中率与页大小
| 结构 | 典型容量 | 覆盖范围(4 KiB 页) | 覆盖范围(2 MiB 页) |
|---|---|---|---|
| L1 dTLB | 64 项 | 256 KiB | 128 MiB |
| L2 STLB | 1536 项 | 6 MiB | 3 GiB |
这张表说明大页为什么有效:同样 64 个 TLB 项,2 MiB 页能覆盖 128 MiB 工作集,4 KiB 页只能覆盖 256 KiB。数据库、JVM、DPDK 这类大工作集程序常用大页减少 TLB 未命中;代价是内部碎片(最后一页没用满也要占 2 MiB)和分配难度。
- 「页表是一张连续的数组」——四级页表是树,未映射的区间根本不分配下级表。一个进程只为自己用到的地址建分支。
- 「TLB 未命中就要读磁盘」——TLB 未命中只是走页表;页表项
P=1时根本不涉及磁盘。只有 PTE 不在内存才可能主缺页。 - 「大页一定更快」——大页减少 TLB 压力,但增加内部碎片和内存碎片;小工作集用大页可能浪费内存,甚至因缺页变慢。
- 「虚拟地址连续,物理地址也连续」——完全无关。相邻 VA 可以映射到任意物理页;只有大页才保证 2 MiB/1 GiB 的物理连续性。
1.9.4 多级页表、大页与 TLB 维护
单级页表在 48 位地址下不可行:页数 ,每项 8 字节,需要 字节 = 512 GiB 的页表。多级页表把这张大表拆成树,只给已映射的区间分配分支。
代价是「每级一次访存」。一个进程的页表内存可以这样估:
- 每个页表页 512 × 8 B = 4 KiB;
- 一条映射路径最多 4 个页表页(PML4/PDPT/PD/PT),共 16 KiB;
- 若一个进程映射 1 GiB 连续匿名内存,PT 级需要 512 个页表页(每个覆盖 2 MiB),PD 级 1 个,PDPT/PML4 各 1 个,约 2 MiB 页表;
- 理论上限(完整 48 位空间全部映射)是 ,实际进程远小于此。
大页在页表里用 PS 位表示:
| 层级 | 页大小 | 偏移位 | 典型用途 |
|---|---|---|---|
| PT(最后一级) | 4 KiB | 12 | 普通页 |
PD(PS=1) | 2 MiB | 21 | THP、数据库、JVM 堆 |
PDPT(PS=1) | 1 GiB | 30 | 虚拟化、DPDK、大内存预留 |
Linux 的透明大页(transparent huge page, THP)可以在不修改程序的情况下把相邻的 4 KiB 页合并成 2 MiB 页:
cat /sys/kernel/mm/transparent_hugepage/enabled
# [always] madvise never
grep -i huge /proc/meminfo
always 由内核主动合并,madvise 只在程序调用 madvise(MADV_HUGEPAGE) 时合并,never 关闭。生产环境常选 madvise,避免内核在后台做内存规整(compaction)带来尾延迟。
TLB 一致性:shootdown
页表改了,TLB 里的旧翻译必须失效,否则会访问到已经取消映射的物理页。单核上只需 invlpg;多核上,修改页表的 CPU 要向所有可能缓存了该翻译的 CPU 发送处理器间中断(IPI),让它们执行 invlpg。这叫 TLB shootdown,是 munmap、mprotect、页迁移的隐性成本。
PCID(process-context identifier)给 TLB 项打上地址空间标签,上下文切换时不必整体冲刷;但同一地址空间内的页表修改仍然需要 shootdown。页表操作不是免费的本地操作,它是一次跨核同步。
1.9.5 Linux 地址空间与 mmap
内核用 mm_struct 描述一个进程的地址空间,用一组 vm_area_struct(VMA)描述其中的连续区间。每个 VMA 记录起止地址、权限、是否文件映射、文件与偏移、操作函数表。
cat /proc/self/maps | sed -n '1,8p'
cat /proc/self/smaps | sed -n '1,20p' # 多出 Rss/Pss/Shared/Private/Swap
maps 的每一行是「起始-结束 权限 偏移 设备 inode 路径」。smaps 额外给出每个 VMA 的内存统计:Rss 是常驻集,Pss 按共享页的份额折算,排查「内存去哪了」时看 Pss 比 Rss 更准。
mmap 把文件或匿名内存映射进地址空间:
void *p = mmap(NULL, length, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
四个组合决定语义:
| 组合 | 语义 | 写时发生什么 |
|---|---|---|
MAP_PRIVATE + 文件 | 私有文件映射 | COW,修改不写回文件 |
MAP_SHARED + 文件 | 共享文件映射 | 修改写回 page cache,其他映射可见 |
MAP_PRIVATE + MAP_ANONYMOUS | 私有匿名内存 | COW 零页,malloc 的大块分配走这里 |
MAP_SHARED + MAP_ANONYMOUS | 共享匿名内存 | 父子进程共享,fork 后仍可见 |
下面这个程序用 mmap 只读映射一个文件并原样输出,不经过 read 的拷贝:
/* mmap_cat.c:mmap 只读映射文件,写标准输出 */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
static int write_all(int fd, const char *p, size_t n)
{
while (n > 0) {
ssize_t w = write(fd, p, n);
if (w == -1) {
if (errno == EINTR)
continue;
return -1;
}
p += (size_t)w;
n -= (size_t)w;
}
return 0;
}
int main(int argc, char **argv)
{
if (argc != 2) {
fprintf(stderr, "usage: %s <file>\n", argv[0]);
return 2;
}
int fd = open(argv[1], O_RDONLY);
if (fd == -1) {
perror("open");
return 1;
}
struct stat st;
if (fstat(fd, &st) == -1) {
perror("fstat");
close(fd);
return 1;
}
if (st.st_size == 0) {
close(fd);
return 0;
}
size_t len = (size_t)st.st_size;
char *p = mmap(NULL, len, PROT_READ, MAP_PRIVATE, fd, 0);
if (p == MAP_FAILED) {
perror("mmap");
close(fd);
return 1;
}
close(fd); /* 映射仍有效,fd 可以关 */
int rc = write_all(STDOUT_FILENO, p, len);
if (rc == -1)
perror("write");
if (munmap(p, len) == -1)
perror("munmap");
return rc == 0 ? 0 : 1;
}
关键行:mmap 成功后 close(fd) 不会破坏映射,因为映射持有文件引用;munmap 的 len 必须与映射长度一致;write_all 处理短写和 EINTR。mmap 不是「把文件读进内存」——它建立的是「访问哪一页就缺页读哪一页」的映射,第一次遍历仍会触发主缺页。
mmap 与 brk 的分工:brk 扩展连续的堆区,适合小块分配;mmap 可以映射任意地址、任意长度,适合大块分配、文件映射和共享内存。glibc malloc 在请求超过 mmap_threshold(默认 128 KiB,动态调整)时直接 mmap,否则从堆/arena 分配。
1.9.6 动态内存分配:从空闲链表到 malloc
分配器要满足四个约束:对齐(返回的指针满足 max_align_t,x86-64 通常 16 字节)、不重叠、不移动已分配块、及时复用。最后一条决定了它必须维护空闲块的数据结构。
隐式空闲链表与边界标记
最简单的方式是把堆组织成一串块,每块有头部和脚部,头部记录「大小 + 是否已分配」,脚部复制大小供反向合并:
低地址 高地址 ┌────────┬──────────────┬────────┬────────┬──────────────┬────────┐ │ header │ payload │ footer │ header │ payload │ footer │ │ size|A │ │ size │ size|A │ │ size │ └────────┴──────────────┴────────┴────────┴──────────────┴────────┘ 已分配块(A=1) 空闲块(A=0,payload 里存前驱/后继指针)
查找空闲块用首次适配(first fit):从头扫描,找到第一个足够大的空闲块。分配时若剩余空间 ≥ 最小块(32 字节),就分裂成「已分配 + 新空闲」;释放时看前后邻居是否空闲,四种情况合并(都不空、前空、后空、都空)。
隐式链表的缺点是查找 O(总块数)。显式空闲链表把空闲块用前驱/后继指针串起来,查找只遍历空闲块;分离空闲链表再按大小分桶(如 16、32、64、…),分配时先查对应桶,失败再查更大桶。
glibc 的 malloc 把这几层叠在一起:
| 结构 | 作用 | 命中时的成本 |
|---|---|---|
| tcache | 每线程 64 个单链表桶,缓存 ≤ 1032 B 的小块 | 几乎无锁,最快 |
| fastbins | 小块空闲块,暂不合并 | 快,但可能加剧碎片 |
| small/large bins | 按大小分类的双向链表 | 需要加 arena 锁 |
| unsorted bin | 刚释放、尚未分类的块 | 分配时先扫描合并 |
| top chunk | 堆顶剩余空间 | 可直接切分 |
mmap | 大于阈值的块单独映射 | free 时直接 munmap |
堆初始是一个 64 KiB 的空闲块(头部 16 B、脚部 8 B,块大小按 16 字节对齐)。请求 100 字节:
- 需要
100 + 16 = 116,向上取整到 128; - 原块 65536 ≥ 128,且剩余 65408 ≥ 32,分裂;
- 返回块 128 B,剩余 65408 B 变为空闲块;
- 返回给用户的指针 = 块起始 + 16。
再请求 200 字节:需要 216 → 224,从剩余块头部切 224。此时若释放第一个 128 B 块,它的前邻是堆头(无),后邻是已分配的 224 B 块,不能合并,只能标记为空闲。若随后再释放 224 B 块,两者合并成 352 B 的空闲块,又可以满足更大的请求。
下面是一个可运行的最小分配器,使用边界标记、首次适配、分裂与双向合并:
/* alloc.c:静态堆上的隐式空闲链表分配器 */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <stddef.h>
#include <stdio.h>
#include <string.h>
#define HEAP_SIZE (64 * 1024)
#define ALIGN 16
#define HDR 16
#define FTR 8
#define MIN_BLOCK 32
static unsigned char heap[HEAP_SIZE] __attribute__((aligned(ALIGN)));
static size_t rd(size_t off)
{
size_t v;
memcpy(&v, heap + off, sizeof(v));
return v;
}
static void wr(size_t off, size_t v)
{
memcpy(heap + off, &v, sizeof(v));
}
static size_t bsize(size_t off) { return rd(off) & ~(size_t)(ALIGN - 1); }
static int bfree(size_t off) { return (rd(off) & 1u) == 0; }
static void mark(size_t off, size_t size, int free)
{
wr(off, size | (free ? 0u : 1u));
wr(off + size - FTR, size);
}
static size_t align_up(size_t n)
{
return (n + (ALIGN - 1)) & ~(size_t)(ALIGN - 1);
}
void *my_malloc(size_t n)
{
size_t need = align_up(n + HDR + FTR); /* footer 也要留 */
if (need < MIN_BLOCK)
need = MIN_BLOCK;
for (size_t off = 0; off + HDR <= HEAP_SIZE; off += bsize(off)) {
size_t size = bsize(off);
if (size < MIN_BLOCK || off + size > HEAP_SIZE)
return NULL; /* 元数据被写坏 */
if (!bfree(off) || size < need)
continue;
if (size - need >= MIN_BLOCK) {
mark(off, need, 0);
mark(off + need, size - need, 1);
} else {
mark(off, size, 0);
}
return heap + off + HDR;
}
return NULL;
}
void my_free(void *p)
{
if (p == NULL)
return;
size_t off = (size_t)((unsigned char *)p - heap - HDR);
if (off >= HEAP_SIZE || off % ALIGN != 0 || bfree(off))
return; /* 非法指针或重复释放 */
size_t size = bsize(off);
mark(off, size, 1);
size_t next = off + size;
if (next < HEAP_SIZE && bfree(next)) {
mark(off, size + bsize(next), 1);
size = bsize(off);
}
if (off > 0) {
size_t prev_size = rd(off - FTR);
size_t prev = off - prev_size;
if (prev < off && bfree(prev))
mark(prev, prev_size + size, 1);
}
}
int main(void)
{
mark(0, HEAP_SIZE, 1);
char *a = my_malloc(100);
char *b = my_malloc(200);
char *c = my_malloc(50);
printf("a=%p b=%p c=%p\n", (void *)a, (void *)b, (void *)c);
my_free(b);
char *d = my_malloc(180);
printf("reuse b: %s\n", d == b ? "yes" : "no");
my_free(a);
my_free(c);
my_free(d);
return 0;
}
这段代码的重点不是性能,而是三个不变量:头部/脚部的大小一致;任何时刻没有两个相邻空闲块;返回的 payload 按 16 字节对齐。工程级分配器还要处理线程安全(arena/tcache)、mmap 大块、内存碎片、realloc 的原地扩展,以及「分配器元数据被越界写坏」的检测。
垃圾收集是另一条路。 C 用显式的 free 回收内存;垃圾收集(garbage collection, GC)让运行时自动判断哪些对象永远不会再被访问并回收。C 没有运行时类型信息,给 C 用的收集器通常是保守式标记-清除(conservative mark-and-sweep):从寄存器、栈、全局数据段出发,把每个落在堆地址范围内的字都当作潜在指针(不确定时宁可保留),标记所有可达块;清扫阶段把未标记的块并回空闲链表。代价是暂停、扫描和少量「假指针」造成的延迟回收;收益是消除 use-after-free 与泄漏。C 生态的主流仍是手动管理或引用计数(shared_ptr 用 weak_ptr 打破循环引用);Java/Go 的精确 GC 依赖类型信息、写屏障与分代/并发算法。Linux 内核不扫描堆,延迟回收用 RCU,而不是 GC。
- 「虚拟内存就是 swap」——虚拟内存是地址翻译与保护;swap 只是页回收的一个去处,没有 swap 系统也能正常工作。
- 「
malloc一定来自堆(brk)」——大块分配直接mmap;glibc 还有 per-thread arena 和 tcache,地址可能分散在多个映射区。 - 「
free会把内存还给操作系统」——通常只还给分配器的空闲链表;只有mmap的大块在free时munmap,或者调用malloc_trim才可能把堆顶还给内核。 - 「页大小总是 4 KiB」——x86-64 基础页是 4 KiB,但支持 2 MiB/1 GiB 大页;ARM64 可用 4/16/64 KiB 基础页。写死 4096 的代码不可移植。
- 「数组越界一定会立刻崩溃」——越界可能写到相邻块的元数据或另一个对象,崩溃点离 bug 很远。用 ASan/Valgrind 才能在越界发生处报错。
- 「
/proc/pid/maps能看出进程用了多少物理内存」——maps只有虚拟区间;物理 resident 看smaps的Rss,共享页按份额看Pss。
1.9.7 常见内存错误与工具
| 错误 | 典型症状 | 首选工具 |
|---|---|---|
| 堆越界写 | 相邻块元数据损坏,free 时崩溃 | ASan、Valgrind |
| 释放后使用(UAF) | 读到已复用对象的数据,偶发崩溃 | ASan、MTE(ARM) |
| 双重释放 | free(): double free detected | glibc 检测、ASan |
| 内存泄漏 | RSS 持续增长,长跑后 OOM | Valgrind --leak-check、heaptrack |
| 未初始化读 | 结果随机,取决于栈/堆残留 | MSan、Valgrind |
| 栈溢出 | 递归过深,SIGSEGV 在栈附近 | ulimit -s、ASan |
AddressSanitizer 是编译期插桩,开销约 2 倍;Valgrind 是动态二进制翻译,开销 10–50 倍但不需要重编译。定位越界时优先 ASan,因为它能给出「哪一行写了哪个对象的哪一个字节」:
cc -std=c17 -g -O1 -fsanitize=address,undefined prog.c -o prog
ASAN_OPTIONS=abort_on_error=1 ./prog
一个常见误区是看到 SEGV 就认为「访问了空指针」。SEGV 的 si_addr 可能是 0x0、0x10(空指针加偏移)、一个已 munmap 的地址、栈溢出附近,或对齐错误。先看 dmesg/core 里的 si_addr 和 si_code,再谈原因。
# 泄漏:只看 definitely lost,避免把第三方库的一次性分配算进来
valgrind --leak-check=full --show-leak-kinds=definite ./prog
# 堆破坏:glibc 自带的轻量检测,适合快速复现
MALLOC_CHECK_=3 ./prog
# 观察某个映射的常驻情况(需要 root 才能读 PFN)
cat /proc/self/smaps | grep -A4 '\[heap\]'
1.9.8 四级页表与地址翻译:手算一次 walk
这一节回答:一个 48 位虚拟地址怎样变成物理地址?CR3 指向哪里、每一级表取哪 9 位、PTE 的每一位什么意思?手算一遍,/proc/self/pagemap 与 perf 的 dTLB-load-misses 才有落点。
地址与表的尺寸
x86-64 的 4 级页表把 48 位虚拟地址切成 5 段:
47 39 38 30 29 21 20 12 11 0
┌───────────┬───────────┬───────────┬───────────┬─────────────┐
│ PML4 idx │ PDPT idx │ PD idx │ PT idx │ offset │
│ 9 位 │ 9 位 │ 9 位 │ 9 位 │ 12 位 │
└───────────┴───────────┴───────────┴───────────┴─────────────┘
每张表 512 项 × 8 字节 = 4 KB,正好一页;CR3 存 PML4 的物理地址(低 12 位是标志)。页大小 4 KB,所以 offset 12 位。5 级页表(Ice Lake+,CONFIG_X86_5LEVEL)把 VA 扩到 57 位,多一级 9 位,寻址上限从 256 TB 变成 128 PB;大多数进程仍用 4 级。
PTE 的位
| 位 | 名称 | 含义 |
|---|---|---|
| 0 | P | 存在;0 时其余位由软件解释(swap 等) |
| 1 | R/W | 0=只读,1=可写 |
| 2 | U/S | 0=内核,1=用户可访问 |
| 3 | PWT | 页级写穿透 |
| 4 | PCD | 禁止缓存(用于 MMIO) |
| 5 | A | 访问过(由 CPU 置位,OS 可清) |
| 6 | D | 脏(写过) |
| 7 | PS | 大页:在 PD 层表示 2 MB,在 PDPT 层表示 1 GB |
| 8 | G | 全局页(TLB 不因 CR3 切换而失效) |
| 9–11 | AVL | 软件可用 |
| 12–51 | PFN | 物理页帧号 |
| 52–58 | 软件 | Linux 用(如 soft-dirty、uprobe) |
| 59–62 | PK | 保护键 |
| 63 | NX | 禁止取指(需 EFER.NXE) |
A/D 位让内核能实现 LRU 式的页回收:扫描时读 A 位判断「最近是否用过」,清 A 位后下一轮再看。这也是为什么 mincore 只能给出「大概驻留」,真正的访问信息要靠 A/D 位与 idle_page_tracking。
手算一次 walk
取虚拟地址 0x00007f8c12345678,CR3 = 0x1a2b3000,各级表项如下(低 12 位是标志):
| 级 | 索引 | 表项地址 | 表项值 | 下一级基址 |
|---|---|---|---|---|
| PML4 | 0xff | 0x1a2b37f8 | 0x1a2b5067 | 0x1a2b5000 |
| PDPT | 0x30 | 0x1a2b5180 | 0x1a2b7067 | 0x1a2b7000 |
| PD | 0x91 | 0x1a2b7488 | 0x1a2b9067 | 0x1a2b9000 |
| PT | 0x145 | 0x1a2b9a28 | 0x1a2bd867 | 页帧 0x1a2bd000 |
计算过程:
- PML4 索引 =
(va >> 39) & 0x1ff = 0xff;表项地址 =CR3 + 0xff*8 = 0x1a2b37f8。表项0x1a2b5067的 PFN 是0x1a2b5,下一张表在0x1a2b5000。 - PDPT 索引 =
(va >> 30) & 0x1ff = 0x30;表项地址 =0x1a2b5000 + 0x30*8 = 0x1a2b5180。 - PD 索引 =
(va >> 21) & 0x1ff = 0x91;表项地址 =0x1a2b7000 + 0x91*8 = 0x1a2b7488。 - PT 索引 =
(va >> 12) & 0x1ff = 0x145;表项地址 =0x1a2b9000 + 0x145*8 = 0x1a2b9a28。 - offset =
va & 0xfff = 0x678;物理地址 =(PTE & 0x000ffffffffff000) | offset = 0x1a2bd678。
最后一级 PTE 0x1a2bd867 的低 12 位是 0x867:P=1、R/W=1、U/S=1、A=1、D=1、PS=0。若 PS=1(2 MB 大页),PD 层就终止,物理地址 = (PDE & ...ffe00000) | (va & 0x1fffff),少一次访存。
TLB:页表的缓存
一次 4 级 walk 要 4 次内存访问(每次都可能 miss),所以 CPU 用 TLB 缓存「VA 页号 → PTE」。典型结构:
| 层次 | 项数 | 相联度 | 命中延迟 |
|---|---|---|---|
| L1 dTLB | 64–96 | 4–8 路 | 1 周期 |
| L1 iTLB | 64–128 | 4–8 路 | 1 周期 |
| L2 STLB | 1536–2048 | 12–16 路 | 7–10 周期 |
| 页表 walk | — | — | 4 次访存,可能 100+ 周期 |
TLB 覆盖的地址范围 = 项数 × 页大小。2048 项 × 4 KB = 8 MB;对随机访问 1 GB 的程序,TLB 命中率会很低。改用 2 MB 大页后,同样的 2048 项覆盖 4 GB,命中率大幅提升——这就是数据库、JVM、DPDK 偏爱大页的原因。
TLB 是按核的。内核改页表后,必须让其他核的 TLB 失效(TLB shootdown),通过 IPI 完成;频繁 munmap/mprotect 的多线程程序会因此变慢。madvise(MADV_DONTNEED) 不一定要 shootdown,但改 PTE 的一定要。
x86-64 要求 bits 63:47 全等于 bit 47。0x00007f8c12345678 的 bit 47 是 0,高位全 0,合法;0x0000800000000000 的 bit 47 是 1 但 bits 63:48 是 0,属于非 canonical,访问会触发 #GP。用户态指针被截断/符号扩展错时,经常看到 0x0000... 与 0xffff... 混用导致的 SIGSEGV。
1.9.9 mmap:字段、标志与失败路径
这一节回答:mmap 的六个参数各自接受什么、返回什么、什么时候 MAP_FAILED、什么时候 SIGBUS?它是虚拟内存暴露给用户态的主接口,malloc 的大块分配、文件 I/O、共享内存都建立在它上面。
签名与参数
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
| 参数 | 取值 | 说明 |
|---|---|---|
addr | NULL 或页对齐地址 | NULL 让内核选;非 NULL 且带 MAP_FIXED 时强制覆盖 |
length | > 0 | 内核按页向上取整;0 返回 EINVAL |
prot | PROT_NONE/READ/WRITE/EXEC | 可组合;与文件打开模式冲突时 EACCES |
flags | MAP_SHARED/PRIVATE/... | 必须二选一(SHARED 或 PRIVATE) |
fd | 文件描述符或 -1 | MAP_ANONYMOUS 时忽略 |
offset | 页大小的倍数 | 文件映射时必须页对齐,否则 EINVAL |
常用 flags:
| 标志 | 作用 |
|---|---|
MAP_SHARED | 修改写回文件,对其他映射可见 |
MAP_PRIVATE | 写时复制,修改不写回 |
MAP_ANONYMOUS | 不关联文件,内容初始为 0 |
MAP_FIXED | 强制在指定地址映射,覆盖已有映射(危险) |
MAP_FIXED_NOREPLACE | 地址被占用则失败(Linux 4.17+,安全版 FIXED) |
MAP_POPULATE | 预读页表,减少后续缺页 |
MAP_NORESERVE | 不预留 swap 空间(overcommit) |
MAP_HUGETLB | 使用大页 |
MAP_STACK/MAP_GROWSDOWN | 栈语义(很少直接用) |
MAP_SYNC/MAP_SHARED_VALIDATE | DAX/校验标志 |
返回值:成功是映射起始地址(页对齐),失败是 MAP_FAILED(即 (void *)-1),并设置 errno。常见 errno:
| errno | 原因 |
|---|---|
EINVAL | length=0、offset 未页对齐、flags 冲突 |
EACCES | 文件权限与 prot 冲突(如只读 fd 上 PROT_WRITE + MAP_SHARED) |
ENOMEM | 虚拟地址空间不足、超过 RLIMIT_AS、overcommit 拒绝 |
ENODEV | 文件系统不支持 mmap |
EBADF | fd 无效(非匿名映射) |
EPERM | MAP_HUGETLB 无权限、/proc 文件被拒绝 |
EOVERFLOW | offset+length 超出文件/地址空间 |
两个信号:SIGSEGV 与 SIGBUS
| 信号 | 触发 | 例子 |
|---|---|---|
SIGSEGV | 访问不在映射内、或权限不符 | 解引用 NULL、写 PROT_READ 页 |
SIGBUS | 映射存在但底层数据不可用 | 访问文件 EOF 之后的整页、文件被截断、DAX 设备错误 |
「文件最后一页的尾部」是零填充,访问不会 SIGBUS;只有完全在 EOF 之后的页才会。1.9.12 的程序用「多映射一页」的方式稳定复现 SIGBUS。
相关调用
| 调用 | 作用 |
|---|---|
munmap(addr, len) | 解除映射;未映射区域返回 EINVAL |
mprotect(addr, len, prot) | 改权限;必须有写权限才能加 PROT_WRITE(否则 EACCES) |
mremap(old, oldsz, newsz, flags) | 扩缩映射,MREMAP_MAYMOVE 允许搬家 |
msync(addr, len, MS_SYNC/ASYNC) | 把 MAP_SHARED 的脏页写回 |
madvise(addr, len, advice) | MADV_DONTNEED/FREE/WILLNEED/HUGEPAGE/MERGEABLE |
mincore(addr, len, vec) | 查询每页是否驻留(近似) |
mlock/munlock | 锁定物理页,禁止换出 |
madvise(MADV_DONTNEED) 会丢弃匿名页的内容(下次访问读回 0),对文件映射则丢弃缓存页;MADV_FREE 更懒,只在内存压力下回收。KSM 的 MADV_MERGEABLE 让内核合并内容相同的匿名页(去重),代价是 CPU 与侧信道风险。
失败路径的写法
void *p = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (p == MAP_FAILED) { /* 不能写 if (!p):0 是合法映射地址(少) */
perror("mmap");
return -1;
}
MAP_FAILED 是 (void *)-1,不是 NULL;虽然 NULL 通常不会作为成功返回值(mmap_min_addr 保护),但判断必须用 MAP_FAILED。munmap 失败通常是编程错误(长度/地址不对),不是运行时噪声。
- 「mmap 比 read 快」——对大文件顺序读,
mmap省一次拷贝;但小文件、随机读、需要错误处理时,read更简单可控,且不会有SIGBUS。 - 「
MAP_PRIVATE不占内存」——它仍然要建立页表、按需分配物理页;写时复制只是延迟了复制。 - 「
msync能保证落盘」——MS_SYNC只保证写到设备缓存,真正的持久化需要fsync/fdatasync或设备写屏障。 - 「
mmap的地址可以随便MAP_FIXED」——它会覆盖已有映射,可能破坏堆/栈;用MAP_FIXED_NOREPLACE。
1.9.10 动态内存分配:从空闲链表到 glibc
这一节回答:malloc 如何在一块大内存里找到合适的空洞?隐式链表、显式链表、分离链表、伙伴系统、slab 各解决什么问题?glibc 的 tcache/arena 又加了什么?
分配器的核心问题
给定一块堆区,维护「已分配块」与「空闲块」的集合,支持:
malloc(n):找到 ≥ n 的空闲块,分割,返回负载指针。free(p):标记为空闲,尽可能与相邻空闲块合并(coalesce)。- 对齐:x86-64 要求 16 字节对齐(
max_align_t)。 - 碎片:内部碎片(块比请求大)、外部碎片(空闲总量够但没有连续块)。
隐式空闲链表
每个块带 header(大小 + 分配位)与 footer(复制大小,便于向前合并):
┌────────┬──────────────┬────────┬────────┬──────────────┬────────┐
│ header │ payload ... │ footer │ header │ payload ... │ footer │
│ size|1 │ │ size │ size|0 │ │ size │
└────────┴──────────────┴────────┴────────┴──────────────┴────────┘
malloc:从堆头线性扫描(first-fit / next-fit / best-fit),找到足够大的空闲块。free:清分配位,检查后块(p + size)与前块(p - prev_size)是否空闲,合并。- 复杂度:
mallocO(块数),freeO(1)。 - 优点:实现简单、合并方便;缺点:分配慢、块头尾开销大(小对象浪费严重)。
1.9.11 的程序就是这种分配器,堆是一块 64 KB 的静态数组。
显式空闲链表与分离链表
隐式链表把「空闲块」也放在堆里顺序扫描;显式链表把空闲块用 next/prev 指针串起来:
| 结构 | 分配复杂度 | 合并 | 适用 |
|---|---|---|---|
| 隐式(implicit) | O(总块数) | O(1) | 教学、小堆 |
| 显式(explicit) | O(空闲块数) | O(1) | 通用 |
| 分离(segregated) | O(1) 近似 | O(1) | 生产分配器 |
| 伙伴(buddy) | O(log n) | O(log n) | 内核页分配 |
| slab/slub | O(1) | 不合并 | 内核对象缓存 |
分离空闲链表按大小分级(如 16、32、64、…、4096),每级一条链表;分配时先查对应级,再用更大级切分。它把「找块」从线性扫描变成「查表 + 摘链」,是现代分配器的主流。
glibc malloc 的结构
| 概念 | 作用 |
|---|---|
| chunk | 分配单位,8/16 字节头(prev_size/size),低 3 位是标志 |
| fastbin | ≤ 128 字节的小块,单链表,LIFO,不合并 |
| tcache | 每线程 64 条小链表(glibc 2.26+),无锁,最快路径 |
| smallbin/largebin | 按大小分级的双向链表 |
| unsorted bin | 刚释放的块先放这里,下次分配时整理 |
| top chunk | 堆顶剩余空间,不够时 brk/mmap 扩展 |
| arena | 每线程/每核的分配区,减少锁竞争;MALLOC_ARENA_MAX 限制 |
| mmap 阈值 | 大于 128 KB(可调)直接 mmap,free 时 munmap |
malloc 的典型路径:tcache → fastbin → smallbin → unsorted → largebin → top → brk/mmap。free 的路径:tcache(未满)→ fastbin → 合并 → unsorted。
常用观测接口:
#include <malloc.h>
malloc_stats(); /* 打印到 stderr */
struct mallinfo2 mi = mallinfo2();
printf("arena=%zu inuse=%zu\n", mi.arena, mi.uordblks);
size_t usable = malloc_usable_size(p);
MALLOC_ARENA_MAX=1 能让多线程程序减少 arena 数量(省内存,但锁竞争增加);MALLOC_TRIM_THRESHOLD_/MALLOC_MMAP_THRESHOLD_ 可调回收策略。
失败与边界
malloc失败返回 NULL 并设ENOMEM;不会抛异常。malloc(0)可以返回 NULL 或一个可free的唯一指针;不要依赖。calloc(n, size)检查乘法溢出;realloc(p, 0)等价于free(p)(C17 起实现定义/已弃用,不要依赖)。realloc可能搬家:返回新地址,旧内容复制到min(old, new),旧指针失效。free非malloc返回的指针、重复free、越界写 header 都是 UB,通常表现为堆损坏或SIGABRT(glibc 的malloc_printerr)。
垃圾收集与语言运行时
C/C++ 手动管理;Java/Go/Python 用 GC(标记-清扫、复制、分代、引用计数)。GC 的根集是栈、寄存器、全局区;保守 GC 把「看起来像指针的字」都当根,精确 GC 依赖编译器生成栈图。Rust 用所有权在编译期决定释放点,Rc/Arc 做引用计数,#[global_allocator] 可替换分配器(jemalloc/mimalloc)。
| 方案 | 释放时机 | 代价 |
|---|---|---|
| 手动(C) | 程序员决定 | 泄漏/悬垂/双重释放 |
| 所有权(Rust) | 编译期确定 | 学习成本、unsafe 边界 |
| 引用计数 | 计数归零 | 循环引用、原子开销 |
| 追踪 GC | 不可达时 | 停顿、内存放大 |
内核没有用户态堆,也不能在中断上下文睡眠;它用 kmalloc/vmalloc/slab,按大小分级、per-CPU 缓存、GFP 标志控制可否睡眠。用户态的 malloc 依赖 brk/mmap,在内核里对应 do_brk/vm_mmap,但分配策略完全不同。第 3 篇会展开 slab/slub。
标记-清扫、根集与保守 GC
垃圾收集要解决两个问题:哪些对象还活着(可达性),怎么回收(清扫/复制/整理)。
标记-清扫(mark-sweep):
- 根集:寄存器、栈、全局数据段、TLS 中的指针;
- 从根出发深度/广度遍历,标记所有可达对象;
- 未标记的对象进入空闲链表;
- 可选「整理(compact)」把存活对象搬在一起,消除外部碎片。
C 没有类型信息,无法区分「一个 8 字节整数」和「一个指针」,所以只能用保守式 GC:把每个落在堆地址范围内的字都当成潜在指针,宁可误保留(假指针/浮动垃圾),也不误回收。代价是延迟回收与不能移动对象(移动后旧指针失效)。
精确 GC(Java/Go)依赖编译器生成栈图(stack map)、写屏障(write barrier)与分代/并发算法,能移动对象、做增量/并发回收。Rust 不用 GC:所有权在编译期决定释放点,Rc/Arc 做引用计数;循环引用要用 Weak 打破。
- GC 不等于「不会泄漏」:全局缓存、未注销的回调、
ThreadLocal仍会持有对象。 - 引用计数不是万能:循环引用需要弱引用/环检测;每次增减都有开销。
- 保守 GC 的「假指针」会延迟回收,不是正确性问题;但可能让内存峰值偏高。
free()后继续用是 use-after-free,GC 也救不了:GC 只回收不可达对象,不检查悬垂引用。
1.9.11 完整程序一:手写隐式空闲链表分配器
这个程序在 64 KB 的静态堆上实现 1.9.10 的隐式空闲链表:header/footer 边界标记、首次适配、分割、前后合并。它不调用 malloc,因此可以完整观察「块大小、对齐、碎片」这些平时被 libc 藏起来的细节。
/* mymalloc.c — 隐式空闲链表分配器:首次适配 + 边界标记 + 合并
* 编译:cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 mymalloc.c
*/
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <stddef.h>
#define HEAP_SIZE (1u << 16)
#define ALIGNMENT 16
#define WSIZE sizeof(size_t)
#define ALIGN(n) (((n) + (ALIGNMENT - 1)) & ~(size_t)(ALIGNMENT - 1))
static unsigned char heap[HEAP_SIZE] __attribute__((aligned(16)));
static size_t heap_used;
static size_t *hdr(size_t off) { return (size_t *)(heap + off); }
static size_t *ftr(size_t off) { return (size_t *)(heap + off + (*hdr(off) & ~(size_t)1) - WSIZE); }
static size_t blk_size(size_t off) { return *hdr(off) & ~(size_t)1; }
static int blk_alloc(size_t off) { return (int)(*hdr(off) & 1); }
/* 块布局:[header][payload ...][footer],size 含头尾,低位置 1 表示已分配 */
static void put_block(size_t off, size_t size, int alloc)
{
*hdr(off) = size | (size_t)alloc;
*ftr(off) = size | (size_t)alloc;
}
static void init_heap(void)
{
put_block(0, 2 * WSIZE, 1); /* 序言块:头+尾 */
put_block(2 * WSIZE, HEAP_SIZE - 2 * WSIZE, 0);/* 初始空闲块 */
heap_used = 0;
}
static size_t find_fit(size_t need)
{
size_t off = 2 * WSIZE;
while (off < HEAP_SIZE) {
size_t sz = blk_size(off);
if (sz == 0) break;
if (!blk_alloc(off) && sz >= need) return off;
off += sz;
}
return 0;
}
void *my_malloc(size_t n)
{
if (n == 0) return NULL;
size_t need = ALIGN(n) + 2 * WSIZE;
if (need < 2 * WSIZE + ALIGNMENT) need = 2 * WSIZE + ALIGNMENT;
size_t off = find_fit(need);
if (!off) return NULL;
size_t sz = blk_size(off);
if (sz - need >= 2 * WSIZE + ALIGNMENT) {
put_block(off, need, 1);
put_block(off + need, sz - need, 0);
} else {
put_block(off, sz, 1);
}
heap_used += blk_size(off);
return heap + off + WSIZE;
}
void my_free(void *p)
{
if (!p) return;
size_t off = (size_t)((unsigned char *)p - heap) - WSIZE;
heap_used -= blk_size(off);
put_block(off, blk_size(off), 0);
/* 与后面的空闲块合并 */
size_t next = off + blk_size(off);
if (next < HEAP_SIZE && next + WSIZE <= HEAP_SIZE && blk_size(next) != 0 && !blk_alloc(next)) {
put_block(off, blk_size(off) + blk_size(next), 0);
}
/* 与前面的空闲块合并:前一块的 footer 在 off-WSIZE */
if (off > 2 * WSIZE) {
size_t prev_sz = *(size_t *)(heap + off - WSIZE) & ~(size_t)1;
size_t prev = off - prev_sz;
if (!blk_alloc(prev)) put_block(prev, prev_sz + blk_size(off), 0);
}
}
int main(void)
{
init_heap();
void *a = my_malloc(24);
void *b = my_malloc(100);
void *c = my_malloc(8);
printf("a=%p b=%p c=%p used=%zu\n", a, b, c, heap_used);
memset(a, 'A', 24); memset(b, 'B', 100); memset(c, 'C', 8);
my_free(b);
void *d = my_malloc(64); /* 应复用 b 的空间 */
printf("d=%p reused=%d used=%zu\n", d, d == b, heap_used);
my_free(a); my_free(c); my_free(d);
printf("after free used=%zu\n", heap_used);
return 0;
}
一次运行的输出(地址随平台/链接方式变化):
a=0x1006d8028 b=0x1006d8058 c=0x1006d80d8 used=208
d=0x1006d8058 reused=1 used=160
after free used=0
逐行读:
- 三次
malloc(24/100/8)的负载对齐到 16 字节,块大小分别是 48、128、32(含 8 字节 header + 8 字节 footer),总计 208。 free(b)后malloc(64)返回的地址与b相同,说明首次适配复用了空洞;used从 208 变成 160(128 的空洞切出 80)。- 全部
free后used=0,说明前后合并没有留下碎片。
这个分配器的局限:malloc 线性扫描(块多时 O(n))、没有分离链表、没有 realloc、不做线程安全、堆固定 64 KB。生产分配器(glibc、jemalloc、mimalloc)在这些点上分别用分离链表、per-thread cache、原子操作、mmap 扩展来解决。但边界标记 + 合并是所有分配器的共同基础,读懂这 100 行,再看 malloc.c 的 chunk 结构就不陌生。
1.9.12 完整程序二:mmap 文件、SIGBUS 与驻留查询
这个程序把文件映射进地址空间,读首尾字节,然后故意访问「文件最后一页之后」的地址,用 sigsetjmp/siglongjmp 捕获 SIGBUS,最后用 mincore 查询驻留页数。它把 1.9.9 的失败路径变成可运行的实验。
/* mapfile.c — mmap 文件、越界 SIGBUS、mincore 查看驻留
* 编译:cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 mapfile.c
* 运行:./mapfile mapfile.c
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <signal.h>
#include <setjmp.h>
#include <sys/mman.h>
#include <sys/stat.h>
static sigjmp_buf bus_env;
static void on_bus(int sig)
{
(void)sig;
siglongjmp(bus_env, 1);
}
int main(int argc, char **argv)
{
if (argc != 2) { fprintf(stderr, "usage: %s file\n", argv[0]); return 2; }
int fd = open(argv[1], O_RDONLY);
if (fd < 0) { perror("open"); return 1; }
struct stat st;
if (fstat(fd, &st) < 0) { perror("fstat"); close(fd); return 1; }
size_t len = (size_t)st.st_size;
if (len == 0) { fprintf(stderr, "empty file\n"); close(fd); return 1; }
long ps = sysconf(_SC_PAGESIZE);
size_t body = ((len + (size_t)ps - 1) / (size_t)ps) * (size_t)ps; /* 文件占用的整页数 */
size_t maplen = body + (size_t)ps; /* 多映射一页,用于触发 SIGBUS */
char *p = mmap(NULL, maplen, PROT_READ, MAP_PRIVATE, fd, 0);
if (p == MAP_FAILED) { perror("mmap"); close(fd); return 1; }
printf("len=%zu first=%c last=%c\n", len, p[0], p[len - 1]);
struct sigaction sa;
memset(&sa, 0, sizeof sa);
sa.sa_handler = on_bus;
sigemptyset(&sa.sa_mask);
if (sigaction(SIGBUS, &sa, NULL) < 0) { perror("sigaction"); return 1; }
if (sigaction(SIGSEGV, &sa, NULL) < 0) { perror("sigaction"); return 1; }
/* 文件占用的最后一页之后没有文件数据:访问该页会 SIGBUS */
if (sigsetjmp(bus_env, 1) == 0) {
volatile char c = p[body]; /* 第一个完全在 EOF 之后的页 */
printf("read beyond EOF: %c (unexpected)\n", c);
} else {
printf("SIGBUS/SIGSEGV caught at p[%zu]\n", body);
}
size_t pages = body / (size_t)ps;
void *vec = malloc(pages);
if (vec && mincore(p, len, vec) == 0) {
size_t resident = 0;
for (size_t i = 0; i < pages; i++) resident += ((unsigned char *)vec)[i] & 1;
printf("pages=%zu resident=%zu\n", pages, resident);
} else {
perror("mincore");
}
free(vec);
munmap(p, maplen);
close(fd);
return 0;
}
在 Linux(4 KB 页)上对 /etc/hostname(13 字节)运行:
$ ./mapfile /etc/hostname
len=13 first=h last=
SIGBUS/SIGSEGV caught at p[4096]
pages=1 resident=1
三个关键点:
body是文件占用的整页数。13 字节占 1 页,body=4096;映射长度多出一页(maplen=8192),访问p[4096]落在完全在 EOF 之后的页,触发SIGBUS。若只映射len字节,p[13]落在第一页的零填充区,读到 0 而不是崩溃。SIGBUS与SIGSEGV都可能:前者表示「映射有效但数据不可用」(EOF 之后、文件被截断),后者表示「地址不在映射内/权限不符」。mapfile两个都捕获。mincore的resident是近似值:它只回答「这一页当前是否在页缓存」,不保证下一秒还在;MADV_DONTNEED之后会立刻变 0。
在 Apple Silicon(16 KB 页)上,同样的代码会打印 caught at p[16384],因为 sysconf(_SC_PAGESIZE) 返回 16384。这提醒一件事:页大小不是常数,跨平台代码必须用 sysconf,不能写死 4096。
1.9.13 实验:用 /proc、mincore、perf 看虚拟内存
/proc/self/maps:地址空间的布局
$ cat /proc/self/maps
55d0a3c1d000-55d0a3c1e000 r--p 00000000 08:01 1234567 /usr/bin/cat
55d0a3c1e000-55d0a3c26000 r-xp 00001000 08:01 1234567 /usr/bin/cat
55d0a3c26000-55d0a3c27000 r--p 00009000 08:01 1234567 /usr/bin/cat
55d0a3c27000-55d0a3c28000 r--p 0000a000 08:01 1234567 /usr/bin/cat
55d0a3c28000-55d0a3c29000 rw-p 0000b000 08:01 1234567 /usr/bin/cat
55d0a4b3a000-55d0a4b5b000 rw-p 00000000 00:00 0 [heap]
7f3c2a000000-7f3c2a021000 rw-p 00000000 00:00 0
7f3c2a021000-7f3c2c000000 ---p 00000000 00:00 0
7f3c2c000000-7f3c2c001000 r--p 00000000 00:00 0 [vvar]
7f3c2c001000-7f3c2c003000 r-xp 00000000 00:00 0 [vdso]
7ffd8a7e5000-7ffd8a806000 rw-p 00000000 00:00 0 [stack]
每行是 起始-结束 权限 偏移 设备 inode 路径。读法:
- 同一个可执行文件通常有 4–5 段:
r--p(只读数据/ELF 头)、r-xp(代码)、r--p(只读常量)、rw-p(数据/BSS)。 [heap]是brk区;[stack]是主线程栈;[vdso]/[vvar]是内核映射的只读页。- 大块
---p是PROT_NONE的 guard page(线程栈的保护区)。 MAP_SHARED的文件映射会显示路径;匿名映射 inode 为 0。
smaps:RSS 与 PSS
$ grep -E 'Rss|Pss|Shared_Clean|Private_Dirty|Swap' /proc/self/smaps_rollup
Rss: 1234 kB
Pss: 987 kB
Shared_Clean: 200 kB
Private_Dirty: 700 kB
Swap: 0 kB
Rss 是驻留集(实际占的物理页),Pss 把共享页按共享进程数均摊。容器内存限制看 Rss/Pss,不是 VSZ(虚拟大小);VSZ 包含未分配的映射,通常大得吓人但没有意义。
pagemap:查一个虚拟页的物理帧
/* 读 /proc/self/pagemap,每 8 字节对应一个虚拟页 */
uint64_t entry;
off_t off = (off_t)((uintptr_t)addr / page_size) * 8;
pread(pm_fd, &entry, 8, off);
int present = (entry >> 63) & 1;
int swapped = (entry >> 62) & 1;
uint64_t pfn = entry & ((1ULL << 55) - 1); /* 需要 CAP_SYS_ADMIN 才非零 */
未授权时 PFN 恒为 0,但 present/swapped 仍可读。它能验证「mmap 后未访问的页不在内存」「MADV_DONTNEED 后 present 变 0」。
perf:缺页计数
$ perf stat -e page-faults,minor-faults,major-faults ./mapfile /etc/hostname
1,234 page-faults
1,200 minor-faults
34 major-faults
minor 是「页在内存但页表没建」(如 COW、首次访问匿名页),major 是「需要读磁盘」(文件页未缓存、swap in)。major-faults 高说明 I/O 或 swap 压力;minor-faults 高说明大量按需分配/COW。
观察 madvise 的效果
size_t len = 64u << 20;
char *p = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
memset(p, 1, len); /* 全部 fault in,RSS 涨到 64 MB */
madvise(p, len, MADV_DONTNEED); /* 丢弃,RSS 回落 */
在两次操作后分别读 /proc/self/status 的 VmRSS,能看到几十 MB 的落差。MADV_FREE 更懒,RSS 不会立刻降,直到内存压力到来。
1.9.14 Rust 的所有权、分配器与 GC 对照
Rust 没有 GC,但也不是「手动 free」:所有权在编译期决定释放点,Drop 在作用域结束时自动调用。
// alloc.rs — rustc -O alloc.rs -o alloc_rs
fn main() {
let v: Vec<u64> = (0..1_000_000).collect(); // 堆分配,容量可能 > 长度
println!("len={} cap={}", v.len(), v.capacity());
let w = v; // 移动,v 不再可用
println!("sum={}", w.iter().sum::<u64>());
} // w 离开作用域,自动 free
| 概念 | C | Rust |
|---|---|---|
| 分配 | malloc | Box::new/Vec/String |
| 释放 | free | Drop(作用域结束) |
| 扩容 | realloc | Vec::reserve(可能搬家) |
| 失败 | 返回 NULL | handle_alloc_error(默认 abort) |
| 自定义 | malloc 钩子 | #[global_allocator] 实现 GlobalAlloc |
| 泄漏 | 忘记 free | mem::forget/Rc 循环 |
| 悬垂 | 可能 | 安全代码不可能,unsafe 可能 |
Rust 的 Vec 与 C 的 realloc 语义相近:容量不足时按几何增长(通常 2 倍),搬家并复制;with_capacity 可以避免多次搬家。shrink_to_fit 对应「缩容」。Rust 的分配失败默认 abort(不是返回 NULL),因为大多数程序无法从 OOM 恢复;需要可失败分配时用 try_reserve。
#[global_allocator] 可以替换全局分配器:
#![allow(unused)]
fn main() {
use std::alloc::{GlobalAlloc, Layout, System};
struct Counting;
unsafe impl GlobalAlloc for Counting {
unsafe fn alloc(&self, l: Layout) -> *mut u8 { System.alloc(l) }
unsafe fn dealloc(&self, p: *mut u8, l: Layout) { System.dealloc(p, l) }
}
#[global_allocator]
static A: Counting = Counting;
}
这跟 C 的 LD_PRELOAD 拦截 malloc 是同一类手段,只是 Rust 在链接期就固定了分配器。生产上常用 jemalloc/mimalloc 替换系统分配器,降低多线程碎片与锁竞争。
1.9.15 十二条虚拟内存与分配陷阱
| # | 现象 | 原因 | 修复 |
|---|---|---|---|
| 1 | 读写已 free 的指针 | use-after-free,块可能已被复用 | ASan/valgrind;置空指针;Rust 用所有权 |
| 2 | 重复 free | double free,glibc 报 SIGABRT | 置空后不再 free;用 -fsanitize=address |
| 3 | free 非堆指针 | 栈/全局/偏移过的指针 | 只 free malloc 原样返回的指针 |
| 4 | 越界写 1 字节 | 破坏下一块的 header | ASan;malloc_usable_size 不是容量承诺 |
| 5 | realloc 后还用旧指针 | realloc 可能搬家 | 用返回值覆盖旧指针 |
| 6 | mmap offset 非页对齐 | 内核返回 EINVAL | offset % sysconf(_SC_PAGESIZE) == 0 |
| 7 | 访问文件 EOF 之后的页 | SIGBUS | 按文件大小截断访问;捕获/处理 SIGBUS |
| 8 | MAP_FIXED 覆盖了堆 | 强制映射破坏已有区域 | 用 MAP_FIXED_NOREPLACE 或不指定地址 |
| 9 | fork 后子进程写大缓冲区卡顿 | COW 复制页表/物理页 | posix_spawn/vfork;减少 fork 前的大内存 |
| 10 | RSS 不降,OOM 被杀 | 分配器缓存/MADV_FREE/碎片 | malloc_trim、MADV_DONTNEED、调 arena 数 |
| 11 | 大页没生效 | THP 为 madvise 且未调用 | madvise(MADV_HUGEPAGE);检查 smaps 的 AnonHugePages |
| 12 | malloc(0) 返回 NULL 被当失败 | 实现允许返回 NULL 或唯一指针 | 单独处理 0 长度;不要解引用 |
- 「
malloc返回的内存是零」——只有calloc保证清零;malloc返回的是未初始化内存(可能含旧数据)。 - 「VSZ 大就是内存泄漏」——虚拟大小包含保留但未提交的映射;看
Rss/Pss与smaps。 - 「
free会把内存还给 OS」——小块通常留在 arena/tcache 里复用;只有 top chunk 收缩或mmap块munmap才还给内核。
1.9.16 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 拆分 48 位地址 | 能算出四级索引与 offset | 1.9.3 |
| 手算一次页表遍历 | 会算表项地址、PFN、PA | 1.9.3 |
| 解释 TLB 与大页 | 能说出 TLB reach 与内部碎片 | 1.9.4 |
| 区分 mmap 四种组合 | 能说出写时是否 COW/写回 | 1.9.5 |
| 解释隐式链表的分裂/合并 | 能画边界标记并算块大小 | 1.9.6 |
| 定位 UAF/越界 | 知道 ASan 与 Valgrind 的取舍 | 1.9.7 |
Q1 VA 0x000055555555a1b0 的四级索引与页内偏移分别是多少?
Q2 一个进程映射了 1 GiB 连续匿名内存,页表大约占多少内存(4 KiB 页)?
Q3 malloc(64 * 1024 * 1024) 在 glibc 上通常走 brk 还是 mmap?free 之后内存会立刻还给内核吗?
Q4 p = mmap(..., MAP_PRIVATE, fd, 0) 后修改 p[0],文件会变吗?如果改成 MAP_SHARED 呢?
答案
A1 索引依次为 (VA>>39)&0x1ff=170、(VA>>30)&0x1ff=341、(VA>>21)&0x1ff=170、(VA>>12)&0x1ff=346,offset = 0x1b0。
A2 1 GiB / 2 MiB = 512 个 PT 页,每个 4 KiB,共 2 MiB;加一个 PD 页、一个 PDPT 页、一个 PML4 页(各 4 KiB),约 2 MiB + 12 KiB。这是页表本身的开销,不含数据页。
A3 64 MiB 远大于 mmap_threshold(默认 128 KiB),glibc 通常直接 mmap 一块匿名内存。free 时这块会 munmap 还给内核;若是从 arena 分配的中小块,free 通常只回到空闲链表,堆顶不一定缩小。
A4 MAP_PRIVATE 下写触发 COW,修改只对当前映射可见,不写回文件。MAP_SHARED 下写会修改 page cache,其他映射同一文件的进程可见,并由内核在合适时机回写磁盘;要保证落盘还需 msync(MS_SYNC) 或 fsync。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| C(CSAPP) | 第 9 章 虚拟内存(9.1–9.12) | 1.9.1–1.9.7 | 完整:地址翻译、TLB、多级页表、mmap、分配器、内存错误 |
| O(OSTEP) | 第 13–20 章 地址空间、分段、分页、TLB、较小页表 | 1.9.1–1.9.4 | 理论骨架:翻译、页表结构、TLB |
| O(OSTEP) | 第 21–23 章 交换、策略、VAX/VMS | 1.9.2、1.9.4 | 背景:缺页与替换策略,详见 1.13 |
| K(Kerrisk) | 第 7 章 内存分配 | 1.9.5–1.9.6 | 精选:brk、mmap、malloc 实现 |
| K(Kerrisk) | 第 49 章 内存映射 | 1.9.5 | 完整:mmap 标志、msync、madvise |
| R(奔跑吧) | 第 2.2、2.7–2.10 节 页表/VMA/缺页 | 1.9.2–1.9.5 | 背景:Linux 侧实现,详见第 3 篇 |
CSAPP 第 9 章小节清单
| CSAPP 小节 | 页码 | 状态 | 本书落点 |
|---|---|---|---|
| 9.1 物理和虚拟寻址 | 596 | 已覆盖 | 1.9.1 |
| 9.2 地址空间 | 596 | 合并 | 1.9.1、1.9.5 |
| 9.3 虚拟内存作为缓存的工具 | 597 | 已覆盖 | 1.9.2 |
| 9.4 虚拟内存作为内存管理的工具 | 601 | 合并 | 1.9.5 |
| 9.5 虚拟内存作为内存保护的工具 | 603 | 合并 | 1.9.2、1.9.4 |
| 9.6 地址翻译 | 603 | 已覆盖 | 1.9.3、1.9.8(四级页表手算、PTE 位、TLB) |
| 9.7 案例研究:Intel Core i7/Linux 内存系统 | 612 | 合并 | 1.9.4、1.9.8;原 i7 细节更新为 4/5 级页表与现代 TLB |
| 9.8 内存映射 | 618 | 已覆盖 | 1.9.5、1.9.9、1.9.12(mmap/SIGBUS) |
| 9.9 动态内存分配 | 623 | 已覆盖 | 1.9.6、1.9.10、1.9.11(隐式/显式/分离/glibc) |
| 9.10 垃圾收集 | 641 | 已覆盖 | 1.9.10.1(mark-sweep/根集/保守 GC)、1.9.14(Rust 所有权) |
| 9.11 C 程序中常见的与内存有关的错误 | 645 | 已覆盖 | 1.9.7、1.9.15 |
| 9.12 小结 | 649 | 合并 | 1.9.15 |
下一站:页表把内存变成了一个个文件描述符背后的缓冲区,但程序看到的 I/O 接口仍然是
read/write。一次read从 fd 到磁盘要经过几层?短读、缓冲、重定向各自在哪里发生?进入 1.10 系统级 I/O。