一文聊透 Linux 缺页异常的处理—— 图解 Page Faults(中)

6. handle_pte_fault

在上一小节的开头,笔者列举了引起缺页异常主要的三种原因,要么缺页的虚拟内存地址从来还没有被映射过,要么是虽然之前映射过,但是物理内存页被 swap 到磁盘上了,要么是因为访问权限不够的原因引起的缺页。

从总体上来讲引起缺页中断的原因分为两大类,一类是缺页虚拟内存地址背后映射的物理内存页不在内存中,另一类是缺页虚拟内存地址背后映射的物理内存页在内存中。

而每一类下边又包含若干种缺页的场景,在本小节中笔者会带着大家一一把这些场景梳理清楚,下面我们来看第一类,其中分为了三种缺页场景。

第一种场景是,缺页虚拟内存地址 address 在进程页表中间页目录对应的页目录项 pmd_t 是空的,我们可以通过 pmd_none 方法来判断。

c 复制代码
static inline int pmd_none(pmd_t pmd)
{
	unsigned long val = native_pmd_val(pmd);
	return (val & ~_PAGE_KNL_ERRATUM_MASK) == 0;
}

这种情况表示缺页地址 address 对应的 pmd 目前还没有对应的页表,连页表都还没有,那么自然 pte 也是空的,物理内存页就更不用说了,肯定还没有。

第二种场景是,缺页地址 address 对应的 pmd_t 虽然不是空的,页表也存在,但是 address 对应在页表中的 pte 是空的。内核中通过 pte_offset_map 定位 address 在页表中的 pte 。这个过程和前面介绍的定位页目录项的过程一模一样。

c 复制代码
#define pte_offset_map(dir, address) pte_offset_kernel((dir), (address))

static inline pte_t *pte_offset_kernel(pmd_t *pmd, unsigned long address)
{
	return (pte_t *)pmd_page_vaddr(*pmd) + pte_index(address);
}

static inline unsigned long pte_index(unsigned long address)
{
	return (address >> PAGE_SHIFT) & (PTRS_PER_PTE - 1);
}

#define PAGE_SHIFT   12
// 页表可以容纳的页表项 pte_t 的个数
#define PTRS_PER_PTE  512

这种情况下,虽然页表是存在的,但是奈何 address 在页表中的 pte 是空的,和第一种场景一样,都说明了该 address 之前从来还没有被映射过。

既然之前都没有被映射,那么现在就该把这块内容补齐,笔者在之前的文章 《从内核世界透视 mmap 内存映射的本质(原理篇)》 中曾为大家介绍了四种内存映射方式,它们分别为:私有匿名映射,私有文件映射,共享文件映射,共享匿名映射。这四种内存映射方式从总体上来说分为两类:一类是匿名映射,另一类是文件映射。

所以在处理虚拟内存映射区 vma 中的缺页时,也需要分为匿名映射区的缺页处理以及文件映射区的缺页处理。那么在这里,我们该如何区分这个缺页的 vma 到底是属于匿名映射区还是文件映射区呢 ?

还记得笔者之前在 《从内核世界透视 mmap 内存映射的本质(源码实现篇)》 一文中介绍的内存映射核心函数 mmap_region 吗?关于文件映射和匿名映射,有这样的两段代码:

c 复制代码
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)
{
                  ........ 省略 ........
    // 文件映射
    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 {
        // 这里处理私有匿名映射
        // 将  vma->vm_ops 设置为 null,只有文件映射才需要 vm_ops 这样才能将内存与文件映射起来
        vma_set_anonymous(vma);
    }
}

在处理文件映射的代码中,内核调用了一个叫 call_mmap 的函数,内核在该函数中将虚拟内存的相关操作函数 vma->vm_ops 映射成了文件相关的操作函数 ext4_file_vm_ops。正因为如此,后续进程读写这块虚拟内存就相当于读写文件了。

c 复制代码
static int ext4_file_mmap(struct file *file, struct vm_area_struct *vma)
{
        ........ 省略 ........
        
      vma->vm_ops = &ext4_file_vm_ops;
      
        ........ 省略 ........    
}

而在处理匿名映射的代码中,内核调用了一个叫做 vma_set_anonymous 的函数,在这里会将 vma->vm_ops 设置为 null ,因为这里映射的匿名内存页,背后并没有文件来支撑。

c 复制代码
static inline void vma_set_anonymous(struct vm_area_struct *vma)
{
	vma->vm_ops = NULL;
}

所以判断一个虚拟内存区域 vma 到底是文件映射区还是匿名映射区就是要看这个 vma 的 vm_ops 是否为 null。

c 复制代码
static inline bool vma_is_anonymous(struct vm_area_struct *vma)
{
	return !vma->vm_ops;
}

如果 vma_is_anonymous 返回 true,那么内核就会在 handle_pte_fault 函数中调用 do_anonymous_page 进行匿名映射区的缺页处理。

如果 vma_is_anonymous 返回 false,那么内核就调用 do_fault 进行文件映射区的缺页处理。

c 复制代码
    // pte 是空的,表示缺页地址 address 还从来没有被映射过,接下来就要处理物理内存的映射
    if (!vmf->pte) {
        // 判断缺页的虚拟内存地址 address 所在的虚拟内存区域 vma 是否是匿名映射区
        if (vma_is_anonymous(vmf->vma))
            // 处理匿名映射区发生的缺页
            return do_anonymous_page(vmf);
        else
            // 处理文件映射区发生的缺页
            return do_fault(vmf);
    }

第三种缺页场景是,虚拟内存地址 address 在进程页表中的页表项 pte 不是空的,但是其背后映射的物理内存页被内核 swap out 到磁盘上了,CPU 访问的时候依然会产生缺页。

那么我们如何知道 pte 背后映射的物理内存页在不在内存中呢 ?

笔者在之前的文章《一步一图带你构建 Linux 页表体系》 中介绍了页表项 pte 的比特位布局如下图所示:

其中 pte 的第 0 个比特位表示该 pte 映射的物理内存页是否在内存中,值为 1 表示物理内存页在内存中驻留,值为 0 表示物理内存页不在内存中,可能被 swap 到磁盘上了。

c 复制代码
#define _PAGE_BIT_PRESENT 0 /* is present */

#define _PAGE_PRESENT (_AT(pteval_t, 1) << _PAGE_BIT_PRESENT)

如果我们可以把 pte 中的相关权限位提取出来,然后判断权限位第 0 个比特位是否为 1 ,是不是就能知道 pte 映射的物理内存页到底在不在内存中了,这个逻辑封装在 pte_present 方法中:

c 复制代码
static inline int pte_present(pte_t a)
{
	return pte_flags(a) & (_PAGE_PRESENT | _PAGE_PROTNONE);
}

