20、内存文件操作:fmemopen/open_memstream(Linux)、内存映射文件mmap
说到文件操作,大家第一反应肯定是 fopen、fread、fwrite 这些标准函数。但有个场景不知道你遇到过没有——你只是想临时存点数据,或者做个字符串拼接,却要真的去硬盘上创建一个文件?
我早年做嵌入式开发时就踩过这个坑。一个日志模块,每次写日志都去 fopen 一个文件,结果 Flash 寿命刷刷往下掉。后来才发现,原来 Linux 早就提供了「内存文件」这种好东西——数据在内存里,但操作起来跟文件一模一样。
说白了,就是把内存当文件用。不需要磁盘 I/O,速度快,还不伤存储介质。今天我们就来聊聊这几种玩法。
20.1 fmemopen:把内存当文件流
fmemopen 是 glibc 提供的函数。它把一个内存缓冲区包装成 FILE * 流。你可以用 fprintf、fscanf、fread、fwrite 这些标准函数去操作它。
函数原型长这样:
#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的指针。函数会把当前缓冲区大小写到这里。
注意:ptr 和 sizeloc 指向的值会在 fclose 或 fflush 后更新。你可以在任何时候通过它们拿到当前的数据和长度。
看代码:
#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) 来刷新缓冲区,此时 buf 和 size 也会更新。这样你可以在不关闭流的情况下,随时拿到当前数据。
我曾经用 open_memstream 实现过一个动态拼装 JSON 的模块。不用预先估算字符串长度,也不用担心缓冲区溢出。写起来非常顺手。
20.3 mmap:内存映射文件
前面两种方法,数据都在内存里,跟磁盘文件没关系。但 mmap 不一样——它把磁盘文件的一部分直接映射到进程的地址空间。你对这块内存的读写,最终会同步回文件。
说白了,就是「把文件当内存数组用」。不需要 read、write,直接通过指针访问。
函数原型:
#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_READ、PROT_WRITE、PROT_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 三种方式对比
我把它们放在一起对比一下,方便你选型:
| 特性 | fmemopen | open_memstream | mmap |
|---|---|---|---|
| 数据存储 | 用户提供的缓冲区 | 动态分配的内存 | 磁盘文件映射到内存 |
| 大小限制 | 固定大小 | 自动扩容 | 文件大小(可扩展) |
| 持久化 | 不持久 | 不持久 | 写回文件 |
| 适用场景 | 临时数据、格式化 I/O | 动态字符串拼接 | 大文件、共享内存、高性能 I/O |
| 性能 | 快(纯内存) | 快(纯内存) | 极快(零拷贝) |
20.5 避坑指南
嗯,这里有几个我踩过的坑,分享给你:
- fmemopen 的 mode 要小心:如果你用
"w"模式,它会从缓冲区开头写,但不会清空原有内容。我之前因此排查了半天,才发现缓冲区里残留了旧数据。 - open_memstream 记得 free:它内部
malloc的缓冲区,fclose后不会自动释放。你必须手动free(buf)。 - mmap 的 offset 要对齐:
offset必须是系统页大小(通常是 4096)的整数倍。否则mmap会返回EINVAL。 - mmap 的文件大小:映射的文件不能是 0 字节。你需要先用
ftruncate或lseek + write把文件扩展到目标大小。
我曾经在项目里用 mmap 做进程间共享数据,结果忘了设置 MAP_SHARED,用了默认的 MAP_PRIVATE。两个进程各自改了各自的内存,数据根本不互通。排查了一下午才发现是 flags 的问题。
20.6 知识结构图
下面这张图帮你理清这三种内存文件操作的关系:
好了,这一章的内容就到这里。三种内存文件操作各有各的脾气,选对了能省不少事。下次写代码时,不妨想想——这个文件操作,真的需要落到磁盘上吗?