从内核世界透视 mmap 内存映射的本质(原理篇)

Handpc
发布于 2023-10-11 10:34
浏览
0收藏

本文基于内核 5.4 版本源码讨论

之前有不少读者给笔者留言,希望笔者写一篇文章介绍下 mmap 内存映射相关的知识体系,之所以迟迟没有动笔,是因为 mmap 这个系统调用看上去简单,实际上并不简单,可以说是非常复杂的一个系统调用。

如果想要给大家把 mmap 背后的技术本质,正确地,清晰地还原出来,还是有一定难度的,因为 mmap 这一个系统调用就能撬动起整个内存管理系统,文件系统,页表体系,缺页中断等一大片的背景知识,涉及到的知识面广且繁杂。

幸运的是这一整套的背景知识,笔者已经在 ​​《聊聊 Linux 内核》​​ 系列文章中为大家详细介绍过了,所以现在是时候开始动笔了,不过大家不需要担心,虽然涉及到的背景知识比较多,但是在后面的相关章节里,笔者还会为大家重新交代。

从内核世界透视 mmap 内存映射的本质(原理篇)-鸿蒙开发者社区

在上一篇文章 ​《一步一图带你构建 Linux 页表体系》​ 中,笔者为大家介绍了内存映射最为核心的内容 —— 页表体系。通过一步一图的方式为大家展示了整个页表体系的演进过程,并在这个过程中逐步揭开了整个页表体系的全貌。

从内核世界透视 mmap 内存映射的本质(原理篇)-鸿蒙开发者社区

本文的内容依然是内存映射相关的内容,这一次笔者会带着大家围绕页表这个最为核心的体系,在页表的外围进行内存映射相关知识的介绍,核心目的就是彻底为大家还原内存映射背后的技术本质,由浅入深地给大家讲透彻,弄明白。

在我们正式开始今天的内容之前,笔者想首先抛出几个问题给大家思考,建议大家带着这几个问题来阅读接下来的内容,我们共同来将这些迷雾一层一层地慢慢拨开,直到还原出内存映射的本质。

  1. 既然我们是在讨论虚拟内存与物理内存的映射,那么首先你得有虚拟内存,你也得有物理内存吧,在这个基础之上,才能讨论两者之间的映射,而物理内存是怎么来的,笔者已经通过前边文章​​《深入理解 Linux 物理内存分配全链路实现》​​ 介绍的非常清楚了,那虚拟内存是怎么来的呢 ?内核分配虚拟内存的过程是怎样的呢?
  2. 我们知道内存映射是按照物理内存页为单位进行的,而在内存管理中,内存页主要分为两种:一种是匿名页,另一种是文件页,这一点笔者已经在​​《一步一图带你深入理解 Linux 物理内存管理》​​ 一文中反复讲过很多次了。根据物理内存页的类型分类,内存映射自然也分为两种:一种是虚拟内存对匿名物理内存页的映射,另一种是虚拟内存对文件页的映射。关于文件映射,大家或多或少在网上看到过这样的论述——" 通过内存文件映射可以将磁盘上的文件映射到内存中,这样我们就可以通过读写内存来完成磁盘文件的读写 "。关于这个论述,如果对内存管理和文件系统不熟悉的同学,可能感到这句话非常的神奇,会有这样的一个疑问,内存就是内存啊,磁盘上的文件就是文件啊,这是两个完全不同的东西,为什么说读写内存就相当于读写磁盘上的文件呢 ?内存文件映射在内核中到底发生了什么 ?我们经常谈到的内存映射,到底映射的是什么?
  3. 在上篇文章中笔者只是为大家展示了整个页表体系的全貌,以及页表体系一步一步的演进过程,但是在进程被创建出来之后,内核也仅是会为进程分配一张全局页目录表 PGD(Page Global Directory)而已,此时进程虚拟内存空间中只存在一张顶级页目录表,而在上图中所展示的四级页表体系中的上层页目录 PUD(Page Upper Directory),中间页目录 PMD(Page Middle Directory)以及一级页表是不存在的,那么上图展示的这个页表完整体系是在什么时候,又是如何被一步一步构建出来的呢?

本文的主旨就是围绕上述这几个问题来展开的,那么从何谈起呢 ?笔者想了一下,还是应该从我们最为熟悉的,在用户态经常接触到的内存映射系统调用 mmap 开始聊起~~~

1. 详解内存映射系统调用 mmap

#include <sys/mman.h>
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);

// 内核文件:/arch/x86/kernel/sys_x86_64.c
SYSCALL_DEFINE6(mmap, unsigned long, addr, unsigned long, len,
  unsigned long, prot, unsigned long, flags,
  unsigned long, fd, unsigned long, off)