pte_flags 函数用于从 pte 中提取相关的权限位,如何提取呢 ?可还记得我们在上小节中介绍的从页目录项中提取其下一级页目录表的物理内存地址时使用到的掩码 PTE_PFN_MASK 吗 ?

c 复制代码
static inline unsigned long p4d_page_vaddr(p4d_t p4d)
{
    return (unsigned long)__va(p4d_val(p4d) & PTE_PFN_MASK;
}

/* Extracts the PFN from a (pte|pmd|pud|pgd)val_t of a 4KB page */
#define PTE_PFN_MASK        ((pteval_t)PHYSICAL_PAGE_MASK)

#define PHYSICAL_PAGE_MASK  (((signed long)PAGE_MASK) & __PHYSICAL_MASK)

如果我们把掩码 PTE_PFN_MASK 取反,然后在和 pte 做与运算,这样 pte 中的相关权限标记位不就提取出来么。

c 复制代码
#define PTE_FLAGS_MASK		(~PTE_PFN_MASK)

static inline pteval_t pte_flags(pte_t pte)
{
	return native_pte_val(pte) & PTE_FLAGS_MASK;
}

static inline pteval_t native_pte_val(pte_t pte)
{
	return pte.pte;
}

然后用权限标记位 pte_flags 和 _PAGE_PRESENT 做 & 运算就可以知道 pte 背后映射的物理内存页是否在内存中了。

如果我们通过 pte_present 判断映射的物理内存页不在内存中了,说明它已经被内核 swap out 到磁盘上了,这种情况下的缺页处理就需要调用 do_swap_page 函数,将磁盘上的物理内存页重新 swap in 到内存中来。

c 复制代码
   if (!pte_present(vmf->orig_pte))
        // 将之前映射的物理内存页从磁盘中重新 swap in 到内存中
        return do_swap_page(vmf);

以上介绍的这三种缺页场景都是属于缺页内存地址 address 背后映射的物理内存页不在内存中的类别。

下面我们来看下另一类别,也就是缺页虚拟内存地址背后映射的物理内存页在内存中的情况 ,这里又会近一步分为两种缺页场景。

笔者曾在 《深入理解 Linux 物理内存管理》一文中为大家介绍了 Linux 内核在 NUMA 架构下物理内存管理的相关内容。

在 NUMA 架构下,CPU 访问自己的本地内存节点是最快的,但访问其他内存节点就会慢很多,这就导致了 CPU 访问内存的速度不一致。

回到我们缺页处理的场景中就是缺页虚拟内存地址背后映射的物理内存页虽然在内存中,但是它可能是进程所在 CPU 中的本地 NUMA 节点上的内存,也可能是其他 NUMA 节点上的内存。

因为 CPU 对不同 NUMA 节点上的内存有访问速度上的差异,所以内核通常倾向于让 CPU 尽量访问本地 NUMA 节点上的内存。NUMA Balancing 机制就是用来解决这个问题的。

通俗来讲,NUMA Balancing 主要干两件事情,一件事是让内存跟着 CPU 走,另一件事是让 CPU 跟着内存走。

进程申请到的物理内存页可能在当前 CPU 的本地 NUMA 节点上,也可能在其他 NUMA 节点上。

所谓让内存跟着 CPU 走的意思就是,当进程访问的物理内存页不在当前 CPU 的本地 NUMA 节点上时,NUMA Balancing 就会尝试将远程 NUMA 节点上的物理内存页迁移到本地 NUMA 节点上,加快进程访问内存的速度。

所谓让 CPU 跟着内存走的意思就是,当进程经常访问的大部分物理内存页均不在当前 CPU 的本地 NUMA 节点上时,NUMA Balancing 干脆就把进程重新调度到这些物理内存页所在的 NUMA 节点上。当然整个 NUMA Balancing 的过程会根据我们设置的 NUMA policy 以及各个 NUMA 节点上缺页的次数来综合考虑是否迁移内存页。这里涉及到的细节很多,笔者就不一一展开了。

NUMA Balancing 会周期性扫描进程虚拟内存地址空间,如果发现虚拟内存背后映射的物理内存页不在当前 CPU 本地 NUMA 节点的时候,就会把对应的页表项 pte 标记为 _PAGE_PROTNONE,也就是将 pte 的第 8 个 比特位置为 1,随后会将 pte 的 Present 位置为 0 。

c 复制代码
#define _PAGE_PROTNONE	(_AT(pteval_t, 1) << _PAGE_BIT_PROTNONE)

#define _PAGE_BIT_PROTNONE	_PAGE_BIT_GLOBAL

#define _PAGE_BIT_GLOBAL	8

这种情况下调用 pte_present 依然很返回 true ,因为当前的物理内存页毕竟是在内存中的,只不过不在当前 CPU 的本地 NUMA 节点上而已。

当 pte 被标记为 _PAGE_PROTNONE 之后,这意味着该 pte 背后映射的物理内存页进程对其没有读写权限,也没有可执行的权限。进程在访问这段虚拟内存地址的时候就会发生缺页。

当进入缺页异常的处理程序之后,内核会在 handle_pte_fault 函数中通过 pte_protnone 函数判断,缺页的 pte 是否被标记了 _PAGE_PROTNONE 标识。

c 复制代码
static inline int pte_protnone(pte_t pte)
{
	return (pte_flags(pte) & (_PAGE_PROTNONE | _PAGE_PRESENT))
		== _PAGE_PROTNONE;
}

如果 pte 被标记了 _PAGE_PROTNONE,并且对应的虚拟内存区域是一个具有读写,可执行权限的 vma。这就说明该 vma 背后映射的物理内存页不在当前 CPU 的本地 NUMA 节点上。

c 复制代码
static inline bool vma_is_accessible(struct vm_area_struct *vma)
{
	return vma->vm_flags & (VM_READ | VM_EXEC | VM_WRITE);
}

这里需要调用 do_numa_page,将这个远程 NUMA 节点上的物理内存页迁移到当前 CPU 的本地 NUMA 节点上,从而加快进程访问内存的速度。

c 复制代码
  if (pte_protnone(vmf->orig_pte) && vma_is_accessible(vmf->vma))
        return do_numa_page(vmf);

NUMA Balancing 机制看起来非常好,但是同时也会为系统引入很多开销,比如,扫描进程地址空间的开销,缺页的开销,更主要的是页面迁移的开销会很大,这也会引起 CPU 有时候莫名其妙的飙到 100 %。因此笔者建议在一般情况下还是将 NUMA Balancing 关闭为好,除非你有明确的理由开启。

我们可以将内核参数 /proc/sys/kernel/numa_balancing 设置为 0 或者通过 sysctl 命令来关闭 NUMA Balancing。

c 复制代码
echo 0 > /proc/sys/kernel/numa_balancing

sysctl -w kernel.numa_balancing=0

第二种场景就是写时复制了(Copy On Write, COW),这种场景和 NUMA Balancing 一样,都属于缺页虚拟内存地址背后映射的物理内存页在内存中而引起的缺页中断。

COW 在内核的内存管理子系统中很常见了,比如,父进程通过 fork 系统调用创建子进程之后,父子进程的虚拟内存空间完全是一模一样的,包括父子进程的页表内容都是一样的,父子进程页表中的 PTE 均指向同一物理内存页面,此时内核会将父子进程页表中的 PTE 均改为只读的,并将父子进程共同映射的这个物理页面引用计数 + 1。

c 复制代码
static inline unsigned long
copy_one_pte(struct mm_struct *dst_mm, struct mm_struct *src_mm,
        pte_t *dst_pte, pte_t *src_pte, struct vm_area_struct *vma,
        unsigned long addr, int *rss)
{
    /*
     * If it's a COW mapping, write protect it both
     * in the parent and the child
     */
    if (is_cow_mapping(vm_flags) && pte_write(pte)) {
        // 设置父进程的 pte 为只读
        ptep_set_wrprotect(src_mm, addr, src_pte);
        // 设置子进程的 pte 为只读
        pte = pte_wrprotect(pte);
    }
    // 获取 pte 中映射的物理内存页(此时父子进程共享该页)
    page = vm_normal_page(vma, addr, pte);
    // 物理内存页的引用计数 + 1
    get_page(page);
}

当父进程或者子进程对该页面发生写操作的时候,我们现在假设子进程先对页面发生写操作,随后子进程发现自己页表中的 PTE 是只读的,于是产生缺页中断,子进程进入内核态,内核会在本小节介绍的缺页中断处理程序中发现,访问的这个物理页面引用计数大于 1,说明此时该物理内存页面存在多进程共享的情况,于是发生写时复制(Copy On Write, COW),内核为子进程重新分配一个新的物理页面,然后将原来物理页中的内容拷贝到新的页面中,最后子进程页表中的 PTE 指向新的物理页面并将 PTE 的 R/W 位设置为 1,原来物理页面的引用计数 - 1。

后面父进程在对页面进行写操作的时候,同样也会发现父进程的页表中 PTE 是只读的,也会产生缺页中断,但是在内核的缺页中断处理程序中,发现访问的这个物理页面引用计数为 1 了,那么就只需要将父进程页表中的 PTE 的 R/W 位设置为 1 就可以了。

还有笔者在之前的文章 《从内核世界透视 mmap 内存映射的本质(原理篇)》中介绍的私有文件映射,也用到了 COW,当多个进程采用私有文件映射的方式对同一文件的同一部分进行映射的时候,后续产生的 pte 也都是只读的。

当任意进程开始对它的私有文件映射区进行写操作时,就会发生写时复制,随后内核会在这里介绍的缺页中断程序中重新申请一个内存页,然后将 page cache 中的内容拷贝到这个新的内存页中,进程页表中对应的 pte 会重新关联到这个新的内存页上,此时 pte 的权限变为可写。

在以上介绍的两种写时复制应用场景中,他们都有一个共同的特点,就是进程的虚拟内存区域 vma 的权限是可写的,但是其对应在页表中的 pte 却是只读的,而 pte 映射的物理内存页也在内存中

内核正是利用这个特点来判断本次缺页中断是否是由写时复制引起的。如果是,则调用 do_wp_page 进行写时复制的缺页处理。

c 复制代码
    // 判断本次缺页是否为写时复制引起的
    if (vmf->flags & FAULT_FLAG_WRITE) {
        // 这里说明 vma 是可写的,但是 pte 被标记为不可写,说明是写保护类型的中断
        if (!pte_write(entry))
            // 进行写时复制处理,cow 就发生在这里
            return do_wp_page(vmf);
    }

在我们清楚了以上背景知识之后,再来看 handle_pte_fault 的缺页处理逻辑就很清晰了:

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 {
        // 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 表示发生缺页时,address 对应的 pte 值
        vmf->orig_pte = *vmf->pte;

        // 这里 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))
        // 将之前映射的物理内存页从磁盘中重新 swap in 到内存中
        return do_swap_page(vmf);

    // 这里表示 pte 背后映射的物理内存页在内存中,但是 NUMA Balancing 发现该内存页不在当前进程运行的 numa 节点上
    // 所以将该 pte 标记为 _PAGE_PROTNONE(无读写,可执行权限)
    // 进程访问该内存页时发生缺页中断,在这里的 do_numa_page 中,内核将该 page 迁移到进程运行的 numa 节点上。
    if (pte_protnone(vmf->orig_pte) && vma_is_accessible(vmf->vma))
        return do_numa_page(vmf);

    entry = vmf->orig_pte;
    // 如果本次缺页中断是由写操作引起的
    if (vmf->flags & FAULT_FLAG_WRITE) {
        // 这里说明 vma 是可写的,但是 pte 被标记为不可写,说明是写保护类型的中断
        if (!pte_write(entry))
            // 进行写时复制处理,cow 就发生在这里
            return do_wp_page(vmf);
        // 如果 pte 是可写的,就将 pte 标记为脏页
        entry = pte_mkdirty(entry);
    }
    // 将 pte 的 access 比特位置 1 ,表示该 page 是活跃的。避免被 swap 出去
    entry = pte_mkyoung(entry);

    // 经过上面的缺页处理,这里会判断原来的页表项 entry(orig_pte) 值是否发生了变化
    // 如果发生了变化,就把 entry 更新到 vmf->pte 中。
    if (ptep_set_access_flags(vmf->vma, vmf->address, vmf->pte, entry,
                vmf->flags & FAULT_FLAG_WRITE)) {
        // pte 既然变化了,则刷新 mmu (体系结构相关)
        update_mmu_cache(vmf->vma, vmf->address, vmf->pte);
    } else {
        // 如果 pte 内容本身没有变化,则不需要刷新任何东西
        // 但是有个特殊情况就是写保护类型中断,产生的写时复制,产生了新的映射关系,需要刷新一下 tlb
		/*
		 * This is needed only for protection faults but the arch code
		 * is not yet telling us if this is a protection fault or not.
		 * This still avoids useless tlb flushes for .text page faults
		 * with threads.
		 */
        if (vmf->flags & FAULT_FLAG_WRITE)
            flush_tlb_fix_spurious_fault(vmf->vma, vmf->address);
    }

    return 0;
}

7. do_anonymous_page 处理匿名页缺页

在本文的第五小节中,我们完成了各级页目录的补齐填充工作,但是现在最后一级页表还没有着落,所以在处理缺页之前,我们需要调用 pte_alloc 继续把页表补齐了。

c 复制代码
#define pte_alloc(mm, pmd) (unlikely(pmd_none(*(pmd))) && __pte_alloc(mm, pmd))

首先我们通过 pmd_none 判断缺页地址 address 在进程页表中间页目录 PMD 中对应的页目录项 pmd 是否是空的,如果 pmd 是空的,说明此时还不存在一级页表,这样一来,就需要调用 __pte_alloc 来分配一张页表,然后用页表的 pfn 以及初始权限位 _PAGE_TABLE 来填充 pmd。

c 复制代码
static inline void pmd_populate(struct mm_struct *mm, pmd_t *pmd,
                struct page *pte)
{
    // 通过页表 page 获取对应的 pfn
    unsigned long pfn = page_to_pfn(pte);
    // 将页表 page 的 pfn 以及初始权限位 _PAGE_TABLE 填充到 pmd 中
    set_pmd(pmd, __pmd(((pteval_t)pfn << PAGE_SHIFT) | _PAGE_TABLE));
}

这里 __pte_alloc 的流程逻辑和前面我们介绍的__pud_alloc,__pmd_alloc 可以说是一模一样,都是创建其下一级页目录或者页表,然后填充对应的页目录项,这里就不做过多的介绍了。

c 复制代码
int __pte_alloc(struct mm_struct *mm, pmd_t *pmd)
{
    spinlock_t *ptl;
    // 调用 get_zeroed_page 申请一个 4k 物理内存页并初始化为 0 值作为新的 页表
    // new 指向新分配的 页表 起始内存地址
    pgtable_t new = pte_alloc_one(mm);
    if (!new)
        return -ENOMEM;
    // 锁定中间页目录项 pmd
    ptl = pmd_lock(mm, pmd);
    // 如果 pmd 是空的,说明此时 pmd 并未指向页表,下面就需要用新页表 new 来填充 pmd 
    if (likely(pmd_none(*pmd))) {  
        // 更新 mm->pgtables_bytes 计数,该字段用于统计进程页表所占用的字节数
        // 由于这里新增了一张页表,所以计数需要增加 PTRS_PER_PTE * sizeof(pte_t)
        mm_inc_nr_ptes(mm);
        // 将 new 指向的新分配出来的页表 page 的 pfn 以及相关初始权限位填充到 pmd 中
        pmd_populate(mm, pmd, new);
        new = NULL;
    }
    spin_unlock(ptl);
    return 0;
}

// 页表可以容纳的页表项 pte_t 的个数
#define PTRS_PER_PTE  512

现在我们已经有了一级页表,但是页表中的 pte 还都是空的,接下来就该用这个空的 pte 来映射物理内存页了。

首先我们通过 alloc_zeroed_user_highpage_movable 来分配一个物理内存页出来,关于物理内存详细的分配过程,感兴趣的读者可以看下笔者的这篇文章------《深入理解 Linux 物理内存分配全链路实现》

这个物理内存页就是为缺页地址 address 映射的物理内存了,随后我们通过 mk_pte 利用物理内存页 page 的 pfn 以及缺页内存区域 vma 中记录的页属性 vma->vm_page_prot 填充一个新的页表项 entry 出来。

entry 这里只是一个临时的值,后续会将 entry 的值设置到真正的 pte 中。

c 复制代码
#define mk_pte(page, pgprot)   pfn_pte(page_to_pfn(page), (pgprot))

如果缺页内存地址 address 所在的虚拟内存区域 vma 是可写的,那么我们就通过 pte_mkwrite 和 pte_mkdirty 将临时页表项 entry 的 R/W(1) 比特位和D(6) 比特位置为 1 。表示该页表项背后映射的物理内存页 page 是可写的,并且标记为脏页。

c 复制代码
  if (vma->vm_flags & VM_WRITE)
        entry = pte_mkwrite(pte_mkdirty(entry));

注意,此时缺页内存地址 address 在页表中的 pte 还是空的,我们还没有设置呢,目前只是先将值初始化到临时的页表项 entry 中,下面才到设置真正的 pte 的时候。

调用 pte_offset_map_lock,首先获取 address 在一级页表中的真正 pte,然后将一级页表锁定。

c 复制代码
#define pte_offset_map_lock(mm, pmd, address, ptlp) \
({                          \
    // 获取 pmd 映射的一级页表锁
    spinlock_t *__ptl = pte_lockptr(mm, pmd);   \
    // 获取 pte
    pte_t *__pte = pte_offset_map(pmd, address);    \
    *(ptlp) = __ptl;                \
    // 锁定一级页表
    spin_lock(__ptl);               \
    __pte;                      \
})

按理说此时获取到的 pte 应该是空的,如果 pte 不为空,说明已经有其他线程把缺页处理好了,pte 已经被填充了,那么本次缺页处理就该停止,不能在往下走了,直接跳转到 release 处,释放页表锁,释放新分配的物理内存页 page。

c 复制代码
    if (!pte_none(*vmf->pte))
        goto release;

如果 pte 为空,说明此时没有其他线程对缺页进行并发处理,我们可以接着处理缺页。

进程使用到的常驻内存等相关统计信息保存在 task->rss_stat 字段中:

c 复制代码
struct task_struct {
    // 统计进程常驻内存信息
    struct task_rss_stat rss_stat;
}

由于这里我们新分配一个匿名内存页用于缺页处理,所以相关 rss_stat 统计信息 ------ task->rss_stat.count[MM_ANONPAGES] 要加 1 。

c 复制代码
// MM_ANONPAGES ------ Resident anonymous pages 
inc_mm_counter_fast(vma->vm_mm, MM_ANONPAGES);

#define inc_mm_counter_fast(mm, member) add_mm_counter_fast(mm, member, 1)

static void add_mm_counter_fast(struct mm_struct *mm, int member, int val)
{
	struct task_struct *task = current;

	if (likely(task->mm == mm))
		task->rss_stat.count[member] += val;
	else
		add_mm_counter(mm, member, val);
}

随后调用 page_add_new_anon_rmap 建立匿名页的反向映射关系,关于匿名页的反向映射笔者已经在之前的文章 ------ 《深入理解 Linux 物理内存管理》 中详细介绍过了,感兴趣的朋友可以回看下。

反向映射建立好之后,调用 lru_cache_add_active_or_unevictable 将匿名内存页加入到 LRU 活跃链表中。

最后调用 set_pte_at 将之间我们临时填充的页表项 entry 赋值给缺页 address 真正对应的 pte。

c 复制代码
set_pte_at(vma->vm_mm, vmf->address, vmf->pte, entry);

#define set_pte_at(mm, addr, ptep, pte)	native_set_pte_at(mm, addr, ptep, pte)

static inline void native_set_pte_at(struct mm_struct *mm, unsigned long addr,
				     pte_t *ptep , pte_t pte)
{
	native_set_pte(ptep, pte);
}

static inline void native_set_pte(pte_t *ptep, pte_t pte)
{
	WRITE_ONCE(*ptep, pte);
}

到这里我们才算是真正把进程的页表体系给补齐了。

在明白以上内容之后,我们回过头来看在 do_anonymous_page 匿名页缺页处理的逻辑就很清晰了:

c 复制代码
static vm_fault_t do_anonymous_page(struct vm_fault *vmf)
{
    // 缺页地址 address 所在的虚拟内存区域 vma
    struct vm_area_struct *vma = vmf->vma;
    // 指向分配的物理内存页,后面与虚拟内存进行映射
    struct page *page;
    vm_fault_t ret = 0;
    // 临时的 pte 用于构建 pte 中的值,后续会赋值给 address 在页表中对应的真正 pte
    pte_t entry;

    // 如果 pmd 是空的,表示现在还没有一级页表
    // pte_alloc 这里会创建一级页表,并填充 pmd 中的内容
    if (pte_alloc(vma->vm_mm, vmf->pmd))
        return VM_FAULT_OOM;
  
    // 页表创建好之后,这里从伙伴系统中分配一个 4K 物理内存页出来
    page = alloc_zeroed_user_highpage_movable(vma, vmf->address);
    if (!page)
        goto oom;
    // 将 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;
    // 增加 进程 rss 相关计数,匿名内存页计数 + 1
    inc_mm_counter_fast(vma->vm_mm, MM_ANONPAGES);
    // 建立匿名页反向映射关系
    page_add_new_anon_rmap(page, vma, vmf->address, false);
    // 将匿名页添加到 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 的映射
    pte_unmap_unlock(vmf->pte, vmf->ptl);
    return ret;
release:
    // 释放 page 
    put_page(page);
    goto unlock;
oom:
    return VM_FAULT_OOM;
}

8. do_fault 处理文件页缺页

笔者在之前的文章《从内核世界透视 mmap 内存映射的本质(源码实现篇)》 中,在为大家介绍到 mmap 文件映射的源码实现时,特别强调了一下,mmap 内存文件映射的本质其实就是将虚拟映射区 vma 的相关操作 vma->vm_ops 映射成文件的相关操作 ext4_file_vm_ops。

c 复制代码
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)
{
                  ........ 省略 ........
    // 文件映射
    if (file) {
        // 将文件与虚拟内存映射起来
        vma->vm_file = get_file(file);
        // 这一步中将虚拟内存区域 vma 的操作函数 vm_ops 映射成文件的操作函数(和具体文件系统有关)
        // ext4 文件系统中的操作函数为 ext4_file_vm_ops
        // 从这一刻开始,读写内存就和读写文件是一样的了
        error = call_mmap(file, vma);
    } 
}

