Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 空间。

定义 1.9.1 · 虚拟地址空间

虚拟地址空间是进程可见的地址集合。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
虚拟地址到物理地址的四级翻译 CR3 指向 PML4,逐级取 9 位索引,最后加 12 位页内偏移。 x86-64 四级页表:48 位虚拟地址如何变成物理地址虚拟地址 47…0PMLE49bPML49bPDPT9bPD9bPT9bOFFSET12bCR3 → PML4 基址PML4E取 9 位索引 → PDPTPDPTE1 GiB 大页可分叉PDE2 MiB 大页可分叉PTE4 KiB 页基址物理页框PFN + 权限位TLB缓存最近翻译缺页异常页不在内存 / 权限不符page fault 处理分配页 / COW / 换入命中 TLB 则跳过未命中 → 硬件/内核处理完重试指令每级 9 位、页内 12 位:单级页表 512 项;48 位地址空间 = 256 TiB;大页减少级数与 TLB 压力
读图:从右往左读位域:低 12 位是页内偏移,上面四级各 9 位。每级页表 512 项;TLB 命中时整条路径被短路。PDE/PDPTE 的大页位可以让翻译提前结束——这是 2 MiB/1 GiB 大页降低 TLB miss 的原理。

读图时注意两件事:每一级表项都指向「下一级表的物理基址」,而不是虚拟地址;最后一级 PTE 的 PFN 与 offset 拼成物理地址。页表本身也在物理内存里,所以内核遍历页表时用的是物理地址(或内核直接映射区里的虚拟地址)。

例 1.9.1 · 从 0x00007f3c5d2e8a40 走到物理地址

设 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 dTLB64 项256 KiB128 MiB
L2 STLB1536 项6 MiB3 GiB

这张表说明大页为什么有效:同样 64 个 TLB 项,2 MiB 页能覆盖 128 MiB 工作集,4 KiB 页只能覆盖 256 KiB。数据库、JVM、DPDK 这类大工作集程序常用大页减少 TLB 未命中;代价是内部碎片(最后一页没用满也要占 2 MiB)和分配难度。

关于地址翻译的四个误解
  1. 「页表是一张连续的数组」——四级页表是树,未映射的区间根本不分配下级表。一个进程只为自己用到的地址建分支。
  2. 「TLB 未命中就要读磁盘」——TLB 未命中只是走页表;页表项 P=1 时根本不涉及磁盘。只有 PTE 不在内存才可能主缺页。
  3. 「大页一定更快」——大页减少 TLB 压力,但增加内部碎片和内存碎片;小工作集用大页可能浪费内存,甚至因缺页变慢。
  4. 「虚拟地址连续,物理地址也连续」——完全无关。相邻 VA 可以映射到任意物理页;只有大页才保证 2 MiB/1 GiB 的物理连续性。

1.9.4 多级页表、大页与 TLB 维护

单级页表在 48 位地址下不可行:页数 248/212=2362^{48}/2^{12} = 2^{36},每项 8 字节,需要 2392^{39} 字节 = 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 位空间全部映射)是 5123×4 KiB=512 GiB512^3 \times 4\text{ KiB} = 512\text{ GiB},实际进程远小于此。

大页在页表里用 PS 位表示:

层级页大小偏移位典型用途
PT(最后一级)4 KiB12普通页
PD(PS=1)2 MiB21THP、数据库、JVM 堆
PDPT(PS=1)1 GiB30虚拟化、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
例 1.9.2 · 一次分裂与合并

堆初始是一个 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。

关于 malloc 与虚拟内存的六个误解
  1. 「虚拟内存就是 swap」——虚拟内存是地址翻译与保护;swap 只是页回收的一个去处,没有 swap 系统也能正常工作。
  2. 「malloc 一定来自堆(brk)」——大块分配直接 mmap;glibc 还有 per-thread arena 和 tcache,地址可能分散在多个映射区。
  3. 「free 会把内存还给操作系统」——通常只还给分配器的空闲链表;只有 mmap 的大块在 free 时 munmap,或者调用 malloc_trim 才可能把堆顶还给内核。
  4. 「页大小总是 4 KiB」——x86-64 基础页是 4 KiB,但支持 2 MiB/1 GiB 大页;ARM64 可用 4/16/64 KiB 基础页。写死 4096 的代码不可移植。
  5. 「数组越界一定会立刻崩溃」——越界可能写到相邻块的元数据或另一个对象,崩溃点离 bug 很远。用 ASan/Valgrind 才能在越界发生处报错。
  6. 「/proc/pid/maps 能看出进程用了多少物理内存」——maps 只有虚拟区间;物理 resident 看 smaps 的 Rss,共享页按份额看 Pss。

1.9.7 常见内存错误与工具