mmap 内存映射里所谓的内存其实指的是虚拟内存,在调用 mmap 进行匿名映射的时候(比如进行堆内存的分配),是将进程虚拟内存空间中的某一段虚拟内存区域与物理内存中的匿名内存页进行映射,当调用 mmap 进行文件映射的时候,是将进程虚拟内存空间中的某一段虚拟内存区域与磁盘中某个文件中的某段区域进行映射。

而用于内存映射所消耗的这些虚拟内存位于进程虚拟内存空间的哪里呢 ?

笔者在之前的文章​​《一步一图带你深入理解 Linux 虚拟内存管理》​​ 中曾为大家详细介绍过进程虚拟内存空间的布局,在进程虚拟内存空间的布局中,有一段叫做文件映射与匿名映射区的虚拟内存区域,当我们在用户态应用程序中调用 mmap 进行内存映射的时候,所需要的虚拟内存就是在这个区域中划分出来的。

从内核世界透视 mmap 内存映射的本质(原理篇)-鸿蒙开发者社区

在文件映射与匿名映射这段虚拟内存区域中,包含了一段一段的虚拟映射区,每当我们调用一次 mmap 进行内存映射的时候,内核都会在文件映射与匿名映射区中划分出一段虚拟映射区出来,这段虚拟映射区就是我们申请到的虚拟内存。

那么我们申请的这块虚拟内存到底有多大呢 ?这就用到了 mmap 系统调用的前两个参数:

  • addr : 表示我们要映射的这段虚拟内存区域在进程虚拟内存空间中的起始地址(虚拟内存地址),但是这个参数只是给内核的一个暗示,内核并非一定得从我们指定的 addr 虚拟内存地址上划分虚拟内存区域,内核只不过在划分虚拟内存区域的时候会优先考虑我们指定的 addr,如果这个虚拟地址已经被使用或者是一个无效的地址,那么内核则会自动选取一个合适的地址来划分虚拟内存区域。我们一般会将 addr 设置为 NULL,意思就是完全交由内核来帮我们决定虚拟映射区的起始地址。
  • length :从进程虚拟内存空间中的什么位置开始划分虚拟内存区域的问题解决了,那么我们要申请的这段虚拟内存有多大呢 ? 这个就是 length 参数的作用了,如果是匿名映射,length 参数决定了我们要映射的匿名物理内存有多大,如果是文件映射,length 参数决定了我们要映射的文件区域有多大。

addr,length 必须要按照 PAGE_SIZE(4K) 对齐。

从内核世界透视 mmap 内存映射的本质(原理篇)-鸿蒙开发者社区

如果我们通过 mmap 映射的是磁盘上的一个文件,那么就需要通过参数 fd 来指定要映射文件的描述符(file descriptor),通过参数 offset 来指定文件映射区域在文件中偏移。

从内核世界透视 mmap 内存映射的本质(原理篇)-鸿蒙开发者社区

在内存管理系统中,物理内存是按照内存页为单位组织的,在文件系统中,磁盘中的文件是按照磁盘块为单位组织的,内存页和磁盘块大小一般情况下都是 4K 大小,所以这里的 offset 也必须是按照 4K 对齐的。

而在文件映射与匿名映射区中的这一段一段的虚拟映射区,其实本质上也是虚拟内存区域,它们和进程虚拟内存空间中的代码段,数据段,BSS 段,堆,栈没有任何区别,在内核中都是 struct vm_area_struct 结构来表示的,下面我们把进程空间中的这些虚拟内存区域统称为 VMA。

进程虚拟内存空间中的所有 VMA 在内核中有两种组织形式:一种是双向链表,用于高效的遍历进程 VMA,这个 VMA 双向链表是有顺序的,所有 VMA 节点在双向链表中的排列顺序是按照虚拟内存低地址到高地址进行的。

另一种则是用红黑树进行组织,用于在进程空间中高效的查找 VMA,因为在进程虚拟内存空间中不仅仅是只有代码段,数据段,BSS 段,堆,栈这些虚拟内存区域 VMA,尤其是在数据密集型应用进程中,文件映射与匿名映射区里也会包含有大量的 VMA,进程的各种动态链接库所映射的虚拟内存在这里,进程运行过程中进行的匿名映射,文件映射所需要的虚拟内存也在这里。而内核需要频繁地对进程虚拟内存空间中的这些众多 VMA 进行增,删,改,查。所以需要这么一个红黑树结构,方便内核进行高效的查找。

// 进程虚拟内存空间描述符
struct mm_struct {
    // 串联组织进程空间中所有的 VMA  的双向链表 
    struct vm_area_struct *mmap;  /* list of VMAs */
    // 管理进程空间中所有 VMA 的红黑树
    struct rb_root mm_rb;
}

// 虚拟内存区域描述符
struct vm_area_struct {
    // vma 在 mm_struct->mmap 双向链表中的前驱节点和后继节点
    struct vm_area_struct *vm_next, *vm_prev;
    // vma 在 mm_struct->mm_rb 红黑树中的节点
    struct rb_node vm_rb;
}