static int ext4_file_mmap(struct file *file, struct vm_area_struct *vma)
{     
      vma->vm_ops = &ext4_file_vm_ops;
}

在 vma->vm_ops 中有个重要的函数 fault,在 ext4 文件系统中的实现是:ext4_filemap_fault 函数。

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

vma->vm_ops->fault 函数就是专门用于处理文件映射区缺页的,本小节要介绍的文件页的缺页处理的核心就是依赖这个函数完成的。

我们知道 mmap 进行文件映射的时候只是单纯地建立了虚拟内存与文件之间的映射关系,此时并没有物理内存分配。当进程对这段文件映射区进行读取操作的时候,会触发缺页,然后分配物理内存(文件页),这一部分逻辑在下面的 do_read_fault 函数中完成,它主要处理的是由于对文件映射区的读取操作而引起的缺页情况。

而 mmap 文件映射又分为私有文件映射与共享文件映射两种映射方式,而私有文件映射的核心特点是读共享的,当任意进程对私有文件映射区发生写入操作时候,就会发生写时复制 COW,这一部分逻辑在下面的 do_cow_fault 函数中完成。

对共享文件映射区进行的写入操作而引起的缺页,内核放在 do_shared_fault 函数中进行处理。

c 复制代码
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;

    // 处理 vm_ops->fault 为 null 的异常情况
    if (!vma->vm_ops->fault) {
        // 如果中间页目录 pmd 指向的一级页表不在内存中,则返回 SIGBUS 错误
        if (unlikely(!pmd_present(*vmf->pmd)))
            ret = VM_FAULT_SIGBUS;
        else {
            // 获取缺页的页表项 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
        // 处理共享映射区的写入缺页
        ret = do_shared_fault(vmf);

    return ret;
}

8.1 do_read_fault 处理读操作引起的缺页

当我们调用 mmap 对文件进行映射的时候,无论是采用私有文件映射的方式还是共享文件映射的方式,内核都只是会在进程的地址空间中为本次映射创建出一段虚拟映射区 vma 出来,然后将这段虚拟映射区 vma 与映射文件关联起来就结束了,整个映射过程并未涉及到物理内存的分配。

下面是多进程对同一文件中的同一段文件区域进行私有映射后,内核中的结构图:

当任意进程开始访问其地址空间中的这段虚拟内存区域 vma 时,由于背后没有对应文件页进行映射,所以会发生缺页中断,在缺页中断中内核会首先分配一个物理内存页并加入到 page cache 中,随后将映射的文件内容读取到刚刚创建出来的物理内存页中,然后将这个物理内存页映射到缺页虚拟内存地址 address 对应在进程页表中的 pte 中。

除此之外,内核还会考虑到进程访问内存的空间局部性,所以内核除了会映射本次缺页需要的文件页之外,还会将其相邻的文件页读取到 page cache 中,然后将这些相邻的文件页映射到对应的 pte 中。这一部分预先提前映射的逻辑在 map_pages 函数中实现。

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

如果不满足预先提前映射的条件,那么内核就只会专注处理映射本次缺页所需要的文件页。

首先通过上面的 fault 函数,当映射文件所在文件系统是 ext4 时,该函数的实现为 ext4_filemap_fault,该函数只负责获取本次缺页所需要的文件页。

当获取到文件页之后,内核会调用 finish_fault 函数,将文件页映射到缺页地址 address 在进程页表中对应的 pte 中,do_read_fault 函数处理就完成了,不过需要注意的是,对于私有文件映射的话,此时的这个 pte 还是只读的,多进程之间读共享,当任意进程尝试写入的时候,会发生写时复制。

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);     // 这里需要负责获取文件页,并不映射
    // 将本次缺页所需要的文件页映射到 pte 中。
    ret |= finish_fault(vmf);
    unlock_page(vmf->page);
    return ret;
}

