深入理解 Linux mmap 流程

2025-01-10 251 0

mmap 的全称是 memory map,中文意思是内存映射或地址映射,是 linux 操作系统中的一种系统调用,其作用是将一个文件或其他对象映射到进程的虚拟地址空间,实现磁盘地址和进程虚拟地址空间的一段虚拟地址进行一一对应关系。通过mmap系统调用,我们可以让进程之间通过映射到同一个普通文件实现共享内存。普通文件被映射到进程虚拟地址空间后,进程可以像访问普通内存一样对文件进行一系列操作,而不需要通过I/O系统调用来读取或写入。

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

mmap() 函数声明如下:

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

函数各个参数含义如下:
addr: 待映射的虚拟内存区域在进程虚拟内存空间中的起始地址(虚拟内存地址),通常设置成NULL,即交由内核决定虚拟地址映射区的起始地址(按照PAGE_SIZE对其)。
length: 待申请区域的内存区域大小,如果是匿名映射,则是要映射的匿名物理内存的大小,如果是文件映射,则指要映射的文件区域有多大。
prot: 映射区域保护模式。有 PORT_READ, PORT_WRITE, PORT_EXEC等。
flags: 标志位,可以操控映射区域的特性。常见的有 MAP_SHARED 和 MAP_PRIVATE 等。
fd: 文件描述符,用于指定映射的文件(由 open() 函数返回)。
offset: 映射的起始地址的偏移地址,通常设置为0,该值的大小为PAGE_SIZE的整数倍。

port取值的说明:
PORT_EXEC:映射的区域具有可执行权限
PROT_READ:映射的区域具有可读权限
PROT_WRITE:映射区域具有可写权限
PROT_NONE:映射区域不可被访问;

flags取值的说明:
MAP_SHARED:共享映射(用于多进程之间的通信),对映射区域的写入操作直接反映到文件当中
MAP_FIXED:若在start上无法创建映射则失败(如果没有此标记会自动创建)
MAP_PRIVATE:私有映射,对映射区域的写入操作只反映到缓冲区当中不会写入到真正的文件
MAP_ANONYMOUS:匿名映射将虚拟地址映射到物理内存而不是文件(忽略fd、offset)
MAP_DENYWRITE:拒绝其它文件的写入操作
MAP_LOCKED:锁定映射区域保证其不被置换
MAP_POPULATE:内核在分配完虚拟内存之后,会立即分配物理内存,并在进程页表中建立起虚拟内存与物理内存的映射关系
MAP_HUGETLB:用于大页内存映射;

mmap 映射过程

mmap内存映射的实现过程,总的来说可以分成三个阶段:

  1. 用户进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域。
    1. 进程在用户空间调用库函数mmap;
    2. 在当前进程的虚拟地址空间中,寻找一段空闲的满足需求的连续的虚拟地址区域;
    3. 为此虚拟内存区域分配一个vma结构,接着对这个结构的各个域进行初始化;
    4. 将建立的虚拟区结构vma插入进程的虚拟地址区域链表或红黑树中。
  2. 调用内核空间的系统调用函数mmap,实现文件物理地址和进程虚拟地址的一一映射关系。
    1. 为映射分配了新的虚拟地址区域后,通过待映射的文件指针,在文件描述符表中找到对应的文件描述符,通过文件描述符,链接到已打开文件集中该文件的文件结构体(struct file),每个文件结构体维护着和这个已打开文件的相关信息;
    2. 通过该文件的文件结构体,连接到file_operations模块,调用内核函数mmap,通过虚拟文件系统inode模块定位到文件磁盘物理地址;
    3. 通过remap_pfn_range函数建立页表,即实现了文件地址和虚拟地址区域的映射关系。此时,这片虚拟地址并没有任何数据关联到主存中;
  3. 进程发起对这片映射空间的访问,引发缺页异常,实现文件内容到物理内存(主存)的拷贝。
    1. 进程的读或写操作访问虚拟地址空间的这一段映射地址,通过查询页表,发现这一段地址并不在物理页面上。因为目前只建立了地址映射,真正的硬盘数据还没有拷贝到内存中,因此引发缺页异常;
    2. 缺页异常进行一系列判断,确定无非法操作后,内核发起请求调页过程;
    3. 调页过程先在交换缓存空间(swap cache)中寻找需要访问的内存页,如果没有则调用nopage函数把所缺的页从磁盘装入到主存中;
    4. 之后进程既可对这片主存进行读写操作,如果写操作改变了其内容,一定时间后系统会自动回写脏页面到对应磁盘地址,也即完成了写入到文件的过程;

注意: 修改过的脏页面并不会立即更新回文件中,而是有一段时间的延时,可以调用msync函数来强制同步,将修改过的内容立即保存到文件里。

mmap 的实现原理

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

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

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

  1. addr : 表示我们要映射的这段虚拟内存区域在进程虚拟内存空间中的起始地址(虚拟内存地址),但是这个参数只是给内核的一个暗示,内核并非一定得从我们指定的 addr 虚拟内存地址上划分虚拟内存区域,内核只不过在划分虚拟内存区域的时候会优先考虑我们指定的 addr,如果这个虚拟地址已经被使用或者是一个无效的地址,那么内核则会自动选取一个合适的地址来划分虚拟内存区域。我们一般会将 addr 设置为 NULL,意思就是完全交由内核来帮我们决定虚拟映射区的起始地址。

  2. 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 */
  1. PROT_READ 表示该虚拟内存区域背后映射的物理内存是可读的。
  2. PROT_WRITE 表示该虚拟内存区域背后映射的物理内存是可写的。
  3. PROT_EXEC 表示该虚拟内存区域背后映射的物理内存所存储的内容是可以被执行的,该内存区域内往往存储的是执行程序的机器码,比如进程虚拟内存空间中的代码段,以及动态链接库通过文件映射的方式加载进文件映射与匿名映射区里的代码段,这些 VMA 的权限就是 PROT_EXEC 。
  4. 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 创建出的这片虚拟内存区域背后所映射的物理内存能否在多进程之间共享,又分为了两种内存映射方式:

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

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

私有匿名映射

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 段也会采用私有匿名映射的方式加载到进程虚拟内存空间中。

私有文件映射

#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 。

只要我们找到了文件中的磁盘块,我们就可以寻址到文件在磁盘上的存储内容了,所以使用 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; 
}

当我们理清了内存系统和文件系统这些核心数据结构之间的关联关系之后,现在再来看,下面这幅 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);

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

共享文件映射

#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 目录下:

  1. dirty_writeback_centisecs 内核参数的默认值为 500。单位为 0.01 s。也就是说内核默认会每隔 5s 唤醒一次 flusher 线程来执行相关脏页的回写。
  2. drity_background_ratio :当脏页数量在系统的可用内存 available 中占用的比例达到 drity_background_ratio 的配置值时,内核就会唤醒 flusher 线程异步回写脏页。默认值为:10。表示如果 page cache 中的脏页数量达到系统可用内存的 10% 的话,就主动唤醒 flusher 线程去回写脏页到磁盘。
  3. dirty_background_bytes :如果 page cache 中脏页占用的内存用量绝对值达到指定的 dirty_background_bytes。内核就会唤醒 flusher 线程异步回写脏页。默认为:0。
  4. dirty_ratio : dirty_background_* 相关的内核配置参数均是内核通过唤醒 flusher 线程来异步回写脏页。下面要介绍的 dirty_* 配置参数,均是由用户进程同步回写脏页。表示内存中的脏页太多了,用户进程自己都看不下去了,不用等内核 flusher 线程唤醒,用户进程自己主动去回写脏页到磁盘中。当脏页占用系统可用内存的比例达到 dirty_ratio 配置的值时,用户进程同步回写脏页。默认值为:20 。
  5. dirty_bytes :如果 page cache 中脏页占用的内存用量绝对值达到指定的 dirty_bytes。用户进程同步回写脏页。默认值为:0。
  6. 内核为了避免 page cache 中的脏页在内存中长久的停留,所以会给脏页在内存中的驻留时间设置一定的期限,这个期限可由前边提到的 dirty_expire_centisecs 内核参数配置。默认为:3000。单位为:0.01 s。也就是说在默认配置下,脏页在内存中的驻留时间为 30 s。超过 30 s 之后,flusher 线程将会在下次被唤醒的时候将这些脏页回写到磁盘中。

根据 mmap 共享文件映射多进程之间读写共享(不会发生写时复制)的特点,常用于多进程之间共享内存(page cache),多进程之间的通讯。

共享匿名映射

#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 都是空的,一旦发生缺页,后面的流程就和共享文件映射一样了。我们可以把共享匿名映射看作成一种特殊的共享文件映射方式。

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

大页内存映射

在 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 参数含义:

  1. hugepagesz : 用于指定大页内存池中 HugePage 的 size,我们这里可以指定 hugepagesz=2M 或者 hugepagesz=1G,具体支持多少种大页尺寸由 CPU 架构决定。
  2. 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。

  1. 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 个数。

  1. nr_hugepages 表示当前系统中 default size 大小的 HugePage 个数,我们可以通过 echo HugePageNum > /proc/sys/vm/nr_hugepages 命令来动态增大或者缩小 HugePage (default size )个数。
  2. nr_overcommit_hugepages 表示当系统中的应用程序申请的大页个数超过 nr_hugepages 时,内核允许在额外申请多少个大页。当大页内存池中的大页个数被耗尽时,如果此时继续有进程来申请大页,那么内核则会从当前系统中选取多个连续的普通 4K 大小的内存页,凑出若干个大页来供进程使用,这些被凑出来的大页叫做 surplus_hugepage,surplus_hugepage 的个数不能超过 nr_overcommit_hugepages。当这些 surplus_hugepage 不在被使用时,就会被释放回内核中。nr_hugepages 个数的大页则会一直停留在大页内存池中,不会被释放,也不会被 swap。

以上介绍的是修改默认尺寸大小的 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:

  1. 一种是本文介绍的 mmap 系统调用,需要在 flags 参数中设置 MAP_HUGETLB。另外内核提供了额外的两个枚举值来配合 MAP_HUGETLB 一起使用,它们分别是 MAP_HUGE_2MB 和 MAP_HUGE_1GB。
    1. MAP_HUGETLB | MAP_HUGE_2MB 用于指定我们需要映射的是 2M 的大页。
    2. MAP_HUGETLB | MAP_HUGE_1GB 用于指定我们需要映射的是 1G 的大页。
    3. MAP_HUGETLB 表示按照 default_hugepagesz 指定的默认尺寸来映射大页。
  2. 另一种是 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 配置文件来选择开启或者禁用透明大页:

  1. always 表示系统全局开启透明大页 THP 功能。这意味着每个进程都会去尝试使用透明大页。
  2. never 表示系统全局关闭透明大页 THP 功能。进程将永远不会使用透明大页。
  3. 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 命令来查看某个进程对透明大页的使用情况。

参数 flags 的其他枚举值

在前边的几个小节中,笔者为大家介绍了 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 */

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

如果我们在使用 mmap 系统调用的时候设置了 MAP_POPULATE ,内核在分配完虚拟内存之后,就会马上分配物理内存,并在进程页表中建立起虚拟内存与物理内存的映射关系,这样进程在调用 mmap 之后就可以直接访问这段映射的虚拟内存地址了,不会发生缺页中断。

但是当系统内存资源紧张的时候,内核依然会将 mmap 背后映射的这块物理内存 swap out 到磁盘中,这样进程在访问的时候仍然会发生缺页中断,为了防止这种现象,我们可以在调用 mmap 的时候设置 MAP_LOCKED

现在我们了解了 mmap 内存映射的原理,按照惯例,应着手代码查看具体的实现流程,但是在查看代码之前,先了解两个概念:1. 文件映射与匿名映射区的布局;2. 内核的 overcommit 策略。方便对代码的理解。

文件映射与匿名映射区的布局

mmap 系统调用分配虚拟内存的本质其实就是在进程的虚拟内存空间中的文件映射与匿名映射区,找出一段未被映射过的空闲虚拟内存区域 vma,这个 vma 就是我们申请到的虚拟内存。

由此可以看出 mmap 主要的工作区域是在文件映射与匿名映射区,而在映射区查找空闲 vma 的过程又是和映射区的布局息息相关的,所以在为大家介绍虚拟内存分配流程之前,还是有必要介绍一下文件映射与匿名映射区的布局情况,这样方便大家后续理解虚拟内存分配的逻辑。

文件映射与匿名映射区的布局在 linux 内核中分为两种:一种是经典布局,另一种是新式布局,不同的体系结构可以通过内核参数 /proc/sys/vm/legacy_va_layout 来指定具体采用哪种布局。 1 表示采用经典布局, 0 表示采用新式布局。

在经典布局下,文件映射与匿名映射区的地址增长方向是从低地址到高地址,也就是说映射区是从下往上增长,这也就导致了 mmap 在分配虚拟内存的时候需要从下往上搜索空闲 vma。

经典布局下,文件映射与匿名映射区的起始地址 mm_struct->mmap_base 被设置在 task_size 的三分之一处,task_size 为进程虚拟内存空间与内核空间的分界线,也就说 task_size 是进程虚拟内存空间的末尾,大小为 3G。

这表明了文件映射与匿名映射区起始于进程虚拟内存空间开始的 1G 位置处,而映射区恰好位于整个进程虚拟内存空间的中间,其下方就是堆了,由于代码段,数据段的存在,可供堆进行扩展的空间是小于 1G 的,否则就会与映射区冲突了。

这种布局对于虚拟内存空间非常大的体系结构,比如 AMD64 , 是合适的而且会工作的非常好,因为虚拟内存空间足够的大(128T),堆与映射区都有足够的空间来扩展,不会发生冲突。

但是对于虚拟内存空间比较小的体系结构,比如 IA-32,只能提供 3G 大小的进程虚拟内存空间,就会出现上述冲突问题,于是内核在 2.6.7 版本引入了新式布局。

在新式布局下,文件映射与匿名映射区的地址增长方向是从高地址到低地址,也就是说映射区是从上往下增长,这也就导致了 mmap 在分配虚拟内存的时候需要从上往下搜索空闲 vma。

在新式布局中,栈的空间大小会被限制,栈最大空间大小保存在 task_struct->signal_struct->rlimp[RLIMIT_STACK] 中,我们可以通过修改 /etc/security/limits.conf 文件中 stack 配置项来调整栈最大空间的限制。

由于栈变为有界的了,所以文件映射与匿名映射区可以在栈的下方立即开始,为确保栈与映射区不会冲突,它们中间还设置了 1M 大小的安全间隙 stack_guard_gap。

这样一来堆在进程地址空间中较低的地址处开始向上增长,而映射区位于进程空间较高的地址处向下增长,因此堆区和映射区在新式布局下都可以较好的扩展,直到耗尽剩余的虚拟内存区域。

进程虚拟内存空间的创建以及初始化是由 load_elf_binary 函数负责的,当进程通过 fork() 系统调用创建出子进程之后,子进程可以通过前面介绍的 execve 系统调用加载并执行一个指定的二进制执行文件。

execve 函数会调用到 load_elf_binary,由 load_elf_binary 负责解析指定的 ELF 格式的二进制可执行文件,并将二进制文件中的 .text , .data 映射到新进程的虚拟内存空间中的代码段,数据段,BSS 段中。

随后会通过 setup_new_exec 创建文件映射与匿名映射区,设置映射区的起始地址 mm_struct->mmap_base,通过 setup_arg_pages 创建栈,设置  mm->start_stack 栈的起始地址(栈底)。这样新进程的虚拟内存空间就被创建了出来。

static int load_elf_binary(struct linux_binprm *bprm){
    // 创建文件映射与匿名映射区,设置映射区的起始地址 mm_struct->mmap_base
    setup_new_exec(bprm);
    // 创建栈,设置  mm->start_stack 栈的起始地址(栈底)
    retval = setup_arg_pages(bprm, randomize_stack_top(STACK_TOP),
                 executable_stack);
}

由于本文主要讨论的是 mmap 系统调用,mmap 最重要的一个任务就是在进程虚拟内存空间中的文件映射与匿名映射区划分出一段空闲的虚拟内存区域出来,而划分的逻辑是和文件映射与匿名映射区的布局强相关的,所以这里我们主要介绍文件映射与匿名映射区的布局情况,方便大家后续理解 mmap 分配虚拟内存的逻辑。

void setup_new_exec(struct linux_binprm * bprm){
    // 对文件映射与匿名映射区进行布局
    arch_pick_mmap_layout(current->mm, &bprm->rlim_stack);
}

文件映射与匿名映射区的布局分为两种,一种是经典布局,另一种是新布局。不同的体系结构可以通过设置 HAVE_ARCH_PICK_MMAP_LAYOUT 预处理符号,并提供 arch_pick_mmap_layout 函数的实现来在这两种不同布局之间进行选择。

// 定义在文件:/arch/x86/include/asm/processor.h
#define HAVE_ARCH_PICK_MMAP_LAYOUT 1
// 定义在文件:/arch/x86/mm/mmap.c
void arch_pick_mmap_layout(struct mm_struct *mm, struct rlimit *rlim_stack){
    if (mmap_is_legacy())
        // 经典布局下,映射区分配虚拟内存方法
        mm->get_unmapped_area = arch_get_unmapped_area;
    else
        // 新式布局下,映射区分配虚拟内存方法
        mm->get_unmapped_area = arch_get_unmapped_area_topdown;
    // 映射区布局
    arch_pick_mmap_base(&mm->mmap_base, &mm->mmap_legacy_base,
            arch_rnd(mmap64_rnd_bits), task_size_64bit(0),
            rlim_stack);
}

由于在经典布局下,文件映射与匿名映射区的地址增长方向是从低地址到高地址增长,在新布局下,文件映射与匿名映射区的地址增长方向是从高地址到低地址增长。

所以当 mmap 在文件映射与匿名映射区中寻找空闲 vma 的时候,会受到不同布局的影响,其寻找方向是相反的,因此不同的体系结构需要设置 HAVE_ARCH_UNMAPPED_AREA 预处理符号,并提供 arch_get_unmapped_area 函数的实现。这样一来,如果文件映射与匿名映射区采用的是经典布局,那么 mmap 就会通过这里的 arch_get_unmapped_area 来在映射区查找空闲的 vma。

如果文件映射与匿名映射区采用的是新布局,地址增长方向是从高地址到低地址增长。因此不同的体系结构需要设置 HAVE_ARCH_UNMAPPED_AREA_TOPDOWN 预处理符号,并提供 arch_get_unmapped_area_topdown 函数的实现。mmap 在新布局下则会通过这里的 arch_get_unmapped_area_topdown 函数在文件映射与匿名映射区寻找空闲 vma。

arch_get_unmapped_area 和 arch_get_unmapped_area_topdown 函数,内核都会提供默认的实现,不同体系结构如果没有特殊的定制需求,无需单独实现。

无论是经典布局下的 arch_get_unmapped_area,还是新布局下的 arch_get_unmapped_area_topdown 都会设置到 mm_struct->get_unmapped_area  这个函数指针中,后续 mmap 会利用这个 get_unmapped_area 来在文件映射与匿名映射区中划分虚拟内存区域 vma。

struct mm_struct {
  unsigned long (*get_unmapped_area) (struct file *filp,
     unsigned long addr, unsigned long len,
     unsigned long pgoff, unsigned long flags);
}

内核通过 mmap_is_legacy 函数来判断进程虚拟内存空间布局采用的是经典布局(返回 1)还是新式布局(返回 0)。

static int mmap_is_legacy(void){
    if (current->personality & ADDR_COMPAT_LAYOUT)
        return 1;

    return sysctl_legacy_va_layout;
}

首先内核会判断进程 struct task_struct 结构中的 personality 标志位是否设置为 ADDR_COMPAT_LAYOUT,如果设置了 ADDR_COMPAT_LAYOUT 标志则表示进程虚拟内存空间布局应该采用经典布局。

 #include <sys/personality.h>
 int personality(unsigned long persona);

 struct task_struct {
      // 通过系统调用 personality 设置 task_struct->personality 标志位
      unsigned int personality;
 }

task_struct->personality 如果没有设置 ADDR_COMPAT_LAYOUT,则继续判断 sysctl_legacy_va_layout 内核参数的值,如果为 1 则表示采用经典布局,为 0 则采用新式布局。

用户可通过设置 /proc/sys/vm/legacy_va_layout 内核参数来指定 sysctl_legacy_va_layout 变量的值。

当我们为 mmap 设置好了真正的 mm_struct->get_unmapped_area 函数指针之后,内核会调用 arch_pick_mmap_base 函数来进行具体的文件映射与匿名映射区的布局工作:

mmap 为进程分配虚拟内存的具体工作由这里的 get_unmapped_area 负责。

static void arch_pick_mmap_base(unsigned long *base, unsigned long *legacy_base,
        unsigned long random_factor, unsigned long task_size,
        struct rlimit *rlim_stack){
    // 对文件映射与匿名映射区进行经典布局,经典布局下映射区的起始地址设置在 mm_struct->mmap_legacy_base
    *legacy_base = mmap_legacy_base(random_factor, task_size);
    if (mmap_is_legacy())
        *base = *legacy_base;
    else
        // 对文件映射与匿名映射区进行新布局,无论在新布局下还是在经典布局下
        // 映射区的起始地址最终都会设置在 mm_struct->mmap_base
        *base = mmap_base(random_factor, task_size, rlim_stack);
}

mmap_legacy_base 负责对文件映射与匿名映射区进行经典布局,经典布局下,映射区的起始地址设置在 mm_struct->mmap_legacy_base 字段中。

mmap_base 负责对文件映射与匿名映射区进行新式布局,新布局下,映射区的起始地址设置在 mm_struct->mmap_base 字段中。

struct mm_struct {
        // 文件映射与匿名映射区的起始地址,无论在经典布局下还是在新布局下,起始地址最终都会设置在这里
        unsigned long mmap_base;    /* base of mmap area */
        // 文件映射与匿名映射区在经典布局下的起始地址
        unsigned long mmap_legacy_base; /* base of mmap area in bottom-up allocations */
        // 进程虚拟内存空间与内核空间的分界线(也是用户空间的结束地址)
        unsigned long task_size;    /* size of task vm space */
        // 用户空间中,栈顶位置
        unsigned long start_stack;
}

在经典布局下,文件映射与匿名映射区的起始地址 mmap_legacy_base 被设置为 __TASK_UNMAPPED_BASE,其值为 task_size 的三分之一,也就是说文件映射与匿名映射区起始于进程虚拟内存空间的三分之一处:

#define __TASK_UNMAPPED_BASE(task_size) (PAGE_ALIGN(task_size / 3))

static unsigned long mmap_legacy_base(unsigned long rnd,
           unsigned long task_size){
 return __TASK_UNMAPPED_BASE(task_size) + rnd;
}

如果我们开启了进程虚拟内存空间的随机化,全局变量 randomize_va_space 就会为 1,进程的 flags 标志将会设置为 PF_RANDOMIZE,表示对进程地址空间进行随机化布局。

我们可以通过调整内核参数 /proc/sys/kernel/randomize_va_space 的值来开启或者关闭进程虚拟内存空间布局随机化特性。

在开启进程地址空间随机化布局之后,进程虚拟内存空间中的文件映射与匿名映射区起始地址会加上一个随机偏移 rnd。

事实上,不仅仅文件映射与匿名映射区起始地址会加随机偏移 rnd,虚拟内存空间中的栈顶位置 STACK_TOP,堆的起始位置 start_brk,BSS  段的起始位置 elf_bss,数据段的起始位置 start_data,代码段的起始位置 start_code,都会加上一个随机偏移。

static int load_elf_binary(struct linux_binprm *bprm){
    // 是否开启进程地址空间的随机化布局
    if (!(current->personality & ADDR_NO_RANDOMIZE) && randomize_va_space)
        current->flags |= PF_RANDOMIZE;
    // 创建文件映射与匿名映射区,设置映射区的起始地址 mm_struct->mmap_base
    setup_new_exec(bprm);
    // 创建栈,设置  mm->start_stack 栈的起始地址(栈底)
    retval = setup_arg_pages(bprm, randomize_stack_top(STACK_TOP),
                 executable_stack);
}

内核中通过 arch_rnd 函数来获取进程地址空间随机化偏移值:

arch_pick_mmap_base(&mm->mmap_base, &mm->mmap_legacy_base,
            arch_rnd(mmap64_rnd_bits), task_size_64bit(0),
            rlim_stack);

static unsigned long arch_rnd(unsigned int rndbits){
    // 关闭进程地址空间随机化,偏移值就会为 0 
    if (!(current->flags & PF_RANDOMIZE))
        return 0;
    return (get_random_long() & ((1UL << rndbits) - 1)) << PAGE_SHIFT;
}

下面是文件映射与匿名映射区的新式布局,这里需要注意的是在新式布局下,映射区地址的增长方向是从高地址到低地址的,所以这里映射区的起始地址 mm->mmap_base 位于高地址处,从上往下增长。

进程虚拟内存空间中栈顶 STACK_TOP 的位置一般设置为 task_size,也就是说从进程地址空间的末尾开始向下增长,如果开启地址随机化特性,STACK_TOP 还需要再加上一个随机偏移 stack_maxrandom_size。

整个栈空间的最大长度设置在  rlim_stack->rlim_cur 中,在栈区和映射区之间,有一个 1M 大小的间隙 stack_guard_gap。

映射区的起始地址 mmap_base 与进程地址空间末尾 task_size 的间隔为 gap 大小,gap =  rlim_stack->rlim_cur  + stack_guard_gap。gap 的最小值为 128M,最大值为 (task_size / 6) * 5。

task_size 减去 gap 就是映射区起始地址 mmap_base 的位置,如果启用地址随机化特性,还需要在此基础上减去一个随机偏移 rnd。

// 栈区与映射区之间的间隔 1M
unsigned long stack_guard_gap = 256UL<<PAGE_SHIFT;

static unsigned long mmap_base(unsigned long rnd, unsigned long task_size,
                   struct rlimit *rlim_stack){
    // 栈空间大小
    unsigned long gap = rlim_stack->rlim_cur;
    // 栈区与映射区之间的间隔为 1M 大小,如果开启了地址随机化,还会加上一个随机偏移 stack_maxrandom_size
    unsigned long pad = stack_maxrandom_size(task_size) + stack_guard_gap;
    unsigned long gap_min, gap_max;

    // gap 在这里的语义是映射区的起始地址 mmap_base 距离进程地址空间的末尾 task_size 的距离
    if (gap + pad > gap)
        gap += pad;

    // gap 的最小值为 128M
    gap_min = SIZE_128M;
    // gap 的最大值
    gap_max = (task_size / 6) * 5;

    if (gap < gap_min)
        gap = gap_min;
    else if (gap > gap_max)
        gap = gap_max;
    // 映射区在新式布局下的起始地址 mmap_base,如果开启随机化,则需要在减去一个随机偏移 rnd
    return PAGE_ALIGN(task_size - gap - rnd);
}

内核的 overcommit 策略

正如前边笔者所介绍到的,内核的 overcommit 策略会影响到进程申请虚拟内存的用量,进程对虚拟内存的申请就好比是我们向银行贷款,我们在向银行贷款的时候,银行是需要对我们的还款能力进行审计的,我们抵押的资产越优质,银行贷款给我们的也会越多。

同样的道理,进程再向内核申请虚拟内存的时候,也是需要物理内存作为抵押的,因为虚拟内存说到底最终还是要映射到物理内存上的,背后需要物理内存作为支撑,不能无限制的申请。

所以进程在申请虚拟内存的时候,内核也是需要对申请的虚拟内存用量进行审计的,审计的对象就是那些在未来需要为其分配物理内存的虚拟内存。这也是符合常理的,因为只有在未来需要分配新的物理内存的时候,内核才需要综合物理内存的容量来进行审计,从而决定是否为进程分配这么多的虚拟内存,否则将来可能到处都是 OOM。如果未来不需要为这段虚拟内存分配物理内存,那么内核自然不会对虚拟内存用量进行审计。这取决于 mmap 的映射方式。

比如,这段虚拟内存是私有,可写的,那么在未来,当进程对这段虚拟内存进行写入的时候,内核会通过 cow 的方式为其分配新的物理内存,但是这段虚拟内存是共享的或者是只读的话,内核将不会为这段虚拟内存分配新的物理内存,而是继续共享原来已经映射好的物理内存(内核中只有一份)。

