内存映射:mmap函数、文件映射与匿名映射
内存映射,说白了就是把一个文件或者一块内存,直接映射到进程的地址空间里。你想想看,平时我们读写文件,得用 read、write 那一套,数据在内核缓冲区和用户空间之间拷来拷去,效率上总觉得有点亏。mmap 就不一样了,它让进程可以直接操作这块映射过来的内存,就像操作普通内存一样简单。
我个人习惯把 mmap 理解为「搭桥」——在文件(或物理内存)和进程虚拟地址空间之间搭一座桥。桥搭好了,你在这头改数据,那头的文件也跟着变,反之亦然。嗯,这个比喻虽然糙了点,但道理是通的。
mmap 函数原型
先看看它的样子:
#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 | 文件偏移量 | 0(从头开始) |
prot 参数有几种组合:PROT_READ(可读)、PROT_WRITE(可写)、PROT_EXEC(可执行)、PROT_NONE(不可访问)。我一般至少给个 PROT_READ,不然映射了也干不了啥。
flags 这里有个关键区别:MAP_SHARED 和 MAP_PRIVATE。前者对内存的修改会写回文件,后者只是做个私有副本,改了就改了,不影响原文件。我在项目中遇到过有人用 MAP_PRIVATE 想实现进程间通信,结果两个进程各改各的,谁也看不见谁——这就是没搞清楚这两个标志的区别。
文件映射
文件映射是最常见的用法。把磁盘上的一个文件映射到内存,然后你就可以用指针读写它了。我举个例子:
#include <stdio.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main() {
int fd = open("test.txt", O_RDWR);
if (fd == -1) {
perror("open");
return 1;
}
struct stat sb;
fstat(fd, &sb);
size_t filesize = sb.st_size;
char *map = mmap(NULL, filesize, PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
if (map == MAP_FAILED) {
perror("mmap");
close(fd);
return 1;
}
// 直接修改文件内容
memcpy(map, "Hello, mmap!", 12);
munmap(map, filesize);
close(fd);
return 0;
}
你看,打开文件、获取大小、mmap 映射,然后直接 memcpy 就改了文件内容。没有 read、write,没有缓冲区拷贝。效率上比传统 I/O 高出一截,尤其是大文件场景。
匿名映射
匿名映射就是不依赖文件的映射。它分配一块内存,这块内存初始化为 0,不关联任何文件。怎么用?把 flags 参数加上 MAP_ANONYMOUS(或者 MAP_ANON),fd 传 -1 就行。
void *buf = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (buf == MAP_FAILED) {
perror("mmap");
return 1;
}
// 现在 buf 指向一块 4KB 的匿名内存
strcpy((char *)buf, "匿名映射测试");
printf("%s\n", (char *)buf);
munmap(buf, 4096);
匿名映射本质上就是 malloc 的底层实现之一。glibc 的 malloc 对于大块内存(超过 128KB),就会用 mmap 分配匿名映射。为什么?因为 mmap 分配的内存可以直接归还给系统,而 brk 堆空间就没那么灵活。
mmap 实现父子进程通信
这是 mmap 的一个经典应用场景。父进程先 mmap 一块内存,设置 MAP_SHARED,然后 fork 出子进程。子进程继承了父进程的地址空间,自然也继承了这块映射内存。两个进程就能通过这块共享内存通信了。
为什么会这样?因为 fork 之后,子进程的虚拟地址空间是父进程的完整副本,包括 mmap 映射的区域。如果映射是 MAP_SHARED 的,父子进程实际上指向同一块物理内存。你想想看,这不就是最简单的共享内存机制吗?
#include <stdio.h>
#include <sys/mman.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>
int main() {
// 分配一块共享内存,存放一个整数
int *shared = mmap(NULL, sizeof(int),
PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_ANONYMOUS,
-1, 0);
if (shared == MAP_FAILED) {
perror("mmap");
return 1;
}
*shared = 0;
pid_t pid = fork();
if (pid == 0) {
// 子进程
for (int i = 0; i < 5; i++) {
(*shared)++;
printf("子进程: shared = %d\n", *shared);
sleep(1);
}
munmap(shared, sizeof(int));
return 0;
}
// 父进程
for (int i = 0; i < 5; i++) {
printf("父进程: shared = %d\n", *shared);
sleep(1);
}
wait(NULL);
munmap(shared, sizeof(int));
return 0;
}
运行这段代码,你会看到父子进程交替打印 shared 的值,而且值在递增。这说明它们确实在操作同一块内存。
mmap 实现文件拷贝
用 mmap 做文件拷贝,思路很直接:把源文件映射到内存,把目标文件也映射到内存,然后 memcpy 一下。省去了 read/write 的系统调用开销,对于大文件尤其高效。
#include <stdio.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main(int argc, char *argv[]) {
if (argc != 3) {
fprintf(stderr, "用法: %s 源文件 目标文件\n", argv[0]);
return 1;
}
// 打开源文件
int src_fd = open(argv[1], O_RDONLY);
if (src_fd == -1) {
perror("open source");
return 1;
}
struct stat sb;
fstat(src_fd, &sb);
size_t filesize = sb.st_size;
// 映射源文件
char *src_map = mmap(NULL, filesize, PROT_READ,
MAP_PRIVATE, src_fd, 0);
if (src_map == MAP_FAILED) {
perror("mmap source");
close(src_fd);
return 1;
}
// 创建目标文件
int dst_fd = open(argv[2], O_RDWR | O_CREAT | O_TRUNC, 0644);
if (dst_fd == -1) {
perror("open dest");
munmap(src_map, filesize);
close(src_fd);
return 1;
}
// 目标文件需要先设置大小,否则 mmap 会失败
ftruncate(dst_fd, filesize);
// 映射目标文件
char *dst_map = mmap(NULL, filesize, PROT_READ | PROT_WRITE,
MAP_SHARED, dst_fd, 0);
if (dst_map == MAP_FAILED) {
perror("mmap dest");
munmap(src_map, filesize);
close(src_fd);
close(dst_fd);
return 1;
}
// 核心操作:内存拷贝
memcpy(dst_map, src_map, filesize);
// 清理
munmap(dst_map, filesize);
munmap(src_map, filesize);
close(dst_fd);
close(src_fd);
printf("文件拷贝完成,共 %zu 字节\n", filesize);
return 0;
}
这里有个细节:目标文件在 mmap 之前必须用 ftruncate 设置好大小。不然你映射一个空文件,mmap 返回成功,但一写就触发 SIGBUS。嗯,这个坑我踩过,当时 debug 了半天才发现是文件大小没设置。
知识体系总览
下面这张图把 mmap 的核心知识点串起来了,你可以对照着回顾一下:
mmap 这个东西,说白了就是「以空间换时间」的典型代表。它用虚拟内存映射省去了数据拷贝,但代价是占用了一部分地址空间。用不用它,取决于你的场景:大文件、频繁读写、进程间共享数据——mmap 是首选。小文件、一次性读写——传统 I/O 更简单。
我个人建议,凡是涉及文件 I/O 性能优化的地方,先想想能不能用 mmap。它不一定是最优解,但至少值得一试。我在做数据库存储引擎的时候,就是用 mmap 实现了内存映射文件,读写性能直接翻倍。当然,也要注意它的限制——32 位系统上地址空间有限,映射大文件可能会失败。
公众号:蓝海资料掘金营,微信deep3321