__do_fault 函数底层会调用到 vma->vm_ops->fault,在 ext4 文件系统中对应的实现是 ext4_filemap_fault。

c 复制代码
static vm_fault_t __do_fault(struct vm_fault *vmf)
{
    struct vm_area_struct *vma = vmf->vma;
    vm_fault_t ret;
          ...... 省略 ......
    ret = vma->vm_ops->fault(vmf);
          ...... 省略 ......
    return ret;
}

vm_fault_t ext4_filemap_fault(struct vm_fault *vmf)
{
    ret = filemap_fault(vmf);
    return ret;
}

filemap_fault 主要的任务就是先把缺页所需要的文件页获取出来,为后面的映射做准备。

以下内容涉及到文件以及 page cache 的相关操作,对细节感兴趣的读者可以回看下笔者之前的文章 ------ 《从 Linux 内核角度探秘 JDK NIO 文件读写本质》

内核在这里首先会调用 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 中。

c 复制代码
static const struct address_space_operations ext4_aops = {
    .readpage       = ext4_readpage
}
c 复制代码
vm_fault_t filemap_fault(struct vm_fault *vmf)
{
    int error;
    // 获取映射文件
    struct file *file = vmf->vma->vm_file;
    // 获取 page cache
    struct address_space *mapping = file->f_mapping;    
    // 获取映射文件的 inode
    struct inode *inode = mapping->host;
    // 获取映射文件内容在文件中的偏移
    pgoff_t offset = vmf->pgoff;
    // 从 page cache 读取到的文件页,存放在 vmf->page 中返回
    struct page *page;
    vm_fault_t ret = 0;

    // 根据文件偏移 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);
        }
    }

    ..... 省略 ......
}
EXPORT_SYMBOL(filemap_fault);

