八、进程间通信 (内存映射)

内存映射文件原理详解

前言

内存映射是操作系统虚拟内存体系中最核心的机制之一,也是高性能文件 IO、进程间共享内存的基础实现。本文基于 Linux 内核标准实现,完整、准确地拆解内存映射的全部原理。

一、内存映射的本质定义

内存映射(系统调用 mmap)的核心是:在进程的虚拟地址空间中,分配一段连续区间,与磁盘文件的指定偏移范围建立一一对应的逻辑绑定关系。

  1. 调用 mmap 时内核做了什么
    执行 mmap 仅完成内核数据结构的初始化,全程不读取磁盘、不拷贝数据、不分配物理内存 ,因此映射建立的开销极低。具体操作有三点:
    1. 在当前进程的虚拟地址空间中,找到一段符合长度、权限要求的空闲地址区间。
    2. 创建一个 struct vm_area_struct(简称 VMA)结构体,记录映射的起始地址、长度、权限、关联文件、映射类型(共享 / 私有)等信息,插入进程的 VMA 链表。
    3. 将该 VMA 与文件的 struct address_space 关联,建立虚拟地址到文件页缓存的对应关系,此时页表中无有效物理页映射。
  2. 关键概念澄清
    每个进程都拥有独立、完整的虚拟地址空间,由内核通过页表、VMA 链表统一管理,是真实存在的内核资源。它只是在映射建立初期没有绑定对应的物理内存页,并非空间本身不存在。

二、内存映射完整执行流程

从调用 mmap 到真正读写文件数据,完整分为四个独立阶段,其中仅第一阶段属于 "内存映射" 本身,其余是 CPU 与操作系统的通用内存机制。

阶段 1:建立映射(mmap 系统调用)

完成上述 VMA 创建、地址空间分配与文件关联,返回虚拟地址指针。此时进程拿到的只是一个合法的虚拟地址范围,背后没有任何实际物理数据。

阶段 2:虚拟地址翻译(MMU 硬件机制)

进程通过指针读写映射内存时,CPU 的内存管理单元(MMU)会根据页表,将虚拟地址转换为物理地址。

这是所有内存访问的通用硬件流程,和是否为文件映射无关。

阶段 3:缺页中断与数据加载(核心流程)

刚建立映射时页表无有效物理页条目,MMU 翻译失败,触发缺页异常,由内核中断处理函数执行以下逻辑:

  1. 根据异常虚拟地址,找到对应的 VMA 结构体,确认这是合法的文件映射区间;
  2. 查找内核全局页缓存(Page Cache),判断该文件的对应页面是否已被加载到物理内存;
  3. 若缓存命中:直接将该物理页映射到进程页表,异常返回,进程继续执行;
  4. 若缓存未命中:向块设备发起 IO 请求,将磁盘上对应文件页读取到页缓存中,再更新页表完成映射,进程恢复执行。

澄清误区:缺页处理不会先去 Swap 分区查找文件页。Swap 仅用于匿名内存(栈、堆、私有修改页),文件映射页面的数据源永远是磁盘原文件。

阶段 4:内存不足时的页面回收

物理内存紧张时,内核会回收不活跃的物理页面,区分三类页面处理规则:

  • 干净的文件映射页:直接丢弃,下次访问重新从磁盘读取即可;
  • 已修改的共享映射脏页:先回写到磁盘原文件,再释放内存;
  • 私有映射修改后的页面:属于匿名页,会被换出到 Swap 分区,释放物理内存。

页面回收是操作系统虚拟内存的通用机制,并非内存映射独有。

三、性能对比:mmap 为什么比 read/write 高效

磁盘到物理内存的硬件 DMA 拷贝是所有 IO 都无法省略的,二者的性能差异,核心在于内核态与用户态之间的 CPU 内存拷贝次数。

