关于linux-kernel:从内核世界透视-mmap-内存映射的本质原理篇

51次阅读

共计 29621 个字符,预计需要花费 75 分钟才能阅读完成。

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

之前有不少读者给笔者留言,心愿笔者写一篇文章介绍下 mmap 内存映射相干的常识体系,之所以迟迟没有动笔,是因为 mmap 这个零碎调用看上去简略,实际上并不简略,能够说是非常复杂的一个零碎调用。

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

侥幸的是这一整套的背景常识,笔者曾经在《聊聊 Linux 内核》系列文章中为大家具体介绍过了,所以当初是时候开始动笔了,不过大家不须要放心,尽管波及到的背景常识比拟多,然而在前面的相干章节里,笔者还会为大家从新交代。

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

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

在咱们正式开始明天的内容之前,笔者想首先抛出几个问题给大家思考,倡议大家带着这几个问题来浏览接下来的内容,咱们独特来将这些迷雾一层一层地缓缓拨开,直到还原出内存映射的实质。

  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 零碎调用的前两个参数:

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

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

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

在内存管理系统中,物理内存是依照内存页为单位组织的,在文件系统中,磁盘中的文件是依照磁盘块为单位组织的,内存页和磁盘块大小个别状况下都是 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;
}

上图中的文件映射与匿名映射区里边其实蕴含了大量的 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 在文件映射与匿名映射区中映射进去的这一段虚拟内存区域同过程虚拟内存空间中的其余虚拟内存区域一样,也都是有权限管制的。

比方上图过程虚拟内存空间中的代码段,它是与磁盘上 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 映射出的这段虚拟内存区域 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 项是空的。

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

对于页表相干的常识,不相熟的读者能够回顾下笔者之前的文章《一步一图带你构建 Linux 页表体系》

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

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

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 段,堆,栈。

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。

当过程关上一个文件的时候,内核会为其创立一个 struct file 构造来形容被关上的文件,并在过程文件描述符列表 fd_array 数组中找到一个闲暇地位调配给它,数组中对应的下标,就是咱们在用户空间用到的文件描述符。

而 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 字节。

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

只有咱们找到了文件中的磁盘块,咱们就能够寻址到文件在磁盘上的存储内容了,所以应用 mmap 进行内存文件映射的实质就是建设起虚拟内存区域 VMA 到文件磁盘块之间的映射关系。

调用 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 公有文件映射关系图是不是清晰多了。

page cache 在内核中是应用基树 radix_tree 构造来示意的,这里咱们只须要晓得文件页是挂在 radix_tree 的叶子结点上,radix_tree 中的 root 节点和 node 节点是文件页(叶子节点)的索引节点就能够了。

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

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

随后 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 公有文件映射在内核中的关系图就变成上面这样:

此时过程 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 了。

尽管咱们采纳的是公有文件映射的形式,然而过程 1 和过程 2 如果只是对文件映射局部进行读取的话,文件页其实在多过程之间是共享的,整个内核中只有一份。

然而当任意一个过程通过虚构映射区对文件进行写入操作的时候,状况就产生了变动,尽管通过 mmap 映射的时候指定的这段虚拟内存是可写的,然而因为采纳的是公有文件映射的形式,各个过程页表中对应 PTE 却是只读的,当过程对这段虚拟内存进行写入的时候,MMU 会发现 PTE 是只读的,所以会产生一个写爱护类型的缺页中断,写入过程,比方是过程 1,此时又会陷入到内核态,在写爱护缺页解决中,内核会从新申请一个内存页,而后将 page cache 中的内容拷贝到这个新的内存页中,过程 1 页表中对应的 PTE 会从新关联到这个新的内存页上,此时 PTE 的权限变为可写。

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

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

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

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

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

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

对于数据段来说,尽管它是可写的,然而咱们须要的是多过程之间对数据段的批改相互之间是不可见的,而且对数据段的批改不能回写到磁盘上的二进制文件中,这样当咱们利用这个可执行文件在启动一个过程的时候,过程看到的就是数据段初始化未被批改的状态。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);

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

4. 共享文件映射

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

咱们通过将 mmap 零碎调用中的 flags 参数指定为 MAP_SHARED , 参数 fd 指定为要映射文件的文件描述符(file descriptor)来实现对文件的共享映射。

共享文件映射其实和公有文件映射后面的映射过程是一样的,惟一不同的点在于公有文件映射是读共享的,写的时候会产生写时复制(copy on write),并且多过程针对同一映射文件的批改不会回写到磁盘文件上。

而共享文件映射因为是共享的,多个过程中的虚拟内存映射区最终会通过缺页中断的形式映射到文件的 page cache 中,后续多个过程对各自的这段虚拟内存区域的读写都会间接产生在 page cache 上。

因为映射文件的 page cache 在内核中只有一份,所以对于共享文件映射来说,多过程读写都是共享的,因为多过程间接读写的是 page cache,所以多过程对共享映射区的任何批改,最终都会通过内核回写线程 pdflush 刷新到磁盘文件中。

