饭后和同事散步,聊起最近看的 sandbox snapshot,然后讨论起 mmap

其实是一个很普通的问题,但技术话题经常就是这样,你一言我一语,后面就会牵出一串问题:

  • “映射到内存里”的实际机制是什么?
  • page fault 是异常,还是正常的?
  • mallocmmap 是什么关系?
  • 为什么一个进程看起来占了很大内存,实际上未必真的吃掉了那么多 RAM?

正好趁着这次讨论的点,把这些问题串起来,重新复习一下“内存”这件事。

一、从 mmap 开始:它到底做了什么?

先给一个比较直白的定义:

mmap 是操作系统提供的一种内存映射机制,它可以把文件的一段内容,或者一块匿名内存,映射到进程的虚拟地址空间中。

第一次接触 mmap 时,很容易把它理解成“把文件整个读进内存”。这不算完全错,但不够精确。

更准确的过程是这样的:

  1. 在进程的虚拟地址空间中划出一段地址区间。
  2. 让这段地址区间和文件的某个偏移区间建立关联。
  3. 程序以后访问这段地址时,就像访问普通内存一样。
  4. 真正的数据装载通常不是在 mmap 调用那一刻完成的,而是在访问到具体页面时按需发生。

也就是说,mmap 的关键不是“立刻拷贝”,而是“先建立映射关系,再按页、按需加载”。

这件事的重要性在于,它把“文件访问”改写成了“内存访问”。程序看到的是一段地址,至于这段地址背后的数据什么时候真正进入物理内存,由内核在背后调度。

如果只记一句话,可以记这个:

mmap 的本质,是把文件内容或匿名内存映射进虚拟地址空间,而不是简单地把文件一次性读进 RAM。

二、页是什么:操作系统管理内存的基本单位

说到 mmap,很快就会遇到“页”这个词。

页,英文叫 page,是操作系统管理内存的基本单位。内存不是按 1 字节去管理的,而是切成固定大小的一块一块,这一块就是页。

常见的页大小通常有:

  • 4KB
  • 也可能有大页,比如 2MB1GB

为什么操作系统一定要引入页?

因为如果没有页,很多事情都没法高效完成:

  • 虚拟内存无法统一映射
  • 物理内存无法统一分配
  • 文件无法按需装入
  • 内存无法方便地换入换出
  • 权限也无法细粒度控制

所以,从操作系统视角看,内存最自然的单位不是变量、对象,也不是一整块缓冲区,而是页。

你在程序里访问 buf[100],系统首先关心的不是“你在操作哪个变量”,而是“这个地址属于哪一页”。

三、虚拟地址、页表、物理地址与 page fault

如果要真正理解现代内存模型,这四个概念最好放在一起看。

1. 虚拟地址:程序看到的地址

程序平时操作的地址,通常不是物理地址,而是虚拟地址。

每个进程都有自己的虚拟地址空间。程序以为自己在读写一段连续地址,但这只是进程视角下的“地址世界”,并不等于真实的 RAM 位置。

这也是为什么不同进程可以拥有看起来相似的地址,却互不干扰。因为它们的虚拟地址背后,对应的物理内存并不相同。

2. 页表:负责翻译地址

CPU 不能直接拿虚拟地址去访问物理内存。它需要一张“翻译表”把虚拟地址转换成物理地址,这张表就是页表。

页表记录的是:

  • 某个虚拟页对应哪个物理页帧
  • 这个映射有没有读、写、执行权限
  • 这个页当前是否有效

CPU 在访问一个地址时,会先把它拆成两部分:

  • 虚拟页号
  • 页内偏移

然后用虚拟页号查页表,找到对应的物理页帧,再加上页内偏移,得到最终的物理地址。

这里顺手区分两个概念:

  • 虚拟页:虚拟地址空间中的页
  • 物理页帧:物理内存中等大小的槽位

3. 物理地址:数据真正落在 RAM 的位置

经过页表翻译后,CPU 才知道该去物理内存的哪里取数据。这个位置就是物理地址。

所以程序表面上像是在“直接读内存”,实际上中间始终隔着一层地址翻译。

4. page fault:页没准备好时,内核接管

如果 CPU 查页表时发现:

  • 这个虚拟页还没有映射到物理页
  • 或者当前访问权限不合法

就会触发 page fault

看到 fault 很容易下意识以为这是错误,其实未必。很多时候,它只是内存系统的正常工作方式。

比如:

  • 匿名内存第一次被访问
  • mmap 映射的文件页第一次被访问

这时页还没真正到位,于是发生 page fault,内核介入:

  • 如果访问合法,就为这个页分配物理页,或把文件对应页读入内存,再更新页表
  • 如果访问非法,比如写只读页,才会变成真正的错误,比如 SIGSEGV

四、先看一个普通例子:malloc

很多抽象概念,放到代码里就突然变清楚了。

#include <stdio.h>
#include <stdlib.h>

