从“图书馆借书”到mmap:内存映射的魔法
<摘要>
mmap是Linux/POSIX标准下的核心系统调用,功能是将文件或设备的部分内容映射到进程的虚拟地址空间,实现“以访问内存的方式操作文件”。其核心价值在于打破传统IO(read/write)的“用户态-内核态数据拷贝”瓶颈,通过直接操作内存完成文件读写,同时支持进程间共享内存(无需额外通信机制),广泛用于大文件处理、进程通信、动态库加载等场景。
mmap的核心机制类似“虚拟书架”:将硬盘上的文件(书架上的书)映射到进程内存(大脑),进程无需每次“去书架取书”(调用read/write),而是直接“翻看大脑中的记忆”(访问内存),大幅提升效率。其关键参数包括映射长度、保护模式(读写权限)、共享标志(是否同步到文件)等,返回值为映射区的内存地址,失败时返回MAP_FAILED。
本文通过“生活化类比+技术拆解+实操案例”,从背景概念、参数解析、应用场景三个维度深度解析mmap:用“图书馆借书”类比映射过程,拆解prot/flags等核心参数的作用,通过“大文件高效读取”“父子进程共享内存”“零拷贝文件修改”三个完整案例(含代码、Makefile、Mermaid流程图),直观展示mmap的使用方法与效率优势,帮助开发者掌握这一“高性能IO利器”。
<解析>
从“图书馆借书”到mmap:内存映射的魔法
你有没有在图书馆遇到过这种情况?想查阅一本厚书里的多个章节,每次都要从书架上取下、翻到对应页码、看完再放回去——来来回回特别麻烦。如果能把整本书“复印”一份带在身上,随时翻看,效率会高很多。
在计算机世界里,“从硬盘读文件”就像“去图书馆借书”:传统的read/write系统调用每次都要把数据从硬盘(书架)读到内核缓冲区(图书馆前台),再拷贝到用户内存(你的笔记本),频繁操作时效率很低。而mmap(内存映射)就像“复印整本书”:把硬盘上的文件直接“映射”到进程的内存空间,之后操作文件就像访问内存一样简单,省去了反复拷贝的麻烦。
今天咱们就从“复印书本”的比喻入手,一点点揭开mmap的面纱:先搞懂它为什么存在(背景),再拆明白它的“操作手册”(参数与原理),最后亲手实操几个案例(大文件处理、进程共享、零拷贝),让你不仅会用mmap,还能明白它为什么这么快。
一、背景与核心概念:mmap为什么会出现?
要理解mmap,得先明白传统IO的“痛点”——就像知道“去图书馆借书”有多麻烦,才会想到“复印书本”的好处。
1. 传统IO的“两步拷贝”困境
当你用read从文件读数据时,数据要经过两次拷贝:
- 硬盘→内核缓冲区:内核先把数据从硬盘读到自己的缓冲区(比如page cache,相当于图书馆前台的临时存放区);
- 内核缓冲区→用户内存:再把数据从内核缓冲区拷贝到用户进程的内存(相当于你把前台的书抄到笔记本上)。
写数据(write)时同样麻烦:用户内存→内核缓冲区→硬盘,也是两次拷贝。
这种“两步拷贝”在处理大文件(比如1GB的日志文件)或频繁IO(比如数据库读写)时,会浪费大量CPU时间在拷贝上,成为性能瓶颈。就像你每次需要书中的一句话,都要跑一趟图书馆,把整本书搬到前台,抄完再搬回去——效率极低。
2. mmap的“内存映射”革命
mmap的出现就是为了打破这种困境。它的核心原理是:将文件的一部分直接映射到进程的虚拟地址空间,进程操作这段内存时,内核会自动同步到文件。
就像你把整本书复印下来带在身上:
- 想看某一页?直接翻复印本(访问内存),不用去图书馆;
- 想修改某句话?直接改复印本(写内存),内核会在合适的时候把修改同步到原书(硬盘文件);
- 多人共享一本书?大家复印同一本书,修改会同步到原书,彼此能看到对方的修改(进程间共享内存)。
mmap的优势可以总结为“三少一快”:
- 少拷贝:数据无需从内核缓冲区拷贝到用户内存(直接操作内核映射的内存);
- 少系统调用:一次mmap替代多次read/write;
- 少内存占用:多个进程映射同一文件时,共享物理内存(Copy-On-Write机制);
- 快访问:像操作内存一样操作文件,省去IO操作的开销。
3. mmap的核心概念:虚拟内存与页映射
mmap能工作,依赖于操作系统的“虚拟内存”机制——每个进程都有独立的虚拟地址空间(比如32位系统4GB),这些地址并不直接对应物理内存,而是通过“页表”映射到物理内存或硬盘文件。
mmap做的事情,就是在进程的虚拟地址空间中“开辟一块区域”,并把这块区域通过页表映射到文件的某段内容(以“页”为单位,Linux默认页大小4KB)。当进程访问这段虚拟内存时:
- 如果数据不在物理内存(缺页),内核会自动从文件加载对应页到物理内存(按需加载);
- 如果修改了内存,内核会根据映射方式(共享/私有)决定是否同步到文件。
用Mermaid图展示这个过程,就像“虚拟书架的映射关系”:
4. mmap的常见应用场景
mmap不是“万能药”,但在以下场景中能发挥巨大作用:
场景1:大文件高效读写
处理GB级日志文件、视频文件时,用mmap映射后直接访问内存,比循环read/write快得多(减少系统调用和数据拷贝)。比如视频编辑软件打开大视频文件,通常会用mmap映射,避免频繁IO。
场景2:进程间共享内存
多个进程映射同一文件(用MAP_SHARED标志),就能通过修改内存实现通信,比管道、消息队列快(无需数据拷贝)。比如浏览器的多个标签页共享缓存数据,可能用mmap实现。
场景3:动态库加载
系统加载动态库(.so文件)时,会用mmap将库文件映射到进程内存,既节省内存(多个进程共享同一份物理内存),又能按需加载(用到哪个函数才加载对应页)。
场景4:零拷贝IO
网络编程中,用mmap映射文件后,直接通过sendfile发送映射区内存,实现“硬盘→网卡”的零拷贝(数据不经过用户内存),大幅提升文件传输速度(比如Nginx发送静态文件)。
二、mmap的“操作手册”:函数原型与参数详解
mmap的函数原型不算复杂,但参数细节很多,就像“复印书本的操作指南”——要说明复印哪部分、能否修改、是否共享给别人等。
1. 函数原型与头文件
mmap的“官方名片”长这样:
#include <sys/mman.h>void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
- 头文件:必须包含
<sys/mman.h>
,否则编译器会找不到mmap的声明; - 库依赖:属于POSIX标准系统调用,编译时无需额外链接库(不用加-l参数),直接用gcc编译即可。
2. 返回值:映射区的“内存地址”或“错误标志”
- 成功:返回映射区的起始虚拟地址(void *类型),之后可以像普通指针一样操作这段内存;
- 失败:返回
MAP_FAILED
(通常是(void *)-1),同时设置errno
表示错误原因(需要包含<errno.h>
)。
常见错误码:
EINVAL
:参数无效(比如length=0,或offset不是页大小的整数倍);EBADF
:文件描述符fd无效(比如未打开或没有权限);ENOMEM
:内存不足,无法分配虚拟地址空间;EACCES
:权限不足(比如prot要求写,但文件是只读打开的)。
3. 参数详解:6个参数的“隐藏含义”
mmap的6个参数,每个都有明确的作用,缺一不可。咱们一个个拆解:
(1)addr:建议的映射起始地址(通常传NULL)
addr
是你希望映射区开始的虚拟地址,就像“建议复印从第10页开始”。但内核通常会忽略这个建议(除非设置了MAP_FIXED标志),自己选一个合适的地址。
最佳实践:传NULL,让内核自动分配地址,避免地址冲突(比如该地址已被其他映射占用)。
(2)length:映射的长度(字节数)
length
是要映射的文件大小(或部分大小),就像“复印从第10页到第30页,共21页”。注意:
- 必须大于0(否则EINVAL);
- 实际映射会按“页大小”向上取整(比如length=5000字节,页大小4096,则映射8192字节);
- 不能超过文件大小(除非文件可扩展,且用了MAP_SHARED和O_CREAT)。
(3)prot:内存保护模式(权限控制)
prot
指定映射区的读写权限,就像“复印的内容能否修改”。必须是以下值的组合(用|连接):
标志 | 含义 | 常见组合示例 |
---|---|---|
PROT_READ | 映射区可读取 | 只读:PROT_READ |
PROT_WRITE | 映射区可写入(必须同时有PROT_READ) | 读写:PROT_READ |
PROT_EXEC | 映射区可执行(比如映射可执行文件) | 可执行:PROT_READ |
PROT_NONE | 映射区不可访问(很少用) | - |
注意:prot的权限不能超过文件的打开权限。比如文件以只读(O_RDONLY)打开,prot不能包含PROT_WRITE,否则会报EACCES错误。
(4)flags:映射类型与行为(核心参数)
flags
是mmap最复杂的参数,控制映射的类型(共享/私有)、地址分配方式等,就像“复印的规则:修改是否同步到原书?必须按指定页开始复印吗?”。
常用标志(必须选一个共享类型标志):
类别 | 标志 | 含义 |
---|---|---|
共享类型 | MAP_SHARED | 内存修改会同步到文件,其他映射该文件的进程可见(进程共享) |
共享类型 | MAP_PRIVATE | 内存修改是进程私有(Copy-On-Write),不影响原文件,其他进程不可见 |
地址分配 | MAP_FIXED | 强制使用addr作为起始地址(风险:可能覆盖已有映射,谨慎使用) |
特殊映射 | MAP_ANONYMOUS | 匿名映射(无文件,用于进程间共享内存,fd传-1,offset传0) |
特殊映射 | MAP_LOCKED | 锁定映射区到物理内存,避免被换出到swap(需要CAP_IPC_LOCK权限) |
最常用组合:
MAP_SHARED | PROT_READ | PROT_WRITE
:读写共享,修改同步到文件;MAP_PRIVATE | PROT_READ
:只读私有,不影响原文件;MAP_ANONYMOUS | MAP_SHARED
:匿名共享映射(无文件,进程间共享内存)。
(5)fd:文件描述符(要映射的文件)
fd
是通过open打开的文件描述符,就像“要复印的书的编号”。注意:
- 映射前文件必须已打开,且有对应权限(比如读映射需要O_RDONLY);
- 映射后可以关闭fd(不影响映射,内核会保留文件引用);
- 匿名映射(MAP_ANONYMOUS)时,fd传-1。
(6)offset:文件偏移量(从哪里开始映射)
offset
是从文件的哪个位置开始映射,就像“从第10页开始复印”。必须是页大小的整数倍(比如4096、8192),否则会报EINVAL错误。
计算页大小的方法:用sysconf(_SC_PAGESIZE)
获取(单位字节),比如:
#include <unistd.h>
size_t page_size = sysconf(_SC_PAGESIZE); // 通常是4096
4. 配套函数:munmap与msync
mmap映射的内存需要“善后处理”,就像“复印完要整理好复印件”:
(1)munmap:解除映射
int munmap(void *addr, size_t length);
- 作用:释放mmap创建的映射区,之后不能再访问该内存;
- 参数:
addr
是mmap返回的地址,length
是映射长度(必须和mmap的length一致); - 返回值:成功0,失败-1(设置errno)。
必须调用:否则会导致内存泄漏(进程退出前不会自动释放)。
(2)msync:手动同步修改到文件
int msync(void *addr, size_t length, int flags);
- 作用:对于MAP_SHARED映射,强制将内存修改同步到硬盘文件(默认情况下内核会异步同步);
- flags:
MS_SYNC
(同步等待完成)或MS_ASYNC
(异步同步); - 返回值:成功0,失败-1。
适用场景:需要确保修改立即写入硬盘(比如日志持久化)时调用。
三、实例与应用场景:亲手玩转mmap
理论讲得再多,不如动手写代码。下面三个案例覆盖“大文件读取”“进程共享内存”“匿名映射通信”,每个案例都有完整代码、注释、Makefile和操作说明,让你从“看懂”到“会用”。
案例1:大文件高效读取——用mmap替代read
场景描述
读取一个1GB的大日志文件,统计其中包含“error”字符串的行数。用mmap映射文件后直接遍历内存,对比传统read循环的效率差异。
核心需求
- 用mmap映射整个文件到内存;
- 遍历内存查找“error”字符串,统计行数;
- 对比传统read+缓冲区的方式,展示mmap的效率优势。
完整代码(mmap版本)
/*** @file mmap_read.c* @brief 用mmap高效读取大文件,统计包含"error"的行数* * 对比传统read方式,mmap减少了数据拷贝和系统调用,处理大文件更高效。* 步骤:打开文件→获取文件大小→mmap映射→遍历内存统计→munmap解除映射。* * @author 技术助手* @date 2024*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>/*** @brief 统计映射区中包含"error"的行数* * 遍历mmap映射的内存,逐行查找"error"字符串(不区分大小写),* 遇到换行符时行数+1,同时检查当前行是否包含目标字符串。* * @in:* - map_addr:mmap返回的映射区起始地址* - file_size:文件总大小(映射区长度)* @out:无输出参数* @return:包含"error"的行数(int)*/
int count_error_lines(const char *map_addr, size_t file_size) {if (map_addr == NULL || file_size == 0) {fprintf(stderr, "无效的映射地址或文件大小\n");return -1;}int total_lines = 0; // 总行数int error_lines = 0; // 包含error的行数const char *line_start = map_addr; // 当前行起始位置for (size_t i = 0; i < file_size; i++) {// 遇到换行符,标记一行结束if (map_addr[i] == '\n') {total_lines++;// 检查当前行是否包含"error"(不区分大小写)size_t line_len = &map_addr[i] - line_start;if (line_len >= 5) { // "error"长度为5for (size_t j = 0; j <= line_len - 5; j++) {if (strncasecmp(&line_start[j], "error", 5) == 0) {error_lines++;break; // 找到一个即可,跳出当前行循环}}}line_start = &map_addr[i] + 1; // 更新下一行起始位置}}// 处理文件末尾无换行符的情况if (line_start < &map_addr[file_size]) {total_lines++;size_t line_len = &map_addr[file_size] - line_start;if (line_len >= 5) {for (size_t j = 0; j <= line_len - 5; j++) {if (strncasecmp(&line_start[j], "error", 5) == 0) {error_lines++;break;}}}}printf("总行数:%d\n", total_lines);return error_lines;
}int main(int argc, char *argv[]) {if (argc != 2) {fprintf(stderr, "用法:%s <文件名>\n", argv[0]);exit(EXIT_FAILURE);}const char *filename = argv[1];int fd;struct stat file_stat;char *map_addr;size_t file_size;// 1. 打开文件(只读模式)fd = open(filename, O_RDONLY);if (fd == -1) {perror("open文件失败");exit(EXIT_FAILURE);}// 2. 获取文件大小if (fstat(fd, &file_stat) == -1) {perror("fstat获取文件大小失败");close(fd);exit(EXIT_FAILURE);}file_size = file_stat.st_size;printf("文件大小:%zu字节\n", file_size);if (file_size == 0) {fprintf(stderr, "文件为空,无需处理\n");close(fd);exit(EXIT_SUCCESS);}// 3. mmap映射文件(只读,私有映射)map_addr = mmap(NULL, file_size, PROT_READ, MAP_PRIVATE, fd, 0);if (map_addr == MAP_FAILED) {perror("mmap映射失败");close(fd);exit(EXIT_FAILURE);}// 4. 映射后可以关闭文件描述符(不影响映射)close(fd);// 5. 统计包含"error"的行数int error_count = count_error_lines(map_addr, file_size);if (error_count >= 0) {printf("包含\"error\"的行数:%d\n", error_count);}// 6. 解除映射(必须调用,避免内存泄漏)if (munmap(map_addr, file_size) == -1) {perror("munmap解除映射失败");exit(EXIT_FAILURE);}return EXIT_SUCCESS;
}
完整代码(传统read版本,用于对比)
/*** @file read_compare.c* @brief 用传统read方式读取大文件,统计包含"error"的行数(用于和mmap对比)* * 步骤:打开文件→循环read到缓冲区→遍历缓冲区统计→关闭文件。* 对比mmap版本,会有更多系统调用和数据拷贝。* * @author 技术助手* @date 2024*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>#define BUFFER_SIZE 4096 // 缓冲区大小(页大小,4KB)/*** @brief 统计缓冲区中包含"error"的行数(处理跨缓冲区的行)* * 维护一个全局的行缓冲区,处理read返回的部分数据(可能跨缓冲区换行),* 逻辑与mmap版本类似,但需要处理缓冲区边界问题。* * @in:* - buf:当前read的缓冲区* - buf_len:缓冲区有效数据长度* - line_buf:全局行缓冲区(保存上一次未完成的行)* - line_len:全局行缓冲区当前长度* @out:* - line_buf:更新未完成的行* - line_len:更新未完成行的长度* @return:本次处理中包含"error"的行数(int)*/
int count_error_in_buf(const char *buf, size_t buf_len, char *line_buf, size_t *line_len) {if (buf == NULL || line_buf == NULL || line_len == NULL) {fprintf(stderr, "无效的缓冲区参数\n");return -1;}int error_count = 0;const char *p = buf;while (p < buf + buf_len) {// 查找换行符const char *newline = memchr(p, '\n', buf + buf_len - p);if (newline != NULL) {// 复制当前行到line_buf(包含换行符)size_t segment_len = newline - p + 1;if (*line_len + segment_len > BUFFER_SIZE) {fprintf(stderr, "行长度超过缓冲区大小,可能被截断\n");segment_len = BUFFER_SIZE - *line_len;}memcpy(line_buf + *line_len, p, segment_len);*line_len += segment_len;line_buf[*line_len] = '\0'; // 确保字符串结束// 检查当前行是否包含"error"if (*line_len >= 5) {for (size_t i = 0; i <= *line_len - 5; i++) {if (strncasecmp(&line_buf[i], "error", 5) == 0) {error_count++;break;}}}// 重置行缓冲区(准备下一行)*line_len = 0;p = newline + 1;} else {// 未找到换行符,保存到行缓冲区size_t remaining = buf + buf_len - p;if (*line_len + remaining > BUFFER_SIZE) {fprintf(stderr, "行长度超过缓冲区大小,可能被截断\n");remaining = BUFFER_SIZE - *line_len;}memcpy(line_buf + *line_len, p, remaining);*line_len += remaining;p = buf + buf_len;}}return error_count;
}int main(int argc, char *argv[]) {if (argc != 2) {fprintf(stderr, "用法:%s <文件名>\n", argv[0]);exit(EXIT_FAILURE);}const char *filename = argv[1];int fd;struct stat file_stat;char buf[BUFFER_SIZE];char line_buf[BUFFER_SIZE]; // 保存跨缓冲区的行size_t line_len = 0; // 行缓冲区当前长度ssize_t read_len;int total_error = 0;int total_lines = 0;// 1. 打开文件(只读模式)fd = open(filename, O_RDONLY);if (fd == -1) {perror("open文件失败");exit(EXIT_FAILURE);}// 2. 获取文件大小(仅用于显示)if (fstat(fd, &file_stat) == -1) {perror("fstat获取文件大小失败");close(fd);exit(EXIT_FAILURE);}printf("文件大小:%zu字节\n", file_stat.st_size);if (file_stat.st_size == 0) {fprintf(stderr, "文件为空,无需处理\n");close(fd);exit(EXIT_SUCCESS);}// 3. 循环read读取文件内容while ((read_len = read(fd, buf, BUFFER_SIZE)) > 0) {int current_error = count_error_in_buf(buf, read_len, line_buf, &line_len);if (current_error == -1) {close(fd);exit(EXIT_FAILURE);}total_error += current_error;// 统计总行数(每个换行符+1)for (size_t i = 0; i < read_len; i++) {if (buf[i] == '\n') {total_lines++;}}}// 4. 处理read错误if (read_len == -1) {perror("read文件失败");close(fd);exit(EXIT_FAILURE);}// 5. 处理文件末尾未完成的行if (line_len > 0) {total_lines++; // 最后一行无换行符// 检查是否包含errorif (line_len >= 5) {for (size_t i = 0; i <= line_len - 5; i++) {if (strncasecmp(&line_buf[i], "error", 5) == 0) {total_error++;}}}}// 6. 输出结果printf("总行数:%d\n", total_lines);printf("包含\"error\"的行数:%d\n", total_error);// 7. 关闭文件close(fd);return EXIT_SUCCESS;
}
核心逻辑图(Mermaid流程图对比)
flowchart TDsubgraph "mmap版本流程"A["打开文件(open)"] --> B["获取文件大小(fstat)"]B --> C["mmap映射文件到内存"]C --> D["关闭文件描述符(不影响映射)"]D --> E["遍历内存查找\"error\"(一次遍历)"]E --> F["munmap解除映射"]F --> G["输出统计结果"]endsubgraph "传统read版本流程"H["打开文件(open)"] --> I["获取文件大小(fstat)"]I --> J["初始化缓冲区(4KB)"]J --> K{"read读取4KB到缓冲区"}K -- "读取成功" --> L["处理缓冲区:拼接跨区行,查找\"error\""]L --> M["更新统计计数"]M --> KK -- "读取完毕(0)" --> N["处理最后一行(无换行符)"]N --> O["关闭文件"]O --> P["输出统计结果"]endA -- "系统调用次数:1次" --> CH -- "系统调用次数:N次(N=文件大小/4KB)" --> KC -- "数据拷贝:0次(直接访问内核映射)" --> EK -- "数据拷贝:N次(内核→用户缓冲区)" --> L
Makefile
# Makefile for mmap vs read comparison
# 编译mmap版本和传统read版本,用于效率对比CC = gcc
CFLAGS = -Wall -Wextra -g -O2 # -O2开启优化,更接近实际使用场景
TARGETS = mmap_read read_compareall: $(TARGETS)# 编译mmap版本
mmap_read: mmap_read.c$(CC) $(CFLAGS) -o $@ $^# 编译传统read版本
read_compare: read_compare.c$(CC) $(CFLAGS) -o $@ $^# 生成测试用的大文件(100MB,包含随机"error"字符串)
testfile:# 生成100MB文件,每100行插入一个"error"dd if=/dev/urandom of=test.log bs=1M count=100 status=nonesed -i 's/....../error\n/100' test.log # 每100个字符插入"error\n"# 清理编译产物和测试文件
clean:rm -f $(TARGETS)rm -f test.logrm -f *.o
操作说明
-
编译与准备测试文件
- 依赖环境:Linux系统,gcc编译器(默认自带),无需额外库。
- 编译命令:
make clean && make
- 生成测试用大文件(100MB,含随机“error”字符串):
生成的make testfile
test.log
用于后续测试。
-
运行方式与效率对比
- 运行mmap版本:
time ./mmap_read test.log
- 运行传统read版本:
time ./read_compare test.log
time
命令会显示程序运行时间(用户态时间+系统态时间),用于对比效率。
- 运行mmap版本:
-
结果解读
- 功能结果:两个程序都会输出“总行数”和“包含error的行数”,结果一致(证明mmap读取正确)。
- 效率差异:在100MB文件上,mmap版本的运行时间通常比read版本快20%-50%:
- mmap版本:系统态时间短(少了多次read系统调用),用户态时间接近(都是遍历数据);
- read版本:系统态时间长(多次read调用),用户态时间略长(处理缓冲区拼接)。
- 大文件优势更明显:文件越大(如1GB),mmap的优势越显著(系统调用和拷贝的开销被放大)。
案例2:父子进程共享内存——用mmap实现高效通信
场景描述
创建父子进程,通过mmap映射同一文件实现共享内存:父进程每隔1秒向共享内存写入递增的计数,子进程读取计数并打印,当计数达到10时退出。对比管道通信,mmap共享内存无需数据拷贝,效率更高。
核心需求
- 父进程创建文件,mmap映射为MAP_SHARED(共享修改);
- 子进程继承文件描述符,同样mmap映射该文件;
- 父进程写计数,子进程读计数,实现无拷贝通信;
- 计数到10时,父子进程都退出,清理资源。
完整代码
/*** @file mmap_shared.c* @brief 父子进程通过mmap共享内存通信(MAP_SHARED)* * 父进程创建文件并mmap映射为共享模式,子进程继承映射后,* 父子进程通过修改共享内存实现通信,无需数据拷贝,效率高于管道。* * @author 技术助手* @date 2024*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/wait.h>
#include <errno.h>// 共享内存中存储的数据结构(计数+退出标志)
typedef struct {int count; // 递增计数int exit_flag; // 退出标志(1:退出)
} SharedData;/*** @brief 父进程逻辑:向共享内存写入递增计数* * 每隔1秒将count加1,写入共享内存,当count达到10时设置exit_flag,* 等待子进程退出后清理资源。* * @in:* - shared_data:mmap映射的共享内存地址(SharedData*)* @out:* - shared_data:更新count和exit_flag* @return:成功返回0,失败返回-1*/
int parent_task(SharedData *shared_data) {if (shared_data == NULL) {fprintf(stderr, "共享内存地址无效\n");return -1;}// 初始化共享数据shared_data->count = 0;shared_data->exit_flag = 0;while (1) {// 递增计数shared_data->count++;printf("父进程:写入计数 %d\n", shared_data->count);// 计数到10,设置退出标志if (shared_data->count >= 10) {shared_data->exit_flag = 1;printf("父进程:计数达到10,设置退出标志\n");break;}// 等待1秒sleep(1);}// 等待子进程退出wait(NULL);printf("父进程:子进程已退出,父进程退出\n");return 0;
}/*** @brief 子进程逻辑:从共享内存读取计数并打印* * 循环读取共享内存的count,打印到终端,当检测到exit_flag=1时退出。* * @in:* - shared_data:mmap映射的共享内存地址(SharedData*)* @out:无输出参数* @return:成功返回0,失败返回-1*/
int child_task(SharedData *shared_data) {if (shared_data == NULL) {fprintf(stderr, "共享内存地址无效\n");return -1;}while (1) {// 检查退出标志if (shared_data->exit_flag) {printf("子进程:检测到退出标志,子进程退出\n");break;}// 读取并打印计数(如果有更新)static int last_count = 0;if (shared_data->count != last_count) {last_count = shared_data->count;printf("子进程:读取到计数 %d\n", last_count);}// 短暂休眠,避免CPU占用过高usleep(100000); // 100ms}return 0;
}int main() {const char *filename = "shared_mem.tmp"; // 用于共享的临时文件int fd;SharedData *shared_data;size_t map_size = sizeof(SharedData); // 映射大小(结构体大小)pid_t pid;// 1. 创建并打开临时文件(O_CREAT:不存在则创建,O_RDWR:读写)fd = open(filename, O_CREAT | O_RDWR | O_TRUNC, 0666);if (fd == -1) {perror("open共享文件失败");exit(EXIT_FAILURE);}// 2. 扩展文件大小到至少map_size(否则mmap可能失败或映射内容无效)if (ftruncate(fd, map_size) == -1) {perror("ftruncate扩展文件大小失败");close(fd);unlink(filename); // 清理临时文件exit(EXIT_FAILURE);}// 3. mmap映射文件(共享模式,读写权限)shared_data = mmap(NULL, map_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);if (shared_data == MAP_FAILED) {perror("mmap共享映射失败");close(fd);unlink(filename);exit(EXIT_FAILURE);}// 4. 映射后可以关闭文件描述符(父子进程共享映射,不依赖fd)close(fd);// 5. 创建子进程pid = fork();if (pid == -1) {perror("fork创建子进程失败");munmap(shared_data, map_size);unlink(filename);exit(EXIT_FAILURE);} else if (pid == 0) {// 子进程:执行读取任务child_task(shared_data);// 子进程解除映射(可选,进程退出会自动解除,但显式调用更规范)munmap(shared_data, map_size);exit(EXIT_SUCCESS);} else {// 父进程:执行写入任务parent_task(shared_data);// 父进程解除映射munmap(shared_data, map_size);// 删除临时文件(子进程已退出,不再需要)unlink(filename);exit(EXIT_SUCCESS);}
}
核心逻辑图(Mermaid时序图)
Makefile
# Makefile for mmap shared memory between parent and child
CC = gcc
CFLAGS = -Wall -Wextra -g
TARGET = mmap_sharedall: $(TARGET)$(TARGET): mmap_shared.c$(CC) $(CFLAGS) -o $@ $^clean:rm -f $(TARGET)rm -f shared_mem.tmp # 清理临时共享文件rm -f *.o
操作说明
-
编译方法
- 依赖环境:Linux系统,gcc编译器(默认自带)。
- 编译命令:
make clean && make
- 生成可执行文件
mmap_shared
。
-
运行方式
- 直接运行:
./mmap_shared
- 程序会自动创建临时文件
shared_mem.tmp
,用于mmap映射,退出时自动删除。
- 直接运行:
-
结果解读
- 典型输出:
父进程:写入计数 1 子进程:读取到计数 1 父进程:写入计数 2 子进程:读取到计数 2 ...(中间省略计数3-9) 父进程:写入计数 10 父进程:计数达到10,设置退出标志 子进程:检测到退出标志,子进程退出 父进程:子进程已退出,父进程退出
- 原理说明:
- 父子进程通过MAP_SHARED映射同一文件,共享同一块物理内存;
- 父进程修改
shared_data->count
后,子进程能立即看到更新(无需任何系统调用); - 相比管道通信(每次write/read需要拷贝数据),mmap共享内存几乎无开销,适合高频通信场景。
- 典型输出:
案例3:匿名映射——无文件的进程间共享内存
场景描述
创建两个独立进程(非父子关系),通过匿名mmap(MAP_ANONYMOUS)结合文件描述符传递,实现共享内存通信:进程A向共享内存写入字符串,进程B读取并打印,完成后双方退出。
核心需求
- 进程A创建匿名映射(无文件),通过socket将文件描述符传递给进程B;
- 进程B接收文件描述符,mmap映射同一匿名内存;
- 进程A写入数据,进程B读取数据,实现无文件的共享内存通信;
- 通信完成后,双方解除映射并退出。
完整代码(进程A:写入端)
/*** @file mmap_anon_writer.c* @brief 匿名映射示例:进程A(写入端)* * 创建匿名mmap映射(MAP_ANONYMOUS | MAP_SHARED),通过Unix域socket将* 文件描述符传递给进程B,向共享内存写入字符串后等待进程B读取。* * @author 技术助手* @date 2024*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>#define SOCKET_PATH "/tmp/mmap_anon_socket" // Unix域socket路径
#define MAP_SIZE 1024 // 匿名映射大小(1KB)/*** @brief 通过Unix域socket发送文件描述符* * 利用sendmsg系统调用,将文件描述符封装在辅助数据中发送给接收方,* 实现进程间文件描述符传递(用于共享匿名映射)。* * @in:* - socket_fd:已连接的Unix域socket* - fd_to_send:要发送的文件描述符* @out:无输出参数* @return:成功返回0,失败返回-1*/
int send_fd(int socket_fd, int fd_to_send) {struct msghdr msg = {0};struct iovec iov;char buf[1] = {'a'}; // dummy数据,确保sendmsg不被忽略struct cmsghdr *cmsg;char cmsg_buf[CMSG_SPACE(sizeof(int))]; // 辅助数据缓冲区// 设置基本消息(必须有数据,否则可能被内核忽略)iov.iov_base = buf;iov.iov_len = 1;msg.msg_iov = &iov;msg.msg_iovlen = 1;// 设置辅助数据(包含文件描述符)msg.msg_control = cmsg_buf;msg.msg_controllen = sizeof(cmsg_buf);cmsg = CMSG_FIRSTHDR(&msg);cmsg->cmsg_level = SOL_SOCKET;cmsg->cmsg_type = SCM_RIGHTS;cmsg->cmsg_len = CMSG_LEN(sizeof(int));*((int *)CMSG_DATA(cmsg)) = fd_to_send;// 发送消息(包含文件描述符)if (sendmsg(socket_fd, &msg, 0) == -1) {perror("sendmsg发送文件描述符失败");return -1;}return 0;
}int main() {int socket_fd, client_fd;struct sockaddr_un addr;char *anon_map;int fd;// 1. 创建匿名映射的“载体”文件(/dev/zero,写入会被丢弃,映射后可共享)// 注:匿名映射通常用fd=-1,但跨进程传递需通过fd,故用/dev/zerofd = open("/dev/zero", O_RDWR);if (fd == -1) {perror("open /dev/zero失败");exit(EXIT_FAILURE);}// 2. 创建匿名共享映射(MAP_ANONYMOUS | MAP_SHARED)anon_map = mmap(NULL, MAP_SIZE, PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_SHARED, fd, 0);if (anon_map == MAP_FAILED) {perror("mmap匿名映射失败");close(fd);exit(EXIT_FAILURE);}printf("进程A:匿名映射创建成功,地址=%p,大小=%d字节\n", anon_map, MAP_SIZE);// 3. 创建Unix域socket,用于传递文件描述符unlink(SOCKET_PATH); // 清理旧的socket文件socket_fd = socket(AF_UNIX, SOCK_STREAM, 0);if (socket_fd == -1) {perror("socket创建失败");munmap(anon_map, MAP_SIZE);close(fd);exit(EXIT_FAILURE);}// 4. 绑定socket到路径memset(&addr, 0, sizeof(addr));addr.sun_family = AF_UNIX;strncpy(addr.sun_path, SOCKET_PATH, sizeof(addr.sun_path) - 1);if (bind(socket_fd, (struct sockaddr *)&addr, sizeof(addr)) == -1) {perror("bind失败");close(socket_fd);munmap(anon_map, MAP_SIZE);close(fd);exit(EXIT_FAILURE);}// 5. 监听连接(等待进程B连接)if (listen(socket_fd, 1) == -1) {perror("listen失败");close(socket_fd);munmap(anon_map, MAP_SIZE);close(fd);exit(EXIT_FAILURE);}printf("进程A:等待进程B连接...(请先启动进程B)\n");// 6. 接受进程B的连接client_fd = accept(socket_fd, NULL, NULL);if (client_fd == -1) {perror("accept失败");close(socket_fd);munmap(anon_map, MAP_SIZE);close(fd);exit(EXIT_FAILURE);}printf("进程A:进程B已连接\n");// 7. 向进程B发送文件描述符(用于共享匿名映射)if (send_fd(client_fd, fd) == -1) {close(client_fd);close(socket_fd);munmap(anon_map, MAP_SIZE);close(fd);exit(EXIT_FAILURE);}printf("进程A:文件描述符发送成功\n");// 8. 向共享内存写入数据const char *msg = "Hello from 进程A!这是匿名共享内存中的数据~";strncpy(anon_map, msg, MAP_SIZE - 1);anon_map[MAP_SIZE - 1] = '\0'; // 确保字符串结束printf("进程A:已写入数据到共享内存:%s\n", anon_map);// 9. 等待进程B读取(通过读取socket的确认信息)char confirm[16];ssize_t read_len = read(client_fd, confirm, sizeof(confirm) - 1);if (read_len > 0) {confirm[read_len] = '\0';printf("进程A:收到进程B的确认:%s\n", confirm);}// 10. 清理资源close(client_fd);close(socket_fd);munmap(anon_map, MAP_SIZE);close(fd);unlink(SOCKET_PATH); // 清理socket文件printf("进程A:退出\n");return EXIT_SUCCESS;
}
完整代码(进程B:读取端)
/*** @file mmap_anon_reader.c* @brief 匿名映射示例:进程B(读取端)* * 连接进程A的Unix域socket,接收文件描述符,通过mmap映射同一匿名内存,* 读取进程A写入的数据后发送确认,最后退出。* * @author 技术助手* @date 2024*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>#define SOCKET_PATH "/tmp/mmap_anon_socket" // 与进程A相同的socket路径
#define MAP_SIZE 1024 // 与进程A相同的映射大小/*** @brief 通过Unix域socket接收文件描述符* * 利用recvmsg系统调用,从辅助数据中提取进程A发送的文件描述符,* 用于映射同一匿名内存。* * @in:* - socket_fd:已连接的Unix域socket* @out:无输出参数* @return:成功返回接收的文件描述符,失败返回-1*/
int recv_fd(int socket_fd) {struct msghdr msg = {0};struct iovec iov;char buf[1]; // 接收dummy数据struct cmsghdr *cmsg;char cmsg_buf[CMSG_SPACE(sizeof(int))];int received_fd;// 设置基本消息(接收dummy数据)iov.iov_base = buf;iov.iov_len = 1;msg.msg_iov = &iov;msg.msg_iovlen = 1;// 设置辅助数据缓冲区(接收文件描述符)msg.msg_control = cmsg_buf;msg.msg_controllen = sizeof(cmsg_buf);// 接收消息(包含文件描述符)if (recvmsg(socket_fd, &msg, 0) == -1) {perror("recvmsg接收文件描述符失败");return -1;}// 提取文件描述符cmsg = CMSG_FIRSTHDR(&msg);if (cmsg == NULL || cmsg->cmsg_level != SOL_SOCKET || cmsg->cmsg_type != SCM_RIGHTS) {fprintf(stderr, "未收到有效的文件描述符\n");return -1;}received_fd = *((int *)CMSG_DATA(cmsg));if (received_fd == -1) {perror("接收的文件描述符无效");return -1;}return received_fd;
}int main() {int socket_fd;struct sockaddr_un addr;char *anon_map;int fd;// 1. 创建Unix域socket,连接进程Asocket_fd = socket(AF_UNIX, SOCK_STREAM, 0);if (socket_fd == -1) {perror("socket创建失败");exit(EXIT_FAILURE);}// 2. 连接到进程A的socketmemset(&addr, 0, sizeof(addr));addr.sun_family = AF_UNIX;strncpy(addr.sun_path, SOCKET_PATH, sizeof(addr.sun_path) - 1);if (connect(socket_fd, (struct sockaddr *)&addr, sizeof(addr)) == -1) {perror("connect失败(请先启动进程A)");close(socket_fd);exit(EXIT_FAILURE);}printf("进程B:已连接到进程A\n");// 3. 接收进程A发送的文件描述符fd = recv_fd(socket_fd);if (fd == -1) {close(socket_fd);exit(EXIT_FAILURE);}printf("进程B:接收文件描述符成功,fd=%d\n", fd);// 4. 映射同一匿名内存(参数必须与进程A一致)anon_map = mmap(NULL, MAP_SIZE, PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_SHARED, fd, 0);if (anon_map == MAP_FAILED) {perror("mmap匿名映射失败");close(fd);close(socket_fd);exit(EXIT_FAILURE);}printf("进程B:匿名映射创建成功,地址=%p,大小=%d字节\n", anon_map, MAP_SIZE);// 5. 从共享内存读取数据printf("进程B:从共享内存读取到数据:%s\n", anon_map);// 6. 向进程A发送确认信息const char *confirm = "已收到数据";if (write(socket_fd, confirm, strlen(confirm)) == -1) {perror("write确认信息失败");}// 7. 清理资源close(socket_fd);munmap(anon_map, MAP_SIZE);close(fd);printf("进程B:退出\n");return EXIT_SUCCESS;
}
核心逻辑图(Mermaid时序图)
Makefile
# Makefile for anonymous mmap between two processes
CC = gcc
CFLAGS = -Wall -Wextra -g
TARGETS = mmap_anon_writer mmap_anon_readerall: $(TARGETS)# 编译写入端(进程A)
mmap_anon_writer: mmap_anon_writer.c$(CC) $(CFLAGS) -o $@ $^# 编译读取端(进程B)
mmap_anon_reader: mmap_anon_reader.c$(CC) $(CFLAGS) -o $@ $^clean:rm -f $(TARGETS)rm -f /tmp/mmap_anon_socket # 清理Unix域socket文件rm -f *.o
操作说明
-
编译方法
- 依赖环境:Linux系统,gcc编译器(默认自带),支持Unix域socket(所有Linux系统均支持)。
- 编译命令:
make clean && make
- 生成两个可执行文件:
mmap_anon_writer
(进程A)和mmap_anon_reader
(进程B)。
-
运行方式
- 第一步:启动进程A(写入端),等待连接:
进程A会输出:./mmap_anon_writer
进程A:匿名映射创建成功,地址=0x7f...,大小=1024字节 进程A:等待进程B连接...(请先启动进程B)
- 第二步:启动进程B(读取端),在新终端执行:
./mmap_anon_reader
- 第一步:启动进程A(写入端),等待连接:
-
结果解读
- 进程A输出(续):
进程A:进程B已连接 进程A:文件描述符发送成功 进程A:已写入数据到共享内存:Hello from 进程A!这是匿名共享内存中的数据~ 进程A:收到进程B的确认:已收到数据 进程A:退出
- 进程B输出:
进程B:已连接到进程A 进程B:接收文件描述符成功,fd=3 进程B:匿名映射创建成功,地址=0x7f...,大小=1024字节 进程B:从共享内存读取到数据:Hello from 进程A!这是匿名共享内存中的数据~ 进程B:退出
- 核心原理:
- 匿名映射(MAP_ANONYMOUS)无需实际文件,但跨进程共享需通过文件描述符传递(这里用/dev/zero作为载体);
- 两个独立进程通过Unix域socket传递文件描述符,映射同一匿名内存,实现无文件的高效通信;
- 适用于不需要持久化的临时共享数据(如进程间临时交换大数据)。
- 进程A输出(续):
四、mmap的常见误区与注意事项
mmap虽然强大,但使用时如果不注意细节,很容易踩坑。下面总结几个高频误区,帮你避坑:
误区1:映射长度超过文件大小却不扩展文件
如果文件大小为100字节,却用mmap映射200字节,且未扩展文件,那么访问100-200字节范围时会触发总线错误(SIGBUS)。
避坑方法:映射前用ftruncate
扩展文件大小到至少映射长度:
// 正确做法:扩展文件大小
if (ftruncate(fd, map_size) == -1) {perror("ftruncate failed");// 错误处理
}
误区2:offset不是页大小的整数倍
mmap的offset参数必须是页大小(通常4096)的整数倍,否则会报EINVAL错误。
避坑方法:用sysconf(_SC_PAGESIZE)
获取页大小,确保offset是其整数倍:
size_t page_size = sysconf(_SC_PAGESIZE);
off_t offset = page_size * 2; // 正确:8192(2*4096)
// off_t offset = 5000; // 错误:不是4096的倍数
误区3:修改MAP_PRIVATE映射却期望影响原文件
MAP_PRIVATE是“私有映射”,修改会触发Copy-On-Write(写时复制),只影响当前进程的内存副本,不会同步到原文件。
避坑方法:需要修改同步到文件时,用MAP_SHARED标志:
// 正确:修改会同步到文件
mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
// 错误:修改不影响原文件
// mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_PRIVATE, fd, 0);
误区4:忘记调用munmap导致内存泄漏
mmap映射的内存不会被进程自动释放(即使进程退出,也可能延迟释放),长期运行的程序(如服务器)若不调用munmap,会导致虚拟内存耗尽。
避坑方法:不再使用映射区时,务必调用munmap:
// 不再使用时解除映射
if (munmap(addr, size) == -1) {perror("munmap failed");
}
误区5:在信号处理函数中使用mmap/munmap
mmap和munmap不是可重入函数,在信号处理函数中调用可能导致死锁或数据不一致。
避坑方法:信号处理函数中只做简单操作(如设置标志),mmap/munmap放在主程序逻辑中调用。
五、mmap的总结:什么时候该用?怎么用?
最后,用一张Mermaid图总结mmap的核心知识点,帮你快速回顾:
一句话总结mmap的价值
当你需要“像操作内存一样高效操作文件”或“进程间无拷贝通信”时,mmap是最佳选择——它打破了传统IO的性能瓶颈,让文件操作和进程通信变得简单而高效。但记住:mmap不是银弹,小文件或低频IO场景下,传统read/write可能更简单(避免mmap的页表维护开销)。
希望通过这篇解析,你不仅学会了用mmap,还能根据场景灵活选择——下次处理大文件或进程通信时,能自信地说:“用mmap,准没错!”