上面这幅是多过程通过 mmap 共享文件映射之后的内核数据结构关系图:

同公有文件映射形式一样,当多个过程调用 mmap 对磁盘上的同一个文件进行共享文件映射的时候,内核中的解决都是一样的,也都只是在每个过程的虚拟内存空间中,创立出一段用于共享映射的虚拟内存区域 VMA 进去,随后内核会将各个过程中的这段虚拟内存映射区与映射文件关联起来,mmap 共享文件映射的逻辑就完结了。

惟一不同的是,共享文件映射会在这段用于映射文件的 VMA 中标注是共享映射 —— MAP_SHARED

struct vm_area_struct {
    // MAP_SHARED 共享映射
    unsigned long vm_flags; 
}

在 mmap 共享文件映射的过程中,内核同样不波及任何的物理内存调配,只是调配了一段虚拟内存,在共享映射刚刚建设起来之后,文件对应的 page cache 同样是空的,没有蕴含任何的文件页。

因为 mmap 只是在各个过程中调配了虚拟内存,没有调配物理内存,所以在各个过程的页表中,这段用于文件映射的虚拟内存区域对应的页表项 PTE 是空的,当任意过程对这段虚拟内存进行拜访的时候(读或者写),MMU 就会产生缺页中断,这里咱们以上图中的过程 1 为例,随后过程 1 切换到内核态,执行内核缺页中断处理程序。

同公有文件映射的缺页解决一样,内核会首先通过 vm_area_struct->vm_pgoff 在文件 page cache 中查找是否有缓存相应的文件页(映射的磁盘块对应的文件页)。如果文件页不在 page cache 中,内核则会在物理内存中调配一个内存页,而后将新调配的内存页退出到 page cache 中。

而后调用 readpage 激活块设施驱动从磁盘中读取映射的文件内容,用读取到的内容填充新调配的内存页,当初物理内存有了,最初一步就是在过程 1 的页表中建设共享映射的这段虚拟内存与 page cache 中缓存的文件页之间的关联。

这里和公有文件映射不同的中央是,公有文件映射因为是公有的,所以在内核创立 PTE 的时候会将 PTE 设置为只读,目标是当过程写入的时候触发写爱护类型的缺页中断进行写时复制(copy on write)。

共享文件映射因为是共享的,PTE 被创立进去的时候就是可写的,所以后续过程 1 在对这段虚拟内存区域写入的时候不会触发缺页中断,而是间接写入 page cache 中,整个过程没有切态,没有数据拷贝。

当初咱们在切换到过程 2 的视角中,尽管当初文件中被映射的这部分内容曾经加载进物理内存页,并被缓存在文件的 page cache 中了。然而当初过程 2 中这段虚构映射区在过程 2 页表中对应的 PTE 依然是空的,当过程 2 拜访这段虚构映射区的时候仍然会产生缺页中断。

当过程 2 切换到内核态,解决缺页中断的时候,此时过程 2 通过 vm_area_struct->vm_pgoff 在 page cache 查找文件页的时候,文件页曾经被过程 1 加载进 page cache 了,过程 2 一下就找到了,就不须要再去磁盘中读取映射内容了,内核会间接为过程 2 创立 PTE(因为是共享文件映射,所以这里的 PTE 也是可写的),并插入到过程 2 页表中,随后将过程 2 中的虚构映射区通过 PTE 与 page cache 中缓存的文件页映射关联起来。

当初过程 1 和过程 2 各自虚拟内存空间中的这段虚拟内存区域 VMA,曾经独特映射到了文件的 page cache 中,因为文件的 page cache 在内核中只有一份,它是和过程无关的,page cache 中的内容产生的任何变动,过程 1 和过程 2 都是能够看到的。

重要的一点是,多过程对各自虚拟内存映射区 VMA 的写入操作,内核会依据本人的脏页回写策略将批改内容回写到磁盘文件中。