1. 传统 read/write 流程(两次 CPU 拷贝)

  1. 第一次拷贝(磁盘 → 内核页缓存):硬件 DMA 将文件数据载入内核统一页缓存;
  2. 第二次拷贝(内核页缓存 → 用户缓冲区):CPU 将内核空间的数据,复制到用户进程传入的缓冲区中。

两次拷贝中,第二次是纯 CPU 内存拷贝,会占用 CPU 资源,且在大文件、高频随机读写场景下开销显著。

2. mmap 流程(零态间 CPU 拷贝)

  1. 磁盘数据通过 DMA 载入内核页缓存,这一步与 read 完全一致;
  2. 缺页处理时,内核直接将这块页缓存的物理地址,映射到进程的虚拟地址页表中。

用户态可以直接通过虚拟地址访问同一块物理内存,完全省去了内核态到用户态的 CPU 内存拷贝,这也是 mmap 被归为 "零拷贝" 技术的核心原因。

严谨补充:mmap 消除的是态间 CPU 拷贝,磁盘到内存的硬件 IO 开销依然存在。因此 mmap 的优势主要体现在随机读写、反复访问同一文件、大文件处理场景;顺序小文件读写时,系统调用开销可能抵消拷贝收益。

四、两种核心映射模式

mmap 最关键的两个标志位,决定了数据的可见性与持久化行为,开发中必须明确区分。

  1. MAP_SHARED 共享映射
    • 所有映射同一文件的进程,共享同一份物理页缓存;
    • 进程对内存的修改会标记为脏页,最终由内核异步回写到磁盘原文件;
    • 适用于进程间共享内存、大文件读写、持久化存储场景。
  2. MAP_PRIVATE 私有映射(写时复制 COW)
    只读访问时,与共享映射一致,共享内核页缓存,无额外内存开销;
    • 一旦进程修改页面,内核会立即复制一份独立的物理页给当前进程,后续修改仅对本进程可见;
    • 修改后的私有页面不会回写到原文件,内存不足时会被换入 Swap 分区;
    • 适用于加载动态库、只读文件解析、进程私有数据副本等场景。

五、必须遵守的底层约束

  1. 页对齐要求
    mmap 的文件偏移参数 offset,必须是系统页大小(通常为 4KB)的整数倍;映射长度若不足整数页,内核会向上对齐到整页,超出文件末尾的部分读取为 0,写入则会触发 SIGBUS 信号。
    可通过 sysconf(_SC_PAGESIZE) 获取当前系统页大小。
  2. 数据持久化规则
    共享映射的修改不会立刻写入磁盘,内核会在后台周期性批量刷写脏页。若需要保证数据持久化,必须主动调用 msync() 函数强制同步;调用 munmap() 解除映射时,内核也会自动触发脏页回写。
    程序异常崩溃会导致未同步的脏页数据丢失,关键业务必须手动同步。
  3. Swap 适用范围
    只有匿名页与私有映射修改后的页面会进入 Swap;普通文件映射页永远不会存入 Swap,直接回写原文件或丢弃。

mmap 函数

1. 头文件

c 复制代码
#include <sys/mman.h>

2. 函数原型

c 复制代码
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);

参数

(1) addr:指定新映射的起始虚拟地址。

  • addr = NULL:由内核自动选择页对齐的空闲地址创建映射,这是兼容性最好、推荐使用的写法。
  • addr 非空:内核仅将其作为地址建议。Linux 下内核会选取附近页边界地址,且地址不能低于 /proc/sys/vm/mmap_min_addr 限制。如果该地址区间已有映射占用,内核会另选其他地址。

(2) length:映射长度。

  • 必须大于 0 。
  • 建议使用文件的长度 (可以用 stat 或者 lseek 函数获取文件长度)。