从内核世界透视 mmap 内存映射的本质(原理篇)-鸿蒙开发者社区

上图中的文件映射与匿名映射区里边其实包含了大量的 VMA,这里只是为了清晰的给大家展示虚拟内存在内核中的组织结构,所以只画了一个大的 VMA 来表示文件映射与匿名映射区,这一点大家需要知道。

mmap 系统调用的本质是首先要在进程虚拟内存空间里的文件映射与匿名映射区中划分出一段虚拟内存区域 VMA 出来 ,这段 VMA 区域的大小用 vm_start,vm_end 来表示,它们由 mmap 系统调用参数 addr,length 决定。

struct vm_area_struct {
    unsigned long vm_start;     /* Our start address within vm_mm. */
    unsigned long vm_end;       /* The first byte after our end address */
}

随后内核会对这段 VMA 进行相关的映射,如果是文件映射的话,内核会将我们要映射的文件,以及要映射的文件区域在文件中的 offset,与 VMA 结构中的 vm_file,vm_pgoff 关联映射起来,它们由 mmap 系统调用参数 fd,offset 决定。

struct vm_area_struct {
    struct file * vm_file;      /* File we map to (can be NULL). */
    unsigned long vm_pgoff;     /* Offset (within vm_file) in PAGE_SIZE */
}

另外由 mmap 在文件映射与匿名映射区中映射出来的这一段虚拟内存区域同进程虚拟内存空间中的其他虚拟内存区域一样,也都是有权限控制的。

从内核世界透视 mmap 内存映射的本质(原理篇)-鸿蒙开发者社区

比如上图进程虚拟内存空间中的代码段,它是与磁盘上 ELF 格式可执行文件中的 .text section(磁盘文件中各个区域的单元组织结构)进行映射的,存放的是程序执行的机器码,所以在可执行文件与进程虚拟内存空间进行文件映射的时候,需要指定代码段这个虚拟内存区域的权限为可读(VM_READ),可执行的(VM_EXEC)。

数据段也是通过文件映射进来的,内核会将磁盘上 ELF 格式可执行文件中的 .data section 与数据段映射起来,在映射的时候需要指定数据段这个虚拟内存区域的权限为可读(VM_READ),可写(VM_WRITE)。

与代码段和数据段不同的是,BSS段,堆,栈这些虚拟内存区域并不是从磁盘二进制可执行文件中加载的,它们是通过匿名映射的方式映射到进程虚拟内存空间的。

BSS 段中存放的是程序未初始化的全局变量,这段虚拟内存区域的权限是可读(VM_READ),可写(VM_WRITE)。

堆是用来描述进程在运行期间动态申请的虚拟内存区域的,所以堆也会具有可读(VM_READ),可写(VM_WRITE)权限,在有些情况下,堆也具有可执行(VM_EXEC)的权限,比如 Java 中的字节码存储在堆中,所以需要可执行权限。

栈是用来保存进程运行时的命令行参,环境变量,以及函数调用过程中产生的栈帧的,栈一般拥有可读(VM_READ),可写(VM_WRITE)的权限,但是也可以设置可执行(VM_EXEC)权限,不过出于安全的考虑,很少这么设置。

而在文件映射与匿名映射区中的情况就变得更加复杂了,因为文件映射与匿名映射区里包含了数量众多的 VMA,尤其是在数据密集型应用进程里更是如此,我们每调用一次 mmap ,无论是匿名映射也好还是文件映射也好,都会在文件映射与匿名映射区里产生一个 VMA,而通过 mmap 映射出的这段 VMA 中的相关权限和标志位,是由 mmap 系统调用参数里的  prot,flags 决定的,最终会映射到虚拟内存区域 VMA 结构中的 vm_page_prot,vm_flags 属性中,指定进程对这块虚拟内存区域的访问权限和相关标志位。

除此之外,进程运行过程中所依赖的动态链接库 .so 文件,也是通过文件映射的方式将动态链接库中的代码段,数据段映射进文件映射与匿名映射区中。

struct vm_area_struct {
    /*
     * Access permissions of this VMA.
     */
    pgprot_t vm_page_prot;
    unsigned long vm_flags; 
}

我们可以通过 mmap 系统调用中的参数 prot 来指定其在进程虚拟内存空间中映射出的这段虚拟内存区域 VMA 的访问权限,它的取值有如下四种:

#define PROT_READ 0x1  /* page can be read */
#define PROT_WRITE 0x2  /* page can be written */
#define PROT_EXEC 0x4  /* page can be executed */
#define PROT_NONE 0x0  /* page can not be accessed */
  • PROT_READ 表示该虚拟内存区域背后映射的物理内存是可读的。
  • PROT_WRITE 表示该虚拟内存区域背后映射的物理内存是可写的。
  • PROT_EXEC 表示该虚拟内存区域背后映射的物理内存所存储的内容是可以被执行的,该内存区域内往往存储的是执行程序的机器码,比如进程虚拟内存空间中的代码段,以及动态链接库通过文件映射的方式加载进文件映射与匿名映射区里的代码段,这些 VMA 的权限就是 PROT_EXEC 。
  • PROT_NONE 表示这段虚拟内存区域是不能被访问的,既不可读写,也不可执行。用于实现防范攻击的 guard page。如果攻击者访问了某个 guard page,就会触发 SIGSEV 段错误。除此之外,指定 PROT_NONE 还可以为进程预先保留这部分虚拟内存区域,虽然不能被访问,但是当后面进程需要的时候,可以通过 mprotect 系统调用修改这部分虚拟内存区域的权限。

mprotect 系统调用可以动态修改进程虚拟内存空间中任意一段虚拟内存区域的权限。

从内核世界透视 mmap 内存映射的本质(原理篇)-鸿蒙开发者社区

我们除了要为 mmap 映射出的这段虚拟内存区域 VMA 指定访问权限之外,还需要为这段映射区域 VMA 指定映射方式,VMA 的映射方式由 mmap 系统调用参数 flags 决定。内核为 flags 定义了数量众多的枚举值,下面笔者将一些非常重要且核心的枚举值为大家挑选出来并解释下它们的含义:

#define MAP_FIXED   0x10        /* Interpret addr exactly */
#define MAP_ANONYMOUS   0x20        /* don't use a file */

#define MAP_SHARED  0x01        /* Share changes */
#define MAP_PRIVATE 0x02        /* Changes are private */

前边我们介绍了 mmap 系统调用的 addr 参数,这个参数只是我们给内核的一个暗示并非是强制性的,表示我们希望内核可以根据我们指定的虚拟内存地址  addr 处开始创建虚拟内存映射区域 VMA。

但如果我们指定的 addr 是一个非法地址,比如 ​​[addr , addr + length] ​​​这段虚拟内存地址已经存在映射关系了,那么内核就会自动帮我们选取一个合适的虚拟内存地址开始映射,但是当我们在 mmap 系统调用的参数 flags 中指定了 ​​MAP_FIXED​​​, 这时参数 addr 就变成强制要求了,如果 ​​[addr , addr + length] ​​这段虚拟内存地址已经存在映射关系了,那么内核就会将这段映射关系 unmmap 解除掉映射,然后重新根据我们的要求进行映射,如果 addr 是一个非法地址,内核就会报错停止映射。

操作系统对于物理内存的管理是按照内存页为单位进行的,而内存页的类型有两种:一种是匿名页,另一种是文件页。根据内存页类型的不同,内存映射也自然分为两种:一种是虚拟内存对匿名物理内存页的映射,另一种是虚拟内存对文件页的也映射,也就是我们常提到的匿名映射和文件映射。

当我们将 mmap 系统调用参数 flags 指定为 ​​MAP_ANONYMOUS​​ 时,表示我们需要进行匿名映射,既然是匿名映射,fd 和 offset 这两个参数也就没有了意义,fd 参数需要被设置为 -1 。当我们进行文件映射的时候,只需要指定 fd 和 offset 参数就可以了。

而根据 mmap 创建出的这片虚拟内存区域背后所映射的物理内存能否在多进程之间共享,又分为了两种内存映射方式:

  • ​MAP_SHARED ​​表示共享映射,通过 mmap 映射出的这片内存区域在多进程之间是共享的,一个进程修改了共享映射的内存区域,其他进程是可以看到的,用于多进程之间的通信。
  • ​MAP_PRIVATE ​​表示私有映射,通过 mmap 映射出的这片内存区域是进程私有的,其他进程是看不到的。如果是私有文件映射,那么多进程针对同一映射文件的修改将不会回写到磁盘文件上

这里介绍的这些 flags 参数枚举值是可以相互组合的,我们可以通过这些枚举值组合出如下几种内存映射方式。

2. 私有匿名映射

​MAP_PRIVATE | MAP_ANONYMOUS​​ 表示私有匿名映射,我们常常利用这种映射方式来申请虚拟内存,比如,我们使用 glibc 库里封装的 malloc 函数进行虚拟内存申请时,当申请的内存大于 128K 的时候,malloc 就会调用 mmap 采用私有匿名映射的方式来申请堆内存。因为它是私有的,所以申请到的内存是进程独占的,多进程之间不能共享。

这里需要特别强调一下 mmap 私有匿名映射申请到的只是虚拟内存,内核只是在进程虚拟内存空间中划分一段虚拟内存区域 VMA 出来,并将 VMA 该初始化的属性初始化好,mmap 系统调用就结束了。这里和物理内存还没有发生任何关系。在后面的章节中大家将会看到这个过程。