内核提供了以下六个零碎参数,来供咱们配置调整内核脏页回写的行为,这些参数的配置文件存在于 proc/sys/vm 目录下:

  • dirty_writeback_centisecs 内核参数的默认值为 500。单位为 0.01 s。也就是说内核默认会每隔 5s 唤醒一次 flusher 线程来执行相干脏页的回写。
  • drity_background_ratio:当脏页数量在零碎的可用内存 available 中占用的比例达到 drity_background_ratio 的配置值时,内核就会唤醒 flusher 线程异步回写脏页。默认值为:10。示意如果 page cache 中的脏页数量达到零碎可用内存的 10% 的话,就被动唤醒 flusher 线程去回写脏页到磁盘。
  • dirty_background_bytes:如果 page cache 中脏页占用的内存用量绝对值达到指定的 dirty_background_bytes。内核就会唤醒 flusher 线程异步回写脏页。默认为:0。
  • dirty_ratio:dirty_background_ 相干的内核配置参数均是内核通过唤醒 flusher 线程来异步回写脏页。上面要介绍的 dirty_ 配置参数,均是由用户进程同步回写脏页。示意内存中的脏页太多了,用户过程本人都看不下去了,不必等内核 flusher 线程唤醒,用户过程本人被动去回写脏页到磁盘中。当脏页占用零碎可用内存的比例达到 dirty_ratio 配置的值时,用户进程同步回写脏页。默认值为:20。
  • dirty_bytes:如果 page cache 中脏页占用的内存用量绝对值达到指定的 dirty_bytes。用户进程同步回写脏页。默认值为:0。
  • 内核为了防止 page cache 中的脏页在内存中短暂的停留,所以会给脏页在内存中的驻留工夫设置肯定的期限,这个期限可由前边提到的 dirty_expire_centisecs 内核参数配置。默认为:3000。单位为:0.01 s。也就是说在默认配置下,脏页在内存中的驻留工夫为 30 s。超过 30 s 之后,flusher 线程将会在下次被唤醒的时候将这些脏页回写到磁盘中。

对于脏页回写具体的内容介绍,感兴趣的读者能够回看下《从 Linux 内核角度探秘 JDK NIO 文件读写实质》一文中的“13. 内核回写脏页的触发机会”大节。

依据 mmap 共享文件映射多过程之间读写共享(不会产生写时复制)的特点,罕用于多过程之间共享内存(page cache),多过程之间的通信。

5. 共享匿名映射

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

咱们通过将 mmap 零碎调用中的 flags 参数指定为 MAP_SHARED | MAP_ANONYMOUS ,并将 fd 参数指定为 -1 来实现共享匿名映射,这种映射形式罕用于 父子过程 之间共享内存,父子过程 之间的通信。留神,这里须要和大家强调一下是父子过程,为什么只能是父子过程,笔者前面再给大家解答。

在笔者介绍完 mmap 的公有匿名映射,公有文件映射,以及共享文件映射之后,共享匿名映射看似就非常简单了,因为不对文件进行映射,所以它不波及到文件系统相干的常识,而且又是共享的,多个过程通过将本人的页表指向同一个物理内存页面不就实现共享匿名映射了吗?

看起来简略,实际上并没有那么简略,甚至能够说共享匿名映射是 mmap 这四种映射形式中最为简单的,为什么这么说的?咱们一起来看下共享匿名映射的映射过程。

首先和其余几种映射形式一样,mmap 只是负责在各个过程的虚拟内存空间中划分一段用于共享匿名映射的虚拟内存区域而已,这点笔者曾经强调过很多遍了,整个映射过程并不波及到物理内存的调配。

当多个过程调用 mmap 进行共享匿名映射之后,内核只不过是为每个过程在各自的虚拟内存空间中调配了一段虚拟内存而已,因为并不波及物理内存的调配,所以这段用于映射的虚拟内存在各个过程的页表中对应的页表项 PTE 都还是空的,如下图所示:

当任一过程,比方上图中的过程 1 开始拜访这段虚构映射区的时候,MMU 会产生缺页中断,过程 1 切换到内核态,开始解决缺页中断逻辑,在缺页中断处理程序中,内核为过程 1 调配一个物理内存页,并创立对应的 PTE 插入到过程 1 的页表中,随后用 PTE 将过程 1 的这段虚构映射区与物理内存映射关联起来。过程 1 的缺页解决完结,从此以后,过程 1 就能够读写这段共享映射的物理内存了。

当初咱们把视角切换到过程 2 中,当过程 2 拜访它本人的这段虚构映射区的时候,因为过程 2 页表中对应的 PTE 为空,所以过程 2 也会产生缺页中断,随后切换到内核态解决缺页逻辑。

当过程 2 开始解决缺页逻辑的时候,过程 2 就懵了,为什么呢?起因是过程 2 和过程 1 进行的是共享映射,所以过程 2 不能轻易找一个物理内存页进行映射,过程 2 必须和 过程 1 映射到同一个物理内存页面,这样能力共享内存。那当初的问题是,过程 2 面对着茫茫多的物理内存页,过程 2 怎么晓得过程 1 曾经映射了哪个物理内存页?

内核在缺页中断解决中只能晓得以后正在缺页的过程是谁,以及产生缺页的虚拟内存地址是什么,内核依据这些信息,根本无法晓得,此时是否曾经有其余过程把共享的物理内存页筹备好了。

这一点对于共享文件映射来说特地简略,因为有文件的 page cache 存在,过程 2 能够依据映射的文件内容在文件中的偏移 offset,从 page cache 中查找是否曾经有其余过程把映射的文件内容加载到文件页中。如果文件页曾经存在 page cache 中了,过程 2 间接映射这个文件页就能够了。

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);
}

因为共享匿名映射并没有对文件映射,所以其余过程想要在内存中查找要进行共享的内存页就十分艰难了,那怎么解决这个问题呢?

