文章目录

  • 前言
  • 一、简介
    • 1. MMU 内存管理
    • 2. 缺页中断
    • 3. 页表
    • 4. 小节
  • 二、mmap 提前分配物理内存
    • 1. mm_populate 函数
    • 2. __mm_populate 函数
    • 3. populate_vma_page_range 函数
    • 4. __get_user_pages 函数
    • 5. find_extend_vma 函数
    • 6. find_vma 函数
    • 7. follow_page_mask 函数
    • 8. follow_p4d_mask 函数
    • 9. follow_pud_mask 函数
    • 10. follow_pmd_mask 函数
    • 11. follow_page_pte 函数
    • 12. pte_offset_map_lock 函数
    • 13. vm_normal_page 函数
  • 三、mmap 触发缺页异常 Page Fault
    • 1. mmap 内存映射分配物理内存的流程图
    • 2. faultin_page 函数
    • 3. handle_mm_fault 函数
    • 4. __handle_mm_fault 函数
    • 5. handle_pte_fault 函数
    • 6. do_anonymous_page 函数
    • 7. do_fault 函数
    • 8. do_read_fault 函数
    • 9. do_cow_fault 函数
    • 10. do_shared_fault 函数
    • 11. __do_fault 函数
    • 12. ext4_filemap_fault 函数
    • 13. filemap_fault 函数
    • 14. finish_fault 函数
    • 15. alloc_set_pte 函数
  • 总结

前言

上一篇文章解释了什么是 mmap 内存映射及其在 Linux 内核中的实现原理,然后深入到源码中一步一步分析 mmap 在内核中的源码实现。mmap 内存映射的核心就是在进程虚拟内存空间中为该次映射分配一段虚拟内存出来,然后将这段虚拟内存与磁盘文件建立映射关系。但此时内核并不会为映射分配物理内存,物理内存的分配工作需要延后到这段虚拟内存被 CPU 访问的时候,通过缺页中断来进入内核来分配物理内存,并在页表中建立好映射关系。那么,接下来就跟随笔者一步步深入源码,查看内核是如何分配物理内存并建立虚拟内存与物理内存之间映射关系的?


一、简介

1. MMU 内存管理

MMUMemory Management Unit 内存管理单元,是内存管理中一个重要的硬件模块,用于在 CPU 和内存之间实现虚拟内存管理。其主要功能是将虚拟地址转换为物理地址,同时提供访问权限的控制和缓存管理等功能。MMU 是现代计算机操作系统中重要的组成部分,可以提高系统的稳定性和安全性。

在内存管理方面,MMU 可以通过页表(Page Table)实现虚拟内存管理。页表是一种数据结构,记录了每个虚拟面和其对应的物理页之间的映射关系。当 CPU 发出一个虚拟地址时,MMU 会通过页表查找并将其转换为对应的物理地址。此外,MMU 还可以通过页表实现内存保护和共享等功能,从而提高系统的安全性和效率。
Linux 内核之 mmap 内存映射触发的缺页异常 Page Fault插图

2. 缺页中断

Linux 利用虚拟内存极大的扩展了程序地址空间,使得原来物理内存不能容下的程序也可以通过内存和硬盘之间的不断交换(把暂时不用的内存页交换到硬盘,把需要的内存页从硬盘读到内存)来赢得更多的内存,看起来就像物理内存被扩大了一样。事实上这个过程对程序是完全透明的,程序完全不用理会自己哪一部分、什么时候被交换进内存,一切都有内核的虚拟内存管理来完成。当程序启动的时候,Linux 内核首先检查 CPU 的缓存和物理内存,如果数据已经在内存里就忽略,如果数据不在内存里就引起一个缺页中断(Page Fault),然后从硬盘读取缺页,并把缺页缓存到物理内存里。

缺页中断可分为主缺页中断(Major Page Fault)次缺页中断(Minor Page Fault)

  • 要从磁盘读取数据而产生的中断是主缺页中断;
  • 数据已经被读入内存并被缓存起来,从内存缓存区中而不是直接从硬盘中读取数据而产生的中断是次缺页中断。

缺页异常被触发通常有以下几种情况:

  • 程序设计的不当导致访问了进程的非法地址区域,SIGSEGV 信号杀死进程;
  • 访问的地址是合法的,但是虚拟内存地址 address 还未被映射过,该地址还未分配物理页框,其在页表中对应的各级页目录项以及页表项都还是空的,进程首次访问时触发(接下来重点要分析这种情况);
  • 内存不足的状态下,即虚拟内存地址 address 之前被映射过,但是映射的这块物理内存(进程的匿名页/文件页)被内核 swap out 到磁盘上;
  • 虚拟内存地址 address 背后映射的物理内存还在,只是由于访问权限不够引起的缺页中断,比如:写时复制(COW)机制就属于这一种。