当进程开始访问这段虚拟内存区域时,发现这段虚拟内存区域背后没有任何物理内存与其关联,体现在内核中就是这段虚拟内存地址在页表中的 PTE 项是空的。

从内核世界透视 mmap 内存映射的本质(原理篇)-鸿蒙开发者社区

或者 PTE 中的 P 位为 0 ,这些都是表示虚拟内存还未与物理内存进行映射。

从内核世界透视 mmap 内存映射的本质(原理篇)-鸿蒙开发者社区

关于页表相关的知识,不熟悉的读者可以回顾下笔者之前的文章 ​​《一步一图带你构建 Linux 页表体系》​

这时 MMU 就会触发缺页异常(page fault),这里的缺页指的就是缺少物理内存页,随后进程就会切换到内核态,在内核缺页中断处理程序中,为这段虚拟内存区域分配对应大小的物理内存页,随后将物理内存页中的内容全部初始化为 0 ,最后在页表中建立虚拟内存与物理内存的映射关系,缺页异常处理结束。

当缺页处理程序返回时,CPU 会重新启动引起本次缺页异常的访存指令,这时 MMU 就可以正常翻译出物理内存地址了。

从内核世界透视 mmap 内存映射的本质(原理篇)-鸿蒙开发者社区

mmap 的私有匿名映射除了用于为进程申请虚拟内存之外,还会应用在 execve 系统调用中,execve 用于在当前进程中加载并执行一个新的二进制执行文件:

#include <unistd.h>

int execve(const char* filename, const char* argv[], const char* envp[])

参数 filename 指定新的可执行文件的文件名,argv 用于传递新程序的命令行参数,envp 用来传递环境变量。

既然是在当前进程中重新执行一个程序,那么当前进程的用户态虚拟内存空间就没有用了,内核需要根据这个可执行文件重新映射进程的虚拟内存空间。

既然现在要重新映射进程虚拟内存空间,内核首先要做的就是删除释放旧的虚拟内存空间,并清空进程页表。然后根据 filename 打开可执行文件,并解析文件头,判断可执行文件的格式,不同的文件格式需要不同的函数进行加载。

linux 中支持多种可执行文件格式,比如,elf 格式,a.out 格式。内核中使用 struct linux_binfmt 结构来描述可执行文件,里边定义了用于加载可执行文件的函数指针 load_binary,加载动态链接库的函数指针  load_shlib,不同文件格式指向不同的加载函数:

static struct linux_binfmt elf_format = {
 .module  = THIS_MODULE,
 .load_binary = load_elf_binary,
 .load_shlib = load_elf_library,
 .core_dump = elf_core_dump,
 .min_coredump = ELF_EXEC_PAGESIZE,
};

static struct linux_binfmt aout_format = {
 .module  = THIS_MODULE,
 .load_binary = load_aout_binary,
 .load_shlib = load_aout_library,
};

在 load_binary 中会解析对应格式的可执行文件,并根据文件内容重新映射进程的虚拟内存空间。比如,虚拟内存空间中的 BSS 段,堆,栈这些内存区域中的内容不依赖于可执行文件,所以在 load_binary 中采用私有匿名映射的方式来创建新的虚拟内存空间中的 BSS 段,堆,栈。

从内核世界透视 mmap 内存映射的本质(原理篇)-鸿蒙开发者社区

BSS 段虽然定义在可执行二进制文件中,不过只是在文件中记录了 BSS 段的长度,并没有相关内容关联,所以 BSS 段也会采用私有匿名映射的方式加载到进程虚拟内存空间中。

3. 私有文件映射

#include <sys/mman.h>
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);

我们在调用 mmap 进行内存文件映射的时候可以通过指定参数 flags 为 ​​MAP_PRIVATE​​,然后将参数 fd 指定为要映射文件的文件描述符(file descriptor)来实现对文件的私有映射。

假设现在磁盘上有一个名叫 file-read-write.txt 的磁盘文件,现在多个进程采用私有文件映射的方式,从文件 offset 偏移处开始,映射 length 长度的文件内容到各个进程的虚拟内存空间中,调用完 mmap 之后,相关内存映射内核数据结构关系如下图所示:

为了方便描述,我们指定映射长度 length 为 4K 大小,因为文件系统中的磁盘块大小为 4K ,映射到内存中的内存页刚好也是 4K 。

从内核世界透视 mmap 内存映射的本质(原理篇)-鸿蒙开发者社区

当进程打开一个文件的时候,内核会为其创建一个 struct file 结构来描述被打开的文件,并在进程文件描述符列表 fd_array 数组中找到一个空闲位置分配给它,数组中对应的下标,就是我们在用户空间用到的文件描述符。

