饭后和同事散步,聊起最近看的 sandbox snapshot,然后讨论起 mmap。
其实是一个很普通的问题,但技术话题经常就是这样,你一言我一语,后面就会牵出一串问题:
- “映射到内存里”的实际机制是什么?
page fault是异常,还是正常的?malloc和mmap是什么关系?- 为什么一个进程看起来占了很大内存,实际上未必真的吃掉了那么多 RAM?
正好趁着这次讨论的点,把这些问题串起来,重新复习一下“内存”这件事。
一、从 mmap 开始:它到底做了什么?
先给一个比较直白的定义:
mmap 是操作系统提供的一种内存映射机制,它可以把文件的一段内容,或者一块匿名内存,映射到进程的虚拟地址空间中。
第一次接触 mmap 时,很容易把它理解成“把文件整个读进内存”。这不算完全错,但不够精确。
更准确的过程是这样的:
- 在进程的虚拟地址空间中划出一段地址区间。
- 让这段地址区间和文件的某个偏移区间建立关联。
- 程序以后访问这段地址时,就像访问普通内存一样。
- 真正的数据装载通常不是在
mmap调用那一刻完成的,而是在访问到具体页面时按需发生。
也就是说,mmap 的关键不是“立刻拷贝”,而是“先建立映射关系,再按页、按需加载”。
这件事的重要性在于,它把“文件访问”改写成了“内存访问”。程序看到的是一段地址,至于这段地址背后的数据什么时候真正进入物理内存,由内核在背后调度。
如果只记一句话,可以记这个:
mmap 的本质,是把文件内容或匿名内存映射进虚拟地址空间,而不是简单地把文件一次性读进 RAM。
二、页是什么:操作系统管理内存的基本单位
说到 mmap,很快就会遇到“页”这个词。
页,英文叫 page,是操作系统管理内存的基本单位。内存不是按 1 字节去管理的,而是切成固定大小的一块一块,这一块就是页。
常见的页大小通常有:
4KB- 也可能有大页,比如
2MB、1GB
为什么操作系统一定要引入页?
因为如果没有页,很多事情都没法高效完成:
- 虚拟内存无法统一映射
- 物理内存无法统一分配
- 文件无法按需装入
- 内存无法方便地换入换出
- 权限也无法细粒度控制
所以,从操作系统视角看,内存最自然的单位不是变量、对象,也不是一整块缓冲区,而是页。
你在程序里访问 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 内存,然后往里写两个字节。
但背后可能发生的是:
malloc(8192)向运行库申请 8KB 内存。- 分配器从自己管理的区域里找出一块可用空间返回。
- 返回的是一段虚拟地址。
- 第一次写
p[0]时,所在页可能触发首次映射。 - 写
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,而是直接把文件映射进地址空间,然后像访问一段内存一样访问它。
底层大致发生的是:
mmap建立了“虚拟地址区间 <-> 文件偏移区间”的关系。- 程序第一次访问某页时,如果它尚未装入内存,就会触发
page fault。 - 内核把文件对应页读入物理内存。
- 更新页表。
- 程序继续执行。
所以从用户态看来,像是在直接读内存;从内核视角看,文件页是在访问时按需装进来的。
六、malloc 和 mmap:一个是接口,一个是机制
这是一个非常容易混淆的问题。
更准确的理解是:
malloc是运行库提供的内存分配接口mmap是内核提供的内存映射机制
写 malloc(100),表达的是:
“给我一块 100 字节能用的内存,至于底下怎么实现,由运行库决定。”
写 mmap(...),表达的是:
“我想明确建立一段虚拟地址与文件或匿名内存之间的映射关系。”
很多系统里,malloc 底层本身就会结合使用:
brk/sbrkmmap
尤其是较大的内存分配,经常会直接走 mmap。
七、堆是什么?
说 malloc 时,经常会听到一句话:“分配器从堆里切一块给你。”
这里的“堆”不是堆排序里的堆,也不是优先队列,而是进程虚拟地址空间中的 heap 区域。
一个典型进程的虚拟地址空间,大致可以画成这样:
高地址
+-----------------------------+
| 栈 stack |
| 函数调用、局部变量、返回地址 |
+-----------------------------+
| mmap 映射区 |
| 文件映射、匿名映射、共享库 |
+-----------------------------+
| 堆 heap |
| malloc 常在这里管理小块内存 |
+-----------------------------+
| 数据区 data / bss |
| 全局变量、静态变量 |
+-----------------------------+
| 代码区 text |
| 程序机器指令、只读常量 |
+-----------------------------+
低地址
图里展示的不是“一整块均匀的内存”,而是一张由代码区、数据区、堆、映射区、栈等不同区域拼起来的虚拟地址空间地图。堆和 mmap 区只是其中两块,底层仍然都按页管理。
“从堆里切”的意思是:
- 分配器先向系统申请一大块虚拟地址空间。
- 这块区域通常位于 heap 区域。
- 分配器自己维护这块区域内的空闲块。
- 以后
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 在很多场景里都很关键:
- 大文件随机访问
- 共享内存
- 数据库和索引系统
- 快照、缓存、零拷贝风格的数据处理
- 需要精细控制页级行为的系统程序