(3) prot:控制映射内存页的访问权限。

  • 要操作映射内存,必须要有读的权限。
  • 不能和文件打开模式冲突 (比如只读文件不能设置 PROT_WRITE),这里权限的设置可以理解为必须要小于等于 open 函数设置的权限。
  • 取值为以下标识按位或,或 PROT_NONE
    • PROT_EXEC:可执行的权限
    • PROT_READ:可读的权限
    • PROT_WRITE:可写的权限
    • PROT_NONE:没有权限

(4) flags:主要决定映射修改是否对其他进程可见、修改是否同步到底层文件。

  • 必须且只能选下面三者之一 :
    • MAP_SHARED
      共享映射 :映射区的数据会自动和磁盘文件进行同步,进程间通信必须要设置这个选项。
      • 本进程对内存的修改,其他映射同一文件的进程能看见;
      • 若为文件映射,修改最终会写回磁盘文件;
      • 精确控制刷盘时机需要调用 msync(2)
        特性:会自动忽略 flags 中内核不认识的未知标志。
    • MAP_SHARED_VALIDATE(Linux 4.15 新增,Linux 私有扩展)
      行为和 MAP_SHARED 一致,但增加严格校验:
      • 传入任何内核无法识别的 flagmmap 直接失败,返回 EOPNOTSUPP
      • 使用 MAP_SYNC 等特殊映射标志时,必须搭配此 flag。
    • MAP_PRIVATE
      私有写时复制(Copy-On-Write)映射:
      • 本进程修改不会同步给其他进程,也不会写回原文件;
      • mmap 创建映射后,外部程序对原文件的修改是否能同步到本映射,标准未做规定(行为不确定)。

