mmap技术详解:从原理到实践
前言
在操作系统和系统编程领域,mmap是一个既基础又强大的系统调用。它看似简单——只是建立虚拟内存到物理内存的映射,但正是这个"简单"的能力,催生了共享内存IPC、零拷贝文件IO、内存数据库等众多高性能技术。
本文将从mmap的本质出发,深入剖析其工作原理,并通过多个实际场景展示mmap的强大能力。
一、mmap的本质:建立映射关系
1.1 从虚拟内存说起
在理解mmap之前,需要先理解操作系统的虚拟内存机制。
每个进程都拥有独立的虚拟地址空间(如32位系统下的0x0000 ~ 0xFFFF)。这些虚拟地址通过页表映射到物理内存。正常情况下,进程A的虚拟地址0x1000和进程B的虚拟地址0x1000,映射的是完全不同的物理内存页——这就是进程隔离的基础。
进程A虚拟地址空间 页表 物理内存 页表 进程B虚拟地址空间
┌─────────────────┐ ┌─────────┐ ┌─────────────────┐
│ 0x00001000 │────────────────▶│ 物理页1 │◀────────────────│ 0x00001000 │
│ 0x00002000 │ ├─────────┤ │ 0x00002000 │
│ ... │ │ 物理页N │ │ ... │
└─────────────────┘ └─────────┘ └─────────────────┘
↑
完全独立的两块物理内存
1.2 mmap的核心:让页表指向目标物理地址
mmap的本质:在进程的虚拟地址空间中创建一段区域(VMA),并让这段区域的页表指向特定的物理地址。
这个"特定的物理地址"可以来自:
- 文件(文件映射)→ 指向文件的页缓存
- 共享内存对象(如shm_open)→ 指向共享物理内存
- 匿名内存(匿名映射)→ 指向新分配的物理页
- 设备内存(设备映射)→ 指向设备寄存器或DMA缓冲区
进程虚拟地址空间 页表 物理内存/存储
┌──────────────────┐ ┌──────────────┐ ┌──────────────┐
│ 代码段 │ │ 页表条目 │ │ 物理页 │
│ 数据段 │ │ 虚拟→物理 │ │ │
│ ◀── mmap区域 │────▶│ 映射关系 │─────▶│ 页缓存 │
│ │ │ │ │ ↓ │
│ 堆 │ └──────────────┘ │ 磁盘文件 │
│ 栈 │ └──────────────┘
└──────────────────┘
关键点:mmap只建立映射关系,不立即分配物理内存或读取文件数据。
1.3 映射的两种模式
// MAP_SHARED:多个映射共享同一物理内存,修改互相可见
void *addr = mmap(NULL, size, PROT_READ|PROT_WRITE,
MAP_SHARED, fd, 0);
// 修改会写回文件(通过msync或自动)
// MAP_PRIVATE:写时复制(COW),修改不影响原映射
void *addr = mmap(NULL, size, PROT_READ|PROT_WRITE,
MAP_PRIVATE, fd, 0);
// 修改只在内存副本中,不影响原文件
二、mmap在文件映射中的原理
2.1 传统read方式的问题
传统文件IO使用read/write系统调用:
char buf[4096];
ssize_t n;
while ((n = read(fd, buf, sizeof(buf))) > 0) {
process(buf, n); // 每次read都是系统调用
}
问题:
- 系统调用频繁:每个read/write都是系统调用(用户态↔内核态切换)
- 数据多次拷贝:磁盘→页缓存(DMA),页缓存→用户缓冲区(CPU拷贝)
- 用户态缓冲区开销:需要额外分配和管理用户态缓冲区
用户程序 内核 磁盘
│ │ │
│ read() 系统调用 │ │
│───────────────────▶│ │
│ │ 1. 检查页缓存 │
│ │ 2. 缺页 → 发起读请求 │
│ │───────────────────────▶│
│ │ │ DMA读到页缓存
│ │◀───────────────────────│
│ │ 3. CPU拷贝到用户缓冲区 │
│◀───────────────────│ (数据拷贝) │
│ 返回 │ │
2.2 mmap的文件映射机制
使用mmap加载文件:
struct stat st;
fstat(fd, &st);
char *data = mmap(NULL, st.st_size, PROT_READ, MAP_SHARED, fd, 0);
// 仅一次系统调用,之后直接访问data指针
process(data, st.st_size); // 无系统调用
munmap(data, st.st_size);
完整流程:
用户程序 内核 磁盘
│ │ │
│ mmap() 系统调用 │ │
│───────────────────▶│ │
│ │ 建立VMA映射 │
│◀───────────────────│ (不读取数据) │
│ 返回虚拟地址 │ │
│ │ │
│ 访问data[0] │ │
│───────────────────▶│ 缺页中断 │
│ │───────────────────────▶│
│ │ │ DMA读到页缓存
│ │◀───────────────────────│
│ │ 建立页表映射 │
│◀───────────────────│ (无数据拷贝) │
│ 直接访问页缓存 │ │
2.3 零拷贝的关键:页表映射 + DMA
mmap实现"零拷贝"文件访问的奥秘:
- mmap建立VMA:在进程地址空间预留一块区域,关联到文件
- 首次访问触发缺页中断:MMU发现页表无映射
- DMA直接读数据到页缓存:CPU只发命令,DMA控制器搬运数据
- 建立页表映射:虚拟地址→页缓存物理页
- 后续访问直接命中:MMU硬件完成地址转换,无需内核介入
数据拷贝次数:
- 传统read:2次(磁盘→页缓存,页缓存→用户缓冲区)
- mmap:1次(磁盘→页缓存,通过DMA)
系统调用次数:
- 传统read:每次读取都是1次系统调用
- mmap:仅mmap一次系统调用
2.4 性能对比验证
| 对比维度 | 传统read | mmap |
|---|---|---|
| 系统调用 | 每次read(N次) | mmap一次 + munmap一次 |
| 数据拷贝 | 磁盘→页缓存,页缓存→用户缓冲 | 仅磁盘→页缓存(DMA) |
| 内存使用 | 用户缓冲 + 页缓存(双份) | 仅页缓存(单份) |
| 随机访问 | lseek + read(2次系统调用) | 指针访问(0次系统调用) |
| 适合场景 | 小文件、顺序读 | 大文件、随机访问、频繁读 |
三、mmap的其他使用场景
3.1 进程间通信(共享内存)
这是mmap最经典的应用之一——通过shm_open创建共享内存对象,多个进程mmap到同一物理内存。
// 生产者进程
int fd = shm_open("/myshm", O_CREAT|O_RDWR, 0600);
ftruncate(fd, 4096);
char *shm = mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
strcpy(shm, "Hello from producer"); // 写入共享内存
// 消费者进程
int fd = shm_open("/myshm", O_RDWR, 0600);
char *shm = mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
printf("%s\n", shm); // 读取共享内存
数据流向:
生产者虚拟地址 0x1000 ──┐
├──→ 物理页N ←── 消费者虚拟地址 0x2000
│
写入数据 读取数据
关键优势:
- 零系统调用:映射后直接内存操作
- 零数据拷贝:数据在物理内存只有一份
- 纳秒级延迟:比管道、Socket快1-2个数量级
实际应用:
- 高频交易系统(行情数据分发)
- 视频处理管道(帧数据传递)
- 数据库缓存(查询结果共享)
- Redis等内存数据库的持久化加载
3.2 大文件高效加载
以Redis的RDB文件加载为例:
// mmap加载RDB文件
int load_rdb(const char *filename) {
int fd = open(filename, O_RDONLY);
struct stat st;
fstat(fd, &st);
// 一次系统调用映射整个文件
char *data = mmap(NULL, st.st_size, PROT_READ, MAP_SHARED, fd, 0);
// 直接解析数据,无系统调用
rdb_header_t *header = (rdb_header_t *)data;
parse_rdb_entries(data + sizeof(rdb_header_t), st.st_size);
munmap(data, st.st_size);
close(fd);
return 0;
}
性能提升:
- 1GB的RDB文件:传统read需要约262,144次系统调用(4KB块)
- mmap方式:仅3次系统调用(mmap, fstat, munmap)
- 加载速度提升:约5-10倍
3.3 动态内存分配
malloc的大内存分配通常使用mmap(匿名映射):
// glibc中,大内存分配使用mmap
void *ptr = mmap(NULL, 1024*1024,
PROT_READ|PROT_WRITE,
MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
// 返回的匿名内存初始化为0
优势:
- 直接从操作系统分配大块内存
- 内存可以返回给操作系统(
munmap) - 支持内存超售(overcommit)
3.4 设备内存映射
驱动开发中,mmap用于将设备寄存器或DMA缓冲区映射到用户空间:
// 用户程序访问GPU显存
int fd = open("/dev/gpu", O_RDWR);
void *gpu_mem = mmap(NULL, 64*1024*1024,
PROT_READ|PROT_WRITE,
MAP_SHARED, fd, 0);
// 直接操作GPU内存
gpu_mem[0] = 0x12345678;
应用场景:
- GPU显存映射
- FPGA寄存器访问
- 网卡DMA缓冲区
四、mmap的高级特性与注意事项
4.1 内存访问模式优化
// 告诉内核访问模式,优化性能
madvise(data, size, MADV_SEQUENTIAL); // 顺序访问,内核预读
madvise(data, size, MADV_RANDOM); // 随机访问,禁用预读
madvise(data, size, MADV_WILLNEED); // 预加载到内存
madvise(data, size, MADV_DONTNEED); // 可以释放
4.2 同步与持久化
// 确保修改写回磁盘
msync(data, size, MS_SYNC); // 同步等待写回
msync(data, size, MS_ASYNC); // 异步写回
4.3 重要注意事项
1. 文件大小不能为0
if (st.st_size == 0) {
// mmap空文件会失败
}
2. 访问超出文件范围会SIGBUS
char c = data[st.st_size]; // SIGBUS!
3. 文件被截断
// 如果文件在映射后被truncate,访问被截断部分会SIGBUS
4. 虚拟地址空间限制
// 32位系统用户态地址空间有限(通常3GB)
// 映射大文件可能耗尽地址空间
5. 必须检查返回值
if (data == MAP_FAILED) {
perror("mmap");
// 处理错误
}
五、总结
mmap的本质
mmap = 建立虚拟地址到物理地址的映射关系
这个"建立映射"的能力,通过操作系统的页表机制,实现了:
- 多个进程共享同一物理内存(IPC)
- 用户态程序直接访问页缓存(文件IO)
- 用户态程序直接操作设备内存(设备驱动)
核心价值
| 应用场景 | 解决的问题 | 性能提升 |
|---|---|---|
| 文件映射 | 减少系统调用和数据拷贝 | 大文件IO速度提升5-10倍 |
| 共享内存 | 进程间高效通信 | 延迟从微秒级降到纳秒级 |
| 大内存分配 | 高效分配和回收大块内存 | 优于brk/sbrk |
| 设备映射 | 用户态直接操作硬件 | 减少内核态切换 |
使用建议
✅ 适合使用mmap的场景:
- 大文件(>1MB)的频繁访问
- 进程间高频数据交换
- 随机访问模式的文件
- 需要零拷贝的数据路径
❌ 不适合使用mmap的场景:
- 小文件(<几KB)的一次性访问
- 实时性要求极高的流式写入
- 32位系统下的大文件映射
mmap是操作系统提供的一个强大工具,通过"建立映射"这个简单的概念,解决了文件IO、进程通信、内存管理等多个领域的核心问题。理解mmap的本质,是掌握高性能系统编程的关键一步。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)