Linux内存映射原理
目录
一、为什么需要mmap,传统读文件的缺陷是什么?
二、mm_struct的各个区域划分
三、内存映射的基本原理
(1)分配虚拟地址区间
(2)建立地址、文件的映射关系
(3)缺页中断:触发数据加载
四、文件映射的关键特性:不止步于“读写文件”
(1)两种映射类型:文件映射与匿名映射
(2)映射标志:控制 “修改是否同步到文件”
(3)数据同步:何时写回磁盘?
四、文件映射的优势
1. 减少数据拷贝,提升效率
2. 简化编程:用 “内存操作” 代替 “文件操作”
3. 支持高效共享:多进程共享数据
五、内存映射的使用场景
1. 大文件处理(GB 级)
2. 进程间通信(IPC)
3. 设备操作(内存映射 IO)
在Linux中,内存映射(mmap)是一种让进程像访问物理内存一样操作文件或者其他设备的机制。他跳过了传统文件读写的内核缓冲区拷贝,大幅提升了数据交互的效率,同时也是进程间通信的高效手段。对于新手而言,理解mmap原理不仅能掌握一种高效的进程间通信手段,还能加深对虚拟地址空间的理解。
一、为什么需要mmap,传统读文件的缺陷是什么?
我们先来看看传统的read/write是如何读写文件的。
当读取一个文件的时候,数据会经历3次拷贝:
(1)内核把磁盘数据读入到读缓冲区(内核空间的一块内存)
(2)内核再把数据从内核空间的读缓冲区拷贝到用户空间定义的内存中(C语言层面的缓冲区)。
(3)从C语言层面的缓冲区读到上层应用中。
写入一个文件时则恰恰相反,不过这两种都有一个很明显的问题:数据要在用户空间和内核空间之间来回拷贝,如果处理大文件(比如几个 GB 的日志、数据库文件),频繁的拷贝会严重消耗 CPU 和内存带宽。
而内存映射的核心思想是:把文件或设备的一部分直接 “映射” 到进程的虚拟地址空间。此后,进程操作这块虚拟内存时,就像直接操作文件或设备本身 —— 无需read/write,也无需数据拷贝。
二、mm_struct的各个区域划分
在之前的文章中我们曾提及过用户虚拟地址空间的划分,即又mm_struct宏观管理、vm_area_struct精细管理。
struct mm_struct {/* 1. 内存区域管理核心 */struct vm_area_struct *mmap; // 所有内存区域链表(堆/栈/文件映射等)struct rb_root mm_rb; // 内存区域红黑树(快速查找)int map_count; // 内存区域总数/* 2. 程序代码与数据段(可执行文件加载区域) */unsigned long start_code; // 代码段起始地址(.text段)unsigned long end_code; // 代码段结束地址unsigned long start_data; // 数据段起始地址(.data/.bss段)unsigned long end_data; // 数据段结束地址/* 3. 堆区域 */unsigned long start_brk; // 堆起始地址(固定)unsigned long brk; // 堆当前结束地址(可扩展)/* 4. 栈区域 */unsigned long start_stack; // 用户栈起始地址(高地址)unsigned long stack_limit; // 栈的最低地址限制(栈向下生长的边界)/* 5. 文件映射与共享内存区域 */unsigned long mmap_base; // 文件映射区起始地址(mmap分配的地址从此开始)struct list_head mmap_shared; // 共享映射区域链表(如共享库、共享内存)/* 6. 命令行参数与环境变量区域(用户态初始化数据) */unsigned long arg_start; // 命令行参数起始地址unsigned long arg_end; // 命令行参数结束地址unsigned long env_start; // 环境变量起始地址unsigned long env_end; // 环境变量结束地址/* 7. 页表与地址空间控制 */pgd_t *pgd; // 页全局目录(虚拟地址转物理地址的根)unsigned long task_size; // 进程虚拟地址空间总大小(如32位4GB)/* 8. 引用与同步 */atomic_long_t mm_users; // 共享该内存空间的进程数(如线程)atomic_long_t mm_count; // 自身引用计数struct rw_semaphore mmap_sem; // 保护内存区域操作的信号量
};
大致的示意图如下:
可以看到,一个进程PCB中会有一个mm_struct,而一个mm_struct会划分为多个区域,其中就有一个区域叫做文件映射区,但是文件映射区和其他的区域大不相同,比如堆区、栈区有自己的指针,每一个进程必定会有,所以有brk、start_stack这种指针。而文件映射区纯粹依赖于vm_area_struct链表的管理,在没有文件映射的情况下,不会创建vm_area_struct来精细描述,也就没有文件映射区了。(所有区域中仅仅栈、堆必定有专用指针,其余任何区域都纯粹依赖于vm_area_struct来管理,特点是使用时才创建,不存在时则无这个区域)
注意一点,如果有多个文件映射到这个进程,则每一个文件都有一个vm_area_atruct结构体,因为在上图中我们可以看出来一个vm_area_struct结构体只有一个struct file指针(即一个文件指针)。
三、内存映射的基本原理
内存映射的本质是在进程的虚拟地址空间和文件 / 设备的物理存储之间,建立一座 “映射桥梁”。具体可以拆分为三个关键步骤:
(1)分配虚拟地址区间
当进程调用mmap函数时,内核会在进程的虚拟地址空间中,划分出一块连续的 “空闲虚拟地址区间”,尽管虚拟地址空间已经有一部分被划分为了各个区域和自己的vm_area_struct,但是总归有没有使用到的地方,就给了文件映射区,这个区域通常我们让内核自己去找,而非手动设置。这块区间的大小与要映射的文件 / 设备大小(或指定的长度)一致,比如映射一个 10MB 的文件,就会分配 10MB 的虚拟地址。
注意:此时只是分配了 “虚拟地址”,并没有实际占用物理内存,就像给你一张 “提货单”,但货物还没送到仓库。
(2)建立地址、文件的映射关系
内核会在进程的 “页表”(记录虚拟地址与物理地址对应关系的表格)中,添加一系列 “映射记录”:虚拟地址区间中的每个 “页”(通常 4KB),都会对应文件中的一个 “块”(比如文件的第 0-4095 字节对应虚拟地址的第 0-4095 字节)。
此时,虚拟地址和文件内容的映射关系已建立,但数据还没加载到物理内存。
注意:这一步并未填写页表,页表仍然为空。只是会根据mmap的各个参数,填写到vm_area_struct中,如文件偏移量、映射长度等。
(3)缺页中断:触发数据加载
当进程访问这块虚拟地址时(比如读取某个字节),CPU 会通过页表查找对应的物理地址。但此时物理地址还未分配(因为数据没加载),CPU 会触发 “缺页中断”,让内核处理:
- 内核会从磁盘读取文件中对应的块,加载到物理内存的某个页框(物理内存的最小单位);
- 填写页表,将虚拟地址与刚分配的物理页框关联;
- 中断结束后,进程继续访问,此时就能读到物理内存中的数据了。
后续再访问同一部分数据时,因为已经在物理内存中,就不会触发缺页中断,直接通过页表映射访问即可。
四、文件映射的关键特性:不止步于“读写文件”
内存映射的强大之处,在于它的灵活性和多场景适配,核心特性包括:
(1)两种映射类型:文件映射与匿名映射
- 文件映射:将磁盘文件映射到虚拟地址空间,进程对虚拟地址的修改会同步到文件(取决于映射标志)。这是最常用的场景,比如编辑大文件时,编辑器会通过 mmap 映射文件,避免一次性加载整个文件到内存。
- 匿名映射:不关联任何文件,而是映射一块 “匿名内存”(由内核分配物理内存)。这种方式常用于进程间通信(通过共享匿名内存),或动态分配大块内存(比malloc更高效)。
- 在进程间通信方面,匿名映射比文件映射更加优秀,因为他会省去磁盘IO和文件管理的开销。普通的文件映射用于进程间通信,本质是多个进程映射到同一个物理地址,但是这个地址里面的内容最终还是会从物理内存拷贝到磁盘中,如果频繁修改,则可能产生大量的开销。同时,如果你仅仅使用他通信,最后还需要处理文件残留的问题。
如果是匿名映射则少了写回磁盘的部分:简单来说,匿名映射是轻量级、纯内存的共享方式,适合临时、高频的进程通信;而映射普通文件则更适合需要持久化数据的场景。
(2)映射标志:控制 “修改是否同步到文件”
- MAP_SHARED:进程对虚拟地址的修改会同步到文件,且其他映射该文件的进程也能看到修改(共享变更)。比如多进程协作编辑同一个文件时,用这个标志能实时同步变更。
- MAP_PRIVATE:进程的修改不会同步到文件,也不会被其他进程看到(私有副本)。内核会在进程首次修改时,创建物理内存的 “私有副本”(写时复制,Copy-On-Write),避免影响原文件和其他进程。常用于临时读写文件,但不想污染源文件。
(3)数据同步:何时写回磁盘?
用MAP_SHARED映射时,修改不会立即写入磁盘,而是先存在物理内存的 “页缓存” 中,由内核在合适的时机(比如内存不足、调用msync函数、关闭映射)同步到磁盘。这与传统文件读写的 “延迟写” 机制一致,保证效率的同时减少磁盘 IO。
四、文件映射的优势
相比read/write等传统 IO 函数,内存映射的核心优势体现在:
1. 减少数据拷贝,提升效率
传统 IO 需要 “用户缓冲区←→内核缓冲区” 的拷贝,而内存映射通过虚拟地址直接访问内核页缓存(物理内存中的文件数据),跳过了用户态与内核态之间的拷贝。对于大文件(比如 1GB 以上),这种效率提升非常明显。
2. 简化编程:用 “内存操作” 代替 “文件操作”
进程可以像操作数组一样操作文件,比如:
// 映射文件后,直接通过指针修改虚拟地址
char *addr = mmap(...);
addr[100] = 'A'; // 相当于修改文件的第100个字节
无需调用lseek定位、read/write传输,代码更简洁。
3. 支持高效共享:多进程共享数据
多个进程可以映射同一个文件(MAP_SHARED标志),实现 “共享内存”:一个进程的修改会自动同步到其他进程(因为共享物理内存中的页缓存)。这比管道、消息队列等 IPC 方式更高效,是数据库、分布式系统中进程协作的常用手段。
五、内存映射的使用场景
内存映射不是 “银弹”,但在以下场景中能发挥最大价值:
1. 大文件处理(GB 级)
比如数据库读取超大表文件、日志系统分析海量日志。传统 IO 会因频繁拷贝导致效率低下,而 mmap 只需加载当前访问的部分数据(按需加载),适合处理远大于物理内存的文件。
2. 进程间通信(IPC)
多进程需要高频共享数据时(比如渲染引擎的多个线程共享帧缓存),用MAP_SHARED映射同一个文件(或匿名内存),比用管道传输数据更高效(无拷贝、低延迟)。
3. 设备操作(内存映射 IO)
Linux 中很多设备(如显卡、网卡)的寄存器和缓冲区会被映射到物理地址空间,进程可以通过 mmap 将这些物理地址映射到虚拟地址,直接操作设备(比如向显卡寄存器写入像素数据),这是设备驱动开发的核心方式。