如果进程在向内核申请的虚拟内存在未来是需要重新分配物理内存的话,比如:私有,可写。那么这种虚拟内存的使用量就需要被内核审计起来,因为物理内存总是有限的,不可能为所有虚拟内存都分配物理内存。内核需要确保能够为这段虚拟内存未来分配足够的物理内存,防止 oom。这种虚拟内存称之为 account virtual memory

而进程向内核申请的虚拟内存并不需要内核为其重新分配物理内存的时候(共享或只读),反正不会增加物理内存的使用负担,这种虚拟内存就不需要被内核审计。

/*
 * We account for memory if it's a private writeable mapping,
 * not hugepages and VM_NORESERVE wasn't set.
 */
static inline int accountable_mapping(struct file *file, vm_flags_t vm_flags){
    /*     * hugetlb 类型的大页有其自己的统计方式,不会和普通的虚拟内存统计混合     */
    if (file && is_file_hugepages(file))
        return 0;
    // 私有,可写,并且没有设置 VM_NORESERVE 的相关 VMA 是需要被 account 审计起来的。这样在后续发生缺页的时候,不会导致 OOM
    return (vm_flags & (VM_NORESERVE | VM_SHARED | VM_WRITE)) == VM_WRITE;
}

由于大页内存都是被预先分配在大页内存池中的,所以针对大页的虚拟内存不需要被审计,另外如果这段虚拟内存 vma 设置了 VM_NORESERVE 标志的话,也不需要被内核审计。

所以 account virtual memory 特指那些私有,可写(private ,writeable)的虚拟内存区域,并且这些虚拟内存区域的 vm_flags 没有设置 VM_NORESERVE 标志位,以及这部分虚拟内存不能是映射大页的

这部分 account virtual memory 被记录在 vm_committed_as 字段中,表示被审计起来的虚拟内存,这些虚拟内存在未来都是需要映射新的物理内存的,站在物理内存的角度 vm_committed_as 可以理解为当前系统中已经分配的物理内存和未来可能需要的物理内存总量。

// 定义在文件:/include/linux/mman.h
extern struct percpu_counter vm_committed_as;

static inline void vm_acct_memory(long pages){
    percpu_counter_add_batch(&vm_committed_as, pages, vm_committed_as_batch);
}

static inline void vm_unacct_memory(long pages){
    vm_acct_memory(-pages);
}

每当有进程向内核申请或者释放虚拟内存(account virtual memory )的时候,内核都会通过 vm_acct_memory 和 vm_unacct_memory 函数来更新 vm_committed_as 的值。

当我们使用 mmap 进行内存映射的时候,如果映射出的虚拟内存区域 vma 为私有,可写的,并且参数 flags 没有设置 MAP_NORESERVE 标志,那么这部分虚拟内存就需要被记录在 vm_committed_as 字段中。

vm_committed_as 的值最终会反应在 /proc/meminfo 中的 Committed_AS 字段上。用来记录当前系统中,所有进程申请到的 account virtual memory 总量。

static int meminfo_proc_show(struct seq_file *m, void *v){
    struct sysinfo i;
    unsigned long committed;


    committed = percpu_counter_read_positive(&vm_committed_as);
  
    show_val_kb(m, "Committed_AS:   ", committed);
}

现在 account virtual memory 的概念我们清楚了,那么接下来就该来看一下,内核是如何对这部分虚拟内存的申请进行审计的(account)。

如果 accountable_mapping 函数返回值为 true,表示内核需要对当前进程申请的这部分虚拟内存进行审计,审计的逻辑封装在 __vm_enough_memory 函数中,返回 0 表示有足够的虚拟内存,返回 ENOMEM 表示虚拟内存不足。这里正是内核 overcommit 策略的核心实现。

我们可以通过内核参数 /proc/sys/vm/overcommit_memory 来调整 overcommit 策略 。

内核定义了如下三种 overcommit 策略:

#define OVERCOMMIT_GUESS  0
#define OVERCOMMIT_ALWAYS  1
#define OVERCOMMIT_NEVER  2

OVERCOMMIT_GUESS 是内核默认的 overcommit 策略,在这种策略下,进程对虚拟内存的申请不能超过物理内存总大小和 swap 交换区的总大小 之和。

    if (sysctl_overcommit_memory == OVERCOMMIT_GUESS) {
        if (pages > totalram_pages() + total_swap_pages)
            goto error;
        return 0;
    }

OVERCOMMIT_ALWAYS 策略下应用进程无论申请多大的虚拟内存,内核总是会答应,分配虚拟内存非常的激进。

 if (sysctl_overcommit_memory == OVERCOMMIT_ALWAYS)
        return 0;

OVERCOMMIT_NEVER 策略下,内核会严格控制进程申请虚拟内存的用量,虚拟内存的限制通过 vm_commit_limit 函数计算得出,一般情况下为 (总物理内存大小 - 大页占用的内存大小) * 50% + swap 交换区总大小。所有进程申请到的虚拟内存总量不能超过该值。

vm_commit_limit 函数返回值体现在 /proc/meminfo 中的 CommitLimit 字段中。

注意:只有在 OVERCOMMIT_NEVER 策略下,CommitLimit 的限制才会生效

除此之外,内核会在 CommitLimit 的基础上为进程预留一部分内存,用于在紧急情况下做一些恢复的操作,这部分预留的内存包括两种,一种是 sysctl_admin_reserve_kbytes,另一种是 sysctl_user_reserve_kbytes。它们的大小均可以在 /proc/sys/vm 目录下相应的配置文件中进行调整,单位为 KB。

  • sysctl_admin_reserve_kbytes 表示当进程拥有 root 权限的时候,内核需要为 root 相关的操作保留一部分内存,这样可以使进程在任何情况下都可以顺利执行 root 权限的相关操作。
  • sysctl_user_reserve_kbytes 用于在紧急情况下用户恢复系统。比如系统卡死,用户主动 kill 资源消耗比较大的进程,这个动作需要预留一些 user_reserve 内存。

所以在 OVERCOMMIT_NEVER 策略下,进程可以申请到的虚拟内存容量需要在 CommitLimit 的基础上再减去 sysctl_admin_reserve_kbytes 和 sysctl_user_reserve_kbytes 配置的预留容量。

注意这里对虚拟内存申请的限制是针对所有进程已经申请到的虚拟内存总量 + 本次 mmap 申请的虚拟内存总和的限制

// 用于检查进程虚拟内存空间中是否有足够的虚拟内存可供本次申请使用(需要结合 overcommit 策略来综合判定)
// 返回 0 表示有足够的虚拟内存,返回 ENOMEM 表示虚拟内存不足
int __vm_enough_memory(struct mm_struct *mm, long pages, int cap_sys_admin)
{
    // OVERCOMMIT_NEVER 模式下允许进程申请的虚拟内存大小
    long allowed;
    // 虚拟内存审计字段 vm_committed_as 增加 pages
    vm_acct_memory(pages);

    // 虚拟内存的 overcommit 策略可以通过修改 /proc/sys/vm/overcommit_memory 文件来设置,
    // 它有三个设置选项:
    // OVERCOMMIT_ALWAYS 表示无论应用进程申请多大的虚拟内存,内核总是会答应,分配虚拟内存非常的激进
    if (sysctl_overcommit_memory == OVERCOMMIT_ALWAYS)
        return 0;
    // OVERCOMMIT_GUESS 则相对 always 策略稍微保守一点,也是内核的默认策略
    // 它会对进程能够申请到的虚拟内存大小做一定的限制,特别激进的申请比如申请非常大的虚拟内存则会被拒绝。
    if (sysctl_overcommit_memory == OVERCOMMIT_GUESS) {
        // guess 默认策略下,进程申请的虚拟内存大小不能超过 物理内存总大小和 swap 交换区的总大小之和
        if (pages > totalram_pages() + total_swap_pages)
            goto error;
        return 0;
    }

    // OVERCOMMIT_NEVER 是最为严格的一种控制虚拟内存 overcommit 的策略
    // 进程申请的虚拟内存大小不能超过 vm_commit_limit(),该值也会反应在 /proc/meminfo 中的 CommitLimit 字段中。
    // 只有采用 OVERCOMMIT_NEVER 模式,CommitLimit 的限制才会生效
    // allowed =(总物理内存大小 - 大页占用的内存大小) * 50%  + swap 交换区总大小 
    allowed = vm_commit_limit();

    // cap_sys_admin 表示申请内存的进程拥有 root 权限
    if (!cap_sys_admin)
        // 为 root 进程保存一些内存,这样可以保证 root 相关的操作在任何时候都可以顺利进行
        // 大小为 sysctl_admin_reserve_kbytes,这部分内存普通进程不能申请使用
        // 可通过 /proc/sys/vm/admin_reserve_kbytes 来配置
        allowed -= sysctl_admin_reserve_kbytes >> (PAGE_SHIFT - 10);

    /*     * Don't let a single process grow so big a user can't recover     */
    if (mm) {
        // 可通过 /proc/sys/vm/user_reserve_kbytes 来配置
        // 用于在紧急情况下,用户恢复系统,比如系统卡死,用户主动 kill 资源消耗比较大的进程,这个动作需要预留一些 user_reserve 内存
        long reserve = sysctl_user_reserve_kbytes >> (PAGE_SHIFT - 10);

        allowed -= min_t(long, mm->total_vm / 32, reserve);
    }
    // Committed_AS (系统中所有进程已经申请的虚拟内存总量 + 本次 mmap 申请的)不可以超过 CommitLimit(allowed)
    if (percpu_counter_read_positive(&vm_committed_as) < allowed)
        return 0;
error:
    vm_unacct_memory(pages);

    return -ENOMEM;
}

下面我们来看一下,OVERCOMMIT_NEVER 策略下,CommitLimit 的计算逻辑。

有两个内核参数会影响 CommitLimit 的计算,它们分别是 sysctl_overcommit_kbytes 和 sysctl_overcommit_ratio,可通过 /proc/sys/vm 目录下相应的配置文件中进行调整。

如果我们配置了 overcommit_kbytes (单位为 KB), CommitLimit (单位为页)的值就是 sysctl_overcommit_kbytes >> (PAGE_SHIFT - 10) + total_swap_pages