int main() {
    size_t n = 8192;  // 8KB,通常对应两个 4KB 页
    char *p = malloc(n);
    if (!p) {
        perror("malloc");
        return 1;
    }

    printf("p = %p\n", (void *)p);

    p[0] = 'A';
    p[4096] = 'B';

    printf("%c %c\n", p[0], p[4096]);

    free(p);
    return 0;
}

这段代码表面上只是申请了 8KB 内存,然后往里写两个字节。

但背后可能发生的是:

  1. malloc(8192) 向运行库申请 8KB 内存。
  2. 分配器从自己管理的区域里找出一块可用空间返回。
  3. 返回的是一段虚拟地址。
  4. 第一次写 p[0] 时,所在页可能触发首次映射。
  5. p[4096] 时,刚好跨到第二页,第二页也可能首次建立物理映射。

也就是说,malloc 成功返回,通常只代表“你拿到了一段合法的虚拟地址空间”,不代表底下的物理内存已经全部到位。

真正“打实”地占用物理内存,很多时候是在第一次访问具体页面时才发生的。

五、再看 mmap:匿名映射和文件映射

1. 匿名映射

#include <stdio.h>
#include <sys/mman.h>
#include <unistd.h>

int main() {
    size_t n = 8192;  // 两页

    char *p = mmap(NULL, n,
                   PROT_READ | PROT_WRITE,
                   MAP_PRIVATE | MAP_ANONYMOUS,
                   -1, 0);
    if (p == MAP_FAILED) {
        perror("mmap");
        return 1;
    }

    printf("p = %p\n", (void *)p);

    p[0] = 'X';
    p[4096] = 'Y';

    printf("%c %c\n", p[0], p[4096]);

    munmap(p, n);
    return 0;
}

这里的 MAP_ANONYMOUS 表示不映射文件,只是向内核申请一段匿名虚拟地址空间。

它和 malloc 在一个关键点上很像:拿到地址,不代表物理页已经准备好了。通常仍然是访问到某一页时,这一页才真正落到物理内存上。

2. 文件映射

#include <fcntl.h>
#include <stdio.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>

int main() {
    int fd = open("data.txt", O_RDONLY);
    if (fd < 0) {
        perror("open");
        return 1;
    }

    struct stat st;
    if (fstat(fd, &st) < 0) {
        perror("fstat");
        close(fd);
        return 1;
    }

    char *p = mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
    if (p == MAP_FAILED) {
        perror("mmap");
        close(fd);
        return 1;
    }

    write(STDOUT_FILENO, p, st.st_size);

    munmap(p, st.st_size);
    close(fd);
    return 0;
}

这里最有意思的地方在于:我们并没有调用 read,而是直接把文件映射进地址空间,然后像访问一段内存一样访问它。

底层大致发生的是:

  1. mmap 建立了“虚拟地址区间 <-> 文件偏移区间”的关系。
  2. 程序第一次访问某页时,如果它尚未装入内存,就会触发 page fault
  3. 内核把文件对应页读入物理内存。
  4. 更新页表。
  5. 程序继续执行。

所以从用户态看来,像是在直接读内存;从内核视角看,文件页是在访问时按需装进来的。

六、mallocmmap:一个是接口,一个是机制

这是一个非常容易混淆的问题。

更准确的理解是:

  • malloc 是运行库提供的内存分配接口
  • mmap 是内核提供的内存映射机制

malloc(100),表达的是:

“给我一块 100 字节能用的内存,至于底下怎么实现,由运行库决定。”

mmap(...),表达的是:

“我想明确建立一段虚拟地址与文件或匿名内存之间的映射关系。”

很多系统里,malloc 底层本身就会结合使用:

  • brk/sbrk
  • mmap

尤其是较大的内存分配,经常会直接走 mmap

七、堆是什么?

malloc 时,经常会听到一句话:“分配器从堆里切一块给你。”

这里的“堆”不是堆排序里的堆,也不是优先队列,而是进程虚拟地址空间中的 heap 区域。

一个典型进程的虚拟地址空间,大致可以画成这样:

高地址
+-----------------------------+
| 栈 stack                    |
| 函数调用、局部变量、返回地址 |
+-----------------------------+
| mmap 映射区                 |
| 文件映射、匿名映射、共享库   |
+-----------------------------+
| 堆 heap                     |
| malloc 常在这里管理小块内存 |
+-----------------------------+
| 数据区 data / bss           |
| 全局变量、静态变量          |
+-----------------------------+
| 代码区 text                 |
| 程序机器指令、只读常量      |
+-----------------------------+
低地址

图里展示的不是“一整块均匀的内存”,而是一张由代码区、数据区、堆、映射区、栈等不同区域拼起来的虚拟地址空间地图。堆和 mmap 区只是其中两块,底层仍然都按页管理。

“从堆里切”的意思是:

  1. 分配器先向系统申请一大块虚拟地址空间。
  2. 这块区域通常位于 heap 区域。
  3. 分配器自己维护这块区域内的空闲块。
  4. 以后 malloc(100)malloc(200) 就在这里继续切小块返回。