文件页现在有了,接下来内核就会调用 finish_fault 将文件页映射到 pte 中。

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

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

    // 对于私有映射来说,这里需要检查进程地址空间是否被标记了 MMF_UNSTABLE
    // 如果是,那么 oom 后续会回收这块地址空间,这会导致私有映射的文件页丢失
    // 所以在为私有映射建立 pte 映射之前,需要检查一下
    if (!(vmf->vma->vm_flags & VM_SHARED))
        // 地址空间没有被标记 MMF_UNSTABLE 则会返回 o
        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;
}

alloc_set_pte 将之前我们准备好的文件页,映射到缺页地址 address 在进程页表对应的 pte 中。

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 (!vmf->pte) {
        // 如果 pmd 为空,则创建一个页表出来,并填充 pmd
        // 如果页表存在,则获取 address 在页表中对应的 pte 保存在 vmf->pte 中
        ret = pte_alloc_one_map(vmf);
        if (ret)
            return ret;
    }
    // 根据之前分配出来的内存页 pfn 以及相关页属性 vma->vm_page_prot 构造一个 pte 出来
    // 对于私有文件映射来说,这里的 pte 是只读的
    entry = mk_pte(page, vma->vm_page_prot);
    // 如果是写时复制,这里才会将 pte 改为可写的
    if (write) 
        entry = maybe_mkwrite(pte_mkdirty(entry), vma);
    // 将构造出来的 pte (entry)赋值给 address 在页表中真正对应的 vmf->pte
    // 现在进程页表体系就全部被构建出来了,文件页缺页处理到此结束
    set_pte_at(vma->vm_mm, vmf->address, vmf->pte, entry);
    // 刷新 mmu
    update_mmu_cache(vma, vmf->address, vmf->pte);

    return 0;
}

