23、内存映射文件:mmap函数详解、内存映射的优势、映射文件读写示例

说到文件操作,很多人第一反应就是 freadfwrite 这些标准库函数。嗯,它们确实好用,但如果你追求极致性能,或者要处理超大文件,那就得请出我们今天的主角——内存映射文件

我最早接触 mmap 是在做一个嵌入式视频采集项目的时候。当时要实时写入 4K 分辨率的图像数据,用 fwrite 写一次要卡几十毫秒,根本扛不住。后来换成 mmap,延迟直接降到微秒级。说实话,那感觉就像从绿皮火车换成了高铁。

什么是内存映射文件?

说白了,就是把一个文件的一部分或全部,直接映射到进程的虚拟地址空间。这样一来,你读写文件就像读写内存一样简单——直接操作指针就行,不需要调用 readwrite

你想想看,操作系统帮你把磁盘上的数据和内存地址一一对应起来。当你访问某个地址时,如果对应的数据还没在内存里,内核会自动触发缺页中断,把数据从磁盘加载进来。这一切对应用程序是透明的。

核心思想:文件 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_SHAREDMAP_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 vs 内存映射 I/O 数据流对比 传统 I/O(4次拷贝) 磁盘 内核缓冲区 用户缓冲区 DMA 拷贝 CPU 拷贝 内存映射 I/O(1次拷贝) 磁盘 进程地址空间 DMA 拷贝(缺页时) 传统 I/O 需要 2 次系统调用 + 2 次数据拷贝 mmap 只需要 1 次缺页中断 + 0 次显式拷贝 性能对比 指标 传统 I/O mmap 系统调用次数 2 次(read/write) 1 次(mmap) 数据拷贝次数 2 次(内核↔用户) 0 次(直接访问)

从图上能清楚看到:传统 I/O 需要先把数据从磁盘读到内核缓冲区,再从内核缓冲区拷贝到用户缓冲区。而 mmap 直接把磁盘上的数据映射到进程地址空间,省掉了中间那一次拷贝。

优势总结起来就三点:

  1. 性能高:减少数据拷贝,大文件场景尤其明显。
  2. 使用方便:用指针操作代替 read/write 调用,代码更简洁。
  3. 支持大文件:可以只映射文件的一部分,不占用太多物理内存。

我的经验:如果文件小于 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