内存映射: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_SHAREDMAP_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 高出一截,尤其是大文件场景。

小提示:文件映射时,文件大小最好是系统页大小的整数倍(通常是 4096 字节)。如果文件大小不是页对齐,mmap 会自动补齐,但多出来的部分会被填充为 0,而且你不能往那部分写数据——写了会触发 SIGBUS 信号。

匿名映射

匿名映射就是不依赖文件的映射。它分配一块内存,这块内存初始化为 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 堆空间就没那么灵活。

核心区别:文件映射需要 fd,匿名映射不需要。文件映射可以 MAP_SHARED 实现持久化,匿名映射只能用于进程间通信或私有内存分配。

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 的值,而且值在递增。这说明它们确实在操作同一块内存。

注意:这里没有加任何同步机制。如果父子进程同时写 shared,会出现竞态条件。实际项目中记得用信号量或互斥锁保护共享数据。我曾经在一个多进程日志系统里吃过这个亏——两个进程同时写日志缓冲区,结果日志内容互相穿插,排查了好久才发现是 mmap 共享区没加锁。

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 了半天才发现是文件大小没设置。

性能对比:对于几百 MB 的大文件,mmap 拷贝比传统的 read/write 循环快 2-3 倍。原因很简单——减少了系统调用次数和内存拷贝次数。但小文件(几 KB)差别不大,甚至 read/write 还快一点,因为 mmap 本身有建立页表的开销。

知识体系总览

下面这张图把 mmap 的核心知识点串起来了,你可以对照着回顾一下:

mmap 内存映射 文件映射 匿名映射 父子进程通信 MAP_SHARED / MAP_PRIVATE 文件 I/O 替代方案 文件拷贝实现 MAP_ANONYMOUS malloc 底层实现 私有内存分配 fork 继承映射 MAP_SHARED 共享 需同步机制 核心:减少拷贝次数,提升 I/O 效率

mmap 这个东西,说白了就是「以空间换时间」的典型代表。它用虚拟内存映射省去了数据拷贝,但代价是占用了一部分地址空间。用不用它,取决于你的场景:大文件、频繁读写、进程间共享数据——mmap 是首选。小文件、一次性读写——传统 I/O 更简单。

我个人建议,凡是涉及文件 I/O 性能优化的地方,先想想能不能用 mmap。它不一定是最优解,但至少值得一试。我在做数据库存储引擎的时候,就是用 mmap 实现了内存映射文件,读写性能直接翻倍。当然,也要注意它的限制——32 位系统上地址空间有限,映射大文件可能会失败。


公众号:蓝海资料掘金营,微信deep3321