错误典型症状首选工具
堆越界写相邻块元数据损坏,free 时崩溃ASan、Valgrind
释放后使用(UAF)读到已复用对象的数据,偶发崩溃ASan、MTE(ARM)
双重释放free(): double free detectedglibc 检测、ASan
内存泄漏RSS 持续增长,长跑后 OOMValgrind --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 的位

位名称含义
0P存在;0 时其余位由软件解释(swap 等)
1R/W0=只读,1=可写
2U/S0=内核,1=用户可访问
3PWT页级写穿透
4PCD禁止缓存(用于 MMIO)
5A访问过(由 CPU 置位,OS 可清)
6D脏(写过)
7PS大页:在 PD 层表示 2 MB,在 PDPT 层表示 1 GB
8G全局页(TLB 不因 CR3 切换而失效)
9–11AVL软件可用
12–51PFN物理页帧号
52–58软件Linux 用(如 soft-dirty、uprobe)
59–62PK保护键
63NX禁止取指(需 EFER.NXE)

A/D 位让内核能实现 LRU 式的页回收:扫描时读 A 位判断「最近是否用过」,清 A 位后下一轮再看。这也是为什么 mincore 只能给出「大概驻留」,真正的访问信息要靠 A/D 位与 idle_page_tracking。

手算一次 walk

取虚拟地址 0x00007f8c12345678,CR3 = 0x1a2b3000,各级表项如下(低 12 位是标志):

级索引表项地址表项值下一级基址
PML40xff0x1a2b37f80x1a2b50670x1a2b5000
PDPT0x300x1a2b51800x1a2b70670x1a2b7000
PD0x910x1a2b74880x1a2b90670x1a2b9000
PT0x1450x1a2b9a280x1a2bd867页帧 0x1a2bd000

计算过程:

  1. PML4 索引 = (va >> 39) & 0x1ff = 0xff;表项地址 = CR3 + 0xff*8 = 0x1a2b37f8。表项 0x1a2b5067 的 PFN 是 0x1a2b5,下一张表在 0x1a2b5000。
  2. PDPT 索引 = (va >> 30) & 0x1ff = 0x30;表项地址 = 0x1a2b5000 + 0x30*8 = 0x1a2b5180。
  3. PD 索引 = (va >> 21) & 0x1ff = 0x91;表项地址 = 0x1a2b7000 + 0x91*8 = 0x1a2b7488。
  4. PT 索引 = (va >> 12) & 0x1ff = 0x145;表项地址 = 0x1a2b9000 + 0x145*8 = 0x1a2b9a28。
  5. 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 dTLB64–964–8 路1 周期
L1 iTLB64–1284–8 路1 周期
L2 STLB1536–204812–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 的一定要。

判断一个地址是否 canonical

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);
参数取值说明
addrNULL 或页对齐地址NULL 让内核选;非 NULL 且带 MAP_FIXED 时强制覆盖
length> 0内核按页向上取整;0 返回 EINVAL
protPROT_NONE/READ/WRITE/EXEC可组合;与文件打开模式冲突时 EACCES
flagsMAP_SHARED/PRIVATE/...必须二选一(SHARED 或 PRIVATE)
fd文件描述符或 -1MAP_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_VALIDATEDAX/校验标志

返回值:成功是映射起始地址(页对齐),失败是 MAP_FAILED(即 (void *)-1),并设置 errno。常见 errno:

errno原因
EINVALlength=0、offset 未页对齐、flags 冲突
EACCES文件权限与 prot 冲突(如只读 fd 上 PROT_WRITE + MAP_SHARED)
ENOMEM虚拟地址空间不足、超过 RLIMIT_AS、overcommit 拒绝
ENODEV文件系统不支持 mmap
EBADFfd 无效(非匿名映射)
EPERMMAP_HUGETLB 无权限、/proc 文件被拒绝
EOVERFLOWoffset+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 的四个误解
  1. 「mmap 比 read 快」——对大文件顺序读,mmap 省一次拷贝;但小文件、随机读、需要错误处理时,read 更简单可控,且不会有 SIGBUS。
  2. 「MAP_PRIVATE 不占内存」——它仍然要建立页表、按需分配物理页;写时复制只是延迟了复制。
  3. 「msync 能保证落盘」——MS_SYNC 只保证写到设备缓存,真正的持久化需要 fsync/fdatasync 或设备写屏障。
  4. 「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)是否空闲,合并。
  • 复杂度:malloc O(块数),free O(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/slubO(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不可达时停顿、内存放大
为什么内核不用 glibc malloc

内核没有用户态堆,也不能在中断上下文睡眠;它用 kmalloc/vmalloc/slab,按大小分级、per-CPU 缓存、GFP 标志控制可否睡眠。用户态的 malloc 依赖 brk/mmap,在内核里对应 do_brk/vm_mmap,但分配策略完全不同。第 3 篇会展开 slab/slub。

标记-清扫、根集与保守 GC

垃圾收集要解决两个问题:哪些对象还活着(可达性),怎么回收(清扫/复制/整理)。

标记-清扫(mark-sweep):

  1. 根集:寄存器、栈、全局数据段、TLS 中的指针;
  2. 从根出发深度/广度遍历,标记所有可达对象;
  3. 未标记的对象进入空闲链表;
  4. 可选「整理(compact)」把存活对象搬在一起,消除外部碎片。

C 没有类型信息,无法区分「一个 8 字节整数」和「一个指针」,所以只能用保守式 GC:把每个落在堆地址范围内的字都当成潜在指针,宁可误保留(假指针/浮动垃圾),也不误回收。代价是延迟回收与不能移动对象(移动后旧指针失效)。

精确 GC(Java/Go)依赖编译器生成栈图(stack map)、写屏障(write barrier)与分代/并发算法,能移动对象、做增量/并发回收。Rust 不用 GC:所有权在编译期决定释放点,Rc/Arc 做引用计数;循环引用要用 Weak 打破。

GC 的四个误解
  1. GC 不等于「不会泄漏」:全局缓存、未注销的回调、ThreadLocal 仍会持有对象。
  2. 引用计数不是万能:循环引用需要弱引用/环检测;每次增减都有开销。
  3. 保守 GC 的「假指针」会延迟回收,不是正确性问题;但可能让内存峰值偏高。
  4. 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

三个关键点:

  1. body 是文件占用的整页数。13 字节占 1 页,body=4096;映射长度多出一页(maplen=8192),访问 p[4096] 落在完全在 EOF 之后的页,触发 SIGBUS。若只映射 len 字节,p[13] 落在第一页的零填充区,读到 0 而不是崩溃。
  2. SIGBUS 与 SIGSEGV 都可能:前者表示「映射有效但数据不可用」(EOF 之后、文件被截断),后者表示「地址不在映射内/权限不符」。mapfile 两个都捕获。
  3. 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
概念CRust
分配mallocBox::new/Vec/String
释放freeDrop(作用域结束)
扩容reallocVec::reserve(可能搬家)
失败返回 NULLhandle_alloc_error(默认 abort)
自定义malloc 钩子#[global_allocator] 实现 GlobalAlloc
泄漏忘记 freemem::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重复 freedouble free,glibc 报 SIGABRT置空后不再 free;用 -fsanitize=address
3free 非堆指针栈/全局/偏移过的指针只 free malloc 原样返回的指针
4越界写 1 字节破坏下一块的 headerASan;malloc_usable_size 不是容量承诺
5realloc 后还用旧指针realloc 可能搬家用返回值覆盖旧指针
6mmap offset 非页对齐内核返回 EINVALoffset % sysconf(_SC_PAGESIZE) == 0
7访问文件 EOF 之后的页SIGBUS按文件大小截断访问;捕获/处理 SIGBUS
8MAP_FIXED 覆盖了堆强制映射破坏已有区域用 MAP_FIXED_NOREPLACE 或不指定地址
9fork 后子进程写大缓冲区卡顿COW 复制页表/物理页posix_spawn/vfork;减少 fork 前的大内存
10RSS 不降,OOM 被杀分配器缓存/MADV_FREE/碎片malloc_trim、MADV_DONTNEED、调 arena 数
11大页没生效THP 为 madvise 且未调用madvise(MADV_HUGEPAGE);检查 smaps 的 AnonHugePages
12malloc(0) 返回 NULL 被当失败实现允许返回 NULL 或唯一指针单独处理 0 长度;不要解引用
三个关于虚拟内存的误解
  1. 「malloc 返回的内存是零」——只有 calloc 保证清零;malloc 返回的是未初始化内存(可能含旧数据)。
  2. 「VSZ 大就是内存泄漏」——虚拟大小包含保留但未提交的映射;看 Rss/Pss 与 smaps。
  3. 「free 会把内存还给 OS」——小块通常留在 arena/tcache 里复用;只有 top chunk 收缩或 mmap 块 munmap 才还给内核。

1.9.16 本章出口检查

检查项达标线没达标去哪
拆分 48 位地址能算出四级索引与 offset1.9.3
手算一次页表遍历会算表项地址、PFN、PA1.9.3
解释 TLB 与大页能说出 TLB reach 与内部碎片1.9.4
区分 mmap 四种组合能说出写时是否 COW/写回1.9.5
解释隐式链表的分裂/合并能画边界标记并算块大小1.9.6
定位 UAF/越界知道 ASan 与 Valgrind 的取舍1.9.7
即时自测 · 1.9

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/VMS1.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。