3. 页表

页表Page Table)是一种用于存储虚拟内存地址与物理内存地址映射关系的数据结构。在使用虚拟内存的系统中,每个进程都有自己的虚拟地址空间,而这些虚拟地址空间被分割成许多页(通常大小为 4KB 或更大),而不是一整块连续的内存。因此,当进程需要访问某个虚拟地址时,需要将其翻译成对应的物理地址,翻译过程就是通过页表来完成的。

页表的基本原理是将虚拟地址划分成一个页号和一个偏移量页号用于在页表中查找对应的物理页帧号,而偏移量则用于计算该虚拟地址在物理页帧中的偏移量。通过这种方式,就可以将虚拟地址映射到物理地址,使得进程可以访问对应的内存区域。

页表一般由操作系统来维护,因为操作系统需要掌握虚拟地址和物理地址之间的映射关系。在使用 MMU 的硬件支持的系统中,当进程访问虚拟地址时,MMU 会通过页表将虚拟地址转换为物理地址,并将访问指向正确的物理地址。这样,进程就可以在不知道自己真实物理地址的情况下访问内存。

Linux 内核 4 级页表模型如下:
Linux 内核之 mmap 内存映射触发的缺页异常 Page Fault插图(1)

  • 页全局目录Page Global Directory,缩写 PGD,其包含若干页上级目录的地址;
  • 页上级目录Page Upper Directory,缩写 PUD,其包含若干页中间目录的地址;
  • 页中间目录Page Middle Directory,缩写 PMD,其包含若干页表的地址;
  • 页表Page Table,缩写 PT,其内部的每一个页表项 PTE 指向一个页框 Page

Linux 分别采用 pgd_tpmd_tpud_tpte_t 四种数据结构来表示页全局目录项页上级目录项页中间目录项页表项,这四种数据结构本质上都是无符号长整型 unsigned long

首先 CR3 寄存器里面存储的是 pgd 的基地址,用 pgd 基地址+对应偏移量可以在 pgd 中找到下一级页面 pud 的基地址。以此类推分别可以找到 pmdpte 和物理页框 page 的基地址,然后使用物理页框的基地址+页内偏移可以得到最终的物理地址,并在最终的物理地址中找到了使用 sprintf 写入的数据,说明这个虚拟地址到物理地址的转换是正确的。

4. 小节

本节简单介绍一些 Linux 内核有关的基础知识,待后续深入查看源码时,便于理解源码,更多有关 Linux 的知识,待后续笔者继续学习再分享。


二、mmap 提前分配物理内存

上一篇文章在分析 vm_mmap_pgoff 函数的核心流程时,其内部调用 do_mmap_pgoff 函数开始 mmap 内存映射,在进程虚拟内存空间中分配一段虚拟内存区域 vma,并建立相关映射关系,其返回值 ret 表示映射的这段虚拟内存区域的起始地址。此时,如果调用 mmap 函数进行内存映射时,在 flags 参数中设置了 MAP_POPULATE 或者 MAP_LOCKED 标志位,Linux 内核将调用 mm_populate 函数,为 mmap 刚刚映射出来的这段虚拟内存区域 [ret , ret + populate] 提前分配物理内存。
Linux 内核之 mmap 内存映射触发的缺页异常 Page Fault插图(2)

1. mm_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);
}

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

extern:在 C 语言中,extern 关键字用于声明一个变量或者函数是在别的文件中定义的,或者说在别的文件中声明的。实现跨文件共享变量和函数声明,提高代码模块化和可维护性。使用 extern 时需确保正确链接,否则会导致编译或运行时错误。