从内核世界透视 mmap 内存映射的本质(原理篇)-鸿蒙开发者社区

而 struct file 结构是和进程相关的( fd 的作用域也是和进程相关的),即使多个进程打开同一个文件,那么内核会为每一个进程创建一个 struct file 结构,如上图中所示,进程 1 和 进程 2 都打开了同一个 file-read-write.txt 文件,那么内核会为进程 1 创建一个 struct file 结构,也会为进程 2 创建一个 struct file 结构。

每一个磁盘上的文件在内核中都会有一个唯一的 struct inode 结构,inode 结构和进程是没有关系的,一个文件在内核中只对应一个 inode,inode 结构用于描述文件的元信息,比如,文件的权限,文件中包含多少个磁盘块,每个磁盘块位于磁盘中的什么位置等等。

// ext4 文件系统中的 inode 结构
struct ext4_inode {
   // 文件权限
  __le16  i_mode;    /* File mode */
  // 文件包含磁盘块的个数
  __le32  i_blocks_lo;  /* Blocks count */
  // 存放文件包含的磁盘块
  __le32  i_block[EXT4_N_BLOCKS];/* Pointers to blocks */
};

那么什么是磁盘块呢 ?我们可以类比内存管理系统,Linux 是按照内存页为单位来对物理内存进行管理和调度的,在文件系统中,Linux 是按照磁盘块为单位对磁盘中的数据进行管理的,它们的大小均是 4K 。

如下图所示,磁盘盘面上一圈一圈的同心圆叫做磁道,磁盘上存储的数据就是沿着磁道的轨迹存放着,随着磁盘的旋转,磁头在磁道上读写硬盘中的数据。而在每个磁盘上,会进一步被划分成多个大小相等的圆弧,这个圆弧就叫做扇区,磁盘会以扇区为单位进行数据的读写。每个扇区大小为 512 字节。

从内核世界透视 mmap 内存映射的本质(原理篇)-鸿蒙开发者社区

而在 Linux 的文件系统中是按照磁盘块为单位对数据读写的,因为每个扇区大小为 512 字节,能够存储的数据比较小,而且扇区数量众多,这样在寻址的时候比较困难,Linux 文件系统将相邻的扇区组合在一起,形成一个磁盘块,后续针对磁盘块整体进行操作效率更高。

只要我们找到了文件中的磁盘块,我们就可以寻址到文件在磁盘上的存储内容了,所以使用 mmap 进行内存文件映射的本质就是建立起虚拟内存区域 VMA 到文件磁盘块之间的映射关系 。

从内核世界透视 mmap 内存映射的本质(原理篇)-鸿蒙开发者社区

调用 mmap 进行内存文件映射的时候,内核首先会在进程的虚拟内存空间中创建一个新的虚拟内存区域 VMA 用于映射文件,通过 vm_area_struct->vm_file 将映射文件的 struct flle 结构与虚拟内存映射关联起来。

struct vm_area_struct {
    struct file * vm_file;      /* File we map to (can be NULL). */
    unsigned long vm_pgoff;     /* Offset (within vm_file) in PAGE_SIZE */
}

根据 vm_file->f_inode 我们可以关联到映射文件的 struct inode,近而关联到映射文件在磁盘中的磁盘块 i_block,这个就是 mmap 内存文件映射最本质的东西

站在文件系统的视角,映射文件中的数据是按照磁盘块来存储的,读写文件数据也是按照磁盘块为单位进行的,磁盘块大小为 4K,当进程读取磁盘块的内容到内存之后,站在内存管理系统的视角,磁盘块中的数据被 DMA 拷贝到了物理内存页中,这个物理内存页就是前面提到的文件页。

根据程序的时间局部性原理我们知道,磁盘文件中的数据一旦被访问,那么它很有可能在短期内被再次访问,所以为了加快进程对文件数据的访问,内核会将已经访问过的磁盘块缓存在文件页中。

一个文件包含多个磁盘块,当它们被读取到内存之后,一个文件也就对应了多个文件页,这些文件页在内存中统一被一个叫做 page cache 的结构所组织。

每一个文件在内核中都会有一个唯一的 page cache 与之对应,用于缓存文件中的数据,page cache 是和文件相关的,它和进程是没有关系的,多个进程可以打开同一个文件,每个进程中都有有一个 struct file 结构来描述这个文件,但是一个文件在内核中只会对应一个 page cache。

文件的 struct inode 结构中除了有磁盘块的信息之外,还有指向文件 page cache 的 i_mapping 指针。

struct inode {
    struct address_space *i_mapping;
}

page cache 在内核中是使用 struct address_space 结构来描述的:

struct address_space {
    // 这里就是 page cache。里边缓存了文件的所有缓存页面
    struct radix_tree_root  page_tree; 
}