8.2 do_cow_fault 处理私有文件映射的写时复制

上小节 do_read_fault 函数处理的场景是,进程在调用 mmap 对文件进行私有映射或者共享映射之后,立马进行读取的缺页场景。

但是如果当我们采用的是 mmap 进行私有文件映射时,在映射之后,立马进行写入操作时,就会发生写时复制,写时复制的缺页处理流程内核封装在 do_cow_fault 函数中。

由于我们这里要进行写时复制,所以首先要调用 alloc_page_vma 从伙伴系统中重新申请一个物理内存页出来,我们先把这个刚刚新申请出来用于写时复制的内存页称为 cow_page

然后调用上小节中介绍的 __do_fault 函数,将原来的文件页从 page cache 中读取出来,我们把原来的文件页称为 page 。

最后调用 copy_user_highpage 将原来文件页 page 中的内容拷贝到刚刚新申请的内存页 cow_page 中,完成写时复制之后,接着调用 finish_fault 将 cow_page 映射到缺页地址 address 在进程页表中的 pte 上。

这样一来,进程的这段虚拟文件映射区就映射到了专属的物理内存页 cow_page 上,而且内容和原来文件页 page 中的内容一模一样,进程对各自虚拟内存区的修改只能反应到各自对应的 cow_page上,而且各自的修改在进程之间是互不可见的。

由于 cow_page 已经脱离了 page cache,所以这些修改也都不会回写到磁盘文件中,这就是私有文件映射的核心特点。

c 复制代码
static vm_fault_t do_cow_fault(struct vm_fault *vmf)
{
    struct vm_area_struct *vma = vmf->vma;
    vm_fault_t ret;
    // 从伙伴系统重新申请一个用于写时复制的物理内存页 cow_page
    vmf->cow_page = alloc_page_vma(GFP_HIGHUSER_MOVABLE, vma, vmf->address);
    // 从  page cache 读取原来的文件页
    ret = __do_fault(vmf);
    // 将原来文件页中的内容拷贝到 cow_page 中完成写时复制
    copy_user_highpage(vmf->cow_page, vmf->page, vmf->address, vma);
    // 将 cow_page 重新映射到缺页地址 address 对应在页表中的 pte 上。
    ret |= finish_fault(vmf);
    unlock_page(vmf->page);
    // 原来的文件页引用计数 - 1
    put_page(vmf->page);
    return ret;
}

8.3 do_shared_fault 处理对共享文件映射区写入引起的缺页

上小节我们介绍的 do_cow_fault 函数处理的场景是,当我们采用 mmap 进行私有文件映射之后,立即对虚拟映射区进行写入操作之后的缺页处理逻辑。

如果我们调用 mmap 对文件进行共享文件映射之后,然后立即对虚拟映射区进行写入操作,这背后的缺页处理逻辑又是怎样的呢 ?

其实和之前的文件缺页处理逻辑的核心流程都差不多,不同的是由于这里我们进行的共享文件映射,所以多个进程中的虚拟文件映射区都会映射到 page cache 中的文件页上,由于没有写时复制,所以进程对文件页的修改都会直接反映到 page cache 中,近而后续会回写到磁盘文件上。

由于共享文件映射涉及到脏页回写,所以在共享文件映射的缺页处理场景中,为了防止数据的丢失会额外有一些文件系统日志的记录工作。