2. __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) {
		if (!locked) {
			locked = 1;
			down_read(&mm->mmap_sem);
			// 以 start 为起始地址,先通过 find_vma 查找 vma,如果没找到 vma,则退出循环
			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;
			
		// 为这段地址范围内的所有 vma 分配物理内存
		ret = populate_vma_page_range(vma, nstart, nend, &locked);
		if (ret < 0) {
			if (ignore_errors) {
				ret = 0;
				continue;	/* continue at next VMA */
			}
			break;
		}
		// 继续为下一个 vma (如果有的话)分配物理内存
		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 填充物理内存。

3. populate_vma_page_range 函数

/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;
	// 计算 vma 中包含的虚拟内存页个数,后续会按照 nr_pages 分配物理内存
	unsigned long nr_pages = (end - start) / PAGE_SIZE;
	int gup_flags;
	 
	/*做一些错误判断,start 和 end 地址必须以页面对齐,VM_BUG_ON_VMA 和 VM_BUG_ON_MM 宏需要
	打开 CONFIG_DEBUG_VM 配置才会起作用,内存管理代码常常使用这些宏来做 debug。*/
	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.
	 */
	// 如果 vma 的标志域 vm_flags 具有可写的属性 VM_WRITE,那么这里必须设置 FOLL_WRITE 标志位
	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; // 如果 vm_flags 是可读、可写和可执行的,那么设置 FOLL_FORCE 标志位

	// 循环遍历 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 函数为其分配物理内存页并建立映射关系。

4. __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 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);
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
*/ // 如果设置了 FOLL_FORCE,则不强制执行完整错误,因为提示错误信息与使用地址空间的任务的引用行为无关
if (!(gup_flags & FOLL_FORCE))
gup_flags |= FOLL_NUMA;
do { // do...while 循环遍历 vma 中的每一个虚拟内存页
struct page *page;
unsigned int foll_flags = gup_flags;
unsigned int page_increm;
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)) {
ret = get_gate_page(mm, start & PAGE_MASK,
gup_flags, &vma,
pages ? &pages[i] : NULL);
if (ret)
goto out;
ctx.page_mask = 0;
goto next_page;
}
if (!vma || check_vma_flags(vma, gup_flags)) {
ret = -EFAULT;
goto out;
}
if (is_vm_hugetlb_page(vma)) {
if (should_force_cow_break(vma, foll_flags))
foll_flags |= FOLL_WRITE;
i = follow_hugetlb_page(mm, vma, pages, vmas,
&start, &nr_pages, i,
foll_flags, nonblocking);
continue;
}
}
if (should_force_cow_break(vma, foll_flags))
foll_flags |= FOLL_WRITE;
retry: // retry 标签的作用:整个 while 循环的目的是获取请求页队列中的每个页,然后反复操作直到满足构建所有内存映射的需求
/*
* If we have a pending SIGKILL, don't keep faulting pages and
* potentially allocating memory.
*/ // 如果当前进程收到一个 SKIGILL 信号,则不需要继续分配内存,直接报错退出
if (fatal_signal_pending(current)) {
ret = -ERESTARTSYS;
goto out;
}
// cond_resched()判断当前进程是否需要被调度,内核代码通常在while()循环中添加cond_resched()来优化系统的延迟
cond_resched();
// 调用 follow_page_mask 函数检查进程页表中 vma 中的虚拟页面是否已经分配了物理内存
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);
switch (ret) {
case 0:
goto retry;
case -EBUSY:
ret = 0;
/* FALLTHRU */
case -EFAULT:
case -ENOMEM:
case -EHWPOISON:
goto out;
case -ENOENT:
goto next_page;
}
BUG();
} else if (PTR_ERR(page) == -EEXIST) {
goto next_page; // 存在适当的页表项,但没有相应的结构页
} else if (IS_ERR(page)) {
ret = PTR_ERR(page);
goto out;
}
if (pages) {
// 分配完页面后,pages 指针数组指向这些 page
pages[i] = page;
// 调用 flush_anon_page 和 flush_dcache_page 来刷新这些页面对应的 cache
flush_anon_page(vma, page, start);
flush_dcache_page(page);
ctx.page_mask = 0;
}
next_page:
if (vmas) { // 为下一次循环做准备
vmas[i] = vma;
ctx.page_mask = 0;
}
page_increm = 1 + (~(start >> PAGE_SHIFT) & ctx.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);
out:
if (ctx.pgmap)
put_dev_pagemap(ctx.pgmap);
return i ? i : ret;
}

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

  • 调用 find_extend_vma 函数查找虚拟内存区域 vma,内部会调用 find_vma 函数来查找 vma,如果 vma->vma_start 大于查找地址 start,那将尝试去扩增 vma,把 vma->vm_start 边界扩大到 start 中。如果没有找到合适的 vma,且 start 地址恰好在 gate_vma 中,那么使用 gate 页面,当然这种情况比较罕见。
  • 调用 follow_page_mask 函数在进程页表中查找该虚拟内存背后是否有物理内存页与之映射,返回用户进程地址空间中已经有映射过的 normal mapping 页面的 struct page 数据结构,如果没有则调用 faultin_page 函数,其底层会调用到 handle_mm_fault 函数进入缺页处理流程,内核在这里会为其分配物理内存页,并在进程页表中建立好映射关系。

5. find_extend_vma 函数

/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); // 调用 find_vma 函数查找 vma
if (!vma)
return NULL;
if (vma->vm_start <= addr) // 表示 addr 在 vma 的地址空间范围内,直接返回 vma
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->vma_start 大于查找地址 addr,则调用 expand_stack 函数去扩增 vma,把 vma->vm_start 边界扩大到 addr。最后再次调用 populate_vma_page_range 函数为扩增后的 vma 分配物理内存。