关于 page cache 的详细介绍,感兴趣的读者可以回看下 ​​《从 Linux 内核角度探秘 JDK NIO 文件读写本质》​​ 一文中的 “5. 页高速缓存 page cache” 小节。

当我们理清了内存系统和文件系统这些核心数据结构之间的关联关系之后,现在再来看,下面这幅 mmap 私有文件映射关系图是不是清晰多了。

从内核世界透视 mmap 内存映射的本质(原理篇)-鸿蒙开发者社区

page cache 在内核中是使用基树 radix_tree 结构来表示的,这里我们只需要知道文件页是挂在 radix_tree 的叶子结点上,radix_tree 中的 root 节点和 node 节点是文件页(叶子节点)的索引节点就可以了。

当多个进程调用 mmap 对磁盘上同一个文件进行私有文件映射的时候,内核只是在每个进程的虚拟内存空间中创建出一段虚拟内存区域 VMA 出来,注意,此时内核只是为进程申请了用于映射的虚拟内存,并将虚拟内存与文件映射起来,mmap 系统调用就返回了,全程并没有物理内存的影子出现。文件的 page cache 也是空的,没有包含任何的文件页。

当任意一个进程,比如上图中的进程 1 开始访问这段映射的虚拟内存时,CPU 会把虚拟内存地址送到 MMU 中进行地址翻译,因为 mmap 只是为进程分配了虚拟内存,并没有分配物理内存,所以这段映射的虚拟内存在页表中是没有页表项 PTE 的。

从内核世界透视 mmap 内存映射的本质(原理篇)-鸿蒙开发者社区

随后 MMU 就会触发缺页异常(page fault),进程切换到内核态,在内核缺页中断处理程序中会发现引起缺页的这段 VMA 是私有文件映射的,所以内核会首先通过 vm_area_struct->vm_pgoff 在文件 page cache 中查找是否有缓存相应的文件页(映射的磁盘块对应的文件页)。

struct vm_area_struct {
    unsigned long vm_pgoff;     /* Offset (within vm_file) in PAGE_SIZE */
}

static inline struct page *find_get_page(struct address_space *mapping,
     pgoff_t offset)
{
   return pagecache_get_page(mapping, offset, 0, 0);
}

如果文件页不在 page cache 中,内核则会在物理内存中分配一个内存页,然后将新分配的内存页加入到 page cache 中,并增加页引用计数。

随后会通过 address_space_operations 重定义的 readpage 激活块设备驱动从磁盘中读取映射的文件内容,然后将读取到的内容填充新分配的内存页。

static const struct address_space_operations ext4_aops = {
    .readpage       = ext4_readpage
}

现在文件中映射的内容已经加载进 page cache 了,此时物理内存才正式登场,在缺页中断处理程序的最后一步,内核会为映射的这段虚拟内存在页表中创建 PTE,然后将虚拟内存与 page cache 中的文件页通过 PTE 关联起来,缺页处理就结束了,但是由于我们指定的私有文件映射,所以 PTE 中文件页的权限是只读的。

从内核世界透视 mmap 内存映射的本质(原理篇)-鸿蒙开发者社区

当内核处理完缺页中断之后,mmap 私有文件映射在内核中的关系图就变成下面这样:

从内核世界透视 mmap 内存映射的本质(原理篇)-鸿蒙开发者社区

此时进程 1 中的页表已经建立起了虚拟内存与文件页的映射关系,进程 1 再次访问这段虚拟内存的时候,其实就等于直接访问文件的 page cache。整个过程是在用户态进行的,不需要切态。

现在我们在将视角切换到进程 2 中,进程 2 和进程 1 一样,都是采用 mmap 私有文件映射的方式映射到了同一个文件中,虽然现在已经有了物理内存了(通过进程 1 的缺页产生),但是目前还和进程 2 没有关系。

因为进程 2 的虚拟内存空间中这段映射的虚拟内存区域 VMA,在进程 2 的页表中还没有 PTE,所以当进程 2 访问这段映射虚拟内存时,同样会产生缺页中断,随后进程 2 切换到内核态,进行缺页处理,这里和进程 1 不同的是,此时被映射的文件内容已经加载到 page cache 中了,进程 2 只需要创建 PTE ,并将 page cache 中的文件页与进程 2 映射的这段虚拟内存通过 PTE 关联起来就可以了。同样,因为采用私有文件映射的原因,进程 2 的 PTE 也是只读的。

现在进程 1 和进程 2 都可以根据各自虚拟内存空间中映射的这段虚拟内存对文件的 page cache 进行读取了,整个过程都发生在用户态,不需要切态,更不需要拷贝,因为虚拟内存现在已经直接映射到 page cache 了。

从内核世界透视 mmap 内存映射的本质(原理篇)-鸿蒙开发者社区

