20、内存文件操作:fmemopen/open_memstream(Linux)、内存映射文件mmap

说到文件操作,大家第一反应肯定是 fopenfreadfwrite 这些标准函数。但有个场景不知道你遇到过没有——你只是想临时存点数据,或者做个字符串拼接,却要真的去硬盘上创建一个文件?

我早年做嵌入式开发时就踩过这个坑。一个日志模块,每次写日志都去 fopen 一个文件,结果 Flash 寿命刷刷往下掉。后来才发现,原来 Linux 早就提供了「内存文件」这种好东西——数据在内存里,但操作起来跟文件一模一样。

说白了,就是把内存当文件用。不需要磁盘 I/O,速度快,还不伤存储介质。今天我们就来聊聊这几种玩法。

20.1 fmemopen:把内存当文件流

fmemopen 是 glibc 提供的函数。它把一个内存缓冲区包装成 FILE * 流。你可以用 fprintffscanffreadfwrite 这些标准函数去操作它。

函数原型长这样:

#include <stdio.h>

FILE *fmemopen(void *buf, size_t size, const char *mode);

参数说明:

  • buf:你提供的缓冲区指针。如果传 NULL,函数会自己 malloc 一块。
  • size:缓冲区大小。
  • mode:打开模式,跟 fopen 一样,比如 "r""w""a""r+" 等。

来看个例子:

#include <stdio.h>
#include <string.h>

int main() {
    char buf[256];
    FILE *fp = fmemopen(buf, sizeof(buf), "w");

    if (!fp) {
        perror("fmemopen");
        return 1;
    }

    fprintf(fp, "Hello, %s! 当前温度: %d°C\n", "内存文件", 28);
    fclose(fp);

    // 现在 buf 里就是写入的内容
    printf("缓冲区内容: %s\n", buf);

    return 0;
}

运行结果:

缓冲区内容: Hello, 内存文件! 当前温度: 28°C
注意: fmemopen 写入的内容不会自动追加 '\0'。如果你需要字符串,记得手动在末尾加 '\0',或者用 fwrite 自己控制长度。

我个人习惯用 fmemopen 做日志缓冲。先把日志写到内存里,攒够一批再一次性刷到磁盘。这样既减少了 I/O 次数,又避免了频繁创建文件句柄。

20.2 open_memstream:自动扩容的字符串流

fmemopen 有个硬伤——缓冲区大小是固定的。你写超了,它就报错或者截断。这在很多场景下很尴尬。

好在 Linux 还提供了 open_memstream。它会自动管理一个动态增长的缓冲区。你只管往里写,它自己 realloc

函数原型:

#include <stdio.h>

FILE *open_memstream(char **ptr, size_t *sizeloc);

参数说明:

  • ptr:指向 char * 的指针。函数会把缓冲区地址写到这里。
  • sizeloc:指向 size_t 的指针。函数会把当前缓冲区大小写到这里。

注意:ptrsizeloc 指向的值会在 fclosefflush 后更新。你可以在任何时候通过它们拿到当前的数据和长度。

看代码:

#include <stdio.h>
#include <stdlib.h>

int main() {
    char *buf = NULL;
    size_t size = 0;

    FILE *fp = open_memstream(&buf, &size);
    if (!fp) {
        perror("open_memstream");
        return 1;
    }

    for (int i = 0; i < 10; i++) {
        fprintf(fp, "第 %d 行数据\n", i + 1);
    }

    fclose(fp);  // 此时 buf 和 size 被更新

    printf("共写入 %zu 字节:\n%s\n", size, buf);

    free(buf);  // 记得释放!
    return 0;
}

运行结果:

共写入 100 字节:
第 1 行数据
第 2 行数据
...
第 10 行数据
小技巧: 你可以在 fclose 之前调用 fflush(fp) 来刷新缓冲区,此时 bufsize 也会更新。这样你可以在不关闭流的情况下,随时拿到当前数据。

我曾经用 open_memstream 实现过一个动态拼装 JSON 的模块。不用预先估算字符串长度,也不用担心缓冲区溢出。写起来非常顺手。

20.3 mmap:内存映射文件

前面两种方法,数据都在内存里,跟磁盘文件没关系。但 mmap 不一样——它把磁盘文件的一部分直接映射到进程的地址空间。你对这块内存的读写,最终会同步回文件。

说白了,就是「把文件当内存数组用」。不需要 readwrite,直接通过指针访问。

函数原型:

#include <sys/mman.h>

void *mmap(void *addr, size_t length, int prot, int flags,
           int fd, off_t offset);

参数有点多,我列个表:

参数说明
addr建议的映射起始地址。传 NULL 让内核选。
length映射的字节数。
prot保护标志:PROT_READPROT_WRITEPROT_EXEC 等。
flags映射类型:MAP_SHARED(修改写回文件)、MAP_PRIVATE(写时复制)。
fd文件描述符。用 open 打开。
offset文件偏移量,必须是页大小的整数倍。

来看一个完整的读写例子:

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>
#include <string.h>

int main() {
    const char *filepath = "test_mmap.dat";
    size_t filesize = 4096;  // 一页大小

    // 1. 创建并初始化文件
    int fd = open(filepath, O_RDWR | O_CREAT | O_TRUNC, 0644);
    if (fd == -1) {
        perror("open");
        return 1;
    }

    // 2. 扩展文件到指定大小
    if (ftruncate(fd, filesize) == -1) {
        perror("ftruncate");
        close(fd);
        return 1;
    }

    // 3. 映射到内存
    char *map = mmap(NULL, filesize, PROT_READ | PROT_WRITE,
                     MAP_SHARED, fd, 0);
    if (map == MAP_FAILED) {
        perror("mmap");
        close(fd);
        return 1;
    }

    close(fd);  // 映射后可以关闭文件描述符

    // 4. 通过指针读写
    strcpy(map, "Hello, mmap!");
    printf("读取: %s\n", map);

    // 5. 解除映射
    if (munmap(map, filesize) == -1) {
        perror("munmap");
        return 1;
    }

    return 0;
}

运行后,test_mmap.dat 文件里就写入了 "Hello, mmap!"。

核心要点:
  • mmap 适合大文件、随机访问频繁的场景。比如数据库、配置文件、共享内存。
  • MAP_SHARED 的修改会写回文件。MAP_PRIVATE 的修改只影响当前进程,不写回文件。
  • 映射大小必须是页大小的整数倍?不,length 可以是任意值,但内核会按页对齐。超出文件大小的部分会填充为 0。

我曾经用 mmap 优化过一个日志分析工具。原来用 fgets 逐行读,处理 2GB 的日志要 40 秒。改成 mmap 后,直接指针遍历,8 秒搞定。为什么快?因为省去了系统调用和内核态用户态切换的开销。

20.4 三种方式对比

我把它们放在一起对比一下,方便你选型:

特性fmemopenopen_memstreammmap
数据存储用户提供的缓冲区动态分配的内存磁盘文件映射到内存
大小限制固定大小自动扩容文件大小(可扩展)
持久化不持久不持久写回文件
适用场景临时数据、格式化 I/O动态字符串拼接大文件、共享内存、高性能 I/O
性能快(纯内存)快(纯内存)极快(零拷贝)

20.5 避坑指南

嗯,这里有几个我踩过的坑,分享给你:

  • fmemopen 的 mode 要小心:如果你用 "w" 模式,它会从缓冲区开头写,但不会清空原有内容。我之前因此排查了半天,才发现缓冲区里残留了旧数据。
  • open_memstream 记得 free:它内部 malloc 的缓冲区,fclose 后不会自动释放。你必须手动 free(buf)
  • mmap 的 offset 要对齐offset 必须是系统页大小(通常是 4096)的整数倍。否则 mmap 会返回 EINVAL
  • mmap 的文件大小:映射的文件不能是 0 字节。你需要先用 ftruncatelseek + write 把文件扩展到目标大小。

我曾经在项目里用 mmap 做进程间共享数据,结果忘了设置 MAP_SHARED,用了默认的 MAP_PRIVATE。两个进程各自改了各自的内存,数据根本不互通。排查了一下午才发现是 flags 的问题。

20.6 知识结构图

下面这张图帮你理清这三种内存文件操作的关系:

内存文件操作知识结构 内存文件操作 fmemopen open_memstream mmap 固定缓冲区 FILE * 流操作 纯内存 自动扩容 FILE * 流操作 纯内存 文件映射到内存 直接指针访问 零拷贝 / 持久化 选型建议:小数据用 fmemopen,动态字符串用 open_memstream,大文件用 mmap

好了,这一章的内容就到这里。三种内存文件操作各有各的脾气,选对了能省不少事。下次写代码时,不妨想想——这个文件操作,真的需要落到磁盘上吗?


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