c 复制代码
static vm_fault_t do_shared_fault(struct vm_fault *vmf)
{
    struct vm_area_struct *vma = vmf->vma;
    vm_fault_t ret, tmp;
    // 从 page cache 中读取文件页
    ret = __do_fault(vmf);
   
    if (vma->vm_ops->page_mkwrite) {
        unlock_page(vmf->page);
        // 将文件页变为可写状态,并为后续记录文件日志做一些准备工作
        tmp = do_page_mkwrite(vmf);
    }

    // 将文件页映射到缺页 address 在页表中对应的 pte 上
    ret |= finish_fault(vmf);

    // 将 page 标记为脏页,记录相关文件系统的日志,防止数据丢失
    // 判断是否将脏页回写
    fault_dirty_shared_page(vma, vmf->page);
    return ret;
}

9. do_wp_page 进行写时复制

本小节即将要介绍的 do_wp_page 函数和之前介绍的 do_cow_fault 函数都是用于处理写时复制的,其最为核心的逻辑都是差不多的,只是在触发场景上会略有不同。

do_cow_fault 函数主要处理的写时复制场景是,当我们使用 mmap 进行私有文件映射时,在刚映射完之后,此时进程的页表或者相关页表项 pte 还是空的,就立即进行写入操作。

do_wp_page 函数主要处理的写时复制场景是,访问的这块虚拟内存背后是有物理内存页映射的,对应的 pte 不为空,只不过相关 pte 的权限是只读的,而虚拟内存区域 vma 是有写权限的,在这种类型的虚拟内存进行写入操作的时候,触发的写时复制就在 do_wp_page 函数中处理。

比如,我们使用 mmap 进行私有文件映射之后,此时只是分配了虚拟内存,进程页表或者相关 pte 还是空的,这时对这块映射的虚拟内存进行访问的时候就会触发缺页中断,最后在之前介绍的 do_read_fault 函数中将映射的文件内容加载到 page cache 中,pte 指向 page cache 中的文件页。

但此时的 pte 是只读的,如果我们对这块映射的虚拟内存进行写入操作,就会发生写时复制,由于现在 pte 不为空,背后也映射着文件页,所以会在 do_wp_page 函数中进行处理。

除了私有映射的文件页之外,do_wp_page 还会对匿名页相关的写时复制进行处理。

比如,我们通过 fork 系统调用创建子进程的时候,内核会拷贝父进程占用的所有资源到子进程中,其中也包括了父进程的地址空间以及父进程的页表。

一个进程中申请的物理内存页既会有文件页也会有匿名页,而这些文件页和匿名页既可以是私有的也可以是共享的,当内核在拷贝父进程的页表时,如果遇到私有的匿名页或者文件页,就会将其对应在父子进程页表中的 pte 设置为只读,进行写保护。并将父子进程共同引用的匿名页或者文件页的引用计数加 1。

c 复制代码
static inline unsigned long
copy_one_pte(struct mm_struct *dst_mm, struct mm_struct *src_mm,
        pte_t *dst_pte, pte_t *src_pte, struct vm_area_struct *vma,
        unsigned long addr, int *rss)
{
    /*
     * If it's a COW mapping, write protect it both
     * in the parent and the child
     */
    if (is_cow_mapping(vm_flags) && pte_write(pte)) {
        // 设置父进程的 pte 为只读
        ptep_set_wrprotect(src_mm, addr, src_pte);
        // 设置子进程的 pte 为只读
        pte = pte_wrprotect(pte);
    }
    // 获取 pte 中映射的物理内存页(此时父子进程共享该页)
    page = vm_normal_page(vma, addr, pte);
    // 物理内存页的引用技术 + 1
    get_page(page);
}

static inline bool is_cow_mapping(vm_flags_t flags)
{
        // vma 是私有可写的
	return (flags & (VM_SHARED | VM_MAYWRITE)) == VM_MAYWRITE;
}

现在父子进程拥有了一模一样的地址空间,页表是一样的,页表中的 pte 均指向同一个物理内存页面,对于私有的物理内存页来说,父子进程的相关 pte 此时均变为了只读的,私有物理内存页的引用计数为 2 。而对于共享的物理内存页来说,内核就只是简单的将父进程的 pte 拷贝到子进程页表中即可,然后将子进程 pte 中的脏页标记清除,其他的不做改变。

当父进程或者子进程对该页面发生写操作的时候,我们现在假设子进程先对页面发生写操作,随后子进程发现自己页表中的 pte 是只读的,于是就会产生写保护类型的缺页中断,由于子进程页表中的 pte 不为空,所以会进入到 do_wp_page 函数中处理。

由于现在子进程和父子进程页表中的相关 pte 指向的均是同一个物理内存页,内核在 do_wp_page 函数中会发现这个物理内存页的引用计数大于 1,存在多进程共享的情况,所以就会触发写时复制,这一过程在 wp_page_copy 函数中处理。

在 wp_page_copy 函数中,内核会首先为子进程分配一个新的物理内存页 new_page,然后调用 cow_user_page 将原有内存页 old_page 中的内容全部拷贝到新内存页中。

创建一个临时的页表项 entry,然后让 entry 指向新的内存页,将 entry 重新设置为可写,通过 set_pte_at_notify 将 entry 值设置到子进程页表中的 pte 上。最后将原有内存页 old_page 的引用计数减 1 。