6. find_vma 函数

/mm/mmap.c
/* 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 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,因此 find_vma 函数首先从进程地址空间中 vma 缓存中开始查找,找到则直接返回。如果找不到则遍历整个 vma 红黑树进行查找,找到则返回查找到的 vma,否则返回 null,表示进程地址空间中目前还没有这样一个 vma 后续需要新建。

回到 4. __get_user_pages 函数,在调用 find_extend_vma 函数查找到 vma 后,调用 follow_page_mask 函数在进程页表中查找该 vma 背后是否有物理内存页与之映射?

7. follow_page_mask 函数

follow_page 函数:内核中用于根据虚拟地址查找对应的物理页函数,完成从页全局目录 PGDPAGE 页的转换。follow_page_mask 函数是内存管理核心 API 函数 follow_page 函数的具体实现,具体有兴趣的可自行学习 Linux 的页表体系。
Linux 内核之 mmap 内存映射触发的缺页异常 Page Fault插图(3)
如上图所示 Linux 内核的 4 级页表,内核根据虚拟地址完成从页全局目录 PGDPAGE 页的转换,整个过程是比较机械的,每次转换先获取物理页基地址,再从线性地址中获取索引,合成物理地址后再访问内存。不管是页表还是要访问的数据都是以页为单位存放在主存中的,因此每次访问内存时都要先获得基址,再通过索引(或偏移量)在页内访问数据,因此可以将线性地址看作是若干个索引的集合。

结合上图再来分析源码,看看内核是怎样一步步从页全局目录 PGDPAGE 页转换的?

/mm/gup.c
static struct page *follow_page_mask(struct vm_area_struct *vma,
unsigned long address, unsigned int flags,
struct follow_page_context *ctx)
{
pgd_t *pgd;
struct page *page;
struct mm_struct *mm = vma->vm_mm;
ctx->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, ctx);
}

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

8. 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,
struct follow_page_context *ctx)
{
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, ctx);
}

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

9. 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,
struct follow_page_context *ctx)
{
pud_t *pud;
spinlock_t *ptl;
struct page *page;
struct mm_struct *mm = vma->vm_mm;
// 调用 pud_offset 辅助函数根据入参 p4d 和虚拟地址 address,找到 address 在页上级目录中相应表项的线性地址
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, &ctx->pgmap);
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, ctx);
}

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

10. 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,
struct follow_page_context *ctx)
{
pmd_t *pmd, pmdval;
spinlock_t *ptl;
struct page *page;
struct mm_struct *mm = vma->vm_mm;
// 调用 pmd_offset 辅助函数根据入参 pud 和虚拟地址 address,找到 address 在页中间目录中相应表项的线性地址
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_pte(vma, address, 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, &ctx->pgmap);
spin_unlock(ptl);
if (page)
return page;
}
if (likely(!pmd_trans_huge(pmdval)))
return follow_page_pte(vma, address, pmd, flags, &ctx->pgmap);
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, &ctx->pgmap);
}
if (flags & (FOLL_SPLIT | FOLL_SPLIT_PMD)) {
int ret;
page = pmd_page(*pmd);
...... // 忽略大页内存相关的逻辑
return ret ? ERR_PTR(ret) :
follow_page_pte(vma, address, pmd, flags, &ctx->pgmap);
}
page = follow_trans_huge_pmd(vma, address, pmd, flags);
spin_unlock(ptl);
ctx->page_mask = HPAGE_PMD_NR - 1;
return page;
}

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

11. 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 dev_pagemap **pgmap)
{
struct mm_struct *mm = vma->vm_mm;
struct page *page;
spinlock_t *ptl;
pte_t *ptep, pte;
// 考虑 PTE 级别的 hugetlb,比如 ARM64 架构上的连续 PTE hugetlb,目前暂不深究
if (is_vm_hugetlb_page(vma)) {
page = follow_huge_pmd_pte(vma, address, flags);
if (page)
return page;
return no_page_table(vma, flags);
}
retry:
if (unlikely(pmd_bad(*pmd))) // 检查 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; // 这里表示处理页表不在内存中的情况
// 如果分配掩码没有定义FOLL_MIGRATION,即这个页面没有在页面合并过程中,那么错误返回
if (likely(!(flags & FOLL_MIGRATION)))
goto no_page;
if (pte_none(pte)) // 如果pte为空,则错误返回
goto no_page;
entry = pte_to_swp_entry(pte);
// 如果 pte 是正在合并中的 swap 页面,那么调用 migrate_entry_wait 函数等待这个页面合并完成后再尝试
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; // 如果分配掩码支持可写属性(FOLL_WRITE),但是pte的表项只具有只读属性,那么也返回NULL
}
// vm_normal_page 函数根据 pte 来返回 normal mapping 页面的 struct page 数据结构(特殊页面不参与内存管理)
page = vm_normal_page(vma, address, pte);
if (!page && pte_devmap(pte) && (flags & FOLL_GET)) {
// 仅在 FOLL_GET 的情况下返回设备映射页,因为它们只有在持有pgmap引用时才有效
*pgmap = get_dev_pagemap(pte_pfn(pte), *pgmap);
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;
}
}
if (flags & FOLL_TOUCH) { 
// flag 设置 FOLL_TOUCH 时,需要标记 page 可访问,调用 mark_page_accessed 函数设置 page 是活跃的
// mark_page_accessed 函数是页面回收的核心辅助函数
if ((flags & FOLL_WRITE) &&
!pte_dirty(pte) && !PageDirty(page))
set_page_dirty(page);
mark_page_accessed(page);
}
if ((flags & FOLL_MLOCK) && (vma->vm_flags & VM_LOCKED)) { // 调用者想将页面锁在内存
/* Do not mlock pte-mapped THP */
if (PageTransCompound(page))
goto out;
// 锁住页面,不交换到外部存储器
if (page->mapping && trylock_page(page)) {
lru_add_drain();  /* push cached pages to LRU */ // 将缓存的页面推送到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 函数,核心流程如下:

  • 调用 pte_offset_map_lock 函数根据 pmd 和虚拟地址 address 获取 pte 页表项,同时会调用 spin_lock 函数申请一个自旋锁,follow_page_pte 函数在返回时需要调用 pte_unmap_unlock 函数,其内部会调用 spin_unlock 函数释放自旋锁。
  • 调用 vm_normal_page 函数根据 pte 页表项查找 normal mapping 页面的 struct page 数据结构,查找到则返回 struct page 数据结构实例,否者报错返回。

12. pte_offset_map_lock 函数

/include/linux/mm.h
#define pte_offset_map_lock(mm, pmd, address, ptlp)	\
({							\
// 获取 pmd 映射的一级页表锁
spinlock_t *__ptl = pte_lockptr(mm, pmd);	\
pte_t *__pte = pte_offset_map(pmd, address);	\
*(ptlp) = __ptl;				\
// 申请获取自旋锁
spin_lock(__ptl);				\
__pte;						\
})
/include/asm/pgtable.h
// 通过页中间目录项 pmd 产生相应页表的页描述符地址
#define pmd_page(pmd)		pfn_to_page(__phys_to_pfn(pmd_val(pmd) & PHYS_MASK))
#define __pte_map(pmd)		(pte_t *)kmap_atomic(pmd_page(*(pmd)))
#define PTRS_PER_PTE        512
#define PAGE_SHIFT  12
// 线性地址 addr 对应的表项在页表中的索引(相对位置)
#define pte_index(addr)		(((addr) >> PAGE_SHIFT) & (PTRS_PER_PTE - 1))
// 根据页中间目录项 pmd 和线性地址 addr 获取与线性地址 addr 相对应的页表项 pte 的线性地址
#define pte_offset_map(pmd,addr)	(__pte_map(pmd) + pte_index(addr))
#define pmd_val(x)      (x)  // 把传入的数据类型转换成一个无符号整数
#define __phys_to_pfn(paddr)    ((unsigned long)((paddr) >> PAGE_SHIFT))

pte_offset_map_lock 函数的作用是根据页中间目录项 pmd 和线性地址 addr 获取与线性地址 addr 相对应的页表项 pte 的线性地址。

13. vm_normal_page 函数

/mm/memory.c
struct page *vm_normal_page(struct vm_area_struct *vma, unsigned long addr,
pte_t pte)
{
// 宏定义函数 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;
if (pte_devmap(pte))
return NULL;
print_bad_pte(vma, addr, pte, NULL);
return NULL;
}
/* !CONFIG_ARCH_HAS_PTE_SPECIAL case follows: */
// 如果没有定义 HAVE_PTE_SPECIAL,检查(VM_PFNMAP|VM_MIXEDMAP)的情况
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) // 判断 special mapping 的情况
return NULL;
// 虚拟地址线性映射到pfn,如果映射是COW mapping(写时复制映射),那么页面也是normal映射
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:
// 函数最后通过 pfn_to_page 函数返回 struct page 数据结构实例
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

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

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