标准规范:MAP_SHARED / MAP_PRIVATE 属于 POSIX 标准;MAP_SHARED_VALIDATE 仅 Linux 支持。

  • flags 可选附加标志(这里只列举部分,全部标志可查看官方文档)
    • MAP_ANONYMOUS (使用需加上 #define _GNU_SOURCE)
      匿名映射,不依赖任何文件。映射内容初始化为 0 。
      • fd 参数会被忽略;但有些系统要求指定为 -1 。
      • offset 应设置为 0 。
      • Linux 从 2.4 开始支持。
    • MAP_ANON
      MAP_ANONYMOUS 的别名。为了兼容其他实现而提供。
    • MAP_FIXED
      不要把 addr 当作建议地址,而是必须映射到的指定地址。
      要求:
      • 地址必须页对齐(通常页大小整数倍),某些架构要求更严格。
      • 如果指定区域与已有映射重叠:原来的映射会被覆盖。
      • 如果地址无法使用:mmap() 失败。
    • MAP_FIXED_NOREPLACE(Linux 4.17)
      类似 MAP_FIXED。可用于线程安全地竞争某段地址:只有一个线程成功。
      旧内核不认识此标志时:会退化成普通 mmap() 行为。所以兼容代码必须检查:ret == addr
      区别:
      • 绝不会覆盖已有映射。
      • 如果地址冲突,返回:EEXIST

(5) fd :需要映射的那个文件的文件描述符。

(6) offset:文件偏移量。

  • 内存映射是以 内存页(默认 4KB)为最小单位进行绑定文件与虚拟内存 ,所以文件偏移 offset 必须是页大小的整数倍,如果填入 offset 值不足一页(不足 4096 字节),mmap 依然会分配一整个物理页。
  • 填 0 代表从文件头部开始映射。

返回值

  • 成功:返回映射区起始虚拟地址指针。
  • 失败:返回 MAP_FAILED(等价 (void *)-1 ),同时设置 errno 标记错误类型。

作用

  • mmap() 会在调用进程的虚拟地址空间中创建一块新内存映射,将一个文件或设备映射到内存中。
  • 文件映射的内存内容,来自文件描述符 fd 指向文件中,从偏移量 offset 开始、长度为 length 的一段数据。
  • mmap 调用成功返回后,可以立刻 close(fd),映射不会失效。

3. 样例1 (父子进程间通信)

一定要先创建一个 test.txt 文件

bash 复制代码
touch test.txt

创建一个 mmap.c 文件

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>     
#include <string.h>
#include <sys/wait.h>

int main()
{
    // 以读写模式打开文件
    int fd = open("test.txt", O_RDWR);
    if(fd == -1)
    {
        perror("open");
        exit(0);
    }

    // 获取文件大小
    int size = lseek(fd, 0, SEEK_END);  // 将读写指针偏移到文件结尾
    if(size == -1)
    {
        perror("lseek");
        exit(0);
    }

    // 创建内存映射区
    void *ptr = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
    if(ptr == MAP_FAILED)
    {
        perror("mmap");
        exit(0);
    }

    // 父子进程通信(内存映射)
    int pid = fork();
    if(pid > 0)
    {
        strcpy(ptr, "你好,我是你的父亲");
        wait(NULL);
    }
    else if(pid == 0)
    {
        char buf[128];
        strcpy(buf, ptr);

        printf("子进程读取映射区数据: %s\n", buf);
    }
    else 
    {
        perror("fork");
        exit(0);
    }


    return 0;
}

可能会有人疑惑 strcpy 的第一个参数类型明明是 chat* ,但这里却直接传入了 ptr,原因是因为 void* 可以直接隐式转换成任意指针类型(包括 char*),不需要强制类型转换。

编译并运行 mmap.c

bash 复制代码
gcc mmap.c -o mmap
./mmao

这里可以看到子进程成功得到了父进程写入文件的数据,但是 test.txt 原来的文件未显示,这是因为 strcpy 函数会自动在复制的字符串后加上文件结束符。

查看一下 test.txt 文件内容

bash 复制代码
vim tst.txt

可以发现文件原来的数据被覆盖了。

4. 样例2 (复制文件)

创建一个被复制文件 english.txt

text 复制代码
Youth 

Youth is not a time of life; it is a state of mind; it is not a matter of rosy cheeks, red lips and supple knees; it is a matter of the will, a quality of the imagination, a vigor of the emotions; it is the freshness of the deep springs of life. 

Youth means a temperamental predominance of courage over timidity, of the appetite for adventure over the love of ease. This often exists in a man of 60 more than a boy of 20. Nobody grows old merely by a number of years. We grow old by deserting our ideals. 

Years may wrinkle the skin, but to give up enthusiasm wrinkles the soul. Worry, fear, self-distrust bows the heart and turns the spirit back to dust. 

Whether 60 or 16, there is in every human being's heart the lure of wonders, the unfailing appetite for what's next and the joy of the game of living. In the center of your heart and my heart, there is a wireless station; so long as it receives messages of beauty, hope, courage and power from man and from the infinite, so long as you are young. 

When your aerials are down, and your spirit is covered with snows of cynicism and the ice of pessimism, then you've grown old, even at 20; but as long as your aerials are up, to catch waves of optimism, there's hope you may die young at 80. 

译文: 
青春 
青春不是年华,而是心境;青春不是桃面、丹唇、柔膝,而是深沉的意志,恢宏的想象,炙热的恋情;青春是生命的深泉在涌流。 
青春气贯长虹,勇锐盖过怯弱,进取压倒苟安。如此锐气,二十后生而有之,六旬男子则更多见。年岁有加,并非垂老,理想丢弃,方堕暮年。 
岁月悠悠,衰微只及肌肤;热忱抛却,颓废必致灵魂。忧烦,惶恐,丧失自信,定使心灵扭曲,意气如灰。 
无论年届花甲,拟或二八芳龄,心中皆有生命之欢乐,奇迹之诱惑,孩童般天真久盛不衰。人人心中皆有一台天线,只要你从天上人间接受美好、希望、欢乐、勇气和力量的信号,你就青春永驻,风华常存。 、 
一旦天线下降,锐气便被冰雪覆盖,玩世不恭、自暴自弃油然而生,即使年方二十,实已垂垂老矣;然则只要树起天线,捕捉乐观信号,你就有望在八十高龄告别尘寰时仍觉年轻。

创建一个 mmap-cpy.c 文件

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>     
#include <string.h>
#include <sys/types.h>

int main()
{
    // 1.打开被复制文件
    int fd1 = open("english.txt", O_RDWR);
    if(fd1 == -1)
    {
        perror("open");
        exit(0);
    }

    // 2.获取被复制文件大小
    int len = lseek(fd1, 0, SEEK_END);
    if(len == -1)
    {
        perror("lseek");
        exit(0);
    }

    // 3.创建目标文件名称
    int fd2 = open("cpy.txt", O_CREAT | O_RDWR, 0664);
    if(fd2 == -1)
    {
        perror("open");
        exit(0);
    }

    // 4.扩展目标文件大小
    int ret = truncate("cpy.txt", len);
    if(ret == -1)
    {
        perror("truncate");
        exit(0);
    }

    // 5.创建文件映射区
    void *ptr1 = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd1, 0);
    if(ptr1 == MAP_FAILED)
    {
        perror("mmap");
        exit(0);
    }

    void *ptr2 = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd2, 0);
    if(ptr2 == MAP_FAILED)
    {
        perror("mmap");
        exit(0);
    }

    // 6.复制文件内容
    memcpy(ptr2, ptr1, len);

    // 7.释放映射区内存
    munmap(ptr2, len);
    munmap(ptr1, len);

    // 8.关闭文件描述符
    close(fd2);
    close(fd1);

    return  0;
}