c 复制代码
static vm_fault_t wp_page_copy(struct vm_fault *vmf)
{
    // 缺页地址 address 所在 vma
    struct vm_area_struct *vma = vmf->vma;
    // 当前进程地址空间
    struct mm_struct *mm = vma->vm_mm;
    // 原来映射的物理内存页,pte 为只读
    struct page *old_page = vmf->page;
    // 用于写时复制的新内存页
    struct page *new_page = NULL;
    // 写时复制之后,需要修改原来的 pte,这里是临时构造的一个 pte 值
    pte_t entry;
    // 是否发生写时复制
    int page_copied = 0;

    // 如果 pte 原来映射的是一个零页
    if (is_zero_pfn(pte_pfn(vmf->orig_pte))) {
        // 新申请一个零页出来,内存页中的内容被零初始化
        new_page = alloc_zeroed_user_highpage_movable(vma,
                                  vmf->address);
        if (!new_page)
            goto oom;
    } else {
        // 新申请一个物理内存页
        new_page = alloc_page_vma(GFP_HIGHUSER_MOVABLE, vma,
                vmf->address);
        if (!new_page)
            goto oom;
        // 将原来内存页 old page 中的内容拷贝到新内存页 new page 中
        cow_user_page(new_page, old_page, vmf->address, vma);
    }

    // 给页表加锁,并重新获取 address 在页表中对应的 pte
    vmf->pte = pte_offset_map_lock(mm, vmf->pmd, vmf->address, &vmf->ptl);
    // 判断加锁前的 pte (orig_pte)与加锁后的 pte (vmf->pte)是否相同
    // 目的是判断此时是否有其他线程正在并发修改 pte
    if (likely(pte_same(*vmf->pte, vmf->orig_pte))) {
        if (old_page) {
            // 更新进程常驻内存信息 rss_state
            if (!PageAnon(old_page)) {
                // 减少 MM_FILEPAGES 计数
                dec_mm_counter_fast(mm,
                        mm_counter_file(old_page));
                // 由于发生写时复制,这里匿名页个数加 1 
                inc_mm_counter_fast(mm, MM_ANONPAGES);
            }
        } else {
            inc_mm_counter_fast(mm, MM_ANONPAGES);
        }
        // 将旧的 tlb 缓存刷出
        flush_cache_page(vma, vmf->address, pte_pfn(vmf->orig_pte));
        // 创建一个临时的 pte 映射到新内存页 new page 上
        entry = mk_pte(new_page, vma->vm_page_prot);
        // 设置 entry 为可写的,正是这里, pte 的权限由只读变为了可写
        entry = maybe_mkwrite(pte_mkdirty(entry), vma);
        // 为新的内存页建立反向映射关系
        page_add_new_anon_rmap(new_page, vma, vmf->address, false);
        // 将新的内存页加入到 LRU active 链表中
        lru_cache_add_active_or_unevictable(new_page, vma);
        // 将 entry 值重新设置到子进程页表 pte 中
        set_pte_at_notify(mm, vmf->address, vmf->pte, entry);
        // 更新 mmu
        update_mmu_cache(vma, vmf->address, vmf->pte);
        if (old_page) {
            // 将原来的内存页从当前进程的反向映射关系中解除
            page_remove_rmap(old_page, false);
        }

        /* Free the old page.. */
        new_page = old_page;
        page_copied = 1;
    } else {
        mem_cgroup_cancel_charge(new_page, memcg, false);
    }
    // 释放页表锁
    pte_unmap_unlock(vmf->pte, vmf->ptl);

    if (old_page) {
        // 旧内存页的引用计数减 1
        put_page(old_page);
    }
    return page_copied ? VM_FAULT_WRITE : 0;
}

现在子进程处理完了,下面我们再来看当父进程发生写入操作的时候会发生什么 ?

首先和子进程一样,现在父进程页表中的相关 pte 仍然是只读的,访问这段虚拟内存地址依然会产生写保护类型的缺页中断,和子进程不同的是,此时父进程 pte 中指向的原有物理内存页 old_page 的引用计数已经变为 1 了,说明父进程是独占的,复用原来的 old_page 即可,不必进行写时复制,只是简单的将父进程页表中的相关 pte 改为可写就行了。

c 复制代码
static inline void wp_page_reuse(struct vm_fault *vmf)
    __releases(vmf->ptl)
{
    struct vm_area_struct *vma = vmf->vma;
    struct page *page = vmf->page;
    pte_t entry;
    // 先将 tlb cache 中缓存的 address 对应的 pte 刷出缓存
    flush_cache_page(vma, vmf->address, pte_pfn(vmf->orig_pte));
    // 将原来 pte 的 access 位置 1 ,表示该 pte 映射的物理内存页是活跃的
    entry = pte_mkyoung(vmf->orig_pte);
    // 将原来只读的 pte 改为可写的,并标记为脏页
    entry = maybe_mkwrite(pte_mkdirty(entry), vma);
    // 将更新后的 entry 值设置到页表 pte 中
    if (ptep_set_access_flags(vma, vmf->address, vmf->pte, entry, 1))
        // 更新 mmu 
        update_mmu_cache(vma, vmf->address, vmf->pte);
    pte_unmap_unlock(vmf->pte, vmf->ptl);
}

理解了上面的核心内容,我们再来看 do_wp_page 的处理逻辑就很清晰了:

c 复制代码
static vm_fault_t do_wp_page(struct vm_fault *vmf)
    __releases(vmf->ptl)
{
    struct vm_area_struct *vma = vmf->vma;
    // 获取 pte 映射的物理内存页
    vmf->page = vm_normal_page(vma, vmf->address, vmf->orig_pte);

         ...... 省略处理特殊映射相关逻辑 ....
    // 物理内存页为匿名页的情况
    if (PageAnon(vmf->page)) {

         ...... 省略处理 ksm page 相关逻辑 ....
        // reuse_swap_page 判断匿名页的引用计数是否为 1
        if (reuse_swap_page(vmf->page, &total_map_swapcount)) {
            // 如果当前物理内存页的引用计数为 1 ,并且只有当前进程在引用该物理内存页
            // 则不做写时复制处理,而是复用当前物理内存页,只是将 pte 改为可写即可 
            wp_page_reuse(vmf);
            return VM_FAULT_WRITE;
        }
        unlock_page(vmf->page);
    } else if (unlikely((vma->vm_flags & (VM_WRITE|VM_SHARED)) ==
                    (VM_WRITE|VM_SHARED))) {
        // 处理共享可写的内存页
        // 由于大家都可写,所以这里也只是调用 wp_page_reuse 复用当前内存页即可,不做写时复制处理
        // 由于是共享的,对于文件页来说是可以回写到磁盘上的,所以会额外调用一次 fault_dirty_shared_page 判断是否进行脏页的回写
        return wp_page_shared(vmf);
    }
copy:
    // 走到这里表示当前物理内存页的引用计数大于 1 被多个进程引用
    // 对于私有可写的虚拟内存区域来说,就要发生写时复制
    // 而对于私有文件页的情况来说,不必判断内存页的引用计数
    // 因为是私有文件页,不管文件页的引用计数是不是 1 ,都要进行写时复制
    return wp_page_copy(vmf);
}
相关推荐
程序员大金10 分钟前
基于SpringBoot+Vue+MySQL的校园一卡通系统
java·javascript·vue.js·spring boot·后端·mysql·tomcat
布说在见25 分钟前
Spring Boot管理用户数据
java·spring boot·后端
zhangxueyi37 分钟前
超详图解 Apache HTTP Server(httpd)安装与验证
linux·http·vmware虚拟机
coder what44 分钟前
基于springboot的图书管理系统
java·spring boot·后端·图书管理系统
码农小旋风44 分钟前
一文详解大语言模型Transformer结构
后端
思禾1 小时前
Qemu开发ARM篇-3、qemu运行uboot演示
linux·arm开发·qemu·uboot
A乐神1 小时前
Django 基础之启动命令和基础配置
后端·python·django
玖石书2 小时前
ubuntu 20.04修改启动项默认等待时间
linux·运维·ubuntu
我写代码菜如坤2 小时前
ubuntu18.04升级到20.04
linux·运维·ubuntu
&黄昏的乐师2 小时前
Ubuntu以及ROS的一些方便设置及使用
linux·运维·ubuntu