三、mmap 触发缺页异常 Page Fault

CPU 访问这段由 mmap 映射出来的虚拟内存区域 vma 中的任意虚拟地址时,MMU 在遍历进程页表的时候就会发现,该虚拟内存地址在进程顶级页目录 PGDPage Global Directory)中对应的页目录项 pgd_t 是空的,该 pgd_t 并没有指向其下一级页目录 PUDPage Upper Directory)。也就是说,此时进程页表中只有一张顶级页目录表 PGD,而上层页目录 PUD,中间页目录 PMDPage Middle Directory),一级页表 PTPage Table)内核都还没有创建。

由于现在被访问到的虚拟内存地址对应的 pgd_t 是空的,进程的四级页表体系还未建立,此时 MMU 会产生一个缺页中断,进程从用户态转入内核态来处理这个缺页异常(Page Fault)。此时 CPU 会将发生缺页异常时,进程正在使用的相关寄存器中的值压入内核栈中。比如,引起进程缺页异常的虚拟内存地址会被存放在 CR2 寄存器中,同时 CPU 还会将缺页异常的错误码 error_code 压入内核栈中。

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

1. mmap 内存映射分配物理内存的流程图

Linux 内核之 mmap 内存映射触发的缺页异常 Page Fault插图(4)
承接上节分析,在 mmap 内存映射完毕后,此时进程页表中映射的虚拟内存区域 vma 背后还没有映射物理页,follow_page_mask 函数是获取不到虚拟地址对应的物理页,因此调用 faultin_page 函数,其底层会调用到 handle_mm_fault 函数触发一个缺页中断,进入缺页处理流程来分配物理内存,并在页表中建立好映射关系。