既然共享文件映射能够轻松解决这个问题,那咱们何不借鉴一下文件映射的形式?

共享匿名映射在内核中是通过一个叫做 tmpfs 的虚构文件系统来实现的,tmpfs 不是传统意义上的文件系统,它是基于内存实现的,挂载在 dev/zero 目录下。

当多个过程通过 mmap 进行共享匿名映射的时候,内核会在 tmpfs 文件系统中创立一个匿名文件,这个匿名文件并不是实在存在于磁盘上的,它是内核为了共享匿名映射而模仿进去的,匿名文件也有本人的 inode 构造以及 page cache。

在 mmap 进行共享匿名映射的时候,内核会把这个匿名文件关联到过程的虚构映射区 VMA 中。这样一来,当过程虚构映射区域与 tmpfs 文件系统中的这个匿名文件映射起来之后,前面的流程就和共享文件映射截然不同了。

struct vm_area_struct {struct file * vm_file;      /* File we map to (can be NULL). */
}

最初,笔者来答复下在本大节开始处抛出的一个问题,就是共享匿名映射只实用于 父子过程 之间的通信,为什么只能是父子过程呢?

因为当父过程进行 mmap 共享匿名映射的时候,内核会为其创立一个匿名文件,并关联到父过程的虚拟内存空间中 vm_area_struct->vm_file 中。然而这时候其余过程并不知道父过程虚拟内存空间中关联的这个匿名文件,因为过程之间的虚拟内存空间都是隔离的。

子过程就不一样了,在父过程调用完 mmap 之后,父过程的虚拟内存空间中曾经有了一段虚构映射区 VMA 并关联到匿名文件了。这时父过程进行 fork() 零碎调用创立子过程,子过程会拷贝父过程的所有资源,当然也包含父过程的虚拟内存空间以及父过程的页表。