注意:代码中对于映射区内存的释放和关闭文件描述符,最好按照先释放或者关闭后创建者,防止后者依赖报错。

编译并运行程序

bash 复制代码
gcc mmap-cpy.c -o cpy
./cpy

查看 cpy.txt 文件内容

这里发现文件数据复制成功。

5. 样例3 (父子进程间匿名映射)

创建一个 mmap-anon.c 文件

c 复制代码
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>     
#include <string.h>
#include <sys/wait.h>

int main()
{
    // 创建匿名映射区
    int len = 4096;  // 4k
    void *ptr = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);

    // 父子进程通过匿名映射通信
    pid_t pid = fork();
    if(pid > 0)
    {
        strcpy(ptr, "你好, 我是你的父亲");
        wait(NULL);
    }
    else if(pid == 0)
    {
        sleep(1);

        char buf[128];
        strcpy(buf, ptr);
        printf("子进程接收到: %s\n", buf);
    }
    else
    {
        perror("fork");
        exit(0);
    }

    return 0;
}

由于内存映射默认非阻塞, 所以对于子进程的处理让他先 sleep(1),等待父进程先运行。

编译并运行 mmap-anon.c

bash 复制代码
gcc mmap-anon.c -o anon
./anon

munmap 函数

1. 头文件

c 复制代码
#include <sys/mman.h>

2, 函数原型

c 复制代码
int munmap(void *addr, size_t length);

参数

  • addr : 要释放的内存的首地址。
  • length : 要释放的内存的大小,要和 mmap 函数中的 length 参数的值一样。

返回值

  • 成功:返回 0;
  • 失败:返回 -1,errno 通常为 EINVAL(地址 / 长度非法)。

作用

  • 释放指定地址区间的内存映射。

使用内存映射实现进程间通信

  • 有关系的进程(父子进程)
    1. 还没有子进程的时候,通过唯一的父进程,先创建内存映射区。
    2. 有了内存映射区以后,创建子进程。
    3. 父子进程共享创建的内存映射区。
  • 没有关系的进程间通信
    1. 准备一个大小不是 0 的磁盘文件。
    2. 进程1 通过磁盘文件创建内存映射区,得到一个操作这块内存的指针。
    3. 进程2 通过磁盘文件创建内存映射区,得到一个操作这块内存的指针。
    4. 使用内存映射区通信。

注意:内存映射区通信,是非阻塞。