2. 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 配置 foll_flags,后续交给 handle_mm_fault 函数进行处理
if ((*flags & (FOLL_POPULATE | FOLL_MLOCK)) == FOLL_MLOCK)
return -ENOENT;
if (*flags & FOLL_WRITE) // 请求可写页表项 pte
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 函数按照 foll_flags 标志位进行处理。其可能的情况有:1、请求调页/按需分配;2、写时复制(COW);3、缺的页位于交换分区,需要换入。

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;
if (flags & FAULT_FLAG_USER)  
// 对用户空间中触发的故障启用 memcg OOM 处理
mem_cgroup_enter_user_fault();
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;
}
EXPORT_SYMBOL_GPL(handle_mm_fault);

handle_mm_fault 函数会对 vmaflag 进行检测,如启用了 VM_HUGETLB,则调用 hugetlb_fault 函数进行大页内存的缺页处理,否则调用**__handle_mm_fault** 函数来处理。

handle_mm_fault 函数会返回一个 unsigned int 类型的位图 vm_fault_t,通过这个位图可以简要描述一下在整个缺页异常处理的过程中究竟发生了哪些状况,方便内核对各种状况进行针对性处理。

4. __handle_mm_fault 函数

/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; // 五级页表下会使用,在四级页表下 p4d 与 pgd 的值一样
vm_fault_t ret;
// 获取 address 在全局页目录表 PGD 中对应的目录项 pgd
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);
}
// 首先 pud_none 判断上层页目录项 pud 是不是空的
// 如果 pud 是空的,则调用 __pmd_alloc 分配一个新的中间页目录表 PMD,然后填充 pud
// 如果 pud 不是空的,则调用 pmd_offset 获取 address 在中间页目录 PMD 中的目录项 pmd
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);
}
// 进行页表的相关处理以及解析具体的缺页原因,后续针对性的进行缺页处理
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 函数进行页表的相关处理以及解析具体的缺页原因,后续针对性的进行缺页处理。

5. 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 函数的核心流程如下:

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

6. do_anonymous_page 函数

/mm/memory.c
static vm_fault_t do_anonymous_page(struct vm_fault *vmf)
{
struct vm_area_struct *vma = vmf->vma; // 缺页虚拟内存地址 address 所在的虚拟内存区域 vma
struct mem_cgroup *memcg;
struct page *page; // 指向分配的物理内存页,后面与虚拟内存进行映射
vm_fault_t ret = 0;
pte_t entry; // 临时的 pte 用于构建 pte 中的值,后续会赋值给 address 在页表中对应的真正 pte
// 如果是共享的匿名映射,但是虚拟内存区域没有提供虚拟内存操作集合(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))
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) // 如果分配失败则返回 oom
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);
// 如果 vma 是可写的,则将 pte 标记为可写,脏页,表示页数据被修改过
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:
// 释放 page 
put_page(page);
oom:
return VM_FAULT_OOM;
}

do_anonymous_page 函数的核心流程如下:

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

7. do_fault 函数