虽然我们采用的是私有文件映射的方式,但是进程 1 和进程 2 如果只是对文件映射部分进行读取的话,文件页其实在多进程之间是共享的,整个内核中只有一份。

但是当任意一个进程通过虚拟映射区对文件进行写入操作的时候,情况就发生了变化,虽然通过 mmap 映射的时候指定的这段虚拟内存是可写的,但是由于采用的是私有文件映射的方式,各个进程页表中对应 PTE 却是只读的,当进程对这段虚拟内存进行写入的时候,MMU 会发现 PTE 是只读的,所以会产生一个写保护类型的缺页中断,写入进程,比如是进程 1,此时又会陷入到内核态,在写保护缺页处理中,内核会重新申请一个内存页,然后将 page cache 中的内容拷贝到这个新的内存页中,进程 1 页表中对应的 PTE 会重新关联到这个新的内存页上,此时 PTE 的权限变为可写。

从内核世界透视 mmap 内存映射的本质(原理篇)-鸿蒙开发者社区

从此以后,进程 1 对这段虚拟内存区域进行读写的时候就不会再发生缺页了,读写操作都会发生在这个新申请的内存页上,但是有一点,进程 1 对这个内存页的任何修改均不会回写到磁盘文件上,这也体现了私有文件映射的特点,进程对映射文件的修改,其他进程是看不到的,并且修改不会同步回磁盘文件中。

进程 2 对这段虚拟映射区进行写入的时候,也是一样的道理,同样会触发写保护类型的缺页中断,进程 2 陷入内核态,内核为进程 2 新申请一个物理内存页,并将 page cache 中的内容拷贝到刚为进程 2 申请的这个内存页中,进程 2 页表中对应的 PTE 会重新关联到新的内存页上, PTE 的权限变为可写。

从内核世界透视 mmap 内存映射的本质(原理篇)-鸿蒙开发者社区

这样一来,进程 1 和进程 2 各自的这段虚拟映射区,就映射到了各自专属的物理内存页上,而且这两个内存页中的内容均是文件中映射的部分,他们已经和 page cache 脱离了。

进程 1 和进程 2 对各自虚拟内存区的修改只能反应到各自对应的物理内存页上,而且各自的修改在进程之间是互不可见的,最重要的一点是这些修改均不会回写到磁盘文件中,这就是私有文件映射的核心特点

我们可以利用 mmap 私有文件映射这个特点来加载二进制可执行文件的 .text , .data section 到进程虚拟内存空间中的代码段和数据段中。

从内核世界透视 mmap 内存映射的本质(原理篇)-鸿蒙开发者社区

因为同一份代码,也就是同一份二进制可执行文件可以运行多个进程,而代码段对于多进程来说是只读的,没有必要为每个进程都保存一份,多进程之间共享这一份代码就可以了,正好私有文件映射的读共享特点可以满足我们的这个需求。

对于数据段来说,虽然它是可写的,但是我们需要的是多进程之间对数据段的修改相互之间是不可见的,而且对数据段的修改不能回写到磁盘上的二进制文件中,这样当我们利用这个可执行文件在启动一个进程的时候,进程看到的就是数据段初始化未被修改的状态。 mmap 私有文件映射的写时复制(copy on write)以及修改不会回写到映射文件中等特点正好也满足我们的需求。

这一点我们可以在负责加载 elf 格式的二进制可执行文件并映射到进程虚拟内存空间的 load_elf_binary 函数,以及负责加载 a.out 格式可执行文件的 load_aout_binary 函数中可以看出。

static int load_elf_binary(struct linux_binprm *bprm)
{
   // 将二进制文件中的 .text .data section 私有映射到虚拟内存空间中代码段和数据段中
  error = elf_map(bprm->file, load_bias + vaddr, elf_ppnt,
        elf_prot, elf_flags, total_size);
}

static int load_aout_binary(struct linux_binprm * bprm)
{
        ............ 省略 .............
        // 将 .text 采用私有文件映射的方式映射到进程虚拟内存空间的代码段
        error = vm_mmap(bprm->file, N_TXTADDR(ex), ex.a_text,
            PROT_READ | PROT_EXEC,
            MAP_FIXED | MAP_PRIVATE | MAP_DENYWRITE | MAP_EXECUTABLE,
            fd_offset);

        // 将 .data 采用私有文件映射的方式映射到进程虚拟内存空间的数据段
        error = vm_mmap(bprm->file, N_DATADDR(ex), ex.a_data,
                PROT_READ | PROT_WRITE | PROT_EXEC,
                MAP_FIXED | MAP_PRIVATE | MAP_DENYWRITE | MAP_EXECUTABLE,
                fd_offset + ex.a_text);

        ............ 省略 .............
}


文章转载自公众号:bin的技术小屋

分类
标签
已于2023-10-11 10:34:23修改
收藏
回复
举报
回复
    相关推荐