如果我们没有配置 overcommit_kbytes,内核则会根据 overcommit_ratio 的值(默认为 50)计算 CommitLimit :``(总物理内存大小 - 大页占用的内存大小) * overcommit_ratio % + total_swap_pages`。

overcommit_kbytes 的优先级要大于 overcommit_ratio

/* * Committed memory limit enforced when OVERCOMMIT_NEVER policy is used */
unsigned long vm_commit_limit(void){
    // 允许申请的虚拟内存大小,单位为页
    unsigned long allowed;
    // 该值可通过 /proc/sys/vm/overcommit_kbytes 来修改
    // sysctl_overcommit_kbytes 设置的是 Committed memory limit 的绝对值
    if (sysctl_overcommit_kbytes)
        // 转换单位为页
        allowed = sysctl_overcommit_kbytes >> (PAGE_SHIFT - 10);
    else
        // sysctl_overcommit_ratio 该值可通过 /proc/sys/vm/overcommit_ratio 来修改,设置的 commit limit 的比例
        // 默认值为 50,(总物理内存大小 - 大页占用的内存大小) * 50%
        allowed = ((totalram_pages() - hugetlb_total_pages())
               * sysctl_overcommit_ratio / 100);

    // 最后都需要加上 swap 交换区的总大小
    allowed += total_swap_pages;
    // (总物理内存大小 - 大页占用的内存大小) * 50%  + swap 交换区总大小 
    return allowed;
}

现在 mmap 的主要工作区域:文件映射与匿名映射区在进程虚拟内存空间中的布局情况,我们已经清楚了。那么接下来,笔者会以 AMD64 体系结构的经典布局为基础,为大家介绍 mmap 是如何分配虚拟内存的。

第一阶段 虚拟内存创建

我们将进入到内核源码实现中,来看一下虚拟内存分配的过程,在这个过程中,我们还可以亲眼看到前面介绍的 mmap 内存映射原理在内核中具体是如何实现的,下面我们就从 mmap 系统调用的入口处来开始本文的内容。

首先看一下mmap内存映射的流程图,结合流程图再看函数实现,会更加清晰明了

Linux 的系统调用对应的函数全部都是由SYSCALL_DEFINE相关的宏定义的,mmap 的系统调用源码如下:

SYSCALL_DEFINE6(mmap, unsigned long, addr, unsigned long, len,
        unsigned long, prot, unsigned long, flags,
        unsigned long, fd, unsigned long, off)
{
    error = ksys_mmap_pgoff(addr, len, prot, flags, fd, off >> PAGE_SHIFT);
}

ksys_mmap_pgoff 预处理大页映射

unsigned long ksys_mmap_pgoff(unsigned long addr, unsigned long len,
                  unsigned long prot, unsigned long flags,
                  unsigned long fd, unsigned long pgoff)
{
    struct file *file = NULL;
    unsigned long retval;

    // 预处理文件映射
    if (!(flags & MAP_ANONYMOUS)) {
        // 根据 fd 获取映射文件的 struct file 结构
        audit_mmap_fd(fd, flags);
        file = fget(fd);
        if (!file)
            // 这里可以看出如果是匿名映射的话必须要指定 MAP_ANONYMOUS 否则这里就返回错误了
            return -EBADF;
        // 映射文件是否是 hugetlbfs 中的文件,hugetlbfs 中的文件默认由大页支持
        if (is_file_hugepages(file))
            // mmap 进行文件大页映射,len 需要和大页尺寸对齐
            len = ALIGN(len, huge_page_size(hstate_file(file)));
        retval = -EINVAL;
        // 这里可以看出如果想要使用 mmap 对文件进行大页映射,那么映射的文件必须是 hugetlbfs 中的
        // mmap 文件大页映射并不需要指定 MAP_HUGETLB,并且 mmap 不能对普通文件进行大页映射
        if (unlikely(flags & MAP_HUGETLB && !is_file_hugepages(file)))
            goto out_fput;
    } else if (flags & MAP_HUGETLB) {
        // 从这里我们可以看出 MAP_HUGETLB 只能支持 MAP_ANONYMOUS 匿名映射的方式使用 HugePage
        struct user_struct *user = NULL;
        // 内核中的大页池(预先创建)
        struct hstate *hs;
        // 选取指定大页尺寸的大页池(内核中存在不同尺寸的大页池)
        hs = hstate_sizelog((flags >> MAP_HUGE_SHIFT) & MAP_HUGE_MASK);
        if (!hs)
            return -EINVAL;
        // 映射长度 len 必须与大页尺寸对齐
        len = ALIGN(len, huge_page_size(hs));

        // 在 hugetlbfs 中创建 anon_hugepage 文件,并预留大页内存(禁止其他进程申请)
        file = hugetlb_file_setup(HUGETLB_ANON_FILE, len,
                VM_NORESERVE,
                &user, HUGETLB_ANONHUGE_INODE,
                (flags >> MAP_HUGE_SHIFT) & MAP_HUGE_MASK);
        if (IS_ERR(file))
            return PTR_ERR(file);
    }

    flags &= ~(MAP_EXECUTABLE | MAP_DENYWRITE);
    // 开始内存映射
    retval = vm_mmap_pgoff(file, addr, len, prot, flags, pgoff);
out_fput:
    if (file)
        // file 引用计数减 1
        fput(file);
    return retval;
}

ksys_mmap_pgoff 函数主要是针对 mmap 大页映射的情况进行预处理,从该函数对大页的预处理逻辑中我们可以提取出如下几个关键信息:

  1. 在使用 mmap 进行匿名映射的时候,必须在 flags 参数中指定 MAP_ANONYMOUS 标志,否则映射流程将会终止,并返回 EBADF 错误。
  2. mmap 在对文件进行大页映射的时候,映射文件必须是 hugetlbfs 中的文件,flags 参数无需设置 MAP_HUGETLB, mmap 不能对普通文件进行大页映射,这种映射方式必须提前手动挂载 hugetlbfs 文件系统到指定路径下。映射长度需要与大页尺寸进行对齐。
  3. MAP_HUGETLB 需要和 MAP_ANONYMOUS 配合一起使用,MAP_HUGETLB 只能支持匿名映射的方式来使用 HugePage,当 mmap 设置 MAP_HUGETLB 标志进行匿名大页映射的时候,在这里需要为进程在大页池(hstate)中预留好本次映射所需要的大页个数,注意此时只是预留,还并未分配给进程,大页池中被预留好的大页不能被其他进程使用。当进程发生缺页的时候,内核会直接从大页池中把这些提前预留好的内存映射到进程的虚拟内存空间中。

这部分被预留好的大页会记录在 cat /proc/meminfo 命令中的 HugePages_Rsvd 字段上。

在内核中,通过 is_file_hugepages 函数来判断映射文件是否由大页支持,我们在用户态使用的大页一般是由两种类型的系统调用来支持的:

  1. mmap 系统调用,背后依赖的是 hugetlbfs 文件系统,这种情况下只需要判断映射文件的 struct file 结构中定义的文件操作是否是 hugetlbfs 文件系统相关的操作,这样就可以确定出映射文件是否为 hugetlbfs 文件系统中的文件。
  2. SYSV 标准的系统调用 shmget 和 shmat,背后依赖 shm 文件系统,同理,只需要判断映射文件是否为 shm 文件系统中的文件即可。
static inline bool is_file_hugepages(struct file *file){
    // hugetlbfs 文件系统中的文件默认由大页支持
    // mmap 通过映射 hugetlbfs 中的文件实现文件大页映射
    if (file->f_op == &hugetlbfs_file_operations)
        return true;

    // 通过 shmat 使用匿名大页,这里不需要关注
    return is_file_shm_hugepages(file);
}

bool is_file_shm_hugepages(struct file *file){
     // SYSV 标准的系统调用 shmget 和 shmat 通过 shm 文件系统来共享内存
     // 通过 shmat 的方式使用大页会设置,这里我们不需要关注
    return file->f_op == &shm_file_operations_huge;
}

mm_populate 立即分配物理内存

在一般情况下,我们调用 mmap 进行内存映射的时候,内核只是会在进程的虚拟内存空间中为这次映射分配一段虚拟内存,然后建立好这段虚拟内存与相关文件之间的映射关系就结束了,内核并不会为映射分配物理内存。

而物理内存的分配工作需要延后到这段虚拟内存被 CPU 访问的时候,通过缺页中断来进入内核,分配物理内存,并在页表中建立好映射关系。

但是当我们调用 mmap 的时候,如果在 flags 参数中设置了 MAP_POPULATE 或者 MAP_LOCKED 标志位之后,物理内存的分配动作会提前发生。

首先会通过 do_mmap_pgoff 函数在进程虚拟内存空间中分配出一段未映射的虚拟内存区域,返回值 ret 表示映射的这段虚拟内存区域的起始地址。

紧接着就会调用 mm_populate,内核会在 mmap 刚刚映射出来的这段虚拟内存区域上,依次扫描这段 vma 中的每一个虚拟页,并对每一个虚拟页触发缺页异常,从而为其立即分配物理内存。

unsigned long vm_mmap_pgoff(struct file *file, unsigned long addr,
        unsigned long len, unsigned long prot,
        unsigned long flag, unsigned long pgoff)
{
    unsigned long ret;
    // 获取进程虚拟内存空间
    struct mm_struct *mm = current->mm;
    // 是否需要为映射的 VMA,提前分配物理内存页,避免后续的缺页
    // 取决于 flag 是否设置了 MAP_POPULATE 或者 MAP_LOCKED,这里的 populate 表示需要分配物理内存的大小
    unsigned long populate;
    // security_ 开头的,都是security linux 相关的,应该没有人的服务器或开这个,返回值为0
    ret = security_mmap_file(file, prot, flag);
    if (!ret) {
        // 对进程虚拟内存空间加写锁保护,防止多线程并发修改
        if (down_write_killable(&mm->mmap_sem))
            return -EINTR;
        // 开始 mmap 内存映射,在进程虚拟内存空间中分配一段 vma,并建立相关映射关系
        // ret 为映射虚拟内存区域的起始地址
        ret = do_mmap_pgoff(file, addr, len, prot, flag, pgoff,
                    &populate, &uf);
        // 释放写锁
        up_write(&mm->mmap_sem);
        if (populate)
            // 提前分配物理内存页面,后续访问不会缺页
            // 为 [ret , ret + populate] 这段虚拟内存立即分配物理内存
            mm_populate(ret, populate);
    }
    return ret;
}

mm_populate 函数的作用主要是在进程虚拟内存空间中,找出 [ret , ret + populate] 这段虚拟地址范围内的所有 vma,并为每一个 vma 填充物理内存。

int __mm_populate(unsigned long start, unsigned long len, int ignore_errors)
{
    struct mm_struct *mm = current->mm;
    unsigned long end, nstart, nend;
    struct vm_area_struct *vma = NULL;
    long ret = 0;

    end = start + len;

    // 依次遍历进程地址空间中 [start , end] 这段虚拟内存范围的所有 vma
    for (nstart = start; nstart < end; nstart = nend) {

              ........ 省略查找指定地址范围内 vma 的过程 ....

        // 为这段地址范围内的所有 vma 分配物理内存
        ret = populate_vma_page_range(vma, nstart, nend, &locked);
        // 继续为下一个 vma (如果有的话)分配物理内存
        nend = nstart + ret * PAGE_SIZE;
        ret = 0;
    }

    return ret; /* 0 or negative error code */
}

populate_vma_page_range 函数则是在 __mm_populate 的处理基础上,为指定地址范围 [start , end] 内的每一个虚拟内存页,通过 __get_user_pages 函数为其分配物理内存。

long populate_vma_page_range(struct vm_area_struct *vma,
        unsigned long start, unsigned long end, int *nonblocking){
    struct mm_struct *mm = vma->vm_mm;
    // 计算 vma 中包含的虚拟内存页个数,后续会按照 nr_pages 分配物理内存
    unsigned long nr_pages = (end - start) / PAGE_SIZE;
    int gup_flags;

    // 循环遍历 vma 中的每一个虚拟内存页,依次为其分配物理内存页
    return __get_user_pages(current, mm, start, nr_pages, gup_flags,
                NULL, NULL, nonblocking);
}

__get_user_pages 会循环遍历 vma 中的每一个虚拟内存页,首先会通过 follow_page_mask 在进程页表中查找该虚拟内存页背后是否有物理内存页与之映射,如果没有则调用 faultin_page,其底层会调用到 handle_mm_fault 进入缺页处理流程,内核在这里会为其分配物理内存页,并在进程页表中建立好映射关系。

static long __get_user_pages(struct task_struct *tsk, struct mm_struct *mm,
        unsigned long start, unsigned long nr_pages,
        unsigned int gup_flags, struct page **pages,
        struct vm_area_struct **vmas, int *nonblocking)
{
    long ret = 0, i = 0;
    struct vm_area_struct *vma = NULL;
    struct follow_page_context ctx = { NULL };

    if (!nr_pages)
        return 0;

    start = untagged_addr(start);
    // 循环遍历 vma 中的每一个虚拟内存页
    do {
        struct page *page;
        unsigned int foll_flags = gup_flags;
        unsigned int page_increm;
        // 在进程页表中检查该虚拟内存页背后是否有物理内存页映射
        page = follow_page_mask(vma, start, foll_flags, &ctx);
        if (!page) {
            // 如果虚拟内存页在页表中并没有物理内存页映射,那么这里调用 faultin_page
            // 底层会调用到 handle_mm_fault 进入缺页处理流程,分配物理内存,在页表中建立好映射关系
            ret = faultin_page(tsk, vma, start, &foll_flags,
                    nonblocking);

    } while (nr_pages);

    return i ? i : ret;
}

do_mmap 虚拟内存创建

do_mmap 是 mmap 系统调用的核心函数,内核会在这里完成内存映射的整个流程,其中最为核心的是如下两个方面的内容:

  1. get_unmapped_area 函数用于在进程地址空间中寻找出一段长度为 len,并且还未映射的虚拟内存区域 vma 出来。返回值 addr 表示这段虚拟内存区域的起始地址。
  2. mmap_region 函数是整个内存映射的核心,它首先会为这段选取出来的映射虚拟内存区域分配 vma 结构,并根据映射信息进行初始化,以及建立 vma 与相关映射文件的关系,最后将这段 vma 插入到进程的虚拟内存空间中。

除了这两个核心内容之外,do_mmap 函数还承担了对一些内存映射约束条件的检查,比如:内核规定一个进程虚拟内存空间内所能映射的虚拟内存区域 vma 是有数量限制的,sysctl_max_map_count 规定了进程虚拟内存空间所能包含 VMA 的最大个数,我们可以通过 /proc/sys/vm/max_map_count 内核参数来调整 sysctl_max_map_count。

进程虚拟内存空间中现有的虚拟内存区域 vma 个数保存在 mm_struct 结构的 map_count 字段中。

struct mm_struct {
    int map_count;   /* number of VMAs */
}

所以在内存映射开始之前,内核需要确保 mm->map_count 不能超过  sysctl_max_map_count 中规定的映射个数。

mmap 系统调用的本质其实就是在进程虚拟内存空间中划分出一段未映射的虚拟内存区域,随后内核会为这段映射出来的虚拟内存区域创建 vma 结构,并初始化 vma 结构的相关属性。

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

而 mmap 系统调用参数 prot (用于指定映射区域的访问权限),flags (指定内存映射方式),最终是要初始化进 vma 结构的 vm_flags 属性中。

struct vm_area_struct {
    unsigned long vm_flags; 
}

内核会通过 calc_vm_prot_bits 函数和 calc_vm_flag_bits 函数来分别将 mmap 系统调用中指定的参数 prot,flags 转换为 vm_ 前缀的标志位,随后一起设置到 vm_flags 中。

前面我们也提到了,如果我们在 flags 参数中设置了 MAP_LOCKED,那么 mmap 系统调用在分配完虚拟内存之后,会立即分配物理内存,并且分配的物理内存会一直驻留锁定在内存中,不会被 swap out 出去。

而在内核中,允许被锁定的物理内存容量是有规定限额的,所以在内存映射之前,内核还需要检查需要锁定的物理内存数量是否超过了规定的限额,如果超过了则会停止映射,返回 EPERM 或者 EAGAIN 错误。

我们可以通过修改 /etc/security/limits.conf 文件中的 memlock 相关配置项来调整能够被锁定的内存资源配额,设置为 unlimited 表示不对锁定内存进行限制。

进程的虚拟内存空间是非常庞大的,远远地超过真实物理内存容量,这就容易给我们造成一种错觉,就是当我们调用 mmap 为应用进程申请虚拟内存的时候,可以无限制的申请,反正都是虚拟的嘛,内核应该痛痛快快的给我们。

但事实上并非如此,内核会对我们申请的虚拟内存容量进行审计(account),结合当前物理内存容量以及 swap 交换区的大小来综合判断是否允许本次虚拟内存的申请。

内核对虚拟内存使用的审计策略定义在 sysctl_overcommit_memory 中,我们可以通过内核参数 /proc/sys/vm/overcommit_memory 来调整 。

内核定义了如下三个 overcommit 策略,这里的 commit 意思是需要申请的虚拟内存,overcommit 的意思是向内核申请过量的(远远超过物理内存容量)虚拟内存:

#define OVERCOMMIT_GUESS  0
#define OVERCOMMIT_ALWAYS  1
#define OVERCOMMIT_NEVER  2

  • OVERCOMMIT_GUESS 是内核的默认 overcommit 策略。在这种模式下,特别激进的,过量的虚拟内存申请将会被拒绝,内核会对虚拟内存能够过量申请多少做出一定的限制,这种策略既不激进也不保守,比较中庸。
  • OVERCOMMIT_ALWAYS 是最为激进的 overcommit 策略,无论进程申请多大的虚拟内存,只要不超过整个进程虚拟内存空间的大小,内核总会痛快的答应。但是这种策略下,虚拟内存的申请虽然容易了,但是当进程遇到缺页,内核为其分配物理内存的时候,会非常容易造成 OOM 。
  • OVERCOMMIT_NEVER 是最为严格的一种控制虚拟内存 overcommit 的策略,在这种模式下,内核会严格的规定虚拟内存的申请用量。

这里我们先对这三种 overcommit 策略做一个简单了解,具体内核在 OVERCOMMIT_GUESS  和 OVERCOMMIT_NEVER 模式下分别能够允许进程 overcommit 多少虚拟内存,笔者在后面相关源码章节在做详细分析。

当我们使用 mmap 系统调用进行虚拟内存申请的时候,会受到内核 overcommit 策略的影响,内核会综合物理内存的总体容量以及 swap 交换区的总体大小来决定是否允许本次虚拟内存用量的申请。mmap 申请过大的虚拟内存,内核会拒绝。

但是当我们在 mmap 系统调用参数 flags 中设置了 MAP_NORESERVE,则内核在分配虚拟内存的时候将不会考虑物理内存的总体容量以及 swap space 的限制因素,无论申请多大的虚拟内存,内核都会满足。但缺页的时候会容易导致 oom。

MAP_NORESERVE 只会在 OVERCOMMIT_GUESS 和 OVERCOMMIT_ALWAYS 模式下才有意义,因为如果内核本身是禁止 overcommit 的话,设置 MAP_NORESERVE 是无意义的。

在我们清楚了以上这些前置知识之后,再来看这段源码实现就非常好理解了:

unsigned long do_mmap(struct file *file, unsigned long addr,
            unsigned long len, unsigned long prot,
            unsigned long flags, vm_flags_t vm_flags,
            unsigned long pgoff, unsigned long *populate,
            struct list_head *uf)
{
    struct mm_struct *mm = current->mm;
    int pkey = 0;
    *populate = 0;

    if (!len)
        return -EINVAL;

    // 如果进程带有READ_IMPLIES_EXEC 标记且文件系统是可执行的,则这段内存空间使用 READ 的属性会附带怎加 EXEC 属性
    if ((prot & PROT_READ) && (current->personality & READ_IMPLIES_EXEC))
        if (!(file && path_noexec(&file->f_path)))
            prot |= PROT_EXEC;

    /* force arch specific MAP_FIXED handling in get_unmapped_area */
    if (flags & MAP_FIXED_NOREPLACE)
        flags |= MAP_FIXED;
    // 如果不使用固定地址,则使用的addr 会进行向下页对其
    if (!(flags & MAP_FIXED))
        addr = round_hint_to_min(addr);

    len = PAGE_ALIGN(len);
    if (!len)
        return -ENOMEM;

    /* Careful about overflows.. */
    if ((pgoff + (len >> PAGE_SHIFT)) << pgoff) //判断申请的内存是否溢出
        return -EOVERFLOW;

    // 一个进程虚拟内存空间内所能包含的虚拟内存区域 vma 是有数量限制的
    // sysctl_max_map_count 规定了进程虚拟内存空间所能包含 VMA 的最大个数
    // 可以通过 /proc/sys/vm/max_map_count 内核参数调整 sysctl_max_map_count
    // mmap 需要再进程虚拟内存空间中创建映射的 VMA,这里需要检查 VMA 的个数是否超过最大限制
    if (mm->map_count > sysctl_max_map_count)
        return -ENOMEM;

    // 在进程虚拟内存空间中寻找一块未映射的虚拟内存范围
    // 这段虚拟内存范围后续将会用于 mmap 内存映射
    addr = get_unmapped_area(file, addr, len, pgoff, flags);

    // 通过 calc_vm_prot_bits 和 calc_vm_flag_bits 将 mmap 参数 prot , flag 中   
    // 设置的访问权限以及映射方式等枚举值转换为统一的 vm_flags,后续一起映射进 VMA 的相应属性中,相应前缀转换为 VM_  
    vm_flags |= calc_vm_prot_bits(prot, pkey) | calc_vm_flag_bits(flags) |
            mm->def_flags | VM_MAYREAD | VM_MAYWRITE | VM_MAYEXEC;

    // 设置了 MAP_LOCKED,表示用户期望 mmap 背后映射的物理内存锁定在内存中,不允许 swap
    if (flags & MAP_LOCKED)
        // 这里需要检查是否可以将本次映射的物理内存锁定
        if (!can_do_mlock())
            return -EPERM;
    // 进一步检查锁定的内存页数是否超过了内核限制
    if (mlock_future_check(mm, vm_flags, len))
        return -EAGAIN;

    if (file) { // 文件映射
        struct inode *inode = file_inode(file);
        unsigned long flags_mask;

        if (!file_mmap_ok(file, inode, pgoff, len))
            return -EOVERFLOW;

        flags_mask = LEGACY_MAP_MASK | file->f_op->mmap_supported_flags;

        switch (flags & MAP_TYPE) {
        case MAP_SHARED: //共享映射
            // 强制使用带有 non-legacy 标志的 MAP_SHARED_VALIDATE.使用MAP_SHARED 忽略不支持的标志,以保持后续兼容性
            fallthrough;
        case MAP_SHARED_VALIDATE:
            if (flags & ~flags_mask)
                return -EOPNOTSUPP;
            if (prot & PROT_WRITE) {
                if (!(file->f_mode & FMODE_WRITE))
                    return -EACCES;
                if (IS_SWAPFILE(file->f_mapping->host))
                    return -ETXTBSY;
            }

            // 确保不向只追加的文件进行写入
            if (IS_APPEND(inode) && (file->f_mode & FMODE_WRITE))
                return -EACCES;

            vm_flags |= VM_SHARED | VM_MAYSHARE;
            if (!(file->f_mode & FMODE_WRITE))
                vm_flags &= ~(VM_MAYWRITE | VM_SHARED);
            fallthrough;
        case MAP_PRIVATE: // 私有文件映射
            if (!(file->f_mode & FMODE_READ)) // 如果文件不可读,报错
                return -EACCES;
            if (path_noexec(&file->f_path)) {
                if (vm_flags & VM_EXEC)
                    return -EPERM;
                vm_flags &= ~VM_MAYEXEC;
            }

            if (!file->f_op->mmap)
                return -ENODEV;
            if (vm_flags & (VM_GROWSDOWN|VM_GROWSUP))
                return -EINVAL;
            break;

        default:
            return -EINVAL;
        }
    } else { // 匿名映射
        switch (flags & MAP_TYPE) {
        case MAP_SHARED:
            if (vm_flags & (VM_GROWSDOWN|VM_GROWSUP))
                return -EINVAL;
            pgoff = 0; // 忽略 pgoff
            vm_flags |= VM_SHARED | VM_MAYSHARE;
            break;
        case MAP_PRIVATE:
            pgoff = addr >> PAGE_SHIFT; // 根据匿名 vma 的addr 设置 pgoff
            break;
        default:
            return -EINVAL;
        }
    }

    // 通常内核会为 mmap 申请虚拟内存的时候会综合考虑 ram 以及 swap space 的总体大小。
    // 当映射的虚拟内存过大,而没有足够的 swap space 的时候, mmap 就会失败。
    // 设置 MAP_NORESERVE,内核将不会考虑上面的限制因素
    // 这样当通过 mmap 申请大量的虚拟内存,并且当前系统没有足够的 swap space 的时候,mmap 系统调用依然能够成功
    if (flags & MAP_NORESERVE) {
        // 设置 MAP_NORESERVE 的目的是为了应用可以申请过量的虚拟内存
        // 如果内核本身是禁止 overcommit 的,那么设置 MAP_NORESERVE 是无意义的
        // 如果内核允许过量申请虚拟内存时(overcommit 为 0 或者 1)
        // 无论映射多大的虚拟内存,mmap 将会始终成功,但缺页的时候会容易导致 oom
        if (sysctl_overcommit_memory != OVERCOMMIT_NEVER)
            // 设置 VM_NORESERVE 表示无论申请多大的虚拟内存,内核总会答应
            vm_flags |= VM_NORESERVE;

        // 大页内存是提前预留出来的,并且本身就不会被 swap
        // 所以不需要像普通内存页那样考虑 swap space 的限制因素
        if (file && is_file_hugepages(file))
            vm_flags |= VM_NORESERVE;
    }
    // 这里就是 mmap 内存映射的核心
    addr = mmap_region(file, addr, len, vm_flags, pgoff, uf);

    // 当 mmap 设置了 MAP_POPULATE 或者 MAP_LOCKED 标志
    // 那么在映射完之后,需要立马为这块虚拟内存分配物理内存页,后续访问就不会发生缺页了
    if (!IS_ERR_VALUE(addr) &&
        ((vm_flags & VM_LOCKED) ||
         (flags & (MAP_POPULATE | MAP_NONBLOCK)) == MAP_POPULATE))
        // 设置需要分配的物理内存大小
        *populate = len;
    return addr;
}

当我们期望对 mmap 背后映射的物理内存进行锁定的时候,内核首先需要调用  can_do_mlock 函数,对能够锁定的物理内存资源配额进行判断,如果配额不足则不能对本次映射的物理内存进行锁定,mmap 返回 EPERM 错误,流程结束。

bool can_do_mlock(void){
    // 内核会限制能够被锁定的内存资源大小,单位为bytes
    // 这里获取 RLIMIT_MEMLOCK 能够锁定的内存资源,如果为 0 ,则不能够锁定内存了。
    // 我们可以通过修改 /etc/security/limits.conf 文件中的 memlock 相关配置项
    // 来调整能够被锁定的内存资源配额,设置为 unlimited 表示不对锁定内存进行限制
    if (rlimit(RLIMIT_MEMLOCK) != 0)
        return true;
    // 检查内核是否允许 mlock ,mlockall 等内存锁定操作
    if (capable(CAP_IPC_LOCK))
        return true;
    return false;
}

进程的相关资源限制配额定义在 task_struct->signal_struct->rlim 数组中:

struct task_struct {
  struct signal_struct *signal;
}

struct signal_struct {
  // 进程相关的资源限制,相关的资源限制以数组的形式组织在 rlim 中
  // RLIMIT_MEMLOCK 下标对应的是进程能够锁定的内存资源,单位为bytes
  struct rlimit rlim[RLIM_NLIMITS];
}

struct rlimit {
 __kernel_ulong_t rlim_cur;
 __kernel_ulong_t rlim_max;
};

内核中通过 rlimit 函数获取进程相关的资源限制:

// 定义在文件:/include/linux/sched/signal.h
static inline unsigned long rlimit(unsigned int limit){
    // 参数 limit 为相关资源的下标
    return task_rlimit(current, limit);
}

static inline unsigned long task_rlimit(const struct task_struct *task,
        unsigned int limit){
    return READ_ONCE(task->signal->rlim[limit].rlim_cur);
}

当通过 can_do_mlock 的检验之后,内核还需要近一步通过 mlock_future_check 函数来检查本次映射需要锁定的物理内存页数加上进程已经锁定的物理内存页数总体上是否超过了内存资源锁定限额 rlimit(RLIMIT_MEMLOCK)。如果已经超过限额,本次 mmap 流程就会停止。

static inline int mlock_future_check(struct mm_struct *mm,
                     unsigned long flags,
                     unsigned long len){
    unsigned long locked, lock_limit;

    if (flags & VM_LOCKED) {
        // 需要锁定的内存页数
        locked = len >> PAGE_SHIFT;
        // 更新进程内存空间中已经锁定的内存页数
        locked += mm->locked_vm;
        // 获取内核还能允许锁定的内存页数
        lock_limit = rlimit(RLIMIT_MEMLOCK);        
        lock_limit >>= PAGE_SHIFT;
        // 如果超出允许锁定的内存限额,那么就返回错误
        if (locked > lock_limit && !capable(CAP_IPC_LOCK))
            return -EAGAIN;
    }
    return 0;
}

get_unmapped_area 虚拟内存的分配

get_unmapped_area 主要的目的就是在具体的映射区布局下,根据布局特点,真正负责划分虚拟内存区域的函数。经过上一小节的介绍我们知道,在经典布局下,mm->get_unmapped_area 指向的函数为 arch_get_unmapped_area。

如果 mmap 进行的是私有匿名映射,那么内核会通过 mm->get_unmapped_area 函数进行虚拟内存的分配。

如果 mmap 进行的是文件映射,那么内核则采用的是特定于文件系统的 file->f_op->get_unmapped_area 函数。比如,我们通过 mmap 映射的是 ext4 文件系统下的文件,那么 file->f_op->get_unmapped_area 指向的是 thp_get_unmapped_area 函数,专门为 ext4 文件映射申请虚拟内存。

const struct file_operations ext4_file_operations = {
        .mmap           = ext4_file_mmap
        .get_unmapped_area = thp_get_unmapped_area,
};

如果 mmap 进行的是共享匿名映射,由于共享匿名映射的本质其实是基于 tmpfs 的虚拟文件系统中的匿名文件进行的共享文件映射,所以这种情况下 get_unmapped_area 函数是需要基于 tmpfs 的虚拟文件系统的,在共享匿名映射的情况下 get_unmapped_area 指向 shmem_get_unmapped_area 函数。

unsigned long get_unmapped_area(struct file *file, unsigned long addr, unsigned long len,
                  unsigned long pgoff, unsigned long flags){
    // 在进程虚拟空间中寻找还未被映射的 VMA 这段核心逻辑是被内核实现在特定于体系结构的函数中
    // 该函数指针用于指向真正的 get_unmapped_area 函数
    // 在经典布局下,真正的实现函数为 arch_get_unmapped_area
    unsigned long (*get_area)(struct file *, unsigned long,
                   unsigned long, unsigned long, unsigned long);

    // 映射的虚拟内存区域长度不能超过进程的地址空间
    if (len > TASK_SIZE)
        return -ENOMEM;
    // 如果是匿名映射,则采用 mm_struct 中保存的特定于体系结构的 arch_get_unmapped_area 函数
    get_area = current->mm->get_unmapped_area;
    if (file) {
        // 如果是文件映射话,则需要使用 file->f_op 中的 get_unmapped_area,来为文件映射申请虚拟内存
        // file->f_op 保存的是特定于文件系统中文件的相关操作
        if (file->f_op->get_unmapped_area)
            get_area = file->f_op->get_unmapped_area;
    } else if (flags & MAP_SHARED) {
        // 共享匿名映射是通过在 tmpfs 中创建的匿名文件实现的
        // 所以这里也有其专有的 get_unmapped_area 函数
        pgoff = 0;
        get_area = shmem_get_unmapped_area;
    }
    
    // 在进程虚拟内存空间中,根据指定的 addr,len 查找合适的VMA
    addr = get_area(file, addr, len, pgoff, flags);
    if (IS_ERR_VALUE(addr))
        return addr;
    // VMA 区域不能超过进程地址空间
    if (addr > TASK_SIZE - len)
        return -ENOMEM;
    // addr 需要与 page size 对齐
    if (offset_in_page(addr))
        return -EINVAL;

    return error ? error : addr;
}

如果我们仔细观察 ext4 文件系统下的 thp_get_unmapped_area 函数以及 tmpfs 虚拟文件系统下的 shmem_get_unmapped_area,会发现,它们最终都会调用到 mm->get_unmapped_area 函数指针指向的函数。

const struct file_operations ext4_file_operations = {
        .mmap           = ext4_file_mmap
        .get_unmapped_area = thp_get_unmapped_area,
};


unsigned long __thp_get_unmapped_area(struct file *filp, unsigned long len,
                loff_t off, unsigned long flags, unsigned long size)
{
        ........... 省略 ........

        addr = current->mm->get_unmapped_area(filp, 0, len_pad,
                                              off >> PAGE_SHIFT, flags);
        return addr;
}
unsigned long shmem_get_unmapped_area(struct file *file,
                      unsigned long uaddr, unsigned long len,
                      unsigned long pgoff, unsigned long flags)
{
    unsigned long (*get_area)(struct file *,
        unsigned long, unsigned long, unsigned long, uns

         ........... 省略 ........

    get_area = current->mm->get_unmapped_area;
    
    return addr;
}

在经典布局下,mm->get_unmapped_area 指向的是 arch_get_unmapped_area 函数,mmap 虚拟内存分配的秘密就隐藏在这里:

首先我们需要明确一下,mmap 可以映射的虚拟内存范围必须在进程虚拟内存空间 mmap_min_addr 到 mmap_end 这段地址范围内,mmap_min_addr 为 TASK_SIZE 的三分之一,mmap_end 为 TASK_SIZE。

内核需要检查本次 mmap 映射的虚拟内存长度 len 是否超过了规定的映射范围,如果超过了则返回 ENOMEM 错误,并停止映射流程。

如果映射长度 len 在规定的映射地址范围内,内核则会根据我们指定的映射起始地址 addr,以及映射长度 len,开始在文件映射与匿名映射区,为本次 mmap 映射寻找一段空闲的虚拟内存区域 vma 出来。

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

如果在 flags 参数中指定了 MAP_FIXED 标志,则意味着我们强制要求内核在我们指定的起始地址 addr 处开始映射 len 长度的虚拟内存区域,无论这段虚拟内存区域 [addr , addr + len] 是否已经存在映射关系,内核都会强行进行映射,如果这块区域已经存在映射关系,那么后续内核会把旧的映射关系覆盖掉。

如果我们指定了 addr,但是并没有指定 MAP_FIXED,则意味着我们只是建议内核优先考虑从我们指定的 addr 地址处开始映射,但是如果 [addr , addr+len] 这段虚拟内存区域已经存在映射关系,内核则不会按照我们指定的 addr 开始映射,而是会自动查找一段空闲的 len 长度的虚拟内存区域。这一部分的工作由 vm_unmapped_area 函数承担。

如果通过查找发现, [addr , addr+len] 这段虚拟内存地址范围并未存在任何映射关系,那么 addr 就会作为 mmap 映射的起始地址。这里面会分为两种情况:

  1. 第一种是我们指定的 addr 比较大,addr 位于文件映射与匿名映射区中所有映射区域 vma 的最后面,这样一来,[addr , addr + len] 这段地址范围当然是空闲的了。
  2. 第二种情况是我们指定的 addr 恰好位于一个 vma 和另一个 vma 中间的地址间隙中,并且这个地址间隙刚好大于或者等于我们指定的映射长度 len。内核就可以将这个地址间隙映射起来。

// 内核标准实现 
unsigned long arch_get_unmapped_area(struct file *filp, unsigned long addr,
         unsigned long len, unsigned long pgoff, unsigned long flags){
    struct mm_struct *mm = current->mm;
    struct vm_area_struct *vma, *prev;
    struct vm_unmapped_area_info info;
    // 进程虚拟内存空间的末尾 TASK_SIZE
    const unsigned long mmap_end = arch_get_mmap_end(addr);
    // 映射区域长度是否超过进程虚拟内存空间
    if (len > mmap_end - mmap_min_addr)
        return -ENOMEM;
    // 如果我们指定了 MAP_FIXED 表示必须要从我们指定的 addr 开始映射 len 长度的区域
    // 如果这块区域已经存在映射关系,那么后续内核会把旧的映射关系覆盖掉
    if (flags & MAP_FIXED)
        return addr;

    // 没有指定 MAP_FIXED,但是我们指定了 addr
    // 我们希望内核从我们指定的 addr 地址开始映射,内核这里会检查我们指定的这块虚拟内存范围是否有效
    if (addr) {
        // addr 先保证与 page size 对齐
        addr = PAGE_ALIGN(addr);
        // 内核这里需要确认一下我们指定的 [addr, addr+len] 这段虚拟内存区域是否存在已有的映射关系
        // [addr, addr+len] 地址范围内已经存在映射关系,则不能按照我们指定的 addr 作为映射起始地址
        // 在进程地址空间中查找第一个符合 addr < vma->vm_end  条件的 VMA
        // 如果不存在这样一个 vma(!vma), 则表示 [addr, addr+len] 这段范围的虚拟内存是可以使用的,内核将会从我们指定的 addr 开始映射
        // 如果存在这样一个 vma ,则表示  [addr, addr+len] 这段范围的虚拟内存区域目前已经存在映射关系了,不能采用 addr 作为映射起始地址
        // 这里还有一种情况是 addr 落在 prev 和 vma 之间的一块未映射区域
        // 如果这块未映射区域的长度满足 len 大小,那么这段未映射区域可以被本次使用,内核也会从我们指定的 addr 开始映射
        vma = find_vma_prev(mm, addr, &prev);
        if (mmap_end - len >= addr && addr >= mmap_min_addr &&
            (!vma || addr + len <= vm_start_gap(vma)) &&
            (!prev || addr >= vm_end_gap(prev)))
            return addr;
    }

    // 如果我们明确指定 addr 但是指定的虚拟内存范围是一段无效的区域或者已经存在映射关系
    // 那么内核会自动在地址空间中寻找一段合适的虚拟内存范围出来
    // 这段虚拟内存范围的起始地址就不是我们指定的 addr 了
    info.flags = 0;
    // VMA 区域长度
    info.length = len;
    // 这里定义从哪里开始查找 VMA, 这里我们会从文件映射与匿名映射区开始查找
    info.low_limit = mm->mmap_base;
    // 查找结束位置为进程地址空间的末尾 TASK_SIZE
    info.high_limit = mmap_end;
    info.align_mask = 0;
    return vm_unmapped_area(&info);
}

find_vma_prev 查找是否有重叠的映射区域

find_vma_prev 的作用就是根据我们指定的映射起始地址 addr,在进程地址空间中查找出符合 addr < vma->vm_end 条件的第一个 vma 出来(下图中的蓝色部分)。

然后在进程地址空间中的 vma 链表 mmap 中,找出它的前驱节点 pprev (上图中的绿色部分)。

struct mm_struct {
    struct vm_area_struct *mmap;  /* list of VMAs */
}

如果不存在这样一个 vma(addr < vma->vm_end),那么内核直接从我们指定的 addr 地址处开始映射就好了,这时 pprev 指向进程地址空间中最后一个 vma。

如果存在这样一个 vma,那么内核就会判断,该 vma 与其前驱节点 pprev 之间的地址间隙 gap 是否能容纳下一段 len 长度的映射区间,如果可以,那么内核就映射在这个地址间隙 gap 中。如果不可以,内核就需要在 vm_unmapped_area 函数中重新到整个进程地址空间中查找出一个 len 长度的空闲映射区域,这种情况下映射区的起始地址就不是我们指定的 addr 了。

struct vm_area_struct *find_vma_prev(struct mm_struct *mm, unsigned long addr,
            struct vm_area_struct **pprev){
    struct vm_area_struct *vma;
    // 在进程地址空间 mm 中查找第一个符合 addr < vma->vm_end 的 VMA
    vma = find_vma(mm, addr);

    if (vma) {
        // 恰好包含 addr 的 VMA 的前一个虚拟内存区域 
        *pprev = vma->vm_prev;
    } else {
        // 如果当前进程地址空间中,addr 不属于任何一个 VMA 
        // 那么这里的 pprev 指向进程地址空间中最后一个 VMA
        struct rb_node *rb_node = rb_last(&mm->mm_rb);

        *pprev = rb_node ? rb_entry(rb_node, struct vm_area_struct, vm_rb) : NULL;
    }
    // 返回查找到的 vma,不存在则返回 null(内核后续会创建 VMA)
    return vma;
}

根据指定地址 addr 在进程地址空间中查找第一个符合 addr < vma->vm_end 条件 vma 的操作在 find_vma 函数中进行,内核为了高效地在进程地址空间中查找特定条件的 vma,会按照地址的增长方向将所有的 vma 组织在一颗红黑树 mm_rb 中。

struct mm_struct {
     struct rb_root mm_rb;
}

find_vma 会根据我们指定的 addr 在这颗红黑树中查找第一个符合 addr < vma->vm_end 条件的 vma 。

/* Look up the first VMA which satisfies  addr < vm_end,  NULL if none. */
struct vm_area_struct *find_vma(struct mm_struct *mm, unsigned long addr){
    struct rb_node *rb_node;
    struct vm_area_struct *vma;

    // 进程地址空间中缓存了最近访问过的 VMA
    // 首先从进程地址空间中 VMA 缓存中开始查找,缓存命中率通常大约为 35%
    // 查找条件为:vma->vm_start <= addr && vma->vm_end > addr
    vma = vmacache_find(mm, addr);
    if (likely(vma))
        return vma;

    // 进程地址空间中的所有 VMA 被组织在一颗红黑树中,为了方便内核在进程地址空间中查找特定的 VMA
    // 这里首先需要获取红黑树的根节点,内核会从根节点开始查找
    rb_node = mm->mm_rb.rb_node;

    while (rb_node) {
        struct vm_area_struct *tmp;
        // 获取位于根节点的 VMA
        tmp = rb_entry(rb_node, struct vm_area_struct, vm_rb);

        if (tmp->vm_end > addr) {
            vma = tmp;
            // 判断 addr 是否恰好落在根节点 VMA 中: vm_start <= addr < vm_end
            if (tmp->vm_start <= addr)
                break;
            // 如果不存在,则继续到左子树中查找
            rb_node = rb_node->rb_left;
        } else
            // 如果根节点的 vm_end <= addr,说明 addr 在根节点 vma 的后边
            // 这种情况则到右子树中继续查找
            rb_node = rb_node->rb_right;
    }

    if (vma)
        // 更新 vma 缓存
        vmacache_update(addr, vma);
    // 返回查找到的 vma,如果没有查找到,则返回 Null,表示进程空间中目前还没有这样一个 VMA ,后续需要新建了。
    return vma;
}

如果我们找到的这个 vma 与 [addr , addr +len] 这段虚拟地址范围有重叠的部分,那么内核就不能按照我们指定的 addr 开始映射,内核需要重新在文件映射与匿名映射区中按照地址的增长方向,找到一段 len 大小的空闲虚拟内存区域。这一部分的逻辑由 vm_unmapped_area 函数承担。

vm_unmapped_area 寻找未映射的虚拟内存区域

/*
 * Search for an unmapped address range.
 *
 * We are looking for a range that:
 * - does not intersect with any VMA;
 * - is contained within the [low_limit, high_limit) interval;
 * - is at least the desired size.
 * - satisfies (begin_addr & align_mask) == (align_offset & align_mask) */
static inline unsigned long vm_unmapped_area(struct vm_unmapped_area_info *info){
    // 按照进程虚拟内存空间中文件映射与匿名映射区的地址增长方向
    // 分为两个函数,来在进程地址空间中查找未映射的 VMA
    if (info->flags & VM_UNMAPPED_AREA_TOPDOWN)
        // 当文件映射与匿名映射区的地址增长方向是从上到下逆向增长时(新式布局)
        // 采用 topdown 后缀的函数查找
        return unmapped_area_topdown(info);
    else
        // 地址增长方向为从下倒上正向增长(经典布局),采用该函数查找
        return unmapped_area(info);
}

本文是以 AMD64 体系为例展开讨论的,在 AMD64 体系结构下,文件映射与匿名映射区的布局采用的是经典布局,地址的增长方向从低地址到高地址增长。因此这里我们选择 unmapped_area 函数。

我们苦苦寻找的 unmapped_area 一定是在文件映射与匿名映射区中某个 vma 与其前驱 vma 之间的地址间隙 gap 中产生的。

所以这就要求这个 gap 的长度必须大于等于映射 length,这样才能容纳下我们要映射的长度。gap 的起始地址 gap_start 一般从 prev 节点的末尾开始:gap_start = vma->vm_prev->vm_end 。gap 的结束地址 gap_end 一般从 vma 的起始地址结束:gap_end = vma->vm_start 。

在此基础之上,gap 还会受到 low_limit(mm->mmap_base)和 high_limit(TASK_SIZE)的地址限制。

因此这个 gap 的起始地址 gap_start 不能高于 high_limit - length,否则我们从 gap_start 地址处开始映射长度 length 的区域就会超出 high_limit 的限制。

gap 的结束地址 gap_end 不能低于 low_limit  + length,否则映射区域的起始地址就会低于 low_limit 的限制。

unmapped_area 函数的核心任务就是在管理进程地址空间这些 vma 的红黑树 mm_struct-> mm_rb 中找到这样的一个地址间隙 gap 出来。

首先内核会从红黑树中的根节点 vma 开始查找,判断根节点的 vma 与其前驱节点 vma->vm_prev 之间的地址间隙 gap 是否满足上述条件,如果根节点 vma 的起始地址 vma->vm_start 也就是 gap_end 低于了 low_limit  + length 的限制,那就说明根节点 vma 与其前驱节点之间的 gap 不适合用来作为 unmapped_area,否则 unmapped_area 的起始地址 gap_start 就会低于 low_limit 的限制。

由于红黑树是按照 vma 的地址增长方向来组织的,左子树中的所有 vma 地址都低于根节点 vma 的地址,右子树的所有 vma 地址均高于根节点 vma 的地址。

现在的情况是 vma->vm_start 的地址太低了,已经小于了 low_limit  + length 的限制,所以左子树的 vma 就不用看了,直接从右子树中去查找。

如果根节点 vma 的起始地址 vma->vm_start 也就是 gap_end 高于 low_limit  + length 的要求,说明 gap_end 是符合我们的要求的,但是目前我们还不能马上对 gap_start 的限制要求进行检查,因为我们需要按照地址从低到高的优先级来查看最合适的 unmapped_area 未映射区域,所以我们需要到左子树中去查找地址更低的 vma。

如果我们在左子树中找到了一个地址最低的 vma,并且这个 vma 与其前驱节点vma->vm_prev 之间的地址间隙 gap 符合上述的三个条件:

  1. gap 的长度大于等于映射长度 length : gap_end - gap_start >= length
  2. gap_end >=  low_limit  + length 。
  3. gap_start  <= high_limit - length。

这里内核还有一个小小的优化点,如果我们遍历完了当前 vma 节点的所有子树(包括左子树和右子树)依然无法找到一个 gap 的长度可以满足我们的映射长度: gap_end - gap_start < length。那我们不是白白遍历了整棵树吗?

能否有一种机制,使我们通过当前 vma 就可以知道其子树中的所有 vma 节点与其前驱节点 vma->vm_prev 之间的地址间隙 gap 的最大长度(包括当前 vma)。

这样我们在遍历一个 vma 节点的时候,只需要检查一下其左右子树中的最大 gap 长度是否能够满足映射长度 length ,如果不能满足,说明整棵树中的 vma 节点与其前驱节点之间的间隙都不能容纳我们要映射的长度,直接就不用遍历了。

事实上,内核会将一个 vma 节点以及它所有子树中存在的最大间隙 gap 保存在 struct vm_area_struct 结构中的 rb_subtree_gap 属性中:

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 within vm_mm. */

    /* linked list of VM areas per task, sorted by address */
    struct vm_area_struct *vm_next, *vm_prev;

    struct rb_node vm_rb;

    // 在当前 vma 的红黑树左右子树中的所有节点 vma (包括当前 vma)
    // 这个集合中的 vma 与其 vm_prev 之间最大的虚拟内存地址 gap (单位字节)保存在 rb_subtree_gap 字段中
    unsigned long rb_subtree_gap;
}

当我们遍历 vma 节点的时候发现:vma->rb_subtree_gap < length。那么整棵红黑树都不需要看了,我们直接从进程地址空间中最后一个 vma->vm_end 处开始映射就好了。

当前进程虚拟内存空间中,地址最高的一个 VMA 的结束地址位置保存在 mm_struct 结构中的 highest_vm_end 属性中:

struct mm_struct {
    // 当前进程虚拟内存空间中,地址最高的一个 VMA 的结束地址位置
    unsigned long highest_vm_end;   /* highest vma end address */
}

以上就是内核在文件映射与匿名映射区寻找 unmapped_area 的核心逻辑,我们明白了这些,在看源码就会清晰很多了:

unsigned long unmapped_area(struct vm_unmapped_area_info *info){
    /*
     * We implement the search by looking for an rbtree node that
     * immediately follows a suitable gap. That is,
     * - gap_start = vma->vm_prev->vm_end <= info->high_limit - length;
     * - gap_end   = vma->vm_start        >= info->low_limit  + length;
     * - gap_end - gap_start >= length     */

    struct mm_struct *mm = current->mm;
    // 寻找未映射区域的参考 vma (该区域以存在映射关系)
    struct vm_area_struct *vma;
    // 未映射区域产生在 vma->vm_prev 与 vma 这两个虚拟内存区域中的间隙 gap 中
    // length 表示本次映射区域的长度
    // low_limit ,high_limit 表示在进程地址空间中哪段地址范围内查找,一个地址下限(mm->mmap_base),另一个标识地址上限(TASK_SIZE)
    // gap_start, gap_end 表示 vma->vm_prev 与 vma 之间的 gap 范围,unmapped_area 将会在这里产生
    unsigned long length, low_limit, high_limit, gap_start, gap_end;

    // gap_start 需要满足的条件:gap_start =  vma->vm_prev->vm_end <= info->high_limit - length
    // 否则 unmapped_area 将会超出 high_limit 的限制
    high_limit = info->high_limit - length;

    // gap_end 需要满足的条件:gap_end = vma->vm_start >= info->low_limit + length
    // 否则 unmapped_area 将会超出 low_limit 的限制
    low_limit = info->low_limit + length;

    // 首先将 vma 红黑树的根节点作为 gap 的参考 vma
    if (RB_EMPTY_ROOT(&mm->mm_rb))
        // 'empty' nodes are nodes that are known not to be inserted in an rbtree
        goto check_highest;
    // 获取红黑树根节点的 vma
    vma = rb_entry(mm->mm_rb.rb_node, struct vm_area_struct, vm_rb);

    // rb_subtree_gap 为当前 vma 及其左右子树中所有 vma 与其对应 vm_prev 之间最大的虚拟内存地址 gap
    // 最大的 gap 如果都不能满足映射长度 length 则跳转到 check_highest 处理
    if (vma->rb_subtree_gap < length)
        // 从进程地址空间最后一个 vma->vm_end 地址处开始映射
        goto check_highest;

    while (true) {
        // 获取当前 vma 的 vm_start 起始虚拟内存地址作为 gap_end
        gap_end = vm_start_gap(vma);
        // gap_end 需要满足:gap_end >= low_limit,否则 unmapped_area 将会超出 low_limit 的限制
        // 如果存在左子树,则需要继续到左子树中去查找,因为我们需要按照地址从低到高的优先级来查看合适的未映射区域
        if (gap_end >= low_limit && vma->vm_rb.rb_left) {
            struct vm_area_struct *left =
                rb_entry(vma->vm_rb.rb_left,
                     struct vm_area_struct, vm_rb);
            // 如果左子树中存在合适的 gap,则继续左子树的查找
            // 否则查找结束,gap 为当前 vma 与其 vm_prev 之间的间隙    
            if (left->rb_subtree_gap >= length) {
                vma = left;
                continue;
            }
        }
        // 获取当前 vma->vm_prev 的 vm_end 作为 gap_start
        gap_start = vma->vm_prev ? vm_end_gap(vma->vm_prev) : 0;
check_current:
        // gap_start 需要满足:gap_start <= high_limit,否则 unmapped_area 将会超出 high_limit 的限制
        if (gap_start > high_limit)
            return -ENOMEM;

        if (gap_end >= low_limit &&
            gap_end > gap_start && gap_end - gap_start >= length)
            // 找到了合适的 unmapped_area 跳转到 found 处理
            goto found;

       // 当前 vma 与其左子树中的所有 vma 均不存在一个合理的 gap
       // 那么从 vma 的右子树中继续查找
        if (vma->vm_rb.rb_right) {
            struct vm_area_struct *right =
                rb_entry(vma->vm_rb.rb_right,
                     struct vm_area_struct, vm_rb);
            if (right->rb_subtree_gap >= length) {
                vma = right;
                continue;
            }
        }

        // 如果在当前 vma 以及它的左右子树中均无法找到一个合适的 gap
        // 那么这里会从当前 vma 节点向上回溯整颗红黑树,在它的父节点中尝试查找是否有合适的 gap
        // 因为这时候有可能会有新的 vma 插入到红黑树中,可能会产生新的 gap
        while (true) {
            struct rb_node *prev = &vma->vm_rb;
            if (!rb_parent(prev))
                goto check_highest;
            vma = rb_entry(rb_parent(prev),
                       struct vm_area_struct, vm_rb);
            if (prev == vma->vm_rb.rb_left) {
                gap_start = vm_end_gap(vma->vm_prev);
                gap_end = vm_start_gap(vma);
                goto check_current;
            }
        }
    }

check_highest:
    // 流程走到这里表示在当前进程虚拟内存空间的所有 VMA 中都无法找到一个合适的 gap 来作为 unmapped_area
    // 那么就从进程地址空间中最后一个 vma->vm_end 开始映射
    // mm->highest_vm_end 表示当前进程虚拟内存空间中,地址最高的一个 VMA 的结束地址位置
    gap_start = mm->highest_vm_end;
    gap_end = ULONG_MAX;  /* Only for VM_BUG_ON below */
    // 这里最后需要检查剩余虚拟内存空间是否满足映射长度
    if (gap_start > high_limit)
        // ENOMEM 表示当前进程虚拟内存空间中虚拟内存不足
        return -ENOMEM;

found:
    // 流程走到这里表示我们已经找到了一个合适的 gap 来作为 unmapped_area 
    // 直接返回 gap_start (需要与 4K 对齐)作为映射的起始地址
    /* We found a suitable gap. Clip it with the original low_limit. */
    if (gap_start < info->low_limit)
        gap_start = info->low_limit;

    /* Adjust gap address to the desired alignment */
    gap_start += (info->align_offset - gap_start) & info->align_mask;

    VM_BUG_ON(gap_start + info->length > info->high_limit);
    VM_BUG_ON(gap_start + info->length > gap_end);
    return gap_start;
}

mmap_region 内存映射的本质

流程走到这里,我们就来到了 mmap 系统调用最为核心的部分了,在之前的内容中,内核已经通过 get_unmapped_area 函数为我们在进程地址空间中挑选出一段地址范围为 [addr , addr + len] 的虚拟内存区域供 mmap 进行映射。

注意:这里的 addr 并不一定是我们指定的映射起始地址。

现在我们只是确定了  [addr , addr + len] 这段虚拟内存区域是可以映射的,这段区域只是被内核先划分出来了,但是还未分配出去,在 mmap_region 函数中,需要为这段虚拟内存区域分配 vma 结构,并根据映射方式对 vma 进行初始化,这样这段虚拟内存才算真正的被分配给了进程。

而在进程虚拟内存空间中允许被映射的虚拟内存总量是有限制的,所以在 mmap_region 开始分配虚拟内存之前,内核需要通过 may_expand_vm 检查本次需要映射的虚拟内存页数 len >> PAGE_SHIFT 是否已经超过了进程地址空间中可以被映射的虚拟内存总量限制。

如果未超过,则内核可以顺利的进行后续的内存映射流程,如果已经超过,内核则需近一步考虑能否消减一下不必要的虚拟内存用量。那么什么可以算作是不必要的虚拟内存用量呢?

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

比如,我们在 mmap 系统调用的 flags 参数中指定了 MAP_FIXED,强制内核从我们指定的 addr 地址处开始映射。

这样一来,[addr , addr + len] 这段范围的虚拟内存就会有很大的可能与现有虚拟内存映射区 vma(上图中蓝色部分)发生重叠,因为这里我们指定的是强制映射 MAP_FIXED,所以内核会将这部分重叠的部分通过 do_munmap 函数先解除映射,然后建立新的映射关系,效果就是将这部分重叠的虚拟内存覆盖掉了。

由于这部分重叠的虚拟内存部分是之前已经分配出去的,本次映射不需要再重新申请,所以真实虚拟内存的用量需要减去这部分重叠的部分。

内核通过 count_vma_pages_range 函数计算出这部分重叠的虚拟内存页个数,然后用本次申请的虚拟内存页个数 len >> PAGE_SHIFT 减去重叠的页数就是本次映射真实的虚拟内存用量。

最后重新通过 may_expand_vm 函数判断是否超过进程地址空间中可以被映射的虚拟内存总量限制,如果依然超过,则返回 ENOMEM 异常。如果没有超过,则正式进入虚拟内存分配的流程。

说到虚拟内存的分配,我们不由的会想到进程的虚拟内存空间,每个进程的虚拟内存空间都是独立的,而且虚拟内存空间的容量非常巨大,在 64 位系统中进程的虚拟内存空间为 128T,在这么巨大的虚拟内存空间下申请虚拟内存,我们想当然的会认为,进程可以随意申请,随意折腾。

理论上是这样,但是事实上,虚拟内存说到底最终还是要映射到物理内存上的,背后需要物理内存作为支撑,如果进程申请的虚拟内存远远超过物理内存大小,那么在运行的过程中就会导致部分内存被 swap 来 swap 去,甚至频繁的发生 oom,导致性能下降严重。

进程申请虚拟内存的过程就好比我们向银行贷款一样,进程的虚拟内存空间好比是现实中的银行,虚拟内存空间中的虚拟内存非常庞大,银行里的钱也非常多,但这并不意味着我们要多少银行就会贷给我们多少,银行需要对我们的资产进行审计,我们的资产越多,银行给我们贷款也会越多,我们的资产越少,银行给我们的贷款也越少。

同样的道理,内核也会对进程申请的虚拟内存进行审计(account),物理内存空间越大,swap 交换区越大,进程能能够申请到的虚拟内存也就越多。内核对虚拟内存申请的审计(account)策略就是我们前面提到的 overcommit_memory 策略,后面的相关章节笔者会详细的介绍,这里大家只需要知道内核的这个 overcommit_memory 策略会影响到进程申请虚拟内存大小。

内核通过 accountable_mapping 函数来判断是否需要对进程申请的虚拟内存进行审计,这就好比我们去银行贷款,如果客户的信用值一般,银行就需要对客户进行审计,如果客户端的信用值很高,资产优质,那么银行就不需要对客户的贷款进行审计。进程对虚拟内存的申请也是一样。

如果需要对虚拟内存进行审计,那么内核接着会调用 security_vm_enough_memory_mm 函数根据 overcommit_memory 策略判断是否允许进程申请这么多的虚拟内存,如果不通过,则返回 ENOMEM 停止虚拟内存申请流程。如果通过则将虚拟内存分配给进程。

内核为进程分配虚拟内存的本质其实就是在进程的虚拟内存空间中,找出一段未被映射的空闲虚拟内存地址范围 [addr , addr + len],就像之前介绍的 get_unmapped_area 函数那样。

然后再 mmap_region 函数中为这段空闲的虚拟内存地址范围  [addr , addr + len],创建 vma 结构,并初始化 vma 相关的属性。然后将这个 vma 结构插入到进程的虚拟内存空间中。

内核为了精细化的控制内存的开销,避免创建没有必要的 vma 结构,内核会本着能省则省的原则,在创建新的 vma 之前,按照最大程度合并的原则,内核会尝试看能不能将当前寻找出来的空闲虚拟内存区域  [addr , addr + len] 与其前一个 vma 以及后一个 vma 进行合并,然后重新调整合并后的 vma 相关属性,比如:vm_start , vm_end , vm_pgoff,以及涉及到相关数据结构的改变。这样一来,内核就不需要为这段空闲虚拟内存创建新的 vma 了。

如果不能合并,内核则只能从 slab 缓存中拿出一个 vma 结构来描述这段虚拟内存地址范围  [addr , addr + len]。并根据 mmap 映射的这段虚拟内存区域属性初始化 vma 结构中的相关字段。

    vma->vm_start = addr;
    vma->vm_end = addr + len;
    vma->vm_flags = vm_flags;
    vma->vm_page_prot = vm_get_page_prot(vm_flags);
    vma->vm_pgoff = pgoff;

如果 mmap 进行的是文件映射,那么这里内核会将映射的文件与虚拟映射区关联起来。

vma->vm_file = get_file(file);

然后内核会通过 call_mmap 函数,将虚拟内存的相关操作函数映射成文件相关的操作函数,大家或多或少在网上看到过这样的论述——" 通过内存文件映射可以将磁盘上的文件映射到内存中,这样我们就可以通过读写内存来完成磁盘文件的读写 ",其实本质就在 call_mmap 函数中,因为经过该函数处理之后,虚拟内存相关的操作函数已经变成文件相关的操作函数了。

struct vm_area_struct {

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

    /* Function pointers to deal with this struct. */
    const struct vm_operations_struct *vm_ops;
}

struct vm_operations_struct {

    vm_fault_t (*fault)(struct vm_fault *vmf);

    void (*map_pages)(struct vm_fault *vmf,
            pgoff_t start_pgoff, pgoff_t end_pgoff);

    vm_fault_t (*page_mkwrite)(struct vm_fault *vmf);
}

我们接着来看 call_mmap 函数,mmap 文件映射的本质就在这里:

static inline int call_mmap(struct file *file, struct vm_area_struct *vma){
    return file->f_op->mmap(file, vma);
}

内核将文件相关的操作全部定义在 struct file 结构中的 f_op 属性中:

struct file {
    const struct file_operations  *f_op;
}

文件的操作与其所在的文件系统是紧密相关的,在 ext4 文件系统中,相关文件的 file->f_op 指向 ext4_file_operations 操作集合:

const struct file_operations ext4_file_operations = {
 .mmap  = ext4_file_mmap,
};

其中 file->f_op->mmap 函数专门用于文件与内存的映射,在这里内核将 vm_area_struct 的内存操作 vma->vm_ops 设置为文件系统的操作 ext4_file_vm_ops,当通过 mmap 将内存与文件映射起来之后,读写内存其实就是读写文件系统的本质就在这里。

static int ext4_file_mmap(struct file *file, struct vm_area_struct *vma){
        ........ 省略 ........
        
      vma->vm_ops = &ext4_file_vm_ops;
      
        ........ 省略 ........    
}
static const struct vm_operations_struct ext4_file_vm_ops = {
    .fault      = ext4_filemap_fault,
    .map_pages  = filemap_map_pages,
    .page_mkwrite   = ext4_page_mkwrite,
};

如果 mmap 进行的是共享匿名映射,父子进程之间需要依赖 tmpfs 文件系统中的匿名文件对共享内存进行访问,当进行共享匿名映射的时候,内核会在 shmem_zero_setup 函数中,到  tmpfs 文件系统里为映射创建一个匿名文件(shmem_kernel_file_setup),随后将 tmpfs 文件系统中的这个匿名文件与虚拟映射区 vma 中的 vm_file 关联映射起来,当然了,vma->vm_ops 也需要映射成 shmem_vm_ops。

当父进程调用 fork 创建子进程的时候,内核会将父进程的虚拟内存空间全部拷贝给子进程,包括这里创建的共享匿名映射区域 vma,这样一来,父子进程就可以通过共同的 vma->vm_file 来实现共享内存的通信了。

这里可以看出 mmap 的共享匿名映射其实本质上还是共享文件映射,只不过这个文件比较特殊,创建于 dev/zero 目录下的 tmpfs 文件系统中。

int shmem_zero_setup(struct vm_area_struct *vma){
    struct file *file;
    loff_t size = vma->vm_end - vma->vm_start;
    // tmpfs 中获取一个匿名文件
    file = shmem_kernel_file_setup("dev/zero", size, vma->vm_flags);
    if (IS_ERR(file))
        return PTR_ERR(file);

    if (vma->vm_file)
        // 如果 vma 中已存在其他文件,则解除与其他文件的映射关系
        fput(vma->vm_file);
    
    // 将 tmpfs 中的匿名文件映射进虚拟内存区域 vma 中
    // 后续 fork 子进程的时候,父子进程就可以通过这个匿名文件实现共享匿名映射 
    vma->vm_file = file;
    // 对这块共享匿名映射区相关操作这里直接映射成 shmem_vm_ops
    vma->vm_ops = &shmem_vm_ops;

    return 0;
}

static const struct vm_operations_struct shmem_vm_ops = {
 .fault  = shmem_fault,
 .map_pages = filemap_map_pages,
#ifdef CONFIG_NUMA
 .set_policy     = shmem_set_policy,
 .get_policy     = shmem_get_policy,
#endif
};

如果 mmap 这里进行的是私有匿名映射的话,情况就变得简单了,由于私有匿名映射并不涉及到与文件之间的映射,所以只需要简单的将 vma->vm_ops 设置为 null 即可。

流程走到这里,本次 mmap 映射所产生的虚拟内存区域 vma 结构就被初始化好了,整个内存映射的核心工作就此完成了,剩下要做的事情就是将这个 vma 结构插入到进程虚拟内存空间中。

经过前面的介绍我们知道,在进程的虚拟内存空间中,所有的 vma 结构是被两种数据结构来组织管理的。一种是  mm_struct->mmap 指向的链表结构,另一种是 mm_struct->mm_rb 指向的红黑树结构。

vma_link 要做的工作就是按照虚拟内存地址的增长方向,将本次映射产生的 vma 结构插入到进程地址空间这两个数据结构中。

static void vma_link(struct mm_struct *mm, struct vm_area_struct *vma,
            struct vm_area_struct *prev, struct rb_node **rb_link,
            struct rb_node *rb_parent){
    // 文件 page cache
    struct address_space *mapping = NULL;

    if (vma->vm_file) {
        // 获取映射文件的 page cache
        mapping = vma->vm_file->f_mapping;
        i_mmap_lock_write(mapping);
    }
    // 将 vma 插入到地址空间中的 vma 链表 mm_struct->mmap 以及红黑树 mm_struct->mm_rb 中
    __vma_link(mm, vma, prev, rb_link, rb_parent);
    // 建立文件与 vma 的反向映射
    __vma_link_file(vma);

    if (mapping)
        i_mmap_unlock_write(mapping);

    // map_count 表示进程地址空间中 vma 的个数
    mm->map_count++;
    validate_mm(mm);
}

除此之外,vma_link 还做了一项重要工作,就是通过 __vma_link_file 函数建立文件与虚拟内存区域 vma (所有进程)的反向映射关系。说起反向映射,笔者在之前的文章 《深入理解 Linux 物理内存》中的 “匿名页的反向映射” 小节中为大家介绍过关于匿名页的反向映射过程,感兴趣的同学可以回看下。

匿名页的反向映射还是相对比较复杂的,文件页的反向映射就很简单了,在之前的文章中笔者曾介绍过,struct file 结构中的 f_maping 属性指向了一个非常重要的数据结构 struct address_space。

struct address_space {
    struct inode        *host;      /* owner: inode, block_device */
    // page cache
    struct radix_tree_root  i_pages;    /* cached pages */
    atomic_t        i_mmap_writable;/* count VM_SHARED mappings */
    // 文件与 vma 反向映射的核心数据结构,i_mmap 也是一颗红黑树
    // 在所有进程的地址空间中,只要与该文件发生映射的 vma 均挂在 i_mmap 中
    struct rb_root_cached   i_mmap;     /* tree of private and shared mappings */
}

struct address_space 结构中有两个非常重要的属性,其中一个是 i_pages ,它指向了我们熟悉的 page cache。另一个就是 i_mmap,它指向的是一颗红黑树,这颗红黑树正是文件页反向映射的核心数据结构,反向映射关系就保存在这里。

我们知道,一个文件可以被多个进程一起映射,这样一来在每个进程的地址空间 mm_struct 结构中都会有一个 vma 结构来与这个文件进行映射,与该文件发生映射关系的所有进程地址空间中的 vma 就挂在 address_space-> i_mmap 这颗红黑树中,通过它,我们可以找到所有与该文件进行映射的进程。

__vma_link_file 函数建立文件页反向映射的核心其实就是将 mmap 映射出的这个 vma 插入到这颗红黑树中。

static void __vma_link_file(struct vm_area_struct *vma)
{
    struct file *file;

    file = vma->vm_file;
    if (file) {
        struct address_space *mapping = file->f_mapping;
        // address_space->i_mmap 也是一颗红黑树,上面挂着的是与该文件映射的所有 vma(所有进程地址空间)
        // 这里将 vma 插入到 i_mmap 中,实现文件与 vma 的反向映射
        vma_interval_tree_insert(vma, &mapping->i_mmap);
    }
}

好了,mmap 内存映射最为核心的部分,到这里笔者就为大家介绍完了,映射原理我们清楚了,接下来我们跟着这副 mmap_region 流程图,来看源码实现就很清晰了:

unsigned long mmap_region(struct file *file, unsigned long addr,
         unsigned long len, vm_flags_t vm_flags, unsigned long pgoff,
         struct list_head *uf){
    struct mm_struct *mm = current->mm;
    struct vm_area_struct *vma, *prev;
    int error;
    struct rb_node **rb_link, *rb_parent;
    unsigned long charged = 0;

    // 检查本次映射是否超过了进程虚拟内存空间中的虚拟内存容量的限制,超过则返回 false
    if (!may_expand_vm(mm, vm_flags, len >> PAGE_SHIFT)) {
        unsigned long nr_pages;

        // 如果 mmap 指定了 MAP_FIXED,表示内核必须要按照用户指定的映射区来进行映射
        // 这种情况下就会导致,我们指定的映射区[addr, addr + len] 有一部分可能与现有映射重叠
        // 内核将会覆盖掉这段已有的映射,重新按照用户指定的映射关系进行映射
        // 所以这里需要计算进程地址空间中与指定映射区[addr, addr + len]重叠的虚拟内存页数 nr_pages
        nr_pages = count_vma_pages_range(mm, addr, addr + len);
        // 由于这里的 nr_pages 表示重叠的虚拟内存部分,将会被覆盖,所以这部分被覆盖的虚拟内存不需要额外申请
        // 这里通过 len >> PAGE_SHIFT 减去这段可以被覆盖的 nr_pages 在重新检查是否超过虚拟内存相关区域的限额
        if (!may_expand_vm(mm, vm_flags,
                    (len >> PAGE_SHIFT) - nr_pages))
            return -ENOMEM;
    }

   // 如果当前进程地址空间中存在于指定映射区域 [addr, addr + len] 重叠的部分
   // 则调用  do_munmap 将这段重叠的映射部分解除掉,后续会重新映射这部分
    while (find_vma_links(mm, addr, addr + len, &prev, &rb_link,
                  &rb_parent)) {
        if (do_munmap(mm, addr, len, uf))
            return -ENOMEM;
    }
   
    /*
     * 判断将来是否会为这段虚拟内存 vma ,申请新的物理内存,比如 私有,可写(private writable)的映射方式,内核将来会通过 cow 重新为其分配新的物理内存。
     * 私有,只读(private readonly)的映射方式,内核则会共享原来映射的物理内存,而不会申请新的物理内存。
     * 如果将来需要申请新的物理内存则会根据当前系统的 overcommit 策略以及当前物理内存的使用情况来
       * 综合判断是否允许本次虚拟内存的申请。如果虚拟内存不足,则返回 ENOMEM,这样的话可以防止缺页的时候发生 OOM
     */
    if (accountable_mapping(file, vm_flags)) {
        charged = len >> PAGE_SHIFT;
        // 根据内核 overcommit 策略以及当前物理内存的使用情况综合判断,是否能够通过本次虚拟内存的申请
        // 虚拟内存的申请一旦这里通过之后,后续发生缺页,内核将会有足够的物理内存为其分配,不会发生 OOM
        if (security_vm_enough_memory_mm(mm, charged))
            return -ENOMEM;
        // 凡是设置了 VM_ACCOUNT 的 VMA,表示这段虚拟内存均已经过 vm_enough_memory 的检测
        // 当虚拟内存发生缺页的时候,内核会有足够的物理内存分配,而不会导致 OOM 
        // 其虚拟内存的用量都会被统计在 /proc/meminfo 的 Committed_AS  字段中    
        vm_flags |= VM_ACCOUNT;
    }

    // 为了精细化的控制内存的开销,内核这里首先需要尝试看能不能和地址空间中已有的 vma 进行合并
    // 尝试将当前 vma 合并到已有的 vma 中
    vma = vma_merge(mm, prev, addr, addr + len, vm_flags,
            NULL, file, pgoff, NULL, NULL_VM_UFFD_CTX);
    if (vma)
        // 如果可以合并,则虚拟内存分配过程结束
        goto out;

    // 如果不可以合并,则只能从 slab 中取出一个新的 vma 结构来
    vma = vm_area_alloc(mm);
    if (!vma) {
        error = -ENOMEM;
        goto unacct_error;
    }
    // 根据我们要映射的虚拟内存区域属性初始化 vma 结构中的相关字段
    vma->vm_start = addr;
    vma->vm_end = addr + len;
    vma->vm_flags = vm_flags;
    vma->vm_page_prot = vm_get_page_prot(vm_flags);
    vma->vm_pgoff = pgoff;

    // 文件映射
    if (file) {
        // 将文件与虚拟内存映射起来
        vma->vm_file = get_file(file);
        // 这一步中将虚拟内存区域 vma 的操作函数 vm_ops 映射成文件的操作函数(和具体文件系统有关)
        // ext4 文件系统中的操作函数为 ext4_file_vm_ops
        // 从这一刻开始,读写内存就和读写文件是一样的了
        error = call_mmap(file, vma);
        if (error)
            goto unmap_and_free_vma;

        addr = vma->vm_start;
        vm_flags = vma->vm_flags;
    } else if (vm_flags & VM_SHARED) {
        // 这里处理共享匿名映射
        // 前面提到共享匿名映射依赖于 tmpfs 文件系统中的匿名文件
        // 父子进程通过这个匿名文件进行通讯
        // 该函数用于在 tmpfs 中创建匿名文件,并映射进当前共享匿名映射区 vma 中
        error = shmem_zero_setup(vma);
        if (error)
            goto free_vma;
    } else {
        // 这里处理私有匿名映射
        // 将  vma->vm_ops 设置为 null,只有文件映射才需要 vm_ops 这样才能将内存与文件映射起来
        vma_set_anonymous(vma);
    }
    // 将当前 vma 按照地址的增长方向插入到进程虚拟内存空间的 mm_struct->mmap 链表以及mm_struct->mm_rb 红黑树中
    // 并建立文件与 vma 的反向映射
    vma_link(mm, vma, prev, rb_link, rb_parent);

    file = vma->vm_file;
out:
    // 更新地址空间 mm_struct 中的相关统计变量
    vm_stat_account(mm, vm_flags, len >> PAGE_SHIFT);
    return addr;
}

may_expand_vm 检查映射的虚拟内存是否超过了内核限制

进程地址空间中对虚拟内存的用量是有限制的,限制分为两个方面:

  1. 对进程地址空间中能够映射的虚拟内存页总数做出限制。
  2. 对进程地址空间中数据区的虚拟内存页总数做出限制。

这里的数据区,在内核中定义的是所有私有,可写的虚拟内存区域(栈区除外):

/*
 * Data area - private, writable, not stack
 */
static inline bool is_data_mapping(vm_flags_t flags){
    // 本次需要映射的虚拟内存区域是否是私有,可写的(数据区)
    return (flags & (VM_WRITE | VM_SHARED | VM_STACK)) == VM_WRITE;
}

以上两个方面的限制,我们可以通过修改 /etc/security/limits.conf 文件进行调整。

内核对进程地址空间中相关区域的虚拟内存用量限制依然保存在 task_struct->signal_struct->rlim 数组中,我们可以通过 RLIMIT_AS 以及 RLIMIT_DATA 下标进行访问。

// 进程地址空间中允许映射的虚拟内存总量,单位为字节
# define RLIMIT_AS  9 /* address space limit */
// 进程地址空间中允许用于私有可写(private,writable)的虚拟内存总量,单位字节
# define RLIMIT_DATA  2 /* max data size */

当前进程地址空间中已经映射的虚拟内存页数保存在 mm_struct->total_vm 中,数据区(私有,可写)已经映射的虚拟内存页数保存在 mm_struct->data_vm 中。

struct mm_struct {
    // 进程地址空间中所有已经映射的虚拟内存页总数
    unsigned long total_vm;    /* Total pages mapped */
    // 进程地址空间中所有私有,可写的虚拟内存页总数
    unsigned long data_vm;     /* VM_WRITE & ~VM_SHARED & ~VM_STACK */
}

may_expand_vm 函数的核心逻辑就是判断经过本次 mmap 映射之后(mmap 需要映射的虚拟内存页数为 npages),mm->total_vm + npages 是否超过了 rlimit(RLIMIT_AS) 中的限制,mm->data_vm + npages 是否超过了 rlimit(RLIMIT_DATA) 中的限制。如果超过,那么本次 mmap 内存映射流程在这里就会停止进行。

// 检查本次映射是否超过了进程虚拟内存空间中的虚拟内存总量的限制,超过则返回 false
bool may_expand_vm(struct mm_struct *mm, vm_flags_t flags, unsigned long npages){
    // mm->total_vm 表示当前进程地址空间中映射的虚拟内存页总数
    // npages 表示此次要映射的虚拟内存页个数
    // rlimit(RLIMIT_AS) 表示进程地址空间中允许映射的虚拟内存总量,单位为字节
    if (mm->total_vm + npages > rlimit(RLIMIT_AS) >> PAGE_SHIFT)
        // 如果映射的虚拟内存页总数超出了内核的限制,那么就返回 false 表示虚拟内存不足
        return false;

    // 检查本次映射是否属于数据区域的映射,这里的数据区域指的是私有,可写的虚拟内存区域(栈区除外)
    // 如果是则需要检查数据区域里的虚拟内存页是否超过了内核的限制
    // rlimit(RLIMIT_DATA) 表示进程地址空间中允许映射的私有,可写的虚拟内存总量,单位为字节
    // 如果超过则返回 false,表示数据区虚拟内存不足
    if (is_data_mapping(flags) &&
        mm->data_vm + npages > rlimit(RLIMIT_DATA) >> PAGE_SHIFT) {
        /* Workaround for Valgrind */
        if (rlimit(RLIMIT_DATA) == 0 &&
            mm->data_vm + npages <= rlimit_max(RLIMIT_DATA) >> PAGE_SHIFT)
            return true;

        pr_warn_once("%s (%d): VmData %lu exceed data ulimit %lu. Update limits%s.\n",
                 current->comm, current->pid,
                 (mm->data_vm + npages) << PAGE_SHIFT,
                 rlimit(RLIMIT_DATA),
                 ignore_rlimit_data ? "" : " or use boot option ignore_rlimit_data");

        if (!ignore_rlimit_data)
            return false;
    }

    return true;
}

find_vma_links函数的作用是在当前进程地址空间中查找是否存在与指定映射区域 [addr, addr+len] 重叠的部分,如果查找到现存的 vma 和该指定映射区域有重叠则返回错误,如果不存在重叠部分,则表示找到 vma 待插入的位置,包括其在链表中的位置 prev 和红黑树中的位置 rb_link 和 rb_parent,分别是待插入节点本身在红黑树中的位置和待插入节点的父节点。

// mnt/mmap.c
static int find_vma_links(struct mm_struct *mm, unsigned long addr,
        unsigned long end, struct vm_area_struct **pprev,
        struct rb_node ***rb_link, struct rb_node **rb_parent)
{
    struct rb_node **__rb_link, *__rb_parent, *rb_prev;
    // 获取红黑树的根节点
    __rb_link = &mm->mm_rb.rb_node;
    rb_prev = __rb_parent = NULL;
    // 遍历整棵红黑树,为[addr,addr+len]这段内存区域查找合适的插入位置
    while (*__rb_link) {
        struct vm_area_struct *vma_tmp;

        __rb_parent = *__rb_link;
        vma_tmp = rb_entry(__rb_parent, struct vm_area_struct, vm_rb);

        if (vma_tmp->vm_end > addr) {
            /* Fail if an existing vma overlaps the area */
            if (vma_tmp->vm_start < end)
                return -ENOMEM;
            __rb_link = &__rb_parent->rb_left;
        } else {
            rb_prev = __rb_parent;
            __rb_link = &__rb_parent->rb_right;
        }
    }
    // pprev 待插入 vma 节点的前一个节点的 vma,如果 rb_prev 为空,
    // 说明待插入节点是最左子节点,在链表mm->mmap中是头节点
    *pprev = NULL;
    if (rb_prev)
        *pprev = rb_entry(rb_prev, struct vm_area_struct, vm_rb);
    *rb_link = __rb_link;
    *rb_parent = __rb_parent;
    return 0;
}

vma_merge 函数解析

经过前面的介绍我们知道,当 mmap 在进程虚拟内存空间中映射出一段 [addr , end] 的虚拟内存区域 area 时,内核需要为这段虚拟内存区域 area 创建一个 vma 结构来描述。

而在创建新的 vma 结构之前,内核会在这里尝试看能不能将 area 与现有的 vma 进行合并,这样就可以避免创建新的 vma 结构,节省了内存的开销。

内核会本着合并最大化的原则,检查当前映射出来的 area 能否与其前后两个 vma 进行合并,能合并就合并,如果不能合并就只能从 slab 中申请新的 vma 结构了。合并条件如下:

  1. area 的 vm_flags 不能设置 VM_SPECIAL 标志,该标志表示 area 区域是不可以被合并的,只能重新创建 vma。
  2. area 的起始地址 addr 必须要与其 prev vma 的结束地址重合,这样,area 才能和它的前一个 vma 进行合并,如果不重合,area 则不能和前一个 vma 进行合并。
  3. area 的结束地址 end 必须要与其 next vma 的起始地址重合,这样,area 才能和它的后一个 vma 进行合并,如果不重合,area 则不能和后一个 vma 进行合并。如果前后都不能合并,那就只能重新创建 vma 结构了。
  4. area 需要与其要合并区域的 vm_flags 必须相同,否则不能合并。
  5. 如果两个合并区域都是文件映射区,那么它们映射的文件必须是同一个。并且他们的文件映射偏移 vm_pgoff 必须是连续的。
  6. 如果两个合并区域都是匿名映射区,那么两个 vma 映射的匿名页 anon_vma 必须是相同的。
  7. 合并区域的 numa policy 必须是相同的。关于 numa policy 的介绍,感兴趣的同学可以查看笔者之前的文章《深入理解 Linux 物理内存》第 “NUMA 的内存分配策略” 小节的内容。
  8. 要合并的 prev 和 next 虚拟内存区域中,不能包含 close 操作,也就是说 vma->vm_ops 不能设置有 close 函数,如果虚拟内存区域操作支持 close,则不能合并,否则会导致现有虚拟内存区域 prev 和 next 的资源无法释放。

can_vma_merge_after 函数用于判断其参数中指定的 vma 能否与其后一个 vma 进行合并。can_vma_merge_before 的逻辑也是一样,用于判断参数指定的 vma 能否与其前一个 vma 合并。

static int can_vma_merge_after(struct vm_area_struct *vma, unsigned long vm_flags,
             struct anon_vma *anon_vma, struct file *file,
             pgoff_t vm_pgoff,
             struct vm_userfaultfd_ctx vm_userfaultfd_ctx){
    // 判断参数中指定的 vma 能否与其后一个 vma 进行合并
    if (is_mergeable_vma(vma, file, vm_flags, vm_userfaultfd_ctx) &&
        is_mergeable_anon_vma(anon_vma, vma->anon_vma, vma)) {
        pgoff_t vm_pglen;
        // vma 区域的长度
        vm_pglen = vma_pages(vma);
        // 判断 vma 和 next 两个文件映射区域的映射偏移 pgoff 是否是连续的
        if (vma->vm_pgoff + vm_pglen == vm_pgoff)
            return 1;
    }
    return 0;
}

is_mergeable_vma 函数用于判断两个 vma 是否能够合并:

static inline int is_mergeable_vma(struct vm_area_struct *vma,
                 struct file *file, unsigned long vm_flags,
                 struct vm_userfaultfd_ctx vm_userfaultfd_ctx){
    // 对比 prev 和 area 的 vm_flags 是否相同,这里需要排除 VM_SOFTDIRTY
    // VM_SOFTDIRTY 用于追踪进程写了哪些内存页,如果 prev 被标记了 soft dirty,那么合并之后的 vma 也应该继续保留 soft dirty 标记
    if ((vma->vm_flags ^ vm_flags) & ~VM_SOFTDIRTY)
        return 0;
    // prev 和 area 如果是文件映射区的话,这里需要检查两者映射的文件是否相同
    if (vma->vm_file != file)
        return 0;
    // 如果 prev 虚拟内存区域中包含了 close 的操作,后续可能会释放 prev 的资源
    // 所以这种情况下不能和 prev 进行合并,否则就会导致 prev 的资源无法释放
    if (vma->vm_ops && vma->vm_ops->close)
        return 0;
    // userfaultfd 是用来在用户态实现缺页处理的机制,这里需要保证两者的 userfaultfd 相同
    // 不过在 mmap_region 中传入的 vm_userfaultfd_ctx 为 null,这里我们不需要关注
    if (!is_mergeable_vm_userfaultfd_ctx(vma, vm_userfaultfd_ctx))
        return 0;
    return 1;
}

在我们清楚了 vma 之间的的合并条件之后,接下来我们来看一下 vma 的合并过程,整个合并过程其实还蛮复杂的,总共涉及到 8 种场景,不过大家别担心,笔者会带着大家从最简单的场景出发来逐渐演变。

经过前面内容的介绍,我们知道,通过 mmap 在进程地址空间中映射出的这个 area 一般是在两个 vma 中产生的,内核源码中使用 prev 指向 area 的前一个 vma,使用 next 指向 area 的后一个 vma,这个原则请大家务必牢记。

如果我们在 mmap 系统调用参数 flags 中设置了 MAP_FIXED 标志,表示需要内核进行强制映射,在这种情况下,area 区域有可能会与 prev 区域和 next 区域有部分重合。

如上图所示,如果 area 区域的结束地址 end 与 next 区域的结束地址重合,内核会将 next 指针继续向后移动一下,指向 next->vm_next 区域。保证 area 始终处于 prev 和 next 之间的 gap 中。

 if (area && area->vm_end == end)        
        next = next->vm_next;

以上这两种基本布局,大家要好好记住,多看几眼,后面 8 种合并情况基本都是脱胎于这两个基本布局。

下面即将要介绍的这 8 种合并情况从总体上来讲会分为两个大的类别:

  1. 第一个类别是 area 的前一个 prev vma 的结束地址与 area 的起始地址 addr 重合,判断条件为:prev->vm_end == addr
  2. 第二个类别是 area 的后一个 next vma 的起始地址与 area 的结束地址 end 重合,判断条件为:end == next->vm_start

其中这两个大的类别将会分别根据前面两个基本布局展开进行,下面我们来看源码中的 case 1 。

注意下面的 8 种 case,笔者按照从简单到复杂的顺序来展示。

case 1 是在基本布局 1 中,area 的起始地址 addr 与 prev vma 的结束地址重合,同时 area 的结束地址 end 与 next vma 的起始地址重合,内核将会删除 next 区域,扩充 prev 区域,也就是说将这三个区域统一合并到 prev 区域中。

case 1 在基本布局 2 下,就演变成了 case 6 的情况,内核会将中间重叠的蓝色区域覆盖掉,然后统一合并到 prev 区域中。

如果只是 area 的起始地址 addr 与 prev vma 的结束地址重合,但是 area 的结束地址 end 不与 next vma 的起始地址重合,就会出现 case 2 , case 5 , case  7 三种情况。

其中 case 2 的情况是  area 的结束地址 end 小于 next vma 的起始地址,内核会扩充 prev 区域,将 area 合并进去,next 区域保持不变。

case 5 的情况是  area 的结束地址 end 大于 next vma 的起始地址,内核会扩充 prev 区域,将 area 以及与 next 重叠的部分合并到 prev 区域中,剩下的继续留在 next 区域保持不变。

case 2 在基本布局 2 下又会演变成 case 7 , 这种情况下内核会将下图中的蓝色区域覆盖,并扩充 prev 区域。next 区域保持不变。

如果只是 area 的结束地址 end 与 next vma 的起始地址重合,但是 area 的起始地址 addr 不与 prev vma 的结束地址重合,同样的道理也会分为三种情况,分别是下面介绍的 case 4 , case 3 , case 8。

case 4 的情况下,area 的起始地址 addr 小于 prev 区域的结束地址,那么内核会缩小 prev 区域,然后扩充 next 区域,将重叠的部分合并到 next 区域中。

如果 area 的起始地址 addr 大于 prev 区域的结束地址的话,就是 case 3 的情况  ,内核会扩充 next 区域,并将 area 合并到 next 中,prev 区域保持不变。

case 3 在基本布局 2 下就会演变为 case 8 ,内核继续保持 prev 区域不变,然后扩充 next 区域并覆盖下图中蓝色部分,将 area 合并到 next 区域中。

好了,现在 vma 合并的流程我们也清楚了,合并的条件也清楚了,接下来在看这部分源码就很简单了。

struct vm_area_struct *vma_merge(struct mm_struct *mm,
             struct vm_area_struct *prev, unsigned long addr,
             unsigned long end, unsigned long vm_flags,
             struct anon_vma *anon_vma, struct file *file,
             pgoff_t pgoff, struct mempolicy *policy,
             struct vm_userfaultfd_ctx vm_userfaultfd_ctx){
    // 本次需要创建的 VMA 区域大小
    pgoff_t pglen = (end - addr) >> PAGE_SHIFT;
    // area 表示当前要创建的 VMA,next 表示 area 的下一个 VMA
    // 事实上 area 会在其 prev 前一个 VMA 和 next 后一个 VMA 之间的间隙 gap 中创建产生
    struct vm_area_struct *area, *next;
    int err;

    // 设置了 VM_SPECIAL 表示 area 区域是不可以被合并的,只能重新创建 VMA,直接退出合并流程。
    if (vm_flags & VM_SPECIAL)
        return NULL;
    // 根据 prev vma 是否存在,设置 area 的 next vma,基本布局 1
    if (prev)
        // area 将在 prev vma 和 next vma 的间隙 gap 中产生
        next = prev->vm_next;
    else
        // 如果 prev 不存在,那么 next 就设置为地址空间中的第一个 vma。
        next = mm->mmap;

    area = next;
    // 新 vma 的 end 与 next->vm_end 相等 ,表示新 vma 与 next vma 是重合的,基本布局 2
    // 那么 next 指向下一个 vma,prev 和 next 这里的语义是始终指向 area 区域的前一个和后一个 vma
    if (area && area->vm_end == end)        /* cases 6, 7, 8 */
        next = next->vm_next;
 
    // 判断 area 是否能够和 prev 进行合并
    if (prev && prev->vm_end == addr &&
            mpol_equal(vma_policy(prev), policy) &&
            can_vma_merge_after(prev, vm_flags,
                        anon_vma, file, pgoff,
                        vm_userfaultfd_ctx)) {
        /*
         * 如何 area 可以和 prev 进行合并,那么这里继续判断 area 能够与 next 进行合并
         * 内核这里需要保证 vma 合并程度的最大化
         */
        if (next && end == next->vm_start &&
                mpol_equal(policy, vma_policy(next)) &&
                can_vma_merge_before(next, vm_flags,
                             anon_vma, file,
                             pgoff+pglen,
                             vm_userfaultfd_ctx) &&
                is_mergeable_anon_vma(prev->anon_vma,
                              next->anon_vma, NULL)) {
            // 流程走到这里表示 area 可以和它的 prev ,next 区域进行合并  /* cases 1,6 */
            // __vma_adjust 是真正执行 vma 合并操作的函数,这里会重新调整已有 vma 的相关属性,比如:vm_start,vm_end,vm_pgoff。以及涉及到相关数据结构的改变
            err = __vma_adjust(prev, prev->vm_start,
                     next->vm_end, prev->vm_pgoff, NULL,
                     prev);
        } else                  /* cases 2, 5, 7 */
            // 流程走到这里表示 area 只能和 prev 进行合并
            err = __vma_adjust(prev, prev->vm_start,
                     end, prev->vm_pgoff, NULL, prev);
        if (err)
            return NULL;
        khugepaged_enter_vma_merge(prev, vm_flags);
        // 返回最终合并好的 vma
        return prev;
    }

    // 下面这种情况属于,area 的结束地址 end 与 next 的起始地址是重合的
    // 但是 area 的起始地址 start 和 prev 的结束地址不是重合的
    if (next && end == next->vm_start &&
            mpol_equal(policy, vma_policy(next)) &&
            can_vma_merge_before(next, vm_flags,
                         anon_vma, file, pgoff+pglen,
                         vm_userfaultfd_ctx)) {
        // area 区域前半部分和 prev 区域的后半部分重合
        // 那么就缩小 prev 区域,然后将 area 合并到 next 区域
        if (prev && addr < prev->vm_end)    /* case 4 */
            err = __vma_adjust(prev, prev->vm_start,
                     addr, prev->vm_pgoff, NULL, next);
        else {                  /* cases 3, 8 */
            // area 区域前半部分和 prev 区域是有间隙 gap 的
            // 那么这种情况下 prev 不变,area 合并到 next 中
            err = __vma_adjust(area, addr, next->vm_end,
                     next->vm_pgoff - pglen, NULL, next);
            // 合并后的 area
            area = next;
        }
        if (err)
            return NULL;
        khugepaged_enter_vma_merge(area, vm_flags);
        // 返回合并后的 vma
        return area;
    }
    
    // prev 的结束地址不与 area 的起始地址重合,并且 area 的结束地址不与 next 的起始地址重合
    // 这种情况就不能执行合并,需要为 area 重新创建新的 vma 结构
    return NULL;
}

vma_link 函数的主要作用如下:

  1. 调用 __vma_link 函数将 vma 插入到链表和红黑树中,其内部调用 __vma_link_list 函数将 vma 插入到 mm->mmap 链表中,调用 __vma_link_rb 函数将 vma 插入到 mm->rb 红黑树中。
  2. 调用 __vma_link_file 函数将 vma 添加到文件树中;
// mm/mmap.c
static void vma_link(struct mm_struct *mm, struct vm_area_struct *vma,
            struct vm_area_struct *prev, struct rb_node **rb_link,
            struct rb_node *rb_parent)
{
    struct address_space *mapping = NULL;// 文件 page cache

    if (vma->vm_file) {
        mapping = vma->vm_file->f_mapping;
        i_mmap_lock_write(mapping);
    }
    // 将 vma 插入到地址空间中的 vma 链表 mm_struct->mmap 以及红黑树 mm_struct->mm_rb 中
    __vma_link(mm, vma, prev, rb_link, rb_parent);
    __vma_link_file(vma); // 建立文件与 vma 的反向映射

    if (mapping)
        i_mmap_unlock_write(mapping);

    mm->map_count++; // map_count 表示进程地址空间中 vma 的个数
    validate_mm(mm);
}

至此,完成 mmap 内存映射过程的第一阶段,用户进程调用 mmap 库函数启动映射过程,在虚拟地址空间中为映射创建虚拟映射区域 vma,并将新建的 vma 插入进程的虚拟地址区域链表或红黑树中。第二阶段是调用内核空间的系统调用函数 mmap(不同于用户空间函数),实现文件物理地址和进程虚拟地址的一一映射关系,那具体是如何实现的?

第二阶段 建立内存映射

内核空间的系统调用函数 mmap(不同于用户空间函数),通过待映射的文件指针,在文件描述符表中找到对应的文件描述符,通过文件描述符,链接到内核“已打开文件集”中该文件的文件结构体(struct file),每个文件结构体维护着这个已打开文件相关的各项信息。通过该文件的文件结构体,链接到 file_operations 模块,并调用内核空间函数 mmap,其原型声明如下

int mmap(struct file *filp, struct vm_area_struct *vma);

函数参数的含义如下:
file:指向待映射文件对象的指针,通过待映射的文件指针,在文件描述符表中找到对应的文件描述符 fd。
vma:指向待虚拟内存区域 vma 的指针。

应用程序使用到的函数在具体的驱动中都有与之对应的函数,比如应用程序中调用 mmap 库函数,那么在内核驱动程序中也得有一个名为 mmap 的函数。每一个系统调用,在内核驱动中都有与之对应的一个驱动函数,在 Linux 内核文件 include/linux/fs.h 中有个叫做 file_operations 的结构体,此结构体就是 Linux 内核驱动操作函数集合:

// include/linux/fs.h
struct file_operations {
    struct module *owner; // owner 拥有该结构体的模块的指针,一般设置为 THIS_MODULE
    loff_t (*llseek) (struct file *, loff_t, int); // llseek 函数用于修改文件当前的读写位置
    ssize_t (*read) (struct file *, char __user *, size_t, loff_t *); // read 函数用于读取设备文件
    ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *); //write 函数用于向设备文件写入(发送)数据
    ......
    unsigned int (*poll) (struct file *, struct poll_table_struct*); // poll 是个轮询函数,用于查询设备是否可以进行非阻塞的读写
    int (*mmap) (struct file *, struct vm_area_struct *); // mmap 函数用于将将设备的内存映射到进程空间中(也就是用户空间),以避免在用户空间和内核空间之间来回复制。
    ......
    int (*open) (struct inode *, struct file *); // open 函数用于打开设备文件
    int (*release) (struct inode *, struct file *); // release 函数用于释放(关闭)设备文件,与应用程序中的 close 函数对应。
    ......
    } __randomize_layout;

用户进程调用 mmap 函数创建文件映射的时候,文件所属的文件系统会注册虚拟内存区域的虚拟内存操作集合,其中也包括内核驱动函数 mmap ,在不同的文件系统中内核驱动函数 mmap 的实现方式有所不同,但其内部都是通过 remap_pfn_range 函数来建立页表,即实现文件地址和虚拟地址区域的映射关系。

remap_pfn_range 建立页表

// mm/memory.c
/**
 * remap_pfn_range - remap kernel memory to userspace
 * @vma: user vma to map to
 * @addr: target user address to start at
 * @pfn: physical address of kernel memory 物理页帧号的起始地址,即要映射的物理页面在内存中的索引
 * @size: size of map area
 * @prot: page protection flags for this mapping
 *
 *  Note: this is only safe if the mm semaphore is held when called.
 */
int remap_pfn_range(struct vm_area_struct *vma, unsigned long addr,
            unsigned long pfn, unsigned long size, pgprot_t prot)
{
    pgd_t *pgd;
    unsigned long next;
    unsigned long end = addr + PAGE_ALIGN(size);// 要映射虚拟地址的尾部
    struct mm_struct *mm = vma->vm_mm;
    unsigned long remap_pfn = pfn;
    int err;

    /*
     * Physically remapped pages are special. Tell the
     * rest of the world about it:
     *   VM_IO tells people not to look at these pages
     *  (accesses can have side effects).
     *   VM_PFNMAP tells the core MM that the base pages are just
     *  raw PFN mappings, and do not have a "struct page" associated
     *  with them.
     *   VM_DONTEXPAND
     *      Disable vma merging and expanding with mremap().
     *   VM_DONTDUMP
     *      Omit vma from core dump, even when VM_IO turned off.
     *
     * There's a horrible special case to handle copy-on-write
     * behaviour that some programs depend on. We mark the "original"
     * un-COW'ed pages by matching them up with "vma->vm_pgoff".
     * See vm_normal_page() for details.
     */
    if (is_cow_mapping(vma->vm_flags)) { // 判断该页是否支持写时复制 cow
        if (addr != vma->vm_start || end != vma->vm_end)
            return -EINVAL;
        vma->vm_pgoff = pfn;
    }

    err = track_pfn_remap(vma, &prot, remap_pfn, addr, PAGE_ALIGN(size));
    if (err)
        return -EINVAL;

    vma->vm_flags |= VM_IO | VM_PFNMAP | VM_DONTEXPAND | VM_DONTDUMP;

    BUG_ON(addr >= end);
    pfn -= addr >> PAGE_SHIFT;
    // 查找 addr 在页全局目录项中对应的页表项的地址
    pgd = pgd_offset(mm, addr);
    // 刷新 TLB 缓存,这个缓存和 CPU 的L1、L2、L3的缓存思想一致,既然进行地址转换需要的内存 IO 次数多,且耗时,
    // 那么干脆就在 CPU 里把页表尽可能地 cache 起来不就行了么,所以就有了 TLB(Translation Lookaside Buffer)
    // 专门用于改进虚拟地址到物理地址转换速度的缓存,其访问速度非常快,和寄存器相当,比 L1 访问还快
    flush_cache_range(vma, addr, end);
    do {
        // 计算下一个将要被映射的虚拟地址,如果 addr 到 end 可以被一个 pgd 映射的话,那么返回 end 的值
        next = pgd_addr_end(addr, end);
        // 完成虚拟内存和物理内存映射,本质就是填写完 CR3 指向的页表
        // 过程就是逐级完成:1级是pgd,上面已经完成;2级是pud,3级是pmd,4级是pte
        err = remap_p4d_range(mm, pgd, addr, next,
                pfn + (addr >> PAGE_SHIFT), prot);
        if (err)
            break;
    } while (pgd++, addr = next, addr != end);

    if (err)
        untrack_pfn(vma, remap_pfn, PAGE_ALIGN(size));

    return err;
}

remap_pfn_range 函数的核心功能是将物理页帧号 pfn 对应的物理内存映射到用户空间中要映射的虚拟内存地址的起始地址处。首先调用 pgd_offset 函数查找 addr 在页全局目录表中对应的页表项地址 pgd ,之后刷新 TLB 缓存,然后从待映射虚拟地址的起始地址 addr 开始,按照 addr 和页帧号 pfn 同步增长的顺序,循环遍历并调用 remap_p4d_range 函数逐页完成虚拟内存页和物理内存页之间的映射,补齐 CR3 指向的页表。

remap_p4d_range 全局页目录项

// mm/memory.c
static inline int remap_p4d_range(struct mm_struct *mm, pgd_t *pgd,
            unsigned long addr, unsigned long end,
            unsigned long pfn, pgprot_t prot)
{
    p4d_t *p4d;// p4d 在五级页表下会使用,在四级页表下 p4d 与 pgd 的值一样
    unsigned long next;
    int err;

    pfn -= addr >> PAGE_SHIFT;
    p4d = p4d_alloc(mm, pgd, addr);// 在四级页表下,这里只是将 pgd 赋值给 p4d,后续均以 p4d 作为全局页目录项
    if (!p4d)
        return -ENOMEM;
    do {
        // 计算下一个将要被映射的虚拟地址,如果 addr 到 end 可以被一个 pud 映射的话,那么返回 end 的值
        next = p4d_addr_end(addr, end);
        // 完成虚拟内存和物理内存映射,补齐页全局目录表
        err = remap_pud_range(mm, p4d, addr, next,
                pfn + (addr >> PAGE_SHIFT), prot);
        if (err)
            return err;
    } while (p4d++, addr = next, addr != end);
    return 0;
}

remap_p4d_range 函数,其内部也是循环遍历,调用 remap_pud_range 函数完成虚拟内存页和物理内存页之间的映射,逐页补齐页全局目录表。

remap_pud_range 上层页目录项

// mm/memory.c
static inline int remap_pud_range(struct mm_struct *mm, p4d_t *p4d,
            unsigned long addr, unsigned long end,
            unsigned long pfn, pgprot_t prot)
{
    pud_t *pud;
    unsigned long next;
    int err;
    // 首先 p4d_none 判断全局页目录项 p4d 是否是空的
    // 如果 p4d 是空的,则调用 __pud_alloc 分配一个新的页上级目录表 PUD,然后填充 p4d
    // 如果 p4d 不是空的,则调用 pud_offset 获取 address 在页上级目录 PUD 中的目录项 pud
    pfn -= addr >> PAGE_SHIFT;
    pud = pud_alloc(mm, p4d, addr);
    if (!pud)
        return -ENOMEM;
    do {
        next = pud_addr_end(addr, end);
        err = remap_pmd_range(mm, pud, addr, next,
                pfn + (addr >> PAGE_SHIFT), prot);
        if (err)
            return err;
    } while (pud++, addr = next, addr != end);
    return 0;
}

remap_pmd_range 中间页目录项

// mm/memory.c
static inline int remap_pmd_range(struct mm_struct *mm, pud_t *pud,
            unsigned long addr, unsigned long end,
            unsigned long pfn, pgprot_t prot)
{
    pmd_t *pmd;
    unsigned long next;
    int err;
    // 首先 pud_none 判断页上级目录项 pud 是不是空的
    // 如果 pud 是空的,则调用 __pmd_alloc 分配一个新的页中间目录表 PMD,然后填充 pud
    // 如果 pud 不是空的,则调用 pmd_offset 获取 address 在页中间目录 PMD 中的目录项 pmd
    pfn -= addr >> PAGE_SHIFT;
    pmd = pmd_alloc(mm, pud, addr);
    if (!pmd)
        return -ENOMEM;
    VM_BUG_ON(pmd_trans_huge(*pmd));
    do {
        next = pmd_addr_end(addr, end);
        err = remap_pte_range(mm, pmd, addr, next,
                pfn + (addr >> PAGE_SHIFT), prot);
        if (err)
            return err;
    } while (pmd++, addr = next, addr != end);
    return 0;
}

remap_pte_range 页表项

// mm/memory.c
static int remap_pte_range(struct mm_struct *mm, pmd_t *pmd,
            unsigned long addr, unsigned long end,
            unsigned long pfn, pgprot_t prot)
{
    pte_t *pte;
    spinlock_t *ptl;
    int err = 0;

    // 首先 pte_alloc 判断页中间目录项 pmd 是不是空的
    // 如果 pmd 是空的,则调用 __pte_alloc 分配一个新的页表 pt,然后填充 pmd
    // 如果 pmd 不是空的,则调用 pte_offset_map_lock 获取 address 在页表 PT 中的页表项 pte
    pte = pte_alloc_map_lock(mm, pmd, addr, &ptl);
    if (!pte)
        return -ENOMEM;
    arch_enter_lazy_mmu_mode();
    do {
        BUG_ON(!pte_none(*pte));
        if (!pfn_modify_allowed(pfn, prot)) {
            err = -EACCES;
            break;
        }
        // 这是映射的最后一级:把物理地址的值填写到 pte 表项
        // pte_mkspecial 函数构造页表项的内容,set_pte_at 函数将构造的页表项赋值给页表中的 pte
        set_pte_at(mm, addr, pte, pte_mkspecial(pfn_pte(pfn, prot)));
        pfn++;// 页帧号加 1,即下一个将要被映射的物理页帧号
    } while (pte++, addr += PAGE_SIZE, addr != end);// 计算页表中下一个将要被填充的页表项的地址
    arch_leave_lazy_mmu_mode();
    pte_unmap_unlock(pte - 1, ptl);
    return err;
}

remap_pte_range 函数首先调用 pte_alloc_map_lock 函数,判断页中间目录项 pmd 是不是空的,如果是空的,则调用 __pte_alloc 分配一个新的页表 pt ,然后填充 pmd ;如果不是空的,则调用 pte_offset_map_lock 获取 address 在页表 PT 中的页表项 pte 。然后在每一次循环中,首先调用 pte_mkspecial 函数构造页表项的内容,然后调用 set_pte_at 函数将构造的页表项赋值给页表中的 pte ,完成虚拟内存页和物理内存页之间的映射,直至循环结束补齐页表。

至此,完成 mmap 内存映射过程的第二阶段,实现文件物理地址和进程虚拟地址的一一映射关系。

第三阶段 缺页异常 Page Fault

当 CPU 访问由 mmap 映射出来的虚拟内存区域 vma 中的任意虚拟地址时。 MMU 在便利进程页表的时候就会发现,该虚拟内存地址在进程顶级页表目录 PGD 中对应的页目录项 pgd_t 是空的,该 pgd_t 并没有指向其下一级页目录 PUD 。也就是说,此时进程页表中只有一张顶级页表 PGD ,而上层页目录 PUD ,中间页目录 PMD ,一级页表 PT 内核都没有创建。
由于现在被访问到的虚拟内存地址对应的 pgd_t 是空的,进程的四级页表体系还未建立,此时 MMU 会产生一个缺页中断,进程从用户态转入内核态来处理这个缺页异常( Page Fault )。此时 CPU 会将发生缺页异常时,进程正在使用的相关寄存器中的值压入内核栈中。比如,引起进程缺页异常的虚拟内存地址会被存放在 CR2 寄存器中,同时 CPU 还会将缺页异常的错误代码 error code 压入内核栈中。

Linux 内核中的 Page Fault 异常处理很复杂,涉及的细节很多,此处对调用 mmap 内核映射时 flags 参数设置了 MMAP_POPULATE 或者 MMAP_LOCKED 标志位,需要立即为这块进程地址空间 vma 分配物理页并建立映射关系的情况进行分析。

对于缺页异常 Page Fault,这里只做一个简单的了解合代码分析,详细的分析,笔者会在后面的文章介绍。

在 mmap 内存映射完毕后,此时进程页表中映射的虚拟内存区域 vma 背后还没有映射物理内存, follow_page_mask 函数时获取不到虚拟地址对应的物理页,因此调用 faultin_page 函数,其底层会调用 handle_mm_fault 函数触发一个缺页中断,进入缺页处理流程来分配物理内存,并在页表中建立好映射关系。

faultin_page 处理缺页异常

// mm/gup.c
static int faultin_page(struct task_struct *tsk, struct vm_area_struct *vma,
        unsigned long address, unsigned int *flags, int *nonblocking)
{
    unsigned int fault_flags = 0;
    vm_fault_t ret;

    /* mlock all present pages, but do not fault in new pages */
    // 根据入参 flags 配置 fault_flags,后续交给 handle_mm_fault 函数进行处理
    if ((*flags & (FOLL_POPULATE | FOLL_MLOCK)) == FOLL_MLOCK)
        return -ENOENT;
    if (*flags & FOLL_WRITE)
        fault_flags |= FAULT_FLAG_WRITE;
    if (*flags & FOLL_REMOTE)
        fault_flags |= FAULT_FLAG_REMOTE;
    if (nonblocking)
        fault_flags |= FAULT_FLAG_ALLOW_RETRY;
    if (*flags & FOLL_NOWAIT)
        fault_flags |= FAULT_FLAG_ALLOW_RETRY | FAULT_FLAG_RETRY_NOWAIT;
    if (*flags & FOLL_TRIED) {
        VM_WARN_ON_ONCE(fault_flags & FAULT_FLAG_ALLOW_RETRY);
        fault_flags |= FAULT_FLAG_TRIED;
    }
    // 调用 handle_mm_fault 函数按照 foll_flags 标志位处理 vma 区域内的缺页异常
    // 返回值 ret 是一个位图,用于描述缺页处理过程中发生的状况信息
    ret = handle_mm_fault(vma, address, fault_flags);
    if (ret & VM_FAULT_ERROR) { // VM_FAULT_WRITE 表示发生了COW
        int err = vm_fault_to_errno(ret, *flags);

        if (err)
            return err;
        BUG();
    }

    if (tsk) {
        if (ret & VM_FAULT_MAJOR)
            tsk->maj_flt++;
        else
            tsk->min_flt++;
    }

    if (ret & VM_FAULT_RETRY) {
        if (nonblocking && !(fault_flags & FAULT_FLAG_RETRY_NOWAIT))
            *nonblocking = 0;
        return -EBUSY;
    }

    /*   
    * 如果当前 vma 中的标志显示当前页不可写,但是用户又执行了页的写操作,那么内核会执行 COW 操作,并且在处理中
    * 会有 VM_FAULT_WRITE 标志。换句话说在执行了 COW 操作后,上面的 if 判断为真,这时就移除 FOLL_WRITE 标志
    */
    if ((ret & VM_FAULT_WRITE) && !(vma->vm_flags & VM_WRITE))
        *flags |= FOLL_COW;
    return 0;
}

faultin_page 函数的作用是处理缺页异常( Page Fault ),其内部实际上调用 handle_mm_fault 函数按照 fault_flags 标志位进行处理。其可能的情况有:
1. 请求调页/按需分配;
2. 写时复制( COW );
3. 缺的页位于交换区,需要换入。

handle_mm_fault 触发缺页中断

// mm/memory.c
vm_fault_t handle_mm_fault(struct vm_area_struct *vma, unsigned long address,
        unsigned int flags)
{
    vm_fault_t ret;

    __set_current_state(TASK_RUNNING);// 设置进程当前执行状态为运行

    count_vm_event(PGFAULT);// vmstat 的 pagefault 加一
    count_memcg_event_mm(vma->vm_mm, PGFAULT);// cgroup 的 pagefault 加一

    /* do counter updates before entering really critical section. */
    check_sync_rss_stat(current);// 更新计数器
    // 判断 vma 是否是可以修改的
    if (!arch_vma_access_permitted(vma, flags & FAULT_FLAG_WRITE,
                        flags & FAULT_FLAG_INSTRUCTION,
                        flags & FAULT_FLAG_REMOTE))
        return VM_FAULT_SIGSEGV;

    /*
     * Enable the memcg OOM handling for faults triggered in user
     * space.  Kernel faults are handled more gracefully.
     */
    if (flags & FAULT_FLAG_USER)
        mem_cgroup_enter_user_fault();// 对用户空间中触发的故障启用 memcg OOM 处理

    if (unlikely(is_vm_hugetlb_page(vma))) // 大页内存的缺页处理
        ret = hugetlb_fault(vma->vm_mm, vma, address, flags);
    else
        ret = __handle_mm_fault(vma, address, flags);

    if (flags & FAULT_FLAG_USER) {// 用户空间中触发的故障启用 memcg OOM 后须主动关闭
        mem_cgroup_exit_user_fault();
        // 任务可能已进入 memcg OOM 情况,如果分配错误处理得当(没有VM_FAULT_OOM),则无需终止任何操作,清理掉 OOM 状态即可
        if (task_in_memcg_oom(current) && !(ret & VM_FAULT_OOM))
            mem_cgroup_oom_synchronize(false);
    }

    return ret;
}

handle_mm_fault 函数会对 vma 的 flag 进行检测,如果启动了 VM_HUGETLB ,则调用 hugetlb_fault 函数进行大页内存的缺页处理,否则调用 __handle_mm_fault 函数来处理。函数返回一个 unsigned int 类型的位图 vm_fault_t 。通过这个位图可以简要描述一下整个缺页异常处理过程中究竟发生了哪些情况,方便内核对各种情况进行正对处理。

// mm/memory.c
static vm_fault_t __handle_mm_fault(struct vm_area_struct *vma,
        unsigned long address, unsigned int flags)
{
    struct vm_fault vmf = { // vm_fault 结构用于封装后续缺页处理用到的相关参数
        .vma = vma, // 发生缺页的 vma
        .address = address & PAGE_MASK, // 引起缺页的虚拟内存地址
        .flags = flags, // 处理缺页的相关标记 FAULT_FLAG_xxx
        .pgoff = linear_page_index(vma, address), // address 在 vma 中的偏移,单位页
        .gfp_mask = __get_fault_gfp_mask(vma), // 后续用于分配物理内存使用的相关掩码 gfp_mask
    };
    unsigned int dirty = flags & FAULT_FLAG_WRITE;
    struct mm_struct *mm = vma->vm_mm;
    pgd_t *pgd;
    p4d_t *p4d;
    vm_fault_t ret;

    pgd = pgd_offset(mm, address);
    // 在四级页表下,这里只是将 pgd 赋值给 p4d,后续均以 p4d 作为全局页目录项
    p4d = p4d_alloc(mm, pgd, address);
    if (!p4d)
        return VM_FAULT_OOM;
    // 首先 p4d_none 判断全局页目录项 p4d 是否是空的
    // 如果 p4d 是空的,则调用 __pud_alloc 分配一个新的上层页目录表 PUD,然后填充 p4d
    // 如果 p4d 不是空的,则调用 pud_offset 获取 address 在上层页目录 PUD 中的目录项 pud
    vmf.pud = pud_alloc(mm, p4d, address);
    if (!vmf.pud)
        return VM_FAULT_OOM;
    if (pud_none(*vmf.pud) && __transparent_hugepage_enabled(vma)) {
        ret = create_huge_pud(&vmf);
        if (!(ret & VM_FAULT_FALLBACK))
            return ret;
    } else {
        pud_t orig_pud = *vmf.pud;

        barrier();
        if (pud_trans_huge(orig_pud) || pud_devmap(orig_pud)) {

            /* NUMA case for anonymous PUDs would go here */

            if (dirty && !pud_write(orig_pud)) {
                ret = wp_huge_pud(&vmf, orig_pud);
                if (!(ret & VM_FAULT_FALLBACK))
                    return ret;
            } else {
                huge_pud_set_accessed(&vmf, orig_pud);
                return 0;
            }
        }
    }

    vmf.pmd = pmd_alloc(mm, vmf.pud, address);
    if (!vmf.pmd)
        return VM_FAULT_OOM;
    if (pmd_none(*vmf.pmd) && __transparent_hugepage_enabled(vma)) {
        ret = create_huge_pmd(&vmf);
        if (!(ret & VM_FAULT_FALLBACK))
            return ret;
    } else {
        pmd_t orig_pmd = *vmf.pmd;

        barrier();
        if (unlikely(is_swap_pmd(orig_pmd))) {
            VM_BUG_ON(thp_migration_supported() &&
                      !is_pmd_migration_entry(orig_pmd));
            if (is_pmd_migration_entry(orig_pmd))
                pmd_migration_entry_wait(mm, vmf.pmd);
            return 0;
        }
        if (pmd_trans_huge(orig_pmd) || pmd_devmap(orig_pmd)) {
            if (pmd_protnone(orig_pmd) && vma_is_accessible(vma))
                return do_huge_pmd_numa_page(&vmf, orig_pmd);

            if (dirty && !pmd_write(orig_pmd)) {
                ret = wp_huge_pmd(&vmf, orig_pmd);
                if (!(ret & VM_FAULT_FALLBACK))
                    return ret;
            } else {
                huge_pmd_set_accessed(&vmf, orig_pmd);
                return 0;
            }
        }
    }
    // 进行页表的相关处理以及解析具体的缺页原因,后续针对性的进行缺页处理
    return handle_pte_fault(&vmf);
}

__handle_mm_fault 函数,首先获取进程虚拟内存空间 mm_struct ,调用 pgd_offset 函数获取虚拟地址 address 在全局页目录表 PGD 中对应的目录项 pgd_t ,然后调用 p4d_alloc 函数, pud_alloc 函数, pmd_alloc 函数获取 pud_t , pmd_t ,将获取到的数据分装到 vm_fault 结构体作为入参传入 handle_pte_fault 函数进行页表的相关处理以及解析具体的缺页原因,后续针对性的进行缺页处理。

handle_pte_fault 缺页处理

// mm/memory.c
static vm_fault_t handle_pte_fault(struct vm_fault *vmf)
{
    pte_t entry;

    if (unlikely(pmd_none(*vmf->pmd))) {
        // 如果 pmd 是空的,说明现在连页表都没有,页表项 pte 自然是空的
        vmf->pte = NULL;
    } else {
        // 判断页中间目录页表是否不稳定
        if (pmd_devmap_trans_unstable(vmf->pmd))
            return 0;
         // vmf->pte 表示缺页虚拟内存地址在页表中对应的页表项 pte,通过 pte_offset_map 定位到虚拟内存地址 address 对应在页表中的 pte
        // 其内部根据 address 获取 pte_index,然后从 pmd 中提取页表起始虚拟内存地址相加获取 pte
        vmf->pte = pte_offset_map(vmf->pmd, vmf->address);
        vmf->orig_pte = *vmf->pte;

        barrier(); // 指令隔离
        // 如果 pmd 不是空的,表示现在是有页表存在的,但缺页虚拟内存地址在页表中的 pte 是空值
        if (pte_none(vmf->orig_pte)) {
            pte_unmap(vmf->pte); // 页表项为空,则取消页直接目录的映射
            vmf->pte = NULL;
        }
    }
     // pte 是空的,表示缺页虚拟内存地址 address 还从来没有被映射过,接下来就要处理物理内存的映射
    if (!vmf->pte) {
         // 判断缺页的虚拟内存地址 address 所在的虚拟内存区域 vma 是否是匿名映射区
        if (vma_is_anonymous(vmf->vma))
            return do_anonymous_page(vmf); // 处理匿名映射区发生的缺页异常
        else
            return do_fault(vmf); // 处理文件映射区发生的缺页异常
    }
     // 流程到这表示 pte 不是空的,但是 pte 中的 p 比特位是 0 值,表示之前映射的物理内存页已不在内存中(swap out)
    if (!pte_present(vmf->orig_pte))
        return do_swap_page(vmf); // do_swap_page 函数将之前映射的物理内存页从磁盘中重新 swap in 到内存中
    // 流程到这表示 pte 背后映射的物理内存页在内存中,但是 NUMA Balancing 发现该内存页不在当前进程运行的 numa 节点上
    // 所以将该 pte 标记为 _PAGE_PROTNONE(无读写,可执行权限)
    if (pte_protnone(vmf->orig_pte) && vma_is_accessible(vmf->vma))
        // 进程访问该内存页时发生缺页中断,此时调用 do_numa_page 函数,内核将该 page 迁移到进程运行的 numa 节点上
        return do_numa_page(vmf);
     // 流程到这,开始处理页表和物理页都存在的情况,说明缺页异常是由于访问权限触发的
    // 获取页表锁地址,页表锁有两种,精粒度锁(一个进程一个锁)和粗粒度锁(一个页表一个锁)
    vmf->ptl = pte_lockptr(vmf->vma->vm_mm, vmf->pmd);
    spin_lock(vmf->ptl); // 申请自旋锁,锁住页表
    entry = vmf->orig_pte;
    if (unlikely(!pte_same(*vmf->pte, entry)))
        goto unlock;
    if (vmf->flags & FAULT_FLAG_WRITE) { // 如果本次缺页中断是由写操作引起的
        // 说明 vma 是可写的,但是 pte 被标记为不可写,说明是写保护类型的中断
        if (!pte_write(entry))
            // 如果页表没有写权限,则调用 do_wp_page 函数执行写时复制 cow
            return do_wp_page(vmf);
        // 如果页表有写权限,设置页表项的脏标志位,表示页数据被修改了
        entry = pte_mkdirty(entry);
    }
     // 将 pte 的 access 比特位置 1,表示该 page 是活跃的,避免被 swap out 到磁盘
    entry = pte_mkyoung(entry);
     // 经过上面的缺页处理,这里会判断原来的页表项 entry(orig_pte) 值是否发生了变化
    if (ptep_set_access_flags(vmf->vma, vmf->address, vmf->pte, entry,
                vmf->flags & FAULT_FLAG_WRITE)) {
        // 如果发生了变化,更新内存管理单元的页表高速缓存 cache
        update_mmu_cache(vmf->vma, vmf->address, vmf->pte);
    } else {
        // 如果 pte 内容本身没有变化,则不需要刷新任何东西
        // 但是有个特殊情况就是写保护类型中断,产生的写时复制,产生了新的映射关系,需要刷新一下 tlb
        if (vmf->flags & FAULT_FLAG_WRITE)
            flush_tlb_fix_spurious_fault(vmf->vma, vmf->address);
    }
unlock:
    pte_unmap_unlock(vmf->pte, vmf->ptl);// 释放自旋锁
    return 0;
}

handle_pte_fault 函数的核心流程如下:
1. 如果页表项 pte 是空的,表示缺页虚拟内存地址 address 还没有被映射过,继续判断缺页虚拟内存地址 address 所在的虚拟内存区域 vma 是否是匿名映射,如果是,则调用 do_anonymous_page 函数处理匿名映射的缺页异常,否则调用 do_fault 函数处理文件映射区的缺页异常。
2. 如果页表项 pte 不是空的,但是 pte 中的 p 比特位是 0 值,表示之前映射的物理内存页已不在内存中,被 swap out 到磁盘中,需要调用 do_swap_page 函数将之前映射的物理内存页从磁盘中重新 swap in 到内存中。
3. 若果本次缺页异常是由写操作引起的,即虚拟内存地址 address 所在的虚拟内存区域 vma 是可写的,但是对应的页表项 pte 被标记为不可写,此时会触发写保护类型中断,调用 do_wp_page 函数进行写时复制 cow 处理。

// mm/memory,c
static vm_fault_t do_anonymous_page(struct vm_fault *vmf)
{
    struct vm_area_struct *vma = vmf->vma;
    struct mem_cgroup *memcg;
    struct page *page;
    vm_fault_t ret = 0;
    pte_t entry;

     // 如果是共享的匿名映射,但是虚拟内存区域没有提供虚拟内存操作集合(vm_area_struct.vm_ops),则返回错误号VM_FAULT_SIGBUS
    // 判断 vma_is_anonymous 是根据 !vma->vm_ops
    if (vma->vm_flags & VM_SHARED)
        return VM_FAULT_SIGBUS;

    // 如果 pmd 是空的,表示现在还没有一级页表 
    // pte_alloc 这里会创建一级页表,并填充 pmd 中的内容
    if (pte_alloc(vma->vm_mm, vmf->pmd, vmf->address))
        return VM_FAULT_OOM;

    // 如果申请的页中间页表指向的上一级页表不稳定则返回失败
    if (unlikely(pmd_trans_unstable(vmf->pmd)))
        return 0;

    // 如果 vma 是不可写(只读)的,并且进程允许使用零页
    if (!(vmf->flags & FAULT_FLAG_WRITE) &&
            !mm_forbids_zeropage(vma->vm_mm)) {
        // 把虚拟页映射到一个专用的零页上(在后面的某个版本会取消零页)
        entry = pte_mkspecial(pfn_pte(my_zero_pfn(vmf->address),
                        vma->vm_page_prot));
        vmf->pte = pte_offset_map_lock(vma->vm_mm, vmf->pmd,
                vmf->address, &vmf->ptl);
         // 如果一级页表项不是空,说明不缺页,可能是其他处理器在使用同一个页直接页表,则直接返回
        if (!pte_none(*vmf->pte))
            goto unlock;
        // 检查内存描述符的内存空间是否稳定
        ret = check_stable_address_space(vma->vm_mm);
        if (ret)
            goto unlock;
        /* Deliver the page fault to userland, check inside PT lock */
        if (userfaultfd_missing(vma)) {
            pte_unmap_unlock(vmf->pte, vmf->ptl);
            return handle_userfault(vmf, VM_UFFD_MISSING);
        }
        goto setpte;// 之前已经分配了零页,这里可以跳过物理页分配,走快速返回路线,设置页表项
    }

    // 完成 vma 内存分配的准备,也就是说 vma 内物理页足够,如果不够则返回 oom
    if (unlikely(anon_vma_prepare(vma)))
        goto oom;
    // 页表创建完毕后,从伙伴系统中分配一个 4K 物理内存页出来,优先从高端内存区域分配,并且是用零初始化
    page = alloc_zeroed_user_highpage_movable(vma, vmf->address);
    if (!page)
        goto oom;

    if (mem_cgroup_try_charge_delay(page, vma->vm_mm, GFP_KERNEL, &memcg,
                    false))
        goto oom_free_page;

    /*
     * The memory barrier inside __SetPageUptodate makes sure that
     * preceeding stores to the page contents become visible before
     * the set_pte_at() write.
     */
    __SetPageUptodate(page);// 指令屏障,并且保证在写入之前,页面存储的内容是可见的
    // 将 page 的页帧号 pfn 以及相关权限标记位 vm_page_prot 初始化一个临时 pte 出来
    entry = mk_pte(page, vma->vm_page_prot);
    if (vma->vm_flags & VM_WRITE)
        entry = pte_mkwrite(pte_mkdirty(entry));
    // 锁定一级页表,并获取 address 在页表中对应的真实 pte
    vmf->pte = pte_offset_map_lock(vma->vm_mm, vmf->pmd, vmf->address,
            &vmf->ptl);
    if (!pte_none(*vmf->pte)) // 是否有其他线程在并发处理缺页
        goto release;

    ret = check_stable_address_space(vma->vm_mm);
    if (ret)
        goto release;

    // 将页面故障发送到 userland,并检查 PT 锁内部
    if (userfaultfd_missing(vma)) {
        pte_unmap_unlock(vmf->pte, vmf->ptl);
        mem_cgroup_cancel_charge(page, memcg, false);
        put_page(page);
        return handle_userfault(vmf, VM_UFFD_MISSING);
    }
    // 增加进程 rss 相关计数,匿名内存页计数 + 1
    inc_mm_counter_fast(vma->vm_mm, MM_ANONPAGES);
    // 建立匿名页反向映射关系,将匿名页面添加到 RMAP 系统
    page_add_new_anon_rmap(page, vma, vmf->address, false);
    mem_cgroup_commit_charge(page, memcg, false, false);
     // 将匿名页添加到 LRU 链表中,方便页回收算法从 LRU 链表祖选择合适的物理页进行回收
    lru_cache_add_active_or_unevictable(page, vma);
setpte:
    // 将 entry 赋值给真正的 pte,这里 pte 就算被填充好了,进程页表体系也就补齐了
    set_pte_at(vma->vm_mm, vmf->address, vmf->pte, entry);

    // 刷新 mmu 
    update_mmu_cache(vma, vmf->address, vmf->pte);
unlock:
    pte_unmap_unlock(vmf->pte, vmf->ptl);
    return ret;
release:
    mem_cgroup_cancel_charge(page, memcg, false);
    put_page(page);
    goto unlock;
oom_free_page:
    put_page(page);
oom:
    return VM_FAULT_OOM;
}

do_anonymouns_page 函数的核心流程如下:
1. 首先,如果页中间目录表 pmd 是空的,也就是现在还没有一级页表,则调用 pte_alloc hanshu,其内部会调用 __pte_alloc 函数创建一级页表,然后用页表的 pfn 以及初始权限位 _PAGE_TABLE 来填充 pmd 。
2. 页表创建完毕后,调用 alloc_zeroed_user_highpage_movable 函数从伙伴系统中分配一个 4K 物理内存页出来,然后调用 mk_pte 函数将刚分配的物理页 page 的 pfn 以及相关权限标记给 vm_page_prot 初始化一个临时的 pte ,再调用 pte_offset_map_lock 函数获取虚拟内存地址 address 在页表中对应的真实 pte ,左后调用 set_pte_at 函数降临时的 pte 赋值给真正的 pte ,补齐进程页表体系。

static vm_fault_t do_fault(struct vm_fault *vmf)
{
    struct vm_area_struct *vma = vmf->vma;
    struct mm_struct *vm_mm = vma->vm_mm;
    vm_fault_t ret;

    /*
     * The VMA was not fully populated on mmap() or missing VM_DONTEXPAND
     */
    // 如果虚拟内存区域 vma 没有提供页错误异常方法vma->vm_ops->fault,返回错误号VM_FAULT_SIGBUS
    if (!vma->vm_ops->fault) {
        /*
         * If we find a migration pmd entry or a none pmd entry, which
         * should never happen, return SIGBUS
         */
        // 如果中间页目录 pmd 指向的一级页表不在内存中,则返回 SIGBUS 错误
        if (unlikely(!pmd_present(*vmf->pmd)))
            ret = VM_FAULT_SIGBUS;
        else {
            // pte_offset_map_lock 函数获取缺页的页表项 pte
            vmf->pte = pte_offset_map_lock(vmf->vma->vm_mm,
                               vmf->pmd,
                               vmf->address,
                               &vmf->ptl);
            /*
             * Make sure this is not a temporary clearing of pte
             * by holding ptl and checking again. A R/M/W update
             * of pte involves: take ptl, clearing the pte so that
             * we don't have concurrent modification by hardware
             * followed by an update.
             */
            if (unlikely(pte_none(*vmf->pte)))
                ret = VM_FAULT_SIGBUS;// 如果 pte 为空,则返回 SIGBUS 错误
            else
                // 如果 pte 不为空,返回 NOPAGE,即本次缺页处理不会分配物理内存页
                ret = VM_FAULT_NOPAGE;

            pte_unmap_unlock(vmf->pte, vmf->ptl);
        }
    } else if (!(vmf->flags & FAULT_FLAG_WRITE))
        ret = do_read_fault(vmf);
    else if (!(vma->vm_flags & VM_SHARED))
        ret = do_cow_fault(vmf);
    else
        ret = do_shared_fault(vmf);

    /* preallocated pagetable is unused: free it */
    // 如果没有使用预分配的页表则释放它并且配置 prealloc_pte 为空
    if (vmf->prealloc_pte) {
        pte_free(vm_mm, vmf->prealloc_pte);
        vmf->prealloc_pte = NULL;
    }
    return ret;
}

do_fault 函数更具缺页的标记 vmf->flags 进行判断:
1. 如果缺页是由读操作引起的,则调用 do_read_fault 函数将文件内容读取到 vmf->page 页面,并为此物理页面建立与缺页地址 address 的映射关系;
2. 如果缺页是由私有映射区的写入操作引起的,则调用 do_cow_fault 函数,其首先从 page cache 读取原来的文件页到 vmf->page 页面,然后将 vmf->page 页面中的内容拷贝到 vmf->cow_page 中,并为 vmf->cow_page 页面分配 pte ,建立缺页地址 address 与 vmf->cow_page 页面的映射关系;
3. 如果缺页是由写共享文件引起的,则调用 do_shared_fault 函数,其首先也是从 page cache 中读取文件页到 vmf->page 页面,并将文件页变为可写状态,为后续记录文件日志做一些准备工作,然后建立缺页地址 address 与 vmf->page 页面的映射关系,最后将 vmf->page 页面标记为脏页,记录相关文件系统的日志(脏页回写时用于判断),防止数据丢失。

// mm/memory.c
static vm_fault_t do_read_fault(struct vm_fault *vmf)
{
    struct vm_area_struct *vma = vmf->vma;
    vm_fault_t ret = 0;

    // map_pages 用于提前预先映射文件页相邻的若干文件页到相关 pte 中,从而减少缺页次数
    // fault_around_bytes 控制预先映射的的字节数默认初始值为 65536(16个物理内存页)
    if (vma->vm_ops->map_pages && fault_around_bytes >> PAGE_SHIFT > 1) {
        // 尝试使用 map_pages 将缺页地址 address 附近的文件页预读进 page cache,然后填充相关的 pte,目的是减少缺页异常次数
        ret = do_fault_around(vmf);
        if (ret)
            return ret;
    }
     // 如果不满足预先映射的条件,则只映射本次需要的文件页
    // 首先会从 page cache 中读取文件页,如果 page cache 中不存在则从磁盘中读取,并预读若干文件页到 page cache 中
    ret = __do_fault(vmf);
    if (unlikely(ret & (VM_FAULT_ERROR | VM_FAULT_NOPAGE | VM_FAULT_RETRY)))
        return ret;
    // 将本次缺页所需要的文件页映射到 pte 中,建立物理页面与缺页地址的映射关系
    ret |= finish_fault(vmf);
    unlock_page(vmf->page);
    if (unlikely(ret & (VM_FAULT_ERROR | VM_FAULT_NOPAGE | VM_FAULT_RETRY)))
        put_page(vmf->page);
    return ret;
}

do_read_fault 函数尝试使用待映射虚拟内存区域 vma 结构体中 vm_ops 设置的 map_pages 函数将缺页虚拟内存地址 address 附近的文件页预读进 page cache ,然后填充相关的页表项 pte ,从而减少缺页次数。如果不满足预先映射的条件,则调用 __do_fault 从磁盘文件中获取对应的文件页,最后调用 finish_fault 函数将本次缺页所需要的文件页映射到 pte 中。

// mm/memory.c
static vm_fault_t do_cow_fault(struct vm_fault *vmf)
{
    struct vm_area_struct *vma = vmf->vma;
    vm_fault_t ret;
    // 完成 vma 内存分配的准备,如创建该 vma 的 av 及 avc 来初始化 vma 中成员变量,并用 anon_vma_chain_link 初始化 avc
    if (unlikely(anon_vma_prepare(vma)))
        return VM_FAULT_OOM;
    // 从伙伴系统申请一个用于写时复制的物理内存页 cow_page
    vmf->cow_page = alloc_page_vma(GFP_HIGHUSER_MOVABLE, vma, vmf->address);
    if (!vmf->cow_page)
        return VM_FAULT_OOM;

    if (mem_cgroup_try_charge_delay(vmf->cow_page, vma->vm_mm, GFP_KERNEL,
                &vmf->memcg, false)) {
        put_page(vmf->cow_page);
        return VM_FAULT_OOM;
    }

    ret = __do_fault(vmf);// 从  page cache 读取原来的文件页到 vmf->page
    if (unlikely(ret & (VM_FAULT_ERROR | VM_FAULT_NOPAGE | VM_FAULT_RETRY)))
        goto uncharge_out;
    if (ret & VM_FAULT_DONE_COW)
        return ret;
     // 将原来文件页中的内容拷贝到 cow_page 中完成写时复制
    copy_user_highpage(vmf->cow_page, vmf->page, vmf->address, vma);
    __SetPageUptodate(vmf->cow_page);// 设置指令屏障,确保在写入之前,页面存储的内容是可见的
    // 将 cow_page 映射到缺页地址 address 对应在页表中的 pte 上
    ret |= finish_fault(vmf);
    unlock_page(vmf->page);
    put_page(vmf->page); // 之前已经分配了物理页,所以 page_count 计数加一
    if (unlikely(ret & (VM_FAULT_ERROR | VM_FAULT_NOPAGE | VM_FAULT_RETRY)))
        goto uncharge_out;
    return ret;
uncharge_out:
    mem_cgroup_cancel_charge(vmf->cow_page, vmf->memcg, false);
    put_page(vmf->cow_page);  // cow_page计数加一
    return ret;
}

do_cow_fault 函数首先从伙伴系统中申请一个用于写时复制的物理内存页 cow_page ,然后调用 __do_fault 函数从 page cache 读取原来的文件页到 vmf->page ,随后调用 copy_user_highpage 函数将原来的文件页中的内容拷贝到刚刚申请的内存页 cow_page 中,完成写诗复制后,接着调用 finish_fault 函数将 cow_page 映射到缺页地址 address 在进程页表中的 pte 上。

// mm/memory.c
static vm_fault_t do_shared_fault(struct vm_fault *vmf)
{
    struct vm_area_struct *vma = vmf->vma;
    vm_fault_t ret, tmp;

    ret = __do_fault(vmf); // 从  page cache 读取原来的文件页到 vmf->page
    if (unlikely(ret & (VM_FAULT_ERROR | VM_FAULT_NOPAGE | VM_FAULT_RETRY)))
        return ret;

    /*
     * Check if the backing address space wants to know that the page is
     * about to become writable
     */
    if (vma->vm_ops->page_mkwrite) { // 如果虚拟内存操作集合有 page_mkwrite 操作方法
        unlock_page(vmf->page);
        tmp = do_page_mkwrite(vmf);  // 调用虚拟内存操作集合的 page_mkwrite 操作方法
        if (unlikely(!tmp ||
                (tmp & (VM_FAULT_ERROR | VM_FAULT_NOPAGE)))) {
            put_page(vmf->page);
            return tmp;
        }
    }
    // 将获取到的 vmf->page 文件页映射到缺页地址 address 对应在页表中的 pte 上
    ret |= finish_fault(vmf);
    if (unlikely(ret & (VM_FAULT_ERROR | VM_FAULT_NOPAGE |
                    VM_FAULT_RETRY))) {
        unlock_page(vmf->page);
        put_page(vmf->page);
        return ret;
    }
    // 将 page 标记为脏页,表示页数据被修改了,并记录相关文件系统的日志,平衡并回写一部分脏页,防止数据丢失
    fault_dirty_shared_page(vma, vmf->page);
    return ret;
}

do_shared_fault 函数首先调用 __do_fault 函数从 page cache 读取原来的文件页到 vmf->page ,接着调用 finish_fault 函数将获取到的 vmf->page 文件页映射到缺页地址 address 对应在页表中的 pte 上,最后由于共享文件映射涉及脏页回写,因此将该文件页标记为脏页,表示页数据被修改过,并记录相关文件系统的日志,防止数据丢失。
综上所述, do_read_fault 函数、 do_cow_fault 函数和 do_shared_fault 函数内主要调用 __do_fault 函数和 finish_fault 函数来完成进程页表的补齐,首先来看 __do_fault 函数获取映射文件的过程。

// mm/memory.c
static vm_fault_t __do_fault(struct vm_fault *vmf)
{
    struct vm_area_struct *vma = vmf->vma;
    vm_fault_t ret;

    /*
     * Preallocate pte before we take page_lock because this might lead to
     * deadlocks for memcg reclaim which waits for pages under writeback:
     *              lock_page(A)
     *              SetPageWriteback(A)
     *              unlock_page(A)
     * lock_page(B)
     *              lock_page(B)
     * pte_alloc_pne
     *   shrink_page_list
     *     wait_on_page_writeback(A)
     *              SetPageWriteback(B)
     *              unlock_page(B)
     *              # flush A, B to clear the writeback
     */
    if (pmd_none(*vmf->pmd) && !vmf->prealloc_pte) {
        vmf->prealloc_pte = pte_alloc_one(vmf->vma->vm_mm,
                          vmf->address);
        if (!vmf->prealloc_pte)
            return VM_FAULT_OOM;
        smp_wmb(); /* See comment in __pte_alloc() */
    }
    // 不同的文件系统中,调用 fault 函数对应的实现函数
    ret = vma->vm_ops->fault(vmf);
    if (unlikely(ret & (VM_FAULT_ERROR | VM_FAULT_NOPAGE | VM_FAULT_RETRY |
                VM_FAULT_DONE_COW)))
        return ret;

    if (unlikely(PageHWPoison(vmf->page))) {
        if (ret & VM_FAULT_LOCKED)
            unlock_page(vmf->page);
        put_page(vmf->page);
        vmf->page = NULL;
        return VM_FAULT_HWPOISON;
    }

    if (unlikely(!(ret & VM_FAULT_LOCKED)))
        lock_page(vmf->page);
    else
        VM_BUG_ON_PAGE(!PageLocked(vmf->page), vmf->page);

    return ret;
}

// fs/ext4/file.c
static const struct vm_operations_struct ext4_file_vm_ops = {
    .fault    = ext4_filemap_fault,
    .map_pages  = filemap_map_pages,
    .page_mkwrite   = ext4_page_mkwrite,
};

__do_fault 函数会调用到待映射虚拟内存区域 vma 结构体中 vm_ops 设置的 fault 函数,进程调用 mmap 函数创建文件映射的时候,文件所属的文件系统会注册虚拟内存区域的虚拟内存操作集合, fault 方法负责处理文件页的缺页异常。
EXT4 文件系统注册的虚拟内存操作集合是 ext4_file_vm_ops , fault 方法就是函数 ext4_filemap_fault 。许多文件系统注册的 fault 方法是通用的 filemap_fault 函数。

// fs/ext4/inode.c
vm_fault_t ext4_filemap_fault(struct vm_fault *vmf){
    struct inode *inode = file_inode(vmf->vma->vm_file);
    vm_fault_t ret;

    down_read(&EXT4_I(inode)->i_mmap_sem);
    ret = filemap_fault(vmf);
    up_read(&EXT4_I(inode)->i_mmap_sem);

    return ret;
}

ext4_filemap_fault 函数内也是调用 filemap_fault 函数来获取所缺的文件页。

// mm/filemap.c
vm_fault_t filemap_fault(struct vm_fault *vmf)
{
    int error;
    struct file *file = vmf->vma->vm_file;// 获取映射文件
    struct address_space *mapping = file->f_mapping;// 获取 page cache
    struct file_ra_state *ra = &file->f_ra;
    struct inode *inode = mapping->host;// 获取映射文件的 inode 节点
    pgoff_t offset = vmf->pgoff;// 获取映射文件内容在文件中的偏移
    pgoff_t max_off;
    struct page *page;// 从 page cache 读取到的文件页,存放在 vmf->page 中返回
    vm_fault_t ret = 0;

    max_off = DIV_ROUND_UP(i_size_read(inode), PAGE_SIZE);
    if (unlikely(offset >= max_off))
        return VM_FAULT_SIGBUS;

    // 根据文件偏移 offset,到 page cache 中查找对应的文件页
    page = find_get_page(mapping, offset);
    if (likely(page) && !(vmf->flags & FAULT_FLAG_TRIED)) {
        // 如果文件页在 page cache 中,则启动异步预读,预读后面的若干文件页到 page cache 中
        do_async_mmap_readahead(vmf->vma, ra, file, page, offset);
    } else if (!page) {
        // 如果文件页不在 page cache 中,则需要启动 io 从文件中读取内容到 page cahe
        // 由于涉及到了磁盘 io,所以本次缺页类型为 VM_FAULT_MAJOR
        // 启动同步预读,将所需的文件数据读取进 page cache 中并同步预读若干相邻的文件数据到 page cache
        do_sync_mmap_readahead(vmf->vma, ra, file, offset);
        count_vm_event(PGMAJFAULT);
        count_memcg_event_mm(vmf->vma->vm_mm, PGMAJFAULT);
        ret = VM_FAULT_MAJOR;
retry_find:
        page = find_get_page(mapping, offset);
        if (!page)
            goto no_cached_page;
    }

    if (!lock_page_or_retry(page, vmf->vma->vm_mm, vmf->flags)) {
        put_page(page);
        return ret | VM_FAULT_RETRY;
    }

    /* Did it get truncated? */
    if (unlikely(page->mapping != mapping)) {
        unlock_page(page);
        put_page(page);
        goto retry_find;
    }
    VM_BUG_ON_PAGE(page->index != offset, page);

    // 页面缓存中有一个锁定的页面,检查它是否是最新的,如果不是则可能是出错
    if (unlikely(!PageUptodate(page)))
        goto page_not_uptodate;

    // 已找到目标文件页,并有引用,此时须在页锁定下重新检查 i_size
    max_off = DIV_ROUND_UP(i_size_read(inode), PAGE_SIZE);
    if (unlikely(offset >= max_off)) {
        unlock_page(page);
        put_page(page);
        return VM_FAULT_SIGBUS;
    }

    vmf->page = page;
    return ret | VM_FAULT_LOCKED;

no_cached_page:
    // 如果页面不是最新的,试着重读一遍,检查是否出错了,由于没有任何性能问题,且需要检查错误,因此这里是同步进行的
    error = page_cache_read(file, offset, vmf->gfp_mask);

    /*
     * The page we want has now been added to the page cache.
     * In the unlikely event that someone removed it in the
     * meantime, we'll just come back here and read it again.
     */
    if (error >= 0)
        goto retry_find;

    /*
     * An error return from page_cache_read can result if the
     * system is low on memory, or a problem occurs while trying
     * to schedule I/O.
     */
    if (error == -ENOMEM)
        return VM_FAULT_OOM;
    return VM_FAULT_SIGBUS;

page_not_uptodate:
    /*
     * Umm, take care of errors if the page isn't up-to-date.
     * Try to re-read it _once_. We do this synchronously,
     * because there really aren't any performance issues here
     * and we need to check for errors.
     */
    ClearPageError(page);
    error = mapping->a_ops->readpage(file, page);
    if (!error) {
        wait_on_page_locked(page);
        if (!PageUptodate(page))
            error = -EIO;
    }
    put_page(page);

    if (!error || error == AOP_TRUNCATED_PAGE)
        goto retry_find;

    /* Things didn't work out. Return zero to tell the mm layer so. */
    shrink_readahead_size_eio(file, ra);
    return VM_FAULT_SIGBUS;
}

filemap_fault 函数的主要作用是先把缺页所需要的文件页获取出来,为后面的映射做准备,其核心流程如下:
1. 首先调用 find_get_page 函数从 page cache 中尝试获取文件页,如果文件页存在,则继续调用 do_async_mmap_readahead 函数启动异步预读机制,将相邻的若干文件页一起预读进 page cache 中。
2. 如果文件页不在 page cach e 中,内核则会调用 do_sync_mmap_readahead 函数来同步预读,这里首先会分配一个物理内存页出来,然后将新分配的内存页加入到 page cache 中,并增加页引用计数。
3. 随后会通过 address_space_operation s 中定义的 readpage 激活块设备驱动从磁盘中读取映射的文件内容,然后将读取到的内容填充新分配的内存页中,并同步预读若干相邻的文件页到 page cache 中

// mm/memory.c
vm_fault_t finish_fault(struct vm_fault *vmf)
{
    struct page *page;// 为本次缺页准备好的物理内存页,即后续需要用 pte 映射的内存页
    vm_fault_t ret = 0;

    /* Did we COW the page? */
    if ((vmf->flags & FAULT_FLAG_WRITE) &&
        !(vmf->vma->vm_flags & VM_SHARED))
        page = vmf->cow_page;// 如果是写时复制场景,页表项 pte 要映射的是 cow 复制过来的内存页
    else
        page = vmf->page;// 在 filemap_fault 函数中读取到的文件页,后面需要将文件页映射到 pte 中

    // 对于私有映射来说,这里需要检查进程地址空间是否被标记了 MMF_UNSTABLE
    // 如果是,那么 oom 后续会回收这块地址空间,这会导致私有映射的文件页丢失
    // 所以在为私有映射建立 pte 映射之前,需要检查一下
    if (!(vmf->vma->vm_flags & VM_SHARED))
        // 如果是读私有内存,需要判断是否有稳定的内存
        ret = check_stable_address_space(vmf->vma->vm_mm);
    if (!ret)
         // 将创建出来的物理内存页映射到 address 对应在页表中的 pte 中
        ret = alloc_set_pte(vmf, vmf->memcg, page);
    if (vmf->pte)
        // 分配到物理页则释放页表锁
        pte_unmap_unlock(vmf->pte, vmf->ptl);
    return ret;
}

finish_fault 函数首先根据缺页的相关标记为本次缺页异常设置好待映射的物理内存页,然后调用 alloc_set_pte 函数将设置好的物理内存页映射到虚拟内存地址 address 对应在页表中的 pte 中。

// mm/memory.c
vm_fault_t alloc_set_pte(struct vm_fault *vmf, struct mem_cgroup *memcg,
        struct page *page)
{
    struct vm_area_struct *vma = vmf->vma;
    bool write = vmf->flags & FAULT_FLAG_WRITE; // 判断本次缺页是否是 写时复制
    pte_t entry;
    vm_fault_t ret;

    if (pmd_none(*vmf->pmd) && PageTransCompound(page) &&
            IS_ENABLED(CONFIG_TRANSPARENT_HUGE_PAGECACHE)) {
        /* THP on COW? */
        VM_BUG_ON_PAGE(memcg, page);

        ret = do_set_pmd(vmf, page);
        if (ret != VM_FAULT_FALLBACK)
            return ret;
    }
     // 如果页表项不存在,调用 pte_alloc_one_map 函数,如果 pmd 为空,则创建一个页表出来,并填充 pmd
    // 如果 pmd 不为空,则获取 address 在页表中对应的 pte 保存在 vmf->pte 中
    if (!vmf->pte) {
        ret = pte_alloc_one_map(vmf);
        if (ret)
            return ret;
    }

    // 再次检查页表是否为空,如果不为空,说明其他处理器使用了这个页表,当前处理器放弃返回错误
    if (unlikely(!pte_none(*vmf->pte)))
        return VM_FAULT_NOPAGE;
    // 分配完页表项后需要刷新 icache,这个函数跟 cpu 架构相关,一般都是空操作
    flush_icache_page(vma, page);
    // 根据之前分配出来的内存页 pfn 以及相关页属性 vma->vm_page_prot 构造一个 pte 出来
    // 对于私有文件映射来说,这里的 pte 是只读的
    entry = mk_pte(page, vma->vm_page_prot);
    if (write) // 如果是写时复制,则将 pte 改为可写的
        entry = maybe_mkwrite(pte_mkdirty(entry), vma);
    /* copy-on-write page */
    if (write && !(vma->vm_flags & VM_SHARED)) { // 如果是写时复制
        inc_mm_counter_fast(vma->vm_mm, MM_ANONPAGES);  // 快速计算 vma 的匿名页数量
        page_add_new_anon_rmap(page, vma, vmf->address, false); // 匿名页面添加到 RMAP 系统
        mem_cgroup_commit_charge(page, memcg, false, false);
         // 把物理页添加到 LRU(最近最少使用)链表,方便页回收算法从 LRU 链表祖选择合适的物理页进行回收
        lru_cache_add_active_or_unevictable(page, vma);
    } else {
        inc_mm_counter_fast(vma->vm_mm, mm_counter_file(page)); // 快速计算 vma 的文件映射也数量
        page_add_file_rmap(page, false);
    }
    // 将构造出来的 pte (entry)赋值给 address 在页表中真正对应的 vmf->pte
    // 现在进程页表体系就全部被构建出来了,文件页缺页处理到此结束
    set_pte_at(vma->vm_mm, vmf->address, vmf->pte, entry);

    // 页表发生变化,更新内存管理单元的页表高速缓存cache,不存在的页面不会被缓存
    update_mmu_cache(vma, vmf->address, vmf->pte);

    return 0;
}

alloc_set_pte 函数的核心流程如下:
1. 如果页表项 pte 不存在,则调用 pte_alloc_one_map 函数,在其内部首先通过 pmd_none 函数判断页中间目录 pmd 是否为空,如不为空则调用 pte_offset_map_lock 函数获取虚拟内存地址 address 在页中间目录表 pmd 中对应的真实 pte 并保存在 vmf->pte 中。
2. 调用 mk_pte 函数将之前分配的物理页 page 的页帧号 pfn 以及相关权限标记位 vm_page_prot 初始化一个临时 pte ,最后调用 set_pte_at 函数将临时将构造出来的 pte (entry) 赋值给 address 在页表中真正对应的 vmf->pte ,补齐进程页表体系( set_pte_at 函数最后调用 native_set_pte 函数,最终交由 WRITE_ONCE 写操作完成赋值)。

WRITE_ONCE 是一个宏,它在 Linux 内核中用于确保对变量的写操作是原子性的,并且不会被编译器的优化重排。这个宏的主要目的是在多线程环境中提供一种安全的方式来写入共享变量,确保其他线程能够看到正确的值。流程至此,调用 mmap 函数进行内存映射时,需立即分配物理页并建立对应的映射关系的全部过程已分析完毕。

mmap 提前分配物理内存流程

回到 vm_mmap_pgoff 函数,这里对大页内存处理做一个详细的分析。当 flags 参数中设置了 MAP_POPULATE 或者 MAP_COCKED 标志位, Linux 内核将调用 mm_populate 函数,为 mmap 刚刚映射出来的这段虚拟内存区域 [ret, ret + populate] 提前分配物理内存。

// include/linux/mm.h
// __mm_populate 函数的实现是在 /mm/gup.c 文件中
extern int __mm_populate(unsigned long addr, unsigned long len,
        int ignore_errors);
static inline void mm_populate(unsigned long addr, unsigned long len)
{
    /* Ignore errors */
    (void) __mm_populate(addr, len, 1);
}

mmap_populate 函数是一个内联函数,其内部调用 /mm/gup.c 中的 __mm_populate 函数。

// mm/gup.c
int __mm_populate(unsigned long start, unsigned long len, int ignore_errors)
{
    struct mm_struct *mm = current->mm;
    unsigned long end, nstart, nend;
    struct vm_area_struct *vma = NULL;
    int locked = 0;
    long ret = 0;

    end = start + len;
    // 依次遍历进程地址空间中 [start , end] 这段虚拟内存范围的所有 vma
    for (nstart = start; nstart < end; nstart = nend) {
        /*
         * We want to fault in pages for [nstart; end) address range.
         * Find first corresponding VMA.
         */
        if (!locked) {
            locked = 1;
            down_read(&mm->mmap_sem);
            vma = find_vma(mm, nstart);
        } else if (nstart >= vma->vm_end)
            vma = vma->vm_next;
        if (!vma || vma->vm_start >= end)
            break;
        /*
         * Set [nstart; nend) to intersection of desired address
         * range with the first VMA. Also, skip undesirable VMA types.
         */
        nend = min(end, vma->vm_end);
        if (vma->vm_flags & (VM_IO | VM_PFNMAP))
            continue;
        if (nstart < vma->vm_start)
            nstart = vma->vm_start;
        /*
         * Now fault in a range of pages. populate_vma_page_range()
         * double checks the vma flags, so that it won't mlock pages
         * if the vma was already munlocked.
         */
         // 为这段地址范围内的所有 vma 分配物理内存
        ret = populate_vma_page_range(vma, nstart, nend, &locked);
        if (ret < 0) {
            if (ignore_errors) {
                ret = 0;
                continue;   /* continue at next VMA */
            }
            break;
        }
        nend = nstart + ret * PAGE_SIZE;
        ret = 0;
    }
    if (locked)
        up_read(&mm->mmap_sem);
    return ret; /* 0 or negative error code */
}

__mm_populate 函数的作用主要是在进程虚拟内存空间中,找出 [ret, ret + populate] 这段虚拟地址范围内的所有 vma ,并调用 populate_vma_page_range 函数为每一个 vma 填充物理内存。

// mm/gup.c
long populate_vma_page_range(struct vm_area_struct *vma,
        unsigned long start, unsigned long end, int *nonblocking)
{
    struct mm_struct *mm = vma->vm_mm;
    unsigned long nr_pages = (end - start) / PAGE_SIZE;
    int gup_flags;

    VM_BUG_ON(start & ~PAGE_MASK);
    VM_BUG_ON(end   & ~PAGE_MASK);
    VM_BUG_ON_VMA(start < vma->vm_start, vma);
    VM_BUG_ON_VMA(end   > vma->vm_end, vma);
    VM_BUG_ON_MM(!rwsem_is_locked(&mm->mmap_sem), mm);

    gup_flags = FOLL_TOUCH | FOLL_POPULATE | FOLL_MLOCK;
    if (vma->vm_flags & VM_LOCKONFAULT)
        gup_flags &= ~FOLL_POPULATE;
    /*
     * We want to touch writable mappings with a write fault in order
     * to break COW, except for shared mappings because these don't COW
     * and we would not want to dirty them for nothing.
     */
    if ((vma->vm_flags & (VM_WRITE | VM_SHARED)) == VM_WRITE)
        gup_flags |= FOLL_WRITE;

    /*
     * We want mlock to succeed for regions that have any permissions
     * other than PROT_NONE.
     */
    if (vma->vm_flags & (VM_READ | VM_WRITE | VM_EXEC))
        gup_flags |= FOLL_FORCE;

    /*
     * We made sure addr is within a VMA, so the following will
     * not result in a stack expansion that recurses back here.
     */
     // 循环遍历 vma 中的每一个虚拟内存页,依次为其分配物理内存页并建立映射关系
    return __get_user_pages(current, mm, start, nr_pages, gup_flags,
                NULL, NULL, nonblocking);
}

populate_vma_page_range 函数是在 __mm_populate 函数处理的基础上,为指定地址范围的 [start, end] 内的每一个虚拟内存页,通过 __get_user_pages 函数为其分配物理内存并建立映射关系。

__get_user_pages 配物理内存并建立映射关系

// mm/gup.c
/*参数说明:
@tsk: 表示进程的struct task_struct数据结构 
@mm: 表示进程管理的struct mm_struct数据结构 
@start: 表示进程地址空间 vma 的起始地址 
@nr_pages: 表示需要分配多少个内存页面 
@gup_flags: 分配掩码 
@pages:表示物理页面的二级指针 
@vmas: 进程地址空间 vma 
@nonblocking: 表示是否等待 I/O 操作
*/
static long __get_user_pages(struct task_struct *tsk, struct mm_struct *mm,
        unsigned long start, unsigned long nr_pages,
        unsigned int gup_flags, struct page **pages,
        struct vm_area_struct **vmas, int *nonblocking)
{
    long i = 0;
    unsigned int page_mask;
    struct vm_area_struct *vma = NULL;

    if (!nr_pages)
        return 0;

    VM_BUG_ON(!!pages != !!(gup_flags & FOLL_GET));

    /*
     * If FOLL_FORCE is set then do not force a full fault as the hinting
     * fault information is unrelated to the reference behaviour of a task
     * using the address space
     */
    if (!(gup_flags & FOLL_FORCE))
        gup_flags |= FOLL_NUMA;

    do {
        struct page *page;
        unsigned int foll_flags = gup_flags;
        unsigned int page_increm;

        /* first iteration or cross vma bound */
        if (!vma || start >= vma->vm_end) {
         // find_extend_vma 函数查找 vma,内部会调用 find_vma 查找 vma,如果 vma->vma_start 大于查找地址 start,
         // 那将尝试去扩增 vma,把 vma->vm_start 边界扩大到 start。如果没有找到合适的 vma,
         // 且 start 地址恰好在 gate_vma 中,那么使用 gate 页面,当然这种情况比较罕见。
            vma = find_extend_vma(mm, start);
            if (!vma && in_gate_area(mm, start)) {
                int ret;
                ret = get_gate_page(mm, start & PAGE_MASK,
                        gup_flags, &vma,
                        pages ? &pages[i] : NULL);
                if (ret)
                    return i ? : ret;
                page_mask = 0;
                goto next_page;
            }

            if (!vma || check_vma_flags(vma, gup_flags))
                return i ? : -EFAULT;
            if (is_vm_hugetlb_page(vma)) {
                i = follow_hugetlb_page(mm, vma, pages, vmas,
                        &start, &nr_pages, i,
                        gup_flags, nonblocking);
                continue;
            }
        }
retry:
        /*
         * If we have a pending SIGKILL, don't keep faulting pages and
         * potentially allocating memory.
         */
         // 如果当前进程收到一个 SKIGILL 信号,则不需要继续分配内存,直接报错退出
        if (unlikely(fatal_signal_pending(current)))
            return i ? i : -ERESTARTSYS;
         // cond_resched()判断当前进程是否需要被调度,内核代码通常在while()循环中添加cond_resched()来优化系统的延迟
        cond_resched();
        // 调用 follow_page_mask 函数检查进程页表中 vma 中的虚拟页面是否已经分配了物理内存
        page = follow_page_mask(vma, start, foll_flags, &page_mask);
        if (!page) {
            int ret;
            ret = faultin_page(tsk, vma, start, &foll_flags,
                    nonblocking);
            switch (ret) {
            case 0:
                goto retry;
            case -EFAULT:
            case -ENOMEM:
            case -EHWPOISON:
                return i ? i : ret;
            case -EBUSY:
                return i;
            case -ENOENT:
                goto next_page;
            }
            BUG();
        } else if (PTR_ERR(page) == -EEXIST) {
            /*
             * Proper page table entry exists, but no corresponding
             * struct page.
             */
            goto next_page;
        } else if (IS_ERR(page)) {
            return i ? i : PTR_ERR(page);
        }
        if (pages) {
            pages[i] = page;
            // 调用 flush_anon_page 和 flush_dcache_page 来刷新这些页面对应的 cache
            flush_anon_page(vma, page, start);
            flush_dcache_page(page);
            page_mask = 0;
        }
next_page:
        if (vmas) {
            vmas[i] = vma;
            page_mask = 0;
        }
        page_increm = 1 + (~(start >> PAGE_SHIFT) & page_mask);
        if (page_increm > nr_pages)
            page_increm = nr_pages;
        i += page_increm;
        start += page_increm * PAGE_SIZE;
        nr_pages -= page_increm;
    } while (nr_pages);
    return i;
}

__get_user_pages 是分配物理内存的接口函数,其核心流程如下:

  1. 调用 find_extend_vma 函数查找虚拟内存区域 vma ,内部调用 find_vma 函数查找 vma ,如果 vma->vma_start 大于查找地址 start ,那就尝试扩大 vma ,把 vma->vm_start 边界扩大到 start 中。如果没有找到 vma ,且 start 地址恰好在 gate_vma 中,那么使用 gate 页面,当然这种情况比较罕见。
  2. 调用 follow_page_mask 函数在进程页表中查找该虚拟内存背后是否有物理内存与之映射,有则返回已经映射过的 normal mapping 页面的 struct page 数据结构体,如果没有则调用 faultin_page 函数,其底层调用到 handle_mm_fault 函数进入缺页处理流程,内核在这里会为其分配物理内存页,并在进程页表中建立号映射关系。
// mm/mmap.c
struct vm_area_struct *
find_extend_vma(struct mm_struct *mm, unsigned long addr)
{
    struct vm_area_struct *vma;
    unsigned long start;

    addr &= PAGE_MASK;
    vma = find_vma(mm, addr);
    if (!vma)
        return NULL;
    if (vma->vm_start <= addr)
        return vma;
    if (!(vma->vm_flags & VM_GROWSDOWN))
        return NULL;
    /* don't alter vm_start if the coredump is running */
    if (!mmget_still_valid(mm))
        return NULL;
    start = vma->vm_start;
    if (expand_stack(vma, addr))
        return NULL;
    if (vma->vm_flags & VM_LOCKED)
        populate_vma_page_range(vma, addr, start, NULL);
    return vma;
}

find_extend_vma 函数内部会调用 find_vma 函数来查找 vma ,如果 vma->vm_start 大于查找地址 addr ,则调用 expand_stack 函数去扩增 vma ,把 vma->vm_start 边界扩大到addr。最后再次调用 populate_vma_range 函数为其扩增后的 vma 分配物理内存。

回到 __get_user_pages 函数,在调用 find_extend_vma 函数查找到 vma 后,调用 follow_page_mask 函数在进程页表中查找该 vma 背后是否有物理内存页与之映射。
filliw_page 函数是内核中用于根据虚拟地址查找对应的物理页函数,完成页全局目录 PGD 到 PAGE 页的转换。 follow_page_mask 函数是内存管理核心 API 函数 follow_page 函数的具体实现。具体的可以学习 Linux 的页表体系。

如上图所示 Linux 内核的 4 级页表,内核根据虚拟地址完成从页全局目录PGD到PAGE页的转换,整个过程是比较机械的,每次转换先获取物理页基地址,再从线性地址中获取索引,合成物理地址后再访问内存。不管是页表还是要访问的数据都是以页为单位存放在主存中的,因此每次访问内存时都要先获得基址,再通过索引(或偏移量)在页内访问数据,因此可以将线性地址看作是若干个索引的集合。结合上图再来分析源码,看看内核是怎样一步步从页全局目录PGD到PAGE页转换的。

// mm/gup.c
static struct page *follow_page_mask(struct vm_area_struct *vma,
                  unsigned long address, unsigned int flags,
                  unsigned int *page_mask)
{
    pgd_t *pgd;
    struct page *page;
    struct mm_struct *mm = vma->vm_mm;

    *page_mask = 0;

    /* make this handle hugepd */
    page = follow_huge_addr(mm, address, flags & FOLL_WRITE);
    if (!IS_ERR(page)) {
        BUG_ON(flags & FOLL_GET);
        return page;
    }
    // 调用 pgd_offset 辅助函数由 mm 和地址 addr 找到当前进程页表对应的 PGD 页全局目录项
    pgd = pgd_offset(mm, address);

    if (pgd_none(*pgd) || unlikely(pgd_bad(*pgd)))
        return no_page_table(vma, flags);

    if (pgd_huge(*pgd)) { // 先忽略大页内存相关的逻辑
        page = follow_huge_pgd(mm, address, pgd, flags);
        if (page)
            return page;
        return no_page_table(vma, flags);
    }
    if (is_hugepd(__hugepd(pgd_val(*pgd)))) {
        page = follow_huge_pd(vma, address,
                      __hugepd(pgd_val(*pgd)), flags,
                      PGDIR_SHIFT);
        if (page)
            return page;
        return no_page_table(vma, flags);
    }

    return follow_p4d_mask(vma, address, pgd, flags, page_mask);
}

follow_page_mask 函数主要作用是为用户空间虚拟地址寻找一个 page 描述符,首先通过 pgd_offset 辅助函数由内存描述符 mm 和虚拟地址 addr 找到当前进程页表对用的 PGD 页全局目录项,用户进程内存管理的 struct mm_struct 结构体的 pgd 成员 mm->pgd 指向用户进程的页表基地址。如果 PGD 表项的内容为空或者表项无效,那么报错返回,否则继续调用 follow_p4d_mask 函数向下一级查找。

// mm/gup.c
static struct page *follow_p4d_mask(struct vm_area_struct *vma,
                    unsigned long address, pgd_t *pgdp,
                    unsigned int flags, unsigned int *page_mask)
{
    p4d_t *p4d;
    struct page *page;
    // 调用 p4d_offset 辅助函数根据入参 pgd 和虚拟地址 address,找到 address 在页四级目录中相应表项的线性地址
    p4d = p4d_offset(pgdp, address);
    if (p4d_none(*p4d))
        return no_page_table(vma, flags);
    BUILD_BUG_ON(p4d_huge(*p4d));
    if (unlikely(p4d_bad(*p4d)))
        return no_page_table(vma, flags);

    if (is_hugepd(__hugepd(p4d_val(*p4d)))) {
        page = follow_huge_pd(vma, address,
                      __hugepd(p4d_val(*p4d)), flags,
                      P4D_SHIFT);
        if (page)
            return page;
        return no_page_table(vma, flags);
    }
    return follow_pud_mask(vma, address, p4d, flags, page_mask);
}

follow_p4d_mask 函数首先调用 p4d_offset 辅助函数根据入参 pgd 和虚拟地址 address ,找到 address 在页四级目录中相应表项的线性地址,如获取不到则报错返回,否者继续调用 follow_pud_mask 函数向下一级查找。

//  mm/gup.c
static struct page *follow_pud_mask(struct vm_area_struct *vma,
                    unsigned long address, p4d_t *p4dp,
                    unsigned int flags, unsigned int *page_mask)
{
    pud_t *pud;
    spinlock_t *ptl;
    struct page *page;
    struct mm_struct *mm = vma->vm_mm;
    //查找pud
    pud = pud_offset(p4dp, address);
    if (pud_none(*pud))
        return no_page_table(vma, flags);
    if (pud_huge(*pud) && vma->vm_flags & VM_HUGETLB) {
        page = follow_huge_pud(mm, address, pud, flags);
        if (page)
            return page;
        return no_page_table(vma, flags);
    }
    if (is_hugepd(__hugepd(pud_val(*pud)))) {
        page = follow_huge_pd(vma, address,
                      __hugepd(pud_val(*pud)), flags,
                      PUD_SHIFT);
        if (page)
            return page;
        return no_page_table(vma, flags);
    }
    if (pud_devmap(*pud)) {
        ptl = pud_lock(mm, pud);
        page = follow_devmap_pud(vma, address, pud, flags);
        spin_unlock(ptl);
        if (page)
            return page;
    }
    if (unlikely(pud_bad(*pud)))
        return no_page_table(vma, flags);

    return follow_pmd_mask(vma, address, pud, flags, page_mask);
}

follow_pud_mask 函数首先调用 pud_offset 辅助函数根据入参 p4d 和虚拟地址 address ,找到 address 在页上级目录中相应表项的线性地址,如获取不到则报错返回,否者继续调用 follow_pmd_mask 函数向下一级查找。

// mm/gup.c
static struct page *follow_pmd_mask(struct vm_area_struct *vma,
                    unsigned long address, pud_t *pudp,
                    unsigned int flags, unsigned int *page_mask)
{
    pmd_t *pmd, pmdval;
    spinlock_t *ptl;
    struct page *page;
    struct mm_struct *mm = vma->vm_mm;
    //查找 pmd
    pmd = pmd_offset(pudp, address);
    /*
     * The READ_ONCE() will stabilize the pmdval in a register or
     * on the stack so that it will stop changing under the code.
     */
    pmdval = READ_ONCE(*pmd);
    if (pmd_none(pmdval))
        return no_page_table(vma, flags);
    if (pmd_huge(pmdval) && vma->vm_flags & VM_HUGETLB) {
        page = follow_huge_pmd(mm, address, pmd, flags);
        if (page)
            return page;
        return no_page_table(vma, flags);
    }
    if (is_hugepd(__hugepd(pmd_val(pmdval)))) {
        page = follow_huge_pd(vma, address,
                      __hugepd(pmd_val(pmdval)), flags,
                      PMD_SHIFT);
        if (page)
            return page;
        return no_page_table(vma, flags);
    }
retry:
    if (!pmd_present(pmdval)) {
        if (likely(!(flags & FOLL_MIGRATION)))
            return no_page_table(vma, flags);
        VM_BUG_ON(thp_migration_supported() &&
                  !is_pmd_migration_entry(pmdval));
        if (is_pmd_migration_entry(pmdval))
            pmd_migration_entry_wait(mm, pmd);
        pmdval = READ_ONCE(*pmd);
        /*
         * MADV_DONTNEED may convert the pmd to null because
         * mmap_sem is held in read mode
         */
        if (pmd_none(pmdval))
            return no_page_table(vma, flags);
        goto retry;
    }
    if (pmd_devmap(pmdval)) {
        ptl = pmd_lock(mm, pmd);
        page = follow_devmap_pmd(vma, address, pmd, flags);
        spin_unlock(ptl);
        if (page)
            return page;
    }
    if (likely(!pmd_trans_huge(pmdval)))
        return follow_page_pte(vma, address, pmd, flags);

    if ((flags & FOLL_NUMA) && pmd_protnone(pmdval))
        return no_page_table(vma, flags);

retry_locked:
    ptl = pmd_lock(mm, pmd);
    if (unlikely(pmd_none(*pmd))) {
        spin_unlock(ptl);
        return no_page_table(vma, flags);
    }
    if (unlikely(!pmd_present(*pmd))) {
        spin_unlock(ptl);
        if (likely(!(flags & FOLL_MIGRATION)))
            return no_page_table(vma, flags);
        pmd_migration_entry_wait(mm, pmd);
        goto retry_locked;
    }
    if (unlikely(!pmd_trans_huge(*pmd))) {
        spin_unlock(ptl);
        return follow_page_pte(vma, address, pmd, flags);
    }
    if (flags & FOLL_SPLIT) {
        int ret;
        page = pmd_page(*pmd);
        if (is_huge_zero_page(page)) {
            spin_unlock(ptl);
            ret = 0;
            split_huge_pmd(vma, pmd, address);
            if (pmd_trans_unstable(pmd))
                ret = -EBUSY;
        } else {
            if (unlikely(!try_get_page(page))) {
                spin_unlock(ptl);
                return ERR_PTR(-ENOMEM);
            }
            spin_unlock(ptl);
            lock_page(page);
            ret = split_huge_page(page);
            unlock_page(page);
            put_page(page);
            if (pmd_none(*pmd))
                return no_page_table(vma, flags);
        }

        return ret ? ERR_PTR(ret) :
            follow_page_pte(vma, address, pmd, flags);
    }
    page = follow_trans_huge_pmd(vma, address, pmd, flags);
    spin_unlock(ptl);
    *page_mask = HPAGE_PMD_NR - 1;
    return page;
}

follow_pmd_mask 函数首先调用 pmd_offset 辅助函数根据入参 pmd 和虚拟地址 address ,找到 address 在页z中间目录中相应表项的线性地址,如获取不到则报错返回,否者继续调用 follow_page_pte 函数向下一级查找。

// mm/gup.c
static struct page *follow_page_pte(struct vm_area_struct *vma,
        unsigned long address, pmd_t *pmd, unsigned int flags)
{
    struct mm_struct *mm = vma->vm_mm;
    struct dev_pagemap *pgmap = NULL;
    struct page *page;
    spinlock_t *ptl;
    pte_t *ptep, pte;

retry:
    if (unlikely(pmd_bad(*pmd)))
        return no_page_table(vma, flags);
     // 调用 pte_offset_map_lock 函数根据 pmd 和虚拟地址 address 获取 pte 页表项,同时会申请一个自旋锁
    ptep = pte_offset_map_lock(mm, pmd, address, &ptl);
    pte = *ptep;
    if (!pte_present(pte)) {// 判断 pte 页表中的 L_PTE_PRESENT 标志位是否置位,该标志位标识该页在内存中
        swp_entry_t entry;
        /*
         * KSM's break_ksm() relies upon recognizing a ksm page
         * even while it is being migrated, so for that case we
         * need migration_entry_wait().
         */
        if (likely(!(flags & FOLL_MIGRATION)))
            goto no_page;
        if (pte_none(pte))
            goto no_page;
        // 如果 pte 是正在合并中的 swap 页面,那么调用 migrate_entry_wait 函数等待这个页面合并完成后再尝试
        entry = pte_to_swp_entry(pte);
        if (!is_migration_entry(entry))
            goto no_page;
        pte_unmap_unlock(ptep, ptl);
        migration_entry_wait(mm, pmd, address);
        goto retry;
    }
    if ((flags & FOLL_NUMA) && pte_protnone(pte))
        goto no_page;
    if ((flags & FOLL_WRITE) && !can_follow_write_pte(pte, flags)) {
        pte_unmap_unlock(ptep, ptl);
        return NULL;
    }
    // vm_normal_page 函数根据 pte 来返回 normal mapping 页面的 struct page 数据结构(特殊页面不参与内存管理)
    page = vm_normal_page(vma, address, pte);
    if (!page && pte_devmap(pte) && (flags & FOLL_GET)) {
        /*
         * Only return device mapping pages in the FOLL_GET case since
         * they are only valid while holding the pgmap reference.
         */
         // 仅在 FOLL_GET 的情况下返回设备映射页,因为它们只有在持有pgmap引用时才有效
        pgmap = get_dev_pagemap(pte_pfn(pte), NULL);
        if (pgmap)
            page = pte_page(pte);
        else
            goto no_page;
    } else if (unlikely(!page)) {
        if (flags & FOLL_DUMP) {
            /* Avoid special (like zero) pages in core dumps */
            page = ERR_PTR(-EFAULT);
            goto out;
        }

        if (is_zero_pfn(pte_pfn(pte))) {
            page = pte_page(pte);
        } else {
            int ret;

            ret = follow_pfn_pte(vma, address, ptep, flags);
            page = ERR_PTR(ret);
            goto out;
        }
    }

    if (flags & FOLL_SPLIT && PageTransCompound(page)) {
        int ret;
        get_page(page);
        pte_unmap_unlock(ptep, ptl);
        lock_page(page);
        ret = split_huge_page(page);
        unlock_page(page);
        put_page(page);
        if (ret)
            return ERR_PTR(ret);
        goto retry;
    }

    if (flags & FOLL_GET) {
        if (unlikely(!try_get_page(page))) {
            page = ERR_PTR(-ENOMEM);
            goto out;
        }

        /* drop the pgmap reference now that we hold the page */
        if (pgmap) {
            put_dev_pagemap(pgmap);
            pgmap = NULL;
        }
    }
    if (flags & FOLL_TOUCH) {
        if ((flags & FOLL_WRITE) &&
            !pte_dirty(pte) && !PageDirty(page))
            set_page_dirty(page);
        /*
         * pte_mkyoung() would be more correct here, but atomic care
         * is needed to avoid losing the dirty bit: it is easier to use
         * mark_page_accessed().
         */
        mark_page_accessed(page);
    }
    if ((flags & FOLL_MLOCK) && (vma->vm_flags & VM_LOCKED)) {
        /* Do not mlock pte-mapped THP */
        if (PageTransCompound(page))
            goto out;

        /*
         * The preliminary mapping check is mainly to avoid the
         * pointless overhead of lock_page on the ZERO_PAGE
         * which might bounce very badly if there is contention.
         *
         * If the page is already locked, we don't need to
         * handle it now - vmscan will handle it later if and
         * when it attempts to reclaim the page.
         */
        if (page->mapping && trylock_page(page)) {
            lru_add_drain();  /* push cached pages to LRU */
            /*
             * Because we lock page here, and migration is
             * blocked by the pte's page reference, and we
             * know the page is still mapped, we don't even
             * need to check for file-cache page truncation.
             */
             // 将缓存的页面推送到LRU
             // 因为这里锁定了页面,并且迁移被 pte 的页面引用阻塞了,而且我们知道页面仍然是映射的
             // 所以我们甚至不需要检查文件缓存页面截断。
            mlock_vma_page(page);
            unlock_page(page);
        }
    }
out:
    pte_unmap_unlock(ptep, ptl);
    return page;
no_page:
    pte_unmap_unlock(ptep, ptl);
    if (!pte_none(pte))
        return NULL;
    return no_page_table(vma, flags);
}

follow_page_pte 函数核心流程如下:

  1. 调用 pte_offset_map_lock 函数根据 pmd 和虚拟地址 address 获取 pte 页表项,同时会调用 spin_lock 函数申请一个自旋锁, follow_page_pte 函数在返回时需要调用 pte_unmap_unlock 函数,其内部会调用 spin_unlock 函数释放自旋锁。
  2. 调用 vm_normal_page 函数根据 pte 页表项查找 normal mapping 页面的 struct page 数据结构,查找到则返回 struct page 数据结构实例,否则报错返回。
// kernel/mm.h
struct page *_vm_normal_page(struct vm_area_struct *vma, unsigned long addr,
                 pte_t pte, bool with_public_device);
#define vm_normal_page(vma, addr, pte) _vm_normal_page(vma, addr, pte, false)
// mm/memory.c
struct page *_vm_normal_page(struct vm_area_struct *vma, unsigned long addr,
                 pte_t pte, bool with_public_device)
{
    // 宏定义函数 pte_pfn,用于从页表项(Page Table Entry,PTE)中提取页帧号(Page Frame Number,PFN)
    // 通过将 PTE 转换为物理地址,然后右移位来获得页帧号,页帧号表示物理页在内存中的位置,用于访问和管理物理内存。
    unsigned long pfn = pte_pfn(pte);

    if (IS_ENABLED(CONFIG_ARCH_HAS_PTE_SPECIAL)) {
        if (likely(!pte_special(pte)))// 如果 pte 的 PTE_SPECIAL 比特位没有置位,则跳转到 check_pfn 继续检查
            goto check_pfn;
        if (vma->vm_ops && vma->vm_ops->find_special_page)
            return vma->vm_ops->find_special_page(vma, addr);
        if (vma->vm_flags & (VM_PFNMAP | VM_MIXEDMAP))// 如果vm_flags设置了下面两个标志位,那么这是special mapping,返回NULL
            return NULL;
        if (is_zero_pfn(pfn))
            return NULL;

        /*
         * Device public pages are special pages (they are ZONE_DEVICE
         * pages but different from persistent memory). They behave
         * allmost like normal pages. The difference is that they are
         * not on the lru and thus should never be involve with any-
         * thing that involve lru manipulation (mlock, numa balancing,
         * ...).
         *
         * This is why we still want to return NULL for such page from
         * vm_normal_page() so that we do not have to special case all
         * call site of vm_normal_page().
         */
        if (likely(pfn <= highest_memmap_pfn)) {
            struct page *page = pfn_to_page(pfn);

            if (is_device_public_page(page)) {
                if (with_public_device)
                    return page;
                return NULL;
            }
        }

        if (pte_devmap(pte))
            return NULL;

        print_bad_pte(vma, addr, pte, NULL);
        return NULL;
    }

    /* !CONFIG_ARCH_HAS_PTE_SPECIAL case follows: */

    if (unlikely(vma->vm_flags & (VM_PFNMAP|VM_MIXEDMAP))) {
        if (vma->vm_flags & VM_MIXEDMAP) {
            if (!pfn_valid(pfn))
                return NULL;
            goto out;
        } else {
            unsigned long off;
            off = (addr - vma->vm_start) >> PAGE_SHIFT;
            if (pfn == vma->vm_pgoff + off)
                return NULL;
            if (!is_cow_mapping(vma->vm_flags))
                return NULL;
        }
    }

    if (is_zero_pfn(pfn))
        return NULL;

check_pfn:
    if (unlikely(pfn > highest_memmap_pfn)) {
        print_bad_pte(vma, addr, pte, NULL);
        return NULL;
    }

    /*
     * NOTE! We still have PageReserved() pages in the page tables.
     * eg. VDSO mappings can cause them to exist.
     */
out:
    return pfn_to_page(pfn);
}

vm_normal_page 函数首先通过宏定义函数 pte_pfn 从页表项( Page Table Entry,PTE )中提取页帧号( Page Frame Number,PFN ),然后在检查过各标志位后,最后通过 pfn_to_page 函数返回 normal mapping 页面的 struct page 数据结构实例。
vm_normal_page 函数把 page 页面分为两类:一个是 normal page ,另一个是 special page 。

  1. normal page :通常指正常 mapping 的页面,例如匿名页面、 page cache 和共享内存页面等。
  2. special page :通常指不正常 mapping 的页面,这些页面不希望参与内存管理的回收及合并等,如映射下述特定页页面:
    1 .VM_IO: 为 I/O 设备映射内存

    1. VM_PFN_MAP: 纯 PFN 映射
    2. VM_MIXEDMAP: 固定映射

流程至此,通过 follow_page_mask 函数在进程页表中查找到虚拟内存区域 vma 背后与之映射的物理内存页,并返回用户进程地址空间中已经有映射过的 normal mapping 页面的 struct page 数据结构。

总结

在原理篇中笔者首先通过五个角度为大家详细介绍了 mmap 的使用方法及其在内核中的实现原理,这五个角度分别是:

  1. 私有匿名映射,其主要用于进程申请虚拟内存,以及初始化进程虚拟内存空间中的 BSS 段,堆,栈这些虚拟内存区域。
  2. 私有文件映射,其核心特点是背后映射的文件页在多进程之间是读共享的,但多个进程对各自虚拟内存区的修改只能反应到各自对应的文件页上,而且各自的修改在进程之间是互不可见的,最重要的一点是这些修改均不会回写到磁盘文件中。我们可以利用这些特点来加载二进制可执行文件的 .text , .data section 到进程虚拟内存空间中的代码段和数据段中。
  3. 共享文件映射,多进程之间读写共享(不会发生写时复制),常用于多进程之间共享内存(page cache),多进程之间的通讯。
  4. 共享匿名映射,用于父子进程之间共享内存,父子进程之间的通讯。父子进程之间需要依赖 tmpfs 中的匿名文件来实现共享内存。是一种特殊的共享文件映射。
  5. 大页内存映射,这里我们介绍了标准大页与透明大页两种大页类型的区别与联系,以及他们各自的实现原理和使用方法。

介绍完原理之后,在本文的源码实现篇中笔者花了大量的篇幅介绍了 mmap 在内核中的源码实现,其中最核心的两个函数是:
1. get_unmapped_area 函数用于在进程虚拟内存空间中为本次 mmap 映射寻找出一段未被映射的空闲虚拟内存地址范围。其中笔者还为大家介绍了文件映射与匿名映射区在进程虚拟内存空间的布局情况。
1. map_region 函数主要是对这段空闲虚拟内存地址范围进行映射,在映射过程中涉及到的重要内容有:
1. 内核的 overcommit 策略
1. vm_merge 合并的流程,其中涉及到 8 种合并场景和 2 中基本布局。

好了,本文的内容到这里就结束了,感谢大家的收看,我们下篇文章见~

相关文章

深入理解 Linux Page Cache 机制
深入理解 Linux PageFault
深入理解 Linux 页表体系
深入理解Linux kmalloc 体系
深入理解Linux slab机制
深入理解 Linux 伙伴系统

发布评论