/mm/memory.c
static vm_fault_t do_fault(struct vm_fault *vmf)
{
struct vm_area_struct *vma = vmf->vma; // 缺页虚拟内存地址 address 所在的虚拟内存区域 vma
struct mm_struct *vm_mm = vma->vm_mm; // 虚拟内存区域 vma 的内存描述符 mm_struct
vm_fault_t ret;
// 如果虚拟内存区域 vma 没有提供页错误异常方法vma->vm_ops->fault,返回错误号VM_FAULT_SIGBUS
if (!vma->vm_ops->fault) {
// 如果中间页目录 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);
// 如果 pte 为空,则返回 SIGBUS 错误
if (unlikely(pte_none(*vmf->pte)))
ret = VM_FAULT_SIGBUS;
else
// 如果 pte 不为空,返回 NOPAGE,即本次缺页处理不会分配物理内存页
ret = VM_FAULT_NOPAGE;
pte_unmap_unlock(vmf->pte, vmf->ptl);
}
} else if (!(vmf->flags & FAULT_FLAG_WRITE))
// 如果缺页异常是读文件触发的,调用 do_read_fault 处理
ret = do_read_fault(vmf);
else if (!(vma->vm_flags & VM_SHARED))
// 如果缺页异常是写私有文件触发的,调用 do_cow_fault 执行写时复制
ret = do_cow_fault(vmf);
else
// 如果缺页异常是写共享文件触发的,调用 do_shared_fault 处理共享映射区的写入缺页
ret = do_shared_fault(vmf);
// 如果没有使用预分配的页表则释放它并且配置 prealloc_pte 为空
if (vmf->prealloc_pte) {
pte_free(vm_mm, vmf->prealloc_pte);
vmf->prealloc_pte = NULL;
}
return ret;
}

do_fault 函数根据缺页的标记 vmf->flags 进行判断:

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

8. do_read_fault 函数

/mm/memory.c
static unsigned long fault_around_bytes __read_mostly =
rounddown_pow_of_two(65536);
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 中。

9. do_cow_fault 函数

/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 上。

10. do_shared_fault 函数

/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 标记为脏页,表示页数据被修改了,并记录相关文件系统的日志,平衡并回写一部分脏页,防止数据丢失
ret |= fault_dirty_shared_page(vmf);
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 函数获取映射文件的过程。

11. __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;
if (pmd_none(*vmf->pmd) && !vmf->prealloc_pte) {
vmf->prealloc_pte = pte_alloc_one(vmf->vma->vm_mm);
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))) {
struct page *page = vmf->page;
vm_fault_t poisonret = VM_FAULT_HWPOISON;
if (ret & VM_FAULT_LOCKED) {
if (page_mapped(page))
unmap_mapping_pages(page_mapping(page),
page->index, 1, false);
/* Retry if a clean page was removed from the cache. */
if (invalidate_inode_page(page))
poisonret = VM_FAULT_NOPAGE;
unlock_page(page);
}
put_page(page);
vmf->page = NULL;
return poisonret;
}
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_opsfault 方法就是函数 ext4_filemap_fault。许多文件系统注册的 fault 方法是通用的 filemap_fault 函数。

12. ext4_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 函数来获取所缺的文件页。

13. filemap_fault 函数

/mm/filemap.c
vm_fault_t filemap_fault(struct vm_fault *vmf)
{
int error;
struct file *file = vmf->vma->vm_file; // 获取映射文件
struct file *fpin = NULL;
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 中
fpin = do_async_mmap_readahead(vmf, page);
} else if (!page) {
// 如果文件页不在 page cache 中,则需要启动 io 从文件中读取内容到 page cahe
// 由于涉及到了磁盘 io,所以本次缺页类型为 VM_FAULT_MAJOR
count_vm_event(PGMAJFAULT);
count_memcg_event_mm(vmf->vma->vm_mm, PGMAJFAULT);
ret = VM_FAULT_MAJOR;
// 启动同步预读,将所需的文件数据读取进 page cache 中并同步预读若干相邻的文件数据到 page cache 
fpin = do_sync_mmap_readahead(vmf);
retry_find:
// 尝试到 page cache 中重新读取文件页,由于已经预读,所以再次读取就可读取到
page = pagecache_get_page(mapping, offset,
FGP_CREAT|FGP_FOR_MMAP,
vmf->gfp_mask);
if (!page) {
if (fpin)
goto out_retry;
return vmf_error(-ENOMEM);
}
}
if (!lock_page_maybe_drop_mmap(vmf, page, &fpin))
goto out_retry;
/* Did it get truncated? */
if (unlikely(compound_head(page)->mapping != mapping)) {
unlock_page(page);
put_page(page);
goto retry_find;
}
VM_BUG_ON_PAGE(page_to_pgoff(page) != offset, page);
// 页面缓存中有一个锁定的页面,检查它是否是最新的,如果不是则可能是出错
if (unlikely(!PageUptodate(page)))
goto page_not_uptodate;
// 流程到此,须放弃 mmap_sem,回到上一级,尝试重新找到 vma 然后重做缺页异常
if (fpin) {
unlock_page(page);
goto out_retry;
}
// 已找到目标文件页,并有引用,此时须在页锁定下重新检查 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;
page_not_uptodate:
// 如果页面不是最新的,试着重读一遍,检查是否出错了,由于没有任何性能问题,且需要检查错误,因此这里是同步进行的
ClearPageError(page);
fpin = maybe_unlock_mmap_for_io(vmf, fpin);
error = mapping->a_ops->readpage(file, page);
if (!error) {
wait_on_page_locked(page);
if (!PageUptodate(page))
error = -EIO;
}
if (fpin)
goto out_retry;
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;
out_retry:
// 预分配的页面表未使用则需释放掉,然后返回到错误处理程序来重新找到 vma,返回并找到我们希望仍然填充的页面
if (page)
put_page(page);
if (fpin)
fput(fpin);
return ret | VM_FAULT_RETRY;
}
EXPORT_SYMBOL(filemap_fault);