这里最重要的层次感是:

  • 堆是一个区域
  • 页是管理粒度

也就是说,堆不是页,但堆由很多虚拟页组成。操作系统仍然按页管理这块区域,而分配器在用户态再把这些页细分成更小的块。

八、进程一启动,虚拟地址空间有多大?

这个问题如果问得不够精确,很容易误导自己。

严格说,进程启动时,并不是系统“先发给它一大块固定大小的内存”,而是:

  • 进程一启动,就拥有一整个虚拟地址空间视图
  • 这个空间的理论上限由 CPU 架构和操作系统决定
  • 真正已经映射出来的,只是其中一部分

比如:

  • 32 位进程理论地址空间上限约 4GB
  • 64 位进程理论上极大,实际用户态可用范围仍取决于 CPU 和 OS

但进程刚启动时,通常只会映射这些必要部分:

  • 程序自己的代码段和数据段
  • 动态链接器
  • 共享库,比如 libc
  • 主线程栈
  • 少量 heap
  • 一些内核辅助区域

也就是说,理论空间很大,但真正“点亮”的区域其实只是一部分。

九、/proc/self/maps:看见进程地址空间

Linux 下有个很好用的入口:/proc/self/maps

它展示的是当前进程虚拟地址空间中,所有已经建立的映射区域。

典型输出可能像这样:

55a1c2d8d000-55a1c2d8e000 r--p 00000000 08:01 123456 /home/user/a.out
55a1c2d8e000-55a1c2d8f000 r-xp 00001000 08:01 123456 /home/user/a.out
55a1c2d8f000-55a1c2d90000 r--p 00002000 08:01 123456 /home/user/a.out
55a1c2d90000-55a1c2d91000 rw-p 00002000 08:01 123456 /home/user/a.out
55a1d4f2a000-55a1d4f4b000 rw-p 00000000 00:00 0      [heap]
7f8c89a00000-7f8c89c00000 r--p 00000000 08:01 234567 /usr/lib/libc.so.6
7f8c89c00000-7f8c89d50000 r-xp 00020000 08:01 234567 /usr/lib/libc.so.6
7ffd5b7c9000-7ffd5b7ea000 rw-p 00000000 00:00 0      [stack]
7ffd5b7f5000-7ffd5b7f7000 r-xp 00000000 00:00 0      [vdso]

这份输出很有价值,因为它会把原本抽象的“进程地址空间”具体化成一张地图:

  • 代码段在哪
  • 数据段在哪
  • 堆在哪
  • 栈在哪
  • 共享库在哪
  • 匿名映射在哪

它会让你更直观地意识到:进程内存不是一块笼统的大空间,而是很多不同用途区域拼起来的一张结构化地图。

当然,maps 展示的是区域,内核底层真正管理它们时,仍然是按页进行的。

十、为什么虚拟内存大,不等于物理内存真的大?

这是理解现代内存系统最关键的一步之一。

#include <stdio.h>
#include <sys/mman.h>
#include <unistd.h>

int main() {
    size_t n = 1024L * 1024 * 1024; // 1GB
    char *p = mmap(NULL, n,
                   PROT_READ | PROT_WRITE,
                   MAP_PRIVATE | MAP_ANONYMOUS,
                   -1, 0);
    if (p == MAP_FAILED) {
        perror("mmap");
        return 1;
    }

    getchar(); // 先观察进程内存

    p[0] = 1;
    p[4096] = 2;

    getchar(); // 再观察一次

    munmap(p, n);
    return 0;
}

程序申请了 1GB 匿名映射,但这里要特别注意:

  • 虚拟地址空间里,确实多出了一段 1GB 映射
  • 物理内存却不一定立刻增加 1GB

原因很简单:绝大多数页还没有被访问。

如果只写了 p[0]p[4096],通常只会让前两个页真正“落地”。这时可能出现的情况是:

  • 虚拟内存占用:1GB
  • 物理内存占用:只有几个 KB

所以监控里的两个指标经常差很多:

  • VIRT:虚拟地址空间大小
  • RSS:当前驻留在物理内存中的页

这也是为什么一个进程看起来“占了很大内存”,不代表它真的把那么多 RAM 吃满了。

结语

内存不是一块简单连续的存储空间,而是由虚拟地址空间、页、页表、物理页、懒加载、文件映射和内核策略共同组织起来的一整套机制。

  • 程序工作在虚拟地址空间中
  • 页是操作系统管理内存的基本单位
  • 页表负责把虚拟页翻译成物理页帧
  • page fault 是懒加载的重要组成部分
  • 文件内容可以直接以地址的形式出现在进程空间里
  • 大块地址映射并不等于大块物理内存占用

从这个角度看,mmap 在很多场景里都很关键:

  • 大文件随机访问
  • 共享内存
  • 数据库和索引系统
  • 快照、缓存、零拷贝风格的数据处理
  • 需要精细控制页级行为的系统程序