先问你一个程序员迟早会撞上的问题:我要读一个几十 GB 的文件,最快的姿势是什么?
很多人第一反应是用 fread 或者 read 一点点读进缓冲区,再在内存里折腾。这个思路本身没错,但它藏着两个成本:一,每读一段就要闯一次"用户态到内核态"的大门(系统调用);二,读进你程序的这段数据,其实是内核帮你从硬盘捞上来、再"抄"到你缓冲区里的一份复制品——同样的数据在内存里躺着两份,纯粹是搬运工来回跑。
mmap 就是来治这两桩病的。它把文件"映射"到你的进程地址空间里,让你把文件当成一块连续的内存来读写——你要读第几个字节,就直接访问那块内存对应的地址,不再有逐段的 read,也不再有那趟冗余的搬运。这一篇,我们把 mmap 从上到下、从原理到坑,一次讲透。
一句话先认识 mmap
mmap 的全名是 memory map,直译就是"内存映射"。mmap、munmap 这两个函数的作用,用 man mmap 里那句最经典的描述就是:map or unmap files or devices into memory——把文件或设备映射进内存,或者反过来解映射。
它的核心思想一句话:让用户空间的程序,把一段文件的字节,直接看成虚拟地址空间里的一段内存来访问。 映射完成后,你不必再走传统的 read / write 系统调用去搬数据,而是像操作普通数组一样读写这块内存。
mmap 还有另外两大用途,不要漏掉:
- 用它实现共享内存:两个(或多个)进程各自
mmap同一个文件(并且用MAP_SHARED标志),它们就各自拥有一段"指向同一批物理页"的映射。一个进程改,另一个进程立刻看得见,这就是进程间共享数据的一种高效手段。 - 用它分配匿名内存:配合
MAP_ANONYMOUS标志,不绑定任何文件,纯粹向内核要一块私有的零初始化内存。glibc的malloc对于大块分配,底层就是靠它。
看到这里你可能已经隐约意识到:mmap 不只是一套文件 IO 的替代品,它是一把"虚拟内存"概念上的万能钥匙——文件是它的宿主,进程间是它的舞台,堆是它的舞台。理解它,等于把你脑子里关于"文件""内存""进程"这三张各画各的图,缝成了一张。
为什么 read/write 慢:一次 IO 的账
在动手写代码前,值得先把"慢在哪"算清楚,否则你体会不到 mmap 的价值。我们以"读一个文件"为例,对比 read 和 mmap 两条路分别发生了什么。
用 read 读一个文件(假设每次读 4KB,即一页):
- 程序调用
read(fd, buf, 4096); - 这是一次系统调用,CPU 要从用户态切到内核态;
- 内核检查页缓存(page cache,后文细讲)里有没有这一页。没有就从磁盘读上来,放进页缓存;
- 内核把页缓存里的这 4096 字节,再复制一遍到你的
buf里; - CPU 切回用户态,
read返回。
注意第 4 步:数据从内核空间的页缓存,复制到用户空间的缓冲区。这一份复制是 read 路线的固有代价——内核不能把页缓存的地址直接交给你操作,因为你用的是"拷进来一段"的语义。
而如果你用 read 读一个 100MB 的文件,每次只读 4KB,那么你要进行 100MB / 4KB = 25600 次 read 系统调用;如果还要写出去,再叠加 25600 次 write。光系统调用就是 51200 次。每次系统调用,都是一次寄存器保存、模式切换、检查复位的往返,量大时这笔开销非常可观。
用 mmap 读同一个文件:
- 程序调用一次
mmap(...),建立映射,返回一个起始地址; - 之后访问这块映射里的任意字节,CPU 直接在内核的协助下把对应的文件页(依旧在页缓存里)接到你的地址空间,没有逐段的
read,也没有那趟"向用户缓冲区复制"; - 用完之后掉头调用一次
munmap(...)。
整个生命周期里,系统调用只有 mmap 加 munmap 两三次(精确地说,访问新页面时还有缺页,那是内核内部的机制,后面单独讲)。文件多大,你都不会因为"块大小太小"而付出成千上万次系统调用的代价。这就是"系统调用次数"这一顶上的核心差异。
当然,"快"不是免费的,mmap 把成本换成了另一笔账:页表和缺页的管理。怎么权衡、什么场景适合,我们在讲完原理后专门讨论。
mmap 的六个参数
正式认识它。函数原型在头文件 <sys/mman.h> 里,两个相关函数如下:
// 建立映射。成功返回映射区起始地址;失败返回 MAP_FAILED(即 (void*)-1)
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
// 解除映射。成功返回 0;失败返回 -1 并设置 errno(通常为 EINVAL)
int munmap(void *addr, size_t length);六个参数,我们掰开揉碎,一个一个来。
第一个参数:addr —— 一个"提示地址"
addr 表示你"希望"映射区从哪个虚拟地址开始。但请把重点放在"希望"两个字上:它只是一个提示(hint),正常情况下会被内核无视。 尤其当你没有足够权限去占用指定地址时,内核不会迁就你,而是自己挑一个合适的位置。如果你传 NULL,系统就会完全自行选择一个合适的起始地址——绝大多数场景你都该传 NULL,把位置交给内核,省心又安全。
只有在你同时指定了 MAP_FIXED 这个标志时,addr 才会被严格当真("必须给我映射到这个地址,不然就报错")。这属于高级用法,后文"边界与坑"一节细讲。
第二个参数:length —— 要映射的字节数
length 是要映射进进程地址空间的字节数。它有一个绕不开的约束:页面是虚拟内存分配与管理的最小单位,所以内核按页来处理你的请求。
如果你给的 length 恰好是页大小的整数倍(常见 4KB,即 4096 字节),那最干净;如果 length 不是整数倍,内核也不会报错,而是向上向最近的页边界取整。比如页大小 4096 字节,你请求映射 3500 字节,内核实际会按 4096 字节结算。这和第 1 节术语"向上舍入"是同一件事。
为什么必须页对齐?因为"虚拟地址 <-> 物理页"的对应关系,是建立在整页地址之上的:一个虚拟页要么整体对应某个物理页(或文件页面),要么整体不存在。你不存在"只映射半个页"的中间态。这一点在讲"offset 为什么也要对齐"时会再次呼应。
第三个参数:prot —— 内存保护属性
prot 指定这段映射内内存的访问权限,来自下面几个值,可按位或(|)组合:
PROT_READ:可读。PROT_WRITE:可写。PROT_EXEC:可执行(里面放的是代码时用到)。PROT_NONE:不可访问(连读都不行,极少直接这么用)。
有一点容易踩坑:写法权限不是白给的。 如果你只是 PROT_READ 映射,之后却试图往这块内存里写,CPU 会因页表里这一页没有写权限而触发段错误(SIGSEGV),程序直接崩给你看。我们后文用示例验证。
第四个参数:flags —— 映射的类型与选项
flags 决定"这个映射是共享的还是私有的",以及其他行为选项。最核心的两个值互斥,你必须在 flags 里二选一(精确来说只能包含其中一个):
MAP_SHARED:共享映射。你写入映射区的修改,可见于同样映射这份文件的其他进程;对文件型映射而言,修改最终会"落"到底层文件里去(具体什么时候落,可通过msync精确控制)。这是"把文件当内存写"功能的开关。MAP_PRIVATE:私有映射,也就是写时复制(copy-on-write)映射。你对映射区的修改,其他映射同一文件的进程看不到,也不会写回到底层文件。这个词组的直译是"私有,各自复制",你可以理解为:一旦你想改,内核立刻给你一份私有的拷贝,你在拷贝上动手,原始文件毫发无损。它是加载可执行文件、动态库时的天然选择(代码段没人想改回磁盘上的二进制)。
除了这两个主要标志,还有一个你可能时常碰见的:
MAP_ANONYMOUS或MAP_ANON:匿名映射标志。指定后,这一块内存不与任何文件关联(此时fd传-1,被忽略,offset也通常传 0),是一段没有文件作后端存储的零初始化内存,常用于进程内部的私有内存分配——比如你后文要看到的"极简 malloc"。它通常和MAP_PRIVATE搭配,即MAP_PRIVATE | MAP_ANONYMOUS。
Linux 还有几个进阶标志,这里先点名,便于你日后查手册时不陌生:MAP_SHARED_VALIDATE(Linux 4.15 起,除 MAP_SHARED 语义外,还强制校验所有传入标志,遇到未知标志会以 EOPNOTSUPP 报错,是许多强大标志如 MAP_SYNC 的使用前提)、MAP_FIXED / MAP_FIXED_NOREPLACE、MAP_POPULATE 等。前两者我们在坑那节细讲。
第五个参数:fd —— 要映射的文件描述符
fd 是一个有效的、指向要映射的文件或设备的文件描述符。一个非常容易被初学者忽略的细节:映射建立后,这个文件描述符本身就不再参与后续的数据访问了——你可以立刻 close(fd),映射依然有效,因为映射关联的是内核里的文件对象(若仍是文件型)和页,而不是你的那个 fd 数字。
另外提醒:很多文件系统要求 O_RDONLY 打开的文件只能用 PROT_READ 映射,想 PROT_WRITE 映射就得用 O_RDWR 或至少 O_WRONLY(合法情况)打开,否则 mmap 会报 EACCES。你写的时候尽量让"打开方式"和"保护属性"匹配上,能少碰很多钉子。
第六个参数:offset —— 映射在文件里的起始偏移
offset 和你给的 length 一起,圈定了"文件的哪一段"被映射。它和 addr 暗示的起始地址对应上:映射区内地址 address 对应的文件偏移,就是 (address - 起始地址) + offset,即整段映射是一个按顺序对应的"窗户",窗口从文件的 offset 处开始,宽度为 length。这条"虚拟地址到文件偏移"的换算关系是理解一切"越界/对齐"问题的钥匙,后文专节展开。
offset 有一个硬性约束:它必须是页大小的整数倍(在绝大多数 Linux 平台上要求如此,可用 sysconf(_SC_PAGE_SIZE) 查询本机页大小)。为什么不满足会报错?原因和第 2 参数一样:映射的粒度是页,你要从文件"半中间"开窗当然可以,但窗口第一页对应的文件位置必须落在一页的边界上,否则无法用整页去承载你指定的起始偏移。想映射文件从第 1000 字节开始的区域?很遗憾,offset=1000 会得到 EINVAL,你得凑整到 4096 的倍数才能开工(这也是为什么很多"内存映射大文件"工具要求你把逻辑偏移对齐)。
返回值和 munmap
mmap 成功时,返回一个指向映射区的指针(void *),它就是你"把文件当内存"的身份证。失败时,返回一个专门用来充当"错误哨兵"的固定值:
#define MAP_FAILED ((void *) -1)MAP_FAILED 就是 (void *)-1,一个出错的哨兵值。判断失败必须拿返回值跟它比,而不是跟 NULL 比——映射成功的地址理论上也能落在很低的位置,只有 MAP_FAILED 才是失败签名的。同时 errno 会被设置为具体原因(比如 EACCES 权限、EINVAL 参数非法、ENOMEM 内存不足等)。所以正确姿势是:
// 以"映射一个文件供只读"为例,检查返回值是否正确
void *m = mmap(NULL, length, PROT_READ, MAP_SHARED, fd, 0);
if (m == MAP_FAILED) { // 用 MAP_FAILED 比对,而不是 NULL
perror("mmap"); // 打印具体失败原因
return -1; // 失败后自行决定如何处理(此处示意直接返回)
}munmap(addr, length) 用来解除映射。成功返回 0;失败返回 -1 并设置 errno。一个最常见的失败原因是 addr 不是页对齐、或者 length(配合 addr)构成的区间不合法,此时通常得到 EINVAL。另外,解除映射后,这段地址空间里的数据就"没了"——如果你的映射是 MAP_PRIVATE 且还没写回,那丢了就是丢了,所以要确保在想保留数据的时候先处理好(共享映射数据在脏页机制下一般已有人保管,私有映射则由不写规则天然豁免)。
close(fd) 在映射建立后就可以调用,它不会自动删除映射;真正"还回去"的动作是 munmap。所以一段典型生命周期是:open → mmap → 用(访问/修改/msync) → munmap → close。
示例一:把文件映射进来,直接写进去
空谈无用,上代码。这份程序创建(或覆盖打开)一个文件,先用 ftruncate 把它扩展到一页大小,再映射进来,往里面填字母,就像填一个普通字符数组。
// write_map.c —— 写一个文件,全程用 mmap 当数组操作
#include <stdio.h> // fprintf, perror
#include <unistd.h> // ftruncate, close, sysconf
#include <sys/stat.h> // fstat 等结构
#include <fcntl.h> // open, O_CREAT, O_RDWR
#include <sys/mman.h> // mmap, munmap, msync
#define SIZE 4096 // 映射 4096 字节,正好一个页面
int main(int argc, char *argv[])
{
if (argc != 2) { // 用法:./write_map 文件名
fprintf(stderr, "用法: %s <文件名>\n", argv[0]);
return 1;
}
// 注意: 要成功进行"写入映射",文件必须以可写方式打开 —— O_RDWR。
// 只读打开时,即便你 PROT_WRITE 映射,也会在 mmap 时因 EACCES 而失败。
int fd = open(argv[1], O_CREAT | O_RDWR, 0666); // 不存在则创建,权限 0666
if (fd < 0) { // open 失败返回负数
perror("open");
return 2;
}
// 关键一步: 新建文件大小为 0,长度 0 的文件没法承载一个页的映射内容。
// 用 ftruncate 把文件扩充到 SIZE 字节,多出来的部分以 0 填充。
if (ftruncate(fd, SIZE) == -1) {
perror("ftruncate");
close(fd);
return 3;
}
// 建立映射:地址提示用 NULL(交给内核选)、长度一页、
// 允许读写、共享映射(改动会写回文件)、偏移从 0(文件开头)开始。
char *m = mmap(NULL, SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (m == MAP_FAILED) { // 失败哨兵值,绝不是 NULL
perror("mmap");
close(fd);
return 4;
}
// 映射建立后,fd 已不再参与本段数据操作,可立刻关闭,映射依旧有效
close(fd);
// 像数组一样填充文件内容:第 i 个字节填 'a' 之后往后循环,填满整页
for (int i = 0; i < SIZE; i++) {
m[i] = 'a' + (i % 26); // 'a'+0..25 覆盖整个字母表循环
}
// 可选:显式让内核把"脏页"写回磁盘。不调用也可以,但调用后你就有保证:
// 数据此刻已落到文件里(MS_SYNC 同步等待写盘完成)。
if (msync(m, SIZE, MS_SYNC) == -1) {
perror("msync");
}
munmap(m, SIZE); // 解除映射,还地址空间
return 0;
}编译运行(一定要含 -g,后文用 gdb 演示内存布局时你会感谢自己):
gcc -g -Wall -o write_map write_map.c
./write_map demo.bin
# ls -l demo.bin 观察文件被填充成 4096 字节
# head -c 100 demo.bin 看看前 100 个字节是不是 abcabc... 的循环运行后 demo.bin 就是一个 4096 字节的文件,内容是 abcdefghijklmnopqrstuvwxyzabcdef... 的循环。你全程没有写 write 一个字节,落盘全靠 MAP_SHARED + 脏页写回和那句 msync。
这里有个值得暂停的点:为什么加 msync?因为 MAP_SHARED 的写回是**"最终会写"而不是"立刻写"**——内核维持一批"脏页",会在某个时刻(后台回写线程、缓存压力、或 msync)把它们刷到磁盘。不刷,数据还安全躺在页缓存里;但一旦断电或进程崩掉,那批脏页可能永远到不了硬盘。msync(m, SIZE, MS_SYNC) 就是"别磨蹭,现在就同步写盘,写完再返回",是"改完就想要落地"时的保险。
示例二:只读映射,把整个文件当内存读
写会了,读也一样自然。这份程序打开一个文件,查清楚它真实大小(用 fstat),长多长就映射多长——映射长度不一定非得是 4096 的倍数,内核会向上取整,你只是别去碰超过文件真实长度的部分(那会引火烧身,见坑那节)。
// read_map.c —— 只读映射一个任意大小的文件,从内存里读前几个字节
#include <stdio.h> // printf, perror
#include <unistd.h> // close
#include <sys/stat.h> // fstat, struct stat
#include <fcntl.h> // open, O_RDONLY
#include <sys/mman.h> // mmap, munmap
int main(int argc, char *argv[])
{
if (argc != 2) { // 用法:./read_map 文件名
fprintf(stderr, "用法: %s <文件名>\n", argv[0]);
return 1;
}
// 只读读取,不需要写权限,O_RDONLY 就够
int fd = open(argv[1], O_RDONLY);
if (fd < 0) {
perror("open");
return 2;
}
struct stat st; // stat 结果放这
if (fstat(fd, &st) == -1) { // 查文件真实大小
perror("fstat");
close(fd);
return 3;
}
// 文件多大,映射多长。读写权限里只声明读;共享映射用于"看别人写的最新内容"
// 此例只是演示, 所以也可用 MAP_PRIVATE(私有) —— 反正我们只读不写,
// 读场景下 SHARED 与 PRIVATE 访问文件内容是等价的。
char *m = mmap(NULL, st.st_size, PROT_READ, MAP_SHARED, fd, 0);
if (m == MAP_FAILED) {
perror("mmap");
close(fd);
return 4;
}
close(fd); // 映射已立,fd 可弃
printf("映射地址: %p, 文件大小: %ld 字节\n", (void *)m, (long)st.st_size);
// 这里直接按"明确长度 + 循环"逐个取字节打印,
// 而不是 printf("%s", m):映射末尾未必有 \0,%s 会一路读到空字符,
// 轻则乱码,重则撞上没映射的页直接崩(详见坑六)。
long n = st.st_size < 10 ? st.st_size : 10; // 至多打印前 10 字节
printf("前 %ld 个字节: ", n);
for (long i = 0; i < n; i++) {
printf("%c", m[i]); // 直接从"内存"里取字节
}
printf("\n");
munmap(m, st.st_size); // 解除映射,长度用当初一样
return 0;
}编译运行:
gcc -g -Wall -o read_map read_map.c
echo "hello mmap, 你好映射" > hello.txt
./read_map hello.txt
# 期望输出: 映射地址: 0x7f..., 文件大小: ...
# 前 N 个字节: hello mmap, ...可以看到,我们一行 read 都没写,却把整个文件内容当内存访问了。文件多大都不怕——mmap 不要求你把全部内容一口气"读进"内存,它只建一条映射关系的"引线",真正把页捞进物理内存的是后文要讲的"缺页"机制,按需即取。
映射之后的访问:虚拟地址、文件偏移、页缓存与缺页
mmap 返回一个指针,神奇地把文件"变成"了内存。但魔法背后不是没有成本——它把"显式的读法"换成了"内核的暗中勤恳"。这一节我们把它拆穿,回答三个问题:内存地址怎么对应到文件字节?那批物理页从哪来?被访问时发生了什么?
虚拟地址到文件偏移:一张换算表
mmap 给你的 m 是虚拟地址(virtual address),不是文件里字节的物理位置。映射建立后,内核在你进程的页表里登记了这样一张对应关系:虚拟页 P ←→ 文件从 offset + (P 相对映射区起点的页序)*页大小 处的一页。
所以给定映射区内任意地址 a,它在文件里的偏移就是:
文件偏移 = (a - m) + offset
其中 m 是 mmap 返回的起始地址,offset 是你传的第六个参数。整段映射就是文件里一个从 offset 开始、宽 length 的"窗口",窗口左右两侧严格逐字节对应。这段线性换算,是理解"页对齐""越界 SIGBUS"等等所有坑的总公式。
页缓存(page cache):谁在给你当仓库
先认识一个老伙计:页缓存。内核为了减少磁盘 IO,把读写过的文件页都保留在内存的一批缓存页里,叫页缓存。你 read 一个文件,先服务于你的其实是页缓存;你用 mmap 读文件,背后承载数据的同样还是这批页缓存。
区别在哪?用 read 时,数据从页缓存(内核空间)复制到你的缓冲区(用户空间),两份;用 mmap 时,你的虚拟页直接映射到那批页缓存页,读写就是在原地操作,一份。这就是我开头说的"少一趟搬运"——mmap 不是绕开了页缓存,而是和页缓存"共用了同一份物理页"。
缺页(page fault):按需取的搬运工
映射建立时,内核只登记了页表关系,并没有真的把文件页搬进物理内存——那样和 read 也没区别了。真正的搬运,发生在你第一次摸到某个地址的那一刻。
当你第 3419 字节 m[3419] = 'x' 时,CPU 发现这个虚拟页"存在映射关系但还没有物理帧",于是触发一次缺页(page fault)。内核随即:把文件对应的那一页从磁盘读进页缓存、建立到你的页表的对应、设置好权限,然后让你继续执行——这一次延迟的类型称为"按需调页"(demand paging)。
接下来你再摸同一个页里的字节,CPU 一下就能命中物理帧,再无缺页,跟访问普通内存一样快。这就是 mmap "读多大文件都只欠两次系统调用,却按需付出缺页成本"的秘密:系统调用省了,但每个首次触碰的页面都要交一次缺页的"过路费"。 所以 mmap 的账是:省下的千百次 read 系统调用,换成了最多几百次缺页(一页一交)。
你可以用 strace 亲眼看看读一个文件时,mmap 方案的系统调用到底有多省:
strace -c ./read_map hello.txt # 统计各项系统调用的次数
strace -c ./write_map demo.bin # 对比写入read_map 的汇总里几乎看不到批量 read,只有 open、fstat、mmap、munmap、close 寥寥几笔——和上一条"用 read 逐块读会有几万次系统调用"形成鲜明对比。这就是"系统调用次数对比"最直观的现场。
脏页回写(dirty pages write back):共享映射的落盘机制
最后是"改完怎么回文件"。MAP_SHARED 映射被写脏的页叫脏页。内核把这些脏页攒着,由后台机制异步写回磁盘;你随时可用 msync 主动同步。若映射是 MAP_PRIVATE,写时内核做的是写时复制给你一份私有页,原始文件页保持干净,自然不需要也无所谓写回。
一句话收束本节的四块拼图:虚拟地址映射文件偏移、页缓存兜底存货、缺页按需搬入、脏页异步落盘。这四件事拼起来,就完整解释了"把文件当内存"到底是怎么转起来的。
用 mmap 极简模拟一个 malloc
mmap 的另一个名场面:模拟 malloc。设想我们直接取消系统调用 malloc/new,纯粹用 mmap + MAP_ANONYMOUS 向内核要内存——这就是一个"极简 malloc"的骨架。虽然生产级的 malloc 远比这复杂(要考虑小块复用、内存池、对齐等),但mmap 确实是它用于大块分配的手段之一,原理就在这里。
// my_malloc.c —— 用 mmap 的匿名映射模拟 malloc/free 的最小实现
#include <stdio.h> // printf, perror
#include <stdlib.h> // exit, EXIT_FAILURE
#include <string.h> // memset
#include <sys/mman.h> // mmap, munmap, MAP_ANONYMOUS
// 用 mmap 分配 size 字节的匿名私有内存
void *my_malloc(size_t size)
{
// NULL : 地址提示交给内核选
// size : 要的字节数
// R|W : 可读可写
// PRIVATE|ANON: 私有映射 + 匿名映射(无后端文件)
// -1 : 匿名映射时 fd 无效,传 -1
// 0 : offset 无意义,传 0
void *p = mmap(NULL, size, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (p == MAP_FAILED) { // 分配失败
perror("mmap");
exit(EXIT_FAILURE);
}
return p; // 返回内存起始地址,语义等同 malloc
}
// 用 munmap 归还内存。注意:原型需带上 size,因为 munmap 要长度。
void my_free(void *p, size_t size)
{
if (munmap(p, size) == -1) { // 解除映射失败
perror("munmap");
exit(EXIT_FAILURE);
}
}
int main(void)
{
size_t size = 4096; // 这次要 4096 字节(正好一页,便于观察)
char *p = (char *)my_malloc(size); // 分配
printf("分配到的地址: %p\n", (void *)p); // 打印这块内存的起始地址
memset(p, 'A', size); // 像普通内存一样写入
printf("第一个字节: %c\n", p[0]); // 读回验证
my_free(p, size); // 归还
return 0;
}编译运行:
gcc -g -Wall -o my_malloc my_malloc.c
./my_malloc你可以用 gdb 停住程序,键入 info proc mapping 查看本进程的地址空间布局。你会看到类似这样的输出——注意那块新增的匿名映射区块:
(gdb) info proc mapping
process 237158
Mapped address spaces:
Start Addr End Addr Size Offset objfile
0x555555554000 0x555555555000 0x1000 0x0 <你的可执行文件>
0x7ffff7fbb000 0x7ffff7fbc000 0x1000 0x0 <-- 这一大块就是 mmap 分配的匿名页
0x7ffffffde000 0x7ffffffff000 0x21000 0x0 [stack]注意到几个点:第一,系统把你要的 4096 字节分布在一个 4KB 对齐的页上,这正是"mmap 要求 4KB 对齐"的直观证据;第二,你能在列表里看到程序本体(文件映射)、libc 的动态库(也是文件映射)、栈、堆等——它们统统一家子,都是某种形式的映射。你常说的"堆""栈""代码段""动态库",在 Linux 眼里全是些不同的映射。
顺带戳破一个常见误会:MAP_ANONYMOUS 分配的内存是"没有文件作为后端存储"的零初始化私有内存,它平时的用途就是给进程当普通内存用。你日常调的 malloc,小分配走的是堆区(brk/sbrk),大分配(超过默认阈值约 128KB)才会走 mmap——因为 mmap 每次分配/释放都伴随页表操作和 TLB 刷新,代价不小,不适合频繁的小块进出。
mmap 常见边界与坑
理论说得再多,不如把坑一个个排出来。下面这些是 mmap 使用者几乎必然撞上的边界与陷阱,逐个攻破。
坑一:文件大小与映射长度的错位(SIGBUS)
这是全篇最经典的一个坑,口诀是:映射长度可以大于文件长度,但越界访问会死。
你 mmap 的 length 会被向上取整到页。如果文件的真实大小不足一整页,那么映射区里"超出文件末尾但还在本页内"的那些字节,从磁盘上根本读不出来——文件里没有这些页面里对应的内容。此时如果你去读或写它们,内核没有任何可提供的数据页,只能向进程发送一个总线错误(SIGBUS),进程当场终止。
// file_len 只有 10 字节, 但 mmap 长度给了 4096(一页)
// 访问 m[0..9] 没问题; 一旦访问 m[10] 及其后,
// 就落到"超过文件末尾、但内核又填不满"的区域 —— 直接 SIGBUS 崩溃。解决办法分两种方向:一是写文件前先 ftruncate 把文件补到想要的长度(示例一就是这么干的);二是在读一个大文件时只映射"确实存在"的长度(示例二用 st.st_size 精确映射)。
趁热打铁: 如果另一个进程在你映射期间把文件截断了(truncate 缩小),你对曾经合法、现在却落在新文件末尾之后的那些地址的访问,同样会命中 SIGBUS。所以运行期文件被缩小,对 mmap 是致命的——你在真实项目里若处理这种并发,务必要防。
坑二:只读映射下写内存 → SIGSEGV
prot 没给 PROT_WRITE,映射区在页表里就标记为"该页不可写"。一旦你往里面写,CPU 报的是段错误(SIGSEGV),程序崩。这和坑一"越写文件末尾报——总线错误 SIGBUS"是两个不同的信号,别混:
- 访问权限不对(写一块只读页 / 访问不属于你的页)→ SIGSEGV;
- 映射内容"后面没有数据可提供"(越过文件末尾)→ SIGBUS。
判断口诀:SIGSEGV 是"没权限/没这块", SIGBUS 是"这块承载物(文件)不够长"。想写就老老实实 PROT_READ | PROT_WRITE + O_RDWR 打开。
坑三:offset 必须页对齐,否则 EINVAL
第一节就埋了伏笔。你 mmap 指定 offset 不是页大小的整数倍,mmap 直接返回 MAP_FAILED,errno = EINVAL,理由是"整页承载不了半页偏移"。加一句补充:addr(在 MAP_FIXED 场景下)也必须是页对齐的,否则同样 EINVAL。拿文件"从某个任意字节开始映射"这种天真需求,是被内核用 EINVAL 严辞拒绝的。
坑四:改动会不会写回 —— 看的是 MAP_SHARED 还是 MAP_PRIVATE
写成这样一段话,比两张分行的列表好记:
MAP_SHARED:改动写回文件(MS_SYNC可立即强制落盘);MAP_PRIVATE:改动私有、不写回(写时复制一份给你)。
很多人栽在这:用 MAP_PRIVATE 改了数据,回头发现文件纹丝不动——那不是 bug,是你问错了"共享"。要"改完文件真的变",请务必 MAP_SHARED,并且文件要以能写的方式打开。
坑五:MAP_FIXED 与 madvise(advise)
MAP_FIXED 是"强制":它要求内核严格按 addr 把映射放在那个精确地址,范围里已有的东西直接覆盖。addr 必须页对齐,且你传的位置必须是你有权用的区域,否则要么失败,要么把原先的映射(栈、库…)压掉造成不可名状的崩溃——因此 MAP_FIXED 是给"极端精确控制地址"的高级场景准备的,普通人默认不要碰它。Linux 4.17 引入的 MAP_FIXED_NOREPLACE 更温和:要求精确地址,但若该处已有映射就报错而不是覆盖,安全性好得多。
再说 advise——指的通常是 madvise(2) 系统调用,它向内核提交"我打算怎么访问这块映射"的建议,内核据此调优(比如预读策略):
madvise(m, FILE_LEN, MADV_SEQUENTIAL); // 我会顺序地读——让内核多预读几页
madvise(m, FILE_LEN, MADV_RANDOM); // 我会随机地读——别浪费预读
madvise(m, FILE_LEN, MADV_WILLNEED); // 我马上要用——提前调进来
madvise(m, FILE_LEN, MADV_DONTNEED); // 我暂时不碰——可以释放对应页它不含强制的语义,只是给内核"吹风",用对了能显著改善大文件顺序/随机遍历时的吞吐。MAP_POPULATE(预分配页)也常和"想一次到位、宁可慢加载也不要边跑边缺页"的场景配套。
坑六:别把映射区当 C 字符串直接 printf
映射区末尾不一定像字符串那样有 \0。示例二特地用长度+循环逐个 putchar,而不是 printf("%s", m)——因为 %s 会一路读到遇到 \0 为止,很可能越界横跨到映射区之外,轻则乱码,重则撞上没映射的页直接 SIGSEGV。凡是"长度已知"的数据,都要带长度处理,别指望 \0。
mmap 的好处、代价与适用场景
把账算完,我们实话说 mmap 不是银弹。这节帮你做取舍。
好处
- 系统调用次数暴跌:整个生命周期就
mmap+munmap两三次,对比read/write每块一次。 - 省一次数据拷贝:直接共享页缓存里的页,少了"内核缓冲 → 用户缓冲"那一趟搬运,大文件读写的 CPU 和内存带宽开销显著下降。
- 按需加载、天然随机访问:缺页机制让大文件只加载真正被碰到的那些页;映射后访问任意偏移就像
arr[i]一样简单,适合解析结构化的二进制大文件。 - 进程间共享:
MAP_SHARED多个进程映射同一文件,天然是共享内存,写者读者直接互通。 - 由内核当"后台刷盘工":改完数据脏页异步落盘,你不用反复调用
write。
代价与注意
- 缺页过路费:每页首次访问都要缺页;小文件、只碰一两个字节时,
read往往更划算。 - 对齐的沉重约束:
offset/addr都必须页对齐,文件长度与映射长度要小心核对。 SIGBUS/SIGSEGV的威胁:越界、文件被截断、只读写内存,都会让程序直接崩溃而非优雅报错,调试成本高。- 异步回写的风险:脏页没落盘前断电/崩溃,数据可能丢。要么重要数据
msync,要么接受"最终一致"。 - 地址空间占用:映射区占据虚拟地址空间;每次分配/释放伴随页表重构与 TLB 刷新,不适合海量极小块内存的频繁分配。
一句话适用指南
- 大文件、常被整体读(比如大型数据集、数据库索引、日志扫描)→
mmap,少系统调用、省拷贝。 - 小文件、或只需要文件某一小块 → 直接
read更简单省钱。 - 进程间要共享一块内存 →
mmap+MAP_SHARED。 - 要精确控制虚拟布局、做二进制装载器 → 高级标志
MAP_FIXED_NOREPLACE+madvise组合。 - 只是复制个文件、写个配置 → 一颗平常心,
read/write完全够,别为炫技上mmap。
思考题与详解
学习一项技术的最深入口,是亲手把它逼到墙角。下面五题,先自己推演,再对详解。
思考题 1
新建文件大小为 0,直接 mmap 它并写第一页会发生什么?为什么示例一要先用 ftruncate?
详解。 直接 mmap 一个空文件并写映射区,会立刻 SIGBUS 崩溃。原因在于"坑一":映射长度按页向上取整后是 4096,但文件只有 0 字节。你写 m[0] 时,这个虚拟页处在"已映射,但对应文件位置根本没有可供加载的页"的状态,内核拿不出任何数据页来填你的请求,于是发送 SIGBUS。ftruncate(fd, SIZE) 先把文件"撑大"到目标长度(新空位以 0 填充),这才让每个要访问的地址都有真实文件页在后面托底,"把文件当内存写"才成立。这正是"写入型映射必须先把文件整理到够长"的底层原因。
思考题 2
用 read + write 复制一个 100MB 文件(每次搬运 4KB),大约要多少次系统调用?用 mmap 呢?
详解。 用 read/write:每次读 4KB,需要 100MB / 4096B = 25600 次 read;再对称写 25600 次 write,合计约 51200 次系统调用。用 mmap:open → mmap → 访问 → munmap → close 这些固定动作加起来也就个位数系统调用,读写的"搬运"全靠缺页机制内部消化(每访问一个新页交一次缺页,但那是内核内部流程,不占你两次 syscall 的往返数量级)。持续加大文件,read/write 的调用次数线性涨,mmap 却几乎不变——这就是它在大文件吞吐上的根本优势。
思考题 3
把文件只读(PROT_READ)映射后,往这块内存里写,程序会收到什么信号?为什么这和 SIGBUS 不一样?
详解。 会收到 段错误 SIGSEGV。因为 mmap 的 prot 决定了页表里这一页的权限位——没给 PROT_WRITE,对应的虚拟页被标记为不可写。CPU 执行写指令时,MMU 检查到页表权限不符,立即触发 SIGSEGV。它和 SIGBUS 的差别在"触发原因":SIGSEGV 是访问方式不被允许(没写权限、或访问的地址根本不在映射里);SIGBUS 是访问区域"承载物不足"(映射存在且权限也对,但地址对应的文件内容不存在/文件被截短)。一句话对照:前者是"没这个权限"或"没这块地",后者是"有地但底下是空的 / 文件太短"。想写,就 PROT_READ|PROT_WRITE + O_RDWR,否则别碰。
思考题 4
为什么 mmap 的 offset 必须按页对齐?不对齐会怎样?
详解。 因为"映射"的最小单位是整页。内核的页表只能表达"虚拟页 ↔ 文件页"的整页对应,无法登记"从文件偏移 1000 字节的'半个页'映射过来"这类半页关系;每一页的文件内容都必须从页边界开始承载,否则指定起始偏移的那一页缺了前半页,后面的对齐关系全乱,也谈不上一页扣一页的线性换算。因此 Linux 规定 offset 必须是页大小(sysconf(_SC_PAGE_SIZE),常见 4096)的整数倍,否则 mmap 返回 MAP_FAILED 且置 errno = EINVAL。同理,MAP_FIXED 语义下的 addr 也必须页对齐,否则一样 EINVAL。若你的业务"必须从文件第 1000 字节开始映射",得自己把逻辑对齐到 4096 的倍数,再处理边界微调。
思考题 5
用 MAP_PRIVATE 映射一个大文件并修改它,修改会不会写回文件?glibc 的 malloc 是不是全部用 mmap 实现?
详解。(分两问) 第一问:不会写回。 MAP_PRIVATE 是写时复制——你第一次写某个映射页时,内核把那页复制一份私有页给你,你在私有副本上动,原始文件页保持干净,自然无从也无需回写。要让改动真正落回文件,必须用 MAP_SHARED 并且文件以可写方式打开。可以用一个小程序自证:MAP_PRIVATE 写入后 ls 文件原样,MAP_SHARED 写入后(配合 msync 或稍候)文件真的变了。
第二问:不是。 巨大的误区。glibc 的 malloc 是"双轨制":小而频繁的分配走堆区——通过 brk/sbrk 把程序 brk(堆尾)往后顶,快速复用连续空间,开销极小;大块分配(超过默认阈值 MMAP_THRESHOLD,约 128KB)才改用 mmap + MAP_ANONYMOUS,因为 mmap 分配的页来去都伴随完整的页表操作与 TLB 刷新,成本高,只对"量少、块大"的场景划算,且分配——释放——分配之间还能避免堆碎片。所以日常你调的 malloc(100) 走堆,malloc(10MB) 落到底层很可能是 mmap。这也能解释为什么 my_malloc 那样的"极简 mmap 分配器"只是教学骨架,而非生产级 malloc 的全貌。
到这里,mmap 从"把文件当内存"的外表,到"虚拟地址↔文件偏移、页缓存、缺页、脏页回写"的内里,再到它的一桩桩边界与坑,我们完整走了一遍。它和你一直熟悉的 read/write,不是谁取代谁的关系,而是两条各有主场的路:文件越大、越要整体频繁访问、越要在进程间共享,mmap 的优势越耀眼;文件越小、只是顺手读写,read 反而干净利落。理解这一点,比背下它所有参数值更重要。
现在你手里有了一张"虚拟地址 ↔ 文件偏移"的换算表和一张"系统调用次数 vs 缺页次数"的取舍账本。下一次再遇到"几十 GB 的文件该怎么高效读""两个进程怎么共享一份数据""为什么这段映射一写就崩"这类问题,你的第一反应就不再是查百度,而是能自己画出一条从映射建立到缺页装载、再到脏页落盘的完整链路。这也是我们从文件 IO 走进"虚拟内存"的第一块台阶——下一站,我们再顺着页表、缺页和地址空间,往 Linux 内存管理的更深处走一段。
还没有评论 — 第一条由你来留。