23、内存映射文件:mmap函数详解、内存映射的优势、映射文件读写示例
说到文件操作,很多人第一反应就是 fread、fwrite 这些标准库函数。嗯,它们确实好用,但如果你追求极致性能,或者要处理超大文件,那就得请出我们今天的主角——内存映射文件。
我最早接触 mmap 是在做一个嵌入式视频采集项目的时候。当时要实时写入 4K 分辨率的图像数据,用 fwrite 写一次要卡几十毫秒,根本扛不住。后来换成 mmap,延迟直接降到微秒级。说实话,那感觉就像从绿皮火车换成了高铁。
什么是内存映射文件?
说白了,就是把一个文件的一部分或全部,直接映射到进程的虚拟地址空间。这样一来,你读写文件就像读写内存一样简单——直接操作指针就行,不需要调用 read 或 write。
你想想看,操作系统帮你把磁盘上的数据和内存地址一一对应起来。当你访问某个地址时,如果对应的数据还没在内存里,内核会自动触发缺页中断,把数据从磁盘加载进来。这一切对应用程序是透明的。
核心思想:文件 I/O 变成了内存 I/O,省去了用户态和内核态之间的数据拷贝。
mmap 函数详解
先看函数原型,这是 Linux 下的标准接口:
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);
参数看着多,其实每个都有明确用途。我习惯按顺序记:
| 参数 | 说明 | 常用值 |
|---|---|---|
addr |
建议的映射起始地址 | 传 NULL,让内核选 |
length |
映射的字节数 | 文件大小,或按页对齐 |
prot |
内存保护标志 | PROT_READ | PROT_WRITE |
flags |
映射类型和行为 | MAP_SHARED 或 MAP_PRIVATE |
fd |
文件描述符 | 由 open() 返回 |
offset |
文件内的偏移量 | 必须是页大小的整数倍 |
注意:offset 必须是系统页大小(通常是 4096 字节)的整数倍。我见过有人传了个 100 进去,结果 mmap 返回 EINVAL,排查了半天才发现是偏移没对齐。
两个关键标志:MAP_SHARED vs MAP_PRIVATE
这两个标志决定了你对映射区域的修改怎么处理:
- MAP_SHARED:修改会写回原文件。适合多进程共享数据,或者需要持久化写入的场景。
- MAP_PRIVATE:修改只影响当前进程的内存副本,不会写回文件。适合只读分析或临时修改。
我记得有一次做日志分析工具,用 MAP_PRIVATE 映射了一个 2GB 的日志文件,在内存里做字符串替换,然后直接输出结果。原文件纹丝不动,非常安全。
内存映射的优势
为什么说 mmap 比传统 I/O 快?我画了张图帮你理解:
从图上能清楚看到:传统 I/O 需要先把数据从磁盘读到内核缓冲区,再从内核缓冲区拷贝到用户缓冲区。而 mmap 直接把磁盘上的数据映射到进程地址空间,省掉了中间那一次拷贝。
优势总结起来就三点:
- 性能高:减少数据拷贝,大文件场景尤其明显。
- 使用方便:用指针操作代替 read/write 调用,代码更简洁。
- 支持大文件:可以只映射文件的一部分,不占用太多物理内存。
我的经验:如果文件小于 64KB,用传统 I/O 反而可能更快。因为 mmap 有建立映射的开销,小文件体现不出优势。这个阈值是我在 ARM 平台上实测出来的,x86 上可能略有不同。
映射文件读写示例
光说不练假把式。下面这个例子演示了如何用 mmap 读取一个文件,然后修改它的内容:
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
#include <string.h>
int main() {
const char *filename = "test.dat";
int fd;
struct stat sb;
char *mapped;
// 1. 打开文件
fd = open(filename, O_RDWR);
if (fd == -1) {
perror("open");
exit(EXIT_FAILURE);
}
// 2. 获取文件大小
if (fstat(fd, &sb) == -1) {
perror("fstat");
close(fd);
exit(EXIT_FAILURE);
}
printf("文件大小: %ld 字节\n", sb.st_size);
// 3. 映射文件到内存
mapped = mmap(NULL, sb.st_size,
PROT_READ | PROT_WRITE,
MAP_SHARED,
fd, 0);
if (mapped == MAP_FAILED) {
perror("mmap");
close(fd);
exit(EXIT_FAILURE);
}
// 4. 文件描述符可以关闭了
// 映射仍然有效
close(fd);
// 5. 直接通过指针读写
printf("原始内容: %.*s\n", 20, mapped);
// 修改前 10 个字节
memcpy(mapped, "HELLO_MMAP", 10);
// 确保修改写回磁盘
msync(mapped, sb.st_size, MS_SYNC);
printf("修改后: %.*s\n", 20, mapped);
// 6. 解除映射
if (munmap(mapped, sb.st_size) == -1) {
perror("munmap");
exit(EXIT_FAILURE);
}
return 0;
}
这段代码有几个关键点我想强调一下:
- 第 4 步:映射完成后就可以关闭文件描述符了。映射关系由内核维护,不依赖 fd。
msync:如果你用的是MAP_SHARED,修改不会立即写回磁盘。调用msync可以强制同步。我一般在关键数据写入后都会调一下,防止意外断电丢数据。munmap:一定要记得解除映射,否则会造成虚拟内存泄漏。进程退出时会自动清理,但长期运行的服务不能依赖这个。
曾经踩过的坑:有一次我映射了一个 4GB 的文件,然后在 32 位系统上跑。结果 mmap 返回 ENOMEM,因为 32 位进程的虚拟地址空间总共才 3GB 左右,根本映射不了这么大的文件。解决方案是用 MAP_FIXED 分段映射,或者直接换 64 位系统。
mmap 的适用场景
不是所有情况都适合用 mmap。我总结了几条经验:
| 适合用 mmap | 不适合用 mmap |
|---|---|
| 大文件(> 64KB)的随机读写 | 小文件的频繁读写 |
| 多进程共享数据(MAP_SHARED) | 文件大小经常变化 |
| 需要零拷贝的场景 | 嵌入式系统内存紧张时 |
| 数据库、日志分析等 | 流式处理(如管道、socket) |
嗯,说到嵌入式系统,我得提醒一句:如果你的设备只有 64MB 内存,却映射了一个 1GB 的文件,虽然虚拟地址空间够用,但频繁的缺页中断会拖慢系统。这时候用传统 I/O 配合缓冲区反而更可控。
小结
内存映射文件是 C 语言文件操作中的一把利器。它把文件 I/O 变成了内存操作,性能提升非常明显。但用之前一定要想清楚场景——大文件、随机访问、共享数据,这些是它的主场。小文件、流式处理,还是老老实实用 fread 吧。
我个人建议,每个 C 程序员都应该掌握 mmap。它不仅能帮你写出高性能的代码,还能让你更深入地理解操作系统的虚拟内存管理机制。下次遇到 I/O 瓶颈,不妨试试 mmap,说不定会有惊喜。
公众号:蓝海资料掘金营,微信 deep3321