filemap_fault 函数的主要作用是先把缺页所需要的文件页获取出来,为后面的映射做准备,其核心流程如下:

  • 首先调用 find_get_page 函数从 page cache 中尝试获取文件页,如果文件页存在,则继续调用 do_async_mmap_readahead 函数启动异步预读机制,将相邻的若干文件页一起预读进 page cache 中。
  • 如果文件页不在 page cache 中,内核则会调用 do_sync_mmap_readahead 函数来同步预读,这里首先会分配一个物理内存页出来,然后将新分配的内存页加入到 page cache 中,并增加页引用计数。
  • 随后会通过 address_space_operations 中定义的 readpage 激活块设备驱动从磁盘中读取映射的文件内容,然后将读取到的内容填充新分配的内存页中,并同步预读若干相邻的文件页到 page cache 中。

14. finish_fault 函数

/mm/memory.c
vm_fault_t finish_fault(struct vm_fault *vmf)
{
struct page *page; // 为本次缺页准备好的物理内存页,即后续需要用 pte 映射的内存页
vm_fault_t ret = 0;
if ((vmf->flags & FAULT_FLAG_WRITE) &&
!(vmf->vma->vm_flags & VM_SHARED))
page = vmf->cow_page; // 如果是写时复制场景,页表项 pte 要映射的是 cow 复制过来的内存页
else
// 在 filemap_fault 函数中读取到的文件页,后面需要将文件页映射到 pte 中
page = vmf->page;
// 对于私有映射来说,这里需要检查进程地址空间是否被标记了 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 中。

15. alloc_set_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;
}
if (!vmf->pte) { // 如果页表项不存在
// 调用 pte_alloc_one_map 函数,如果 pmd 为空,则创建一个页表出来,并填充 pmd
// 如果 pmd 不为空,则获取 address 在页表中对应的 pte 保存在 vmf->pte 中
ret = pte_alloc_one_map(vmf);
if (ret)
return ret;
}
/* Re-check under ptl */
// 再次检查页表是否为空,如果不为空,说明其他处理器使用了这个页表,当前处理器放弃返回错误
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 函数的核心流程如下:

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

WRITE_ONCE 是一个宏,它在 Linux 内核中用于确保对变量的写操作是原子性的,并且不会被编译器的优化重排。这个宏的主要目的是在多线程环境中提供一种安全的方式来写入共享变量,确保其他线程能够看到正确的值。

流程至此,调用 mmap 函数进行内存映射时,需立即分配物理页并建立对应的映射关系的全部过程已分析完毕。


总结

综上所述,在调用 mmap 函数进行内存映射时,如果在 flags 参数中设置了 MAP_POPULATE 或者 MAP_LOCKED 标志位,则表示需要马上为这块进程地址空间 vma 分配物理页并建立映射关系。此时,Linux 内核依次扫描这段 vma 中的每一个虚拟页,并对每一个虚拟页触发缺页异常,从而为其立即分配物理内存,并建立虚拟页与物理页之间的映射关系,补齐进程的页表体系。

本文分析的 mmap 内存映射所引起的缺页中断只是 Linux 内核缺页中断的一个分支,关于 Linux 内核详细的缺页中断流程,有机会再继续分析,有什么错误和疑惑也可以评论区一起探索交流。

本站无任何商业行为
个人在线分享 » Linux 内核之 mmap 内存映射触发的缺页异常 Page Fault
E-->