long _do_fork(unsigned long clone_flags,
       unsigned long stack_start,
       unsigned long stack_size,
       int __user *parent_tidptr,
       int __user *child_tidptr,
       unsigned long tls)
{
              ......... 省略 ..........
     struct pid *pid;
     struct task_struct *p;

              ......... 省略 ..........
    // 拷贝父过程的所有资源
     p = copy_process(clone_flags, stack_start, stack_size,
         child_tidptr, NULL, trace, tls, NUMA_NO_NODE);

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

当 fork 出子过程的时候,这时子过程的虚拟内存空间和父过程的虚拟内存空间齐全是截然不同的,在子过程的虚拟内存空间中天然也有一段虚构映射区 VMA 并且曾经关联到匿名文件中了(继承自父过程)。

当初父子过程的页表也是截然不同的,各自的这段虚构映射区对应的 PTE 都是空的,一旦产生缺页,前面的流程就和共享文件映射一样了。咱们能够把共享匿名映射看作成一种非凡的共享文件映射形式。

6. 参数 flags 的其余枚举值

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

在前边的几个大节中,笔者为大家介绍了 mmap 零碎调用参数 flags 最为外围的三个枚举值:MAP_ANONYMOUS,MAP_SHARED,MAP_PRIVATE。随后咱们通过这三个枚举值组合出了四种内存映射形式:公有匿名映射,公有文件映射,共享文件映射,共享匿名映射。

到当初为止,笔者算是把 mmap 内存映射的外围原理及其在内核中的映射过程给大家具体分析完了,不过参数 flags 的枚举值在内核中并不只是上述三个,除此之外,内核还定义了很多。在本大节的最初,笔者为大家挑了几个绝对重要的枚举值给大家做一些额定的补充,这样可能让大家对 mmap 内存映射有一个更加全面的意识。

#define MAP_LOCKED    0x2000        /* pages are locked */
#define MAP_POPULATE        0x008000    /* populate (prefault) pagetables */
#define MAP_HUGETLB        0x040000    /* create a huge page mapping */

通过后面的介绍咱们晓得,mmap 仅仅只是在过程虚拟内存空间中划分出一段用于映射的虚拟内存区域 VMA,并将这段 VMA 与磁盘上的文件映射起来而已。整个映射过程并不波及物理内存的调配,更别说虚拟内存与物理内存的映射了,这些都是在过程拜访这段 VMA 的时候,通过缺页中断来补齐的。

如果咱们在应用 mmap 零碎调用的时候设置了 MAP_POPULATE,内核在调配完虚拟内存之后,就会马上调配物理内存,并在过程页表中建设起虚拟内存与物理内存的映射关系,这样过程在调用 mmap 之后就能够间接拜访这段映射的虚拟内存地址了,不会产生缺页中断。

然而当零碎内存资源缓和的时候,内核仍然会将 mmap 背地映射的这块物理内存 swap out 到磁盘中,这样过程在拜访的时候依然会产生缺页中断,为了避免这种景象,咱们能够在调用 mmap 的时候设置 MAP_LOCKED

在设置了 MAP_LOCKED 之后,mmap 零碎调用在为过程调配完虚拟内存之后,内核也会马上为其调配物理内存并在过程页表中建设虚拟内存与物理内存的映射关系,这里内核还会额定做一个动作,就是将映射的这块物理内存锁定在内存中,不容许它 swap,这样一来映射的物理内存将会始终停留在内存中,过程无论何时拜访这段映射内存都不会产生缺页中断。

MAP_HUGETLB 则是用于大页内存映射的,在内核中对于物理内存的调度是依照物理内存页为单位进行的,一般物理内存页大小为 4K。但在一些对于内存敏感的应用场景中,咱们往往冀望应用一些比一般 4K 更大的页。

因为这些巨型页要比一般的 4K 内存页要大很多,而且这些巨型页不容许被 swap,所以遇到缺页中断的状况就会绝对缩小,因为缩小了缺页中断所以性能会更高。

另外,因为巨型页比一般页要大,所以巨型页须要的页表项要比一般页要少,页表项里保留了虚拟内存地址与物理内存地址的映射关系,当 CPU 拜访内存的时候须要频繁通过 MMU 拜访页表项获取物理内存地址,因为要频繁拜访,所以页表项个别会缓存在 TLB 中,因为巨型页须要的页表项较少,所以节约了 TLB 的空间同时升高了 TLB 缓存 MISS 的概率,从而减速了内存拜访。

7. 大页内存映射

在 64 位 x86 CPU 架构 Linux 的四级页表体系下,零碎反对的大页尺寸有 2M,1G。咱们能够在 /sys/kernel/mm/hugepages 门路下查看以后零碎所反对的大页尺寸:

要想在应用程序中应用 HugePage,咱们须要在内核编译的时候通过设置 CONFIG_HUGETLBFSCONFIG_HUGETLB_PAGE 这两个编译选项来让内核反对 HugePage。咱们能够通过 cat /proc/filesystems 命令来查看以后内核中是否反对 hugetlbfs 文件系统,这是咱们应用 HugePage 的根底。

因为 HugePage 要求的是一大片间断的物理内存,和一般内存页一样,巨型大页里的内存必须是间断的,然而随着零碎的长时间运行,内存页被频繁无规则的调配与回收,零碎中会产生大量的内存碎片,因为内存碎片的影响,内核很难寻找到大片间断的物理内存,这样一来就很难调配到巨型大页。

所以这就要求内核在系统启动的时候事后为咱们调配好足够多的大页内存,这些大页内存被内核治理在一个大页内存池中,大页内存池中的内存全副是专用的,专门用于巨型大页的调配,不能用于其余目标,即便零碎中没有应用巨型大页,这些大页内存就只能闲暇在那里,另外这些大页内存都是被内核锁定在内存中的,即便零碎内存资源缓和,大页内存也不容许被 swap。而且内核大页池中的这些大页内存应用完了就完了,大页池耗尽之后,应用程序将无奈再应用大页。

既然大页内存池在内核启动的时候就须要被事后创立好,而创立大页内存池,内核须要首先晓得内存池中到底蕴含多少个 HugePage,每个 HugePage 的尺寸是多少。咱们能够将这些参数在内核启动的时候增加到 kernel command line 中,随后内核在启动的过程中就能够依据 kernel command line 中 HugePage 相干的参数进行大页内存池的创立。上面是一些 HugePage 相干的外围 command line 参数含意:

  • hugepagesz:用于指定大页内存池中 HugePage 的 size,咱们这里能够指定 hugepagesz=2M 或者 hugepagesz=1G,具体反对多少种大页尺寸由 CPU 架构决定。
  • hugepages:用于指定内核须要事后创立多少个 HugePage 在大页内存池中,咱们能够通过指定 hugepages=256,来示意内核须要事后创立 256 个 HugePage 进去。除此之外 hugepages 参数还能够有 NUMA 格局,用于通知内核须要在每个 NUMA node 上创立多少个 HugePage。咱们能够通过设置 hugepages=0:1,1:2 ... 来指定 NUMA node 0 上调配 1 个 HugePage,在 NUMA node 1 上调配 2 个 HugePage。
  • default_hugepagesz:用于指定 HugePage 默认大小。各种不同类型的 CPU 架构个别都反对多种 size 的 HugePage,比方 x86 CPU 反对 2M,1G 的 HugePage。arm64 反对 64K,2M,32M,1G 的 HugePage。这么多尺寸的 HugePage 咱们到底该应用哪种尺寸呢?这时就须要通过 default_hugepagesz 来指定默认应用的 HugePage 尺寸。

以上为大家介绍的是在内核启动的时候(boot time)通过向 kernel command line 指定 HugePage 相干的命令行参数来配置大页,除此之外,咱们还能够在零碎刚刚启动之后(run time)来配置大页,因为零碎刚刚启动,所以零碎内存碎片化水平最小,也是一个配置大页的机会:

/proc/sys/vm 门路下有两个零碎参数能够让咱们在零碎 run time 的时候动静调整以后零碎中 default size(由 default_hugepagesz 指定)大小的 HugePage 个数。

  • nr_hugepages 示意以后零碎中 default size 大小的 HugePage 个数,咱们能够通过 echo HugePageNum > /proc/sys/vm/nr_hugepages 命令来动静增大或者放大 HugePage(default size)个数。
  • nr_overcommit_hugepages 示意当零碎中的应用程序申请的大页个数超过 nr_hugepages 时,内核容许在额定申请多少个大页。当大页内存池中的大页个数被耗尽时,如果此时持续有过程来申请大页,那么内核则会从以后零碎中选取多个间断的一般 4K 大小的内存页,凑出若干个大页来供过程应用,这些被凑进去的大页叫做 surplus_hugepage,surplus_hugepage 的个数不能超过 nr_overcommit_hugepages。当这些 surplus_hugepage 不在被应用时,就会被开释回内核中。nr_hugepages 个数的大页则会始终停留在大页内存池中,不会被开释,也不会被 swap。

nr_hugepages 有点像 JDK 线程池中的 corePoolSize 参数,(nr_hugepages + nr_overcommit_hugepages) 有点像线程池中的 maximumPoolSize 参数。

以上介绍的是批改默认尺寸大小的 HugePage,另外,咱们还能够在零碎 run time 的时候动静批改指定尺寸的 HugePage,不同大页尺寸的相干配置文件寄存在 /sys/kernel/mm/hugepages 门路下的对应目录中:

如上图所示,以后零碎中所反对的大页尺寸相干的配置文件,均寄存在对应 hugepages-hugepagesize 格局的目录中,上面咱们以 2M 大页为例,进入到 hugepages-2048kB 目录下,发现同样也有 nr_hugepages 和 nr_overcommit_hugepages 这两个配置文件,它们的含意和上边介绍的一样,只不过这里的是具体尺寸的 HugePage 相干配置。

咱们能够通过如下命令来动静调整零碎中 2M 大页的个数:

echo HugePageNum > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages

同理在 NUMA 架构的零碎下,咱们能够在 /sys/devices/system/node/node_id 门路下批改对应 numa node 节点中的相应尺寸 的大页个数:

echo HugePageNum > /sys/devices/system/node/node_id/hugepages/hugepages-2048kB/nr_hugepages

当初内核曾经反对了大页,并且咱们从内核的 boot time 或者 run time 配置好了大页内存池,咱们终于能够在应用程序中来应用大页内存了,内核给咱们提供了两种形式来应用 HugePage:

  • 一种是本文介绍的 mmap 零碎调用,须要在 flags 参数中设置 MAP_HUGETLB。另外内核提供了额定的两个枚举值来配合 MAP_HUGETLB 一起应用,它们别离是 MAP_HUGE_2MB 和 MAP_HUGE_1GB。

    • MAP_HUGETLB | MAP_HUGE_2MB 用于指定咱们须要映射的是 2M 的大页。
    • MAP_HUGETLB | MAP_HUGE_1GB 用于指定咱们须要映射的是 1G 的大页。
    • MAP_HUGETLB 示意依照 default_hugepagesz 指定的默认尺寸来映射大页。
  • 另一种是 SYSV 规范的零碎调用 shmget 和 shmat。

本大节咱们次要介绍 mmap 零碎调用应用大页的形式:

int main(void)
{addr = mmap(addr, length, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB, -1, 0);
    return 0;
}

MAP_HUGETLB 只能反对 MAP_ANONYMOUS 匿名映射的形式应用 HugePage

当咱们通过 mmap 设置了 MAP_HUGETLB 进行大页内存映射的时候,这个映射过程和一般的匿名映射一样,同样也是首先在过程的虚拟内存空间中划分出一段虚构映射区 VMA 进去,同样不波及物理内存的调配,不一样的中央是,内核在调配完虚拟内存之后,会在大页内存池中为映射的这段虚拟内存 预留 好大页内存,相当于是把行将要应用的大页内存先锁定住,不容许其余过程应用。这些被预留好的 HugePage 个数被记录在上图中的 resv_hugepages 文件中。

当过程在拜访这段虚拟内存的时候,同样会产生缺页中断,随后内核会从大页内存池中将这部分曾经预留好的 resv_hugepages 调配给过程,并在过程页表中建设好虚拟内存与 HugePage 的映射。对于过程页表如何映射内存大页的具体内容,感兴趣的同学能够回看下之前的文章《一步一图带你构建 Linux 页表体系》。

因为这里咱们调用 mmap 映射的是 HugePage,所以零碎调用参数中的 addr,length 须要和大页尺寸进行对齐,在本例中须要和 2M 进行对齐。

前边也提到了 MAP_HUGETLB 须要和 MAP_ANONYMOUS 配合一起应用,只能反对匿名映射的形式来应用 HugePage。那如果咱们想应用 mmap 对文件进行大页映射该怎么办呢?

这就用到了后面提到的 hugetlbfs 文件系统:

hugetlbfs 是一个基于内存的文件系统,相似前边介绍的 tmpfs 文件系统,位于 hugetlbfs 文件系统下的所有文件都是被大页反对的,也就说通过 mmap 对 hugetlbfs 文件系统下的文件进行文件映射,默认都是用 HugePage 进行映射。

hugetlbfs 下的文件反对大多数的文件系统操作,比方:open , close , chmod , read 等等,然而不反对 write 零碎调用,如果想要对 hugetlbfs 下的文件进行写入操作,那么必须通过文件映射的形式将 hugetlbfs 中的文件通过 大页 映射进内存,而后在映射内存中进行写入操作。

所以在咱们应用 mmap 零碎调用对 hugetlbfs 下的文件进行大页映射之前,首先须要做的事件就是在零碎中挂载 hugetlbfs 文件系统到指定的门路下。

mount -t hugetlbfs -o uid=,gid=,mode=,pagesize=,size=,min_size=,nr_inodes= none /mnt/huge

下面的这条命令用于将 hugetlbfs 挂载到 /mnt/huge 目录下,从此以后只有是在 /mnt/huge 目录下创立的文件,背地都是由大页反对的,也就是说如果咱们通过 mmap 零碎调用对 /mnt/huge 目录下的文件进行文件映射,缺页的时候,内核调配的就是内存大页。

只有在 hugetlbfs 下的文件进行 mmap 文件映射的时候能力应用大页,其余一般文件系统下的文件仍然只能映射一般 4K 内存页。

mount 命令中的 uidgid 用于指定 hugetlbfs 根目录的 owner 和 group。

pagesize 用于指定 hugetlbfs 反对的大页尺寸,默认单位是字节,咱们能够通过设置 pagesize=2M 或者 pagesize=1G 来指定 hugetlbfs 中的大页尺寸为 2M 或者 1G。

size 用于指定 hugetlbfs 文件系统能够应用的最大内存容量是多少,单位同 pagesize 一样。

min_size 用于指定 hugetlbfs 文件系统能够应用的最小内存容量是多少。

nr_inodes 用于指定 hugetlbfs 文件系统中 inode 的最大个数,决定该文件系统中最大能够创立多少个文件。

当 hugetlbfs 被咱们挂载好之后,接下来咱们就能够间接通过 mmap 零碎调用对挂载目录 /mnt/huge 下的文件进行内存映射了,当缺页的时候,内核会间接调配大页,大页尺寸是 pagesize

int main(void)
{fd = open(“/mnt/huge/test.txt”, O_CREAT|O_RDWR);
    addr=mmap(0,MAP_LENGTH,PROT_READ|PROT_WRITE,MAP_SHARED, fd, 0);
    return 0;
}

这里须要留神是,通过 mmap 映射 hugetlbfs 中的文件的时候,并不需要指定 MAP_HUGETLB 。而咱们通过 SYSV 规范的零碎调用 shmget 和 shmat 以及前边介绍的 mmap(flags 参数设置 MAP_HUGETLB)进行大页申请的时候,并不需要挂载 hugetlbfs。

在内核中一共反对两种类型的内存大页,一种是规范大页(hugetlb pages),也就是下面内容所介绍的应用大页的形式,咱们能够通过命令 grep Huge /proc/meminfo 来查看规范大页在零碎中的应用状况:

和规范大页相干的统计参数含意如下:

HugePages_Total 示意规范大页池中大页的个数。HugePages_Free 示意大页池中还未被应用的大页个数(未被调配)。

HugePages_Rsvd 示意大页池中曾经被预留进去的大页,这个预留大页是什么意思呢?咱们晓得 mmap 零碎调用只是为过程调配一段虚拟内存而已,并不会调配物理内存,当 mmap 进行大页映射的时候也是一样。不同之处在于,内核为过程调配完虚拟内存之后,还须要为过程在大页池中预留好本次映射所须要的大页个数,留神此时只是预留,还并未调配给过程,大页池中被预留好的大页不能被其余过程应用。这时 HugePages_Rsvd 的个数会相应减少,当过程产生缺页的时候,内核会间接从大页池中把这些提前预留好的大页内存映射到过程的虚拟内存空间中。这时 HugePages_Rsvd 的个数会相应缩小。零碎中真正残余可用的个数其实是 HugePages_Free - HugePages_Rsvd

HugePages_Surp 示意大页池中超额分配的大页个数,这个概念其实笔者后面在介绍 nr_overcommit_hugepages 参数的时候也提到过,nr_overcommit_hugepages 参数示意最多能超额分配多少个大页。当大页池中的大页全副被耗尽的时候,也就是 /proc/sys/vm/nr_hugepages 指定的大页个数全副被调配完了,内核还能够超额为过程调配大页,超额分配出的大页个数就统计在 HugePages_Surp 中。

Hugepagesize 示意零碎中大页的默认 size 大小,单位为 KB。

Hugetlb 示意零碎中所有尺寸的大页所占用的物理内存总量。单位为 KB。

内核中另外一种类型的大页是通明大页 THP (Transparent Huge Pages),这里的通明指的是利用过程在应用 THP 的时候齐全是通明的,不须要像应用规范大页那样须要系统管理员对系统进行显示的大页配置,在应用程序中也不须要向规范大页那样须要显示指定 MAP_HUGETLB , 或者显示映射到 hugetlbfs 里的文件中。

通明大页的应用对用户齐全是通明的,内核会在背地为咱们主动做大页的映射,通明大页不须要像规范大页那样须要提前事后调配好大页内存池,通明大页的调配是动静的,由内核线程 khugepaged 负责在背地默默地将一般 4K 内存页整顿成内存大页给过程应用。然而如果因为内存碎片的因素,内核无奈整顿出内存大页,那么就会降级为应用一般 4K 内存页。然而通明大页这里会有一个问题,当碎片化重大的时候,内核会启动 kcompactd 线程去整顿碎片,冀望取得间断的内存用于大页调配,然而 compact 的过程可能会引起 sys cpu 飙高,应用程序卡顿。

通明大页是容许 swap 的,这一点和规范大页不同,在内存缓和须要 swap 的时候,通明大页会被内核默默拆分成一般 4K 内存页,而后 swap out 到磁盘。

通明大页只反对 2M 的大页,规范大页能够反对 1G 的大页,通明大页次要利用于匿名内存中,能够在 tmpfs 文件系统中应用。

在咱们比照完了通明大页与规范大页之间的区别之后,咱们当初来看一下如何应用通明大页,其实非常简单,咱们能够通过批改 /sys/kernel/mm/transparent_hugepage/enabled 配置文件来抉择开启或者禁用通明大页:

  • always 示意零碎全局开启通明大页 THP 性能。这意味着每个过程都会去尝试应用通明大页。
  • never 示意零碎全局敞开通明大页 THP 性能。过程将永远不会应用通明大页。
  • madvise 示意过程如果想要应用通明大页,须要通过 madvise 零碎调用并设置参数 advice 为 MADV_HUGEPAGE 来倡议内核,在 addr 到 addr+length 这片虚拟内存区域中,须要应用通明大页来映射。
#include <sys/mman.h>

int madvise(void addr, size_t length, int advice);

个别咱们会首先应用 mmap 先映射一段虚拟内存区域,而后通过 madvise 倡议内核,未来在缺页的时候,须要为这段虚拟内存映射通明大页。因为背地须要通过内核线程 khugepaged 来一直的扫描整顿零碎中的一般 4K 内存页,而后将他们拼接成一个大页来给过程应用,其中波及内存整理和回收等耗时的操作,且这些操作会在内存门路中加锁,而 khugepaged 内核线程可能会在谬误的工夫启动扫描和转换大页的操作,造成随机不可控的性能降落。

另外一点,通明大页不像规范大页那样是提前预调配好的,通明大页是在零碎运行时动态分配的,在内存缓和的时候,通明大页和一般 4K 内存页的调配过程一样,有可能会遇到间接内存回收(direct reclaim)以及间接内存整理(direct compaction),这些操作都是同步的并且十分耗时,会对性能造成十分大的影响。

后面在 cat /proc/meminfo 命令中显示的 AnonHugePages 就示意通明大页在零碎中的应用状况。另外咱们能够通过 cat /proc/pid/smaps | grep AnonHugePages 命令来查看某个过程对通明大页的应用状况。

总结

本文笔者从五个角度为大家具体介绍了 mmap 的应用办法及其在内核中的实现原理,这五个角度别离是:

  1. 公有匿名映射,其次要用于过程申请虚拟内存,以及初始化过程虚拟内存空间中的 BSS 段,堆,栈这些虚拟内存区域。
  2. 公有文件映射,其外围特点是背地映射的文件页在多过程之间是读共享的,多个过程对各自虚拟内存区的批改只能反馈到各自对应的文件页上,而且各自的批改在过程之间是互不可见的,最重要的一点是这些批改均不会回写到磁盘文件中。咱们能够利用这些特点来加载二进制可执行文件的 .text , .data section 到过程虚拟内存空间中的代码段和数据段中。
  3. 共享文件映射,多过程之间读写共享(不会产生写时复制),罕用于多过程之间共享内存(page cache),多过程之间的通信。
  4. 共享匿名映射,用于父子过程之间共享内存,父子过程之间的通信。父子过程之间须要依赖 tmpfs 中的匿名文件来实现共享内存。是一种非凡的共享文件映射。
  5. 大页内存映射,这里咱们介绍了规范大页与通明大页两种大页类型的区别与分割,以及他们各自的实现原理和应用办法。

在咱们分明了原理之后,笔者会在下篇文章为大家持续具体介绍 mmap 在内核中的源码实现,感激大家收看到这里,咱们下篇文章见~

正文完
 0