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

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

在前面两篇介绍 mmap 的文章中,笔者分别从原理角度以及源码实现角度带着大家深入到内核世界深度揭秘了 mmap 内存映射的本质。从整个 mmap 映射的过程可以看出,内核只是在进程的虚拟地址空间中寻找出一段空闲的虚拟内存区域 vma 然后分配给本次映射而已。

c 复制代码
    vma = vm_area_alloc(mm);
    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;

如果是文件映射的话,内核还会额外做一项工作,就是将分配出来的这段虚拟内存区域 vma 与映射文件关联映射起来。

c 复制代码
vma->vm_file = get_file(file);
error = call_mmap(file, vma);

映射的核心就是将虚拟内存区域 vm_area_struct 相关的内存操作 vma->vm_ops 设置为文件系统的相关操作 ext4_file_vm_ops。这样一来,进程后续对这段虚拟内存的读写就相当于是读写映射文件了。

无论是匿名映射还是文件映射,内核在处理 mmap 映射过程中貌似都是在进程的虚拟地址空间中和虚拟内存打交道,仅仅只是为 mmap 映射分配出一段虚拟内存而已,整个映射过程我们并没有看到物理内存的身影。

那么大家所关心的物理内存到底是什么时候映射进来的呢 ?这就是今天本文要讨论的主题 ------ 缺页中断。

1. 缺页中断产生的原因

如下图所示,当 mmap 系统调用成功返回之后,内核只是为进程分配了一段 [vm_start , vm_end] 范围内的虚拟内存区域 vma ,由于还未与物理内存发生关联,所以此时进程页表中与 mmap 映射的虚拟内存相关的各级页目录和页表项还都是空的。

当 CPU 访问这段由 mmap 映射出来的虚拟内存区域 vma 中的任意虚拟地址时,MMU 在遍历进程页表的时候就会发现,该虚拟内存地址在进程顶级页目录 PGD(Page Global Directory)中对应的页目录项 pgd_t 是空的,该 pgd_t 并没有指向其下一级页目录 PUD(Page Upper Directory)。

也就是说,此时进程页表中只有一张顶级页目录表 PGD,而上层页目录 PUD(Page Upper Directory),中间页目录 PMD(Page Middle Directory),一级页表(Page Table)内核都还没有创建。

由于现在被访问到的虚拟内存地址对应的 pgd_t 是空的,进程的四级页表体系还未建立,所以 MMU 会产生一个缺页中断,进程从用户态转入内核态来处理这个缺页异常。

此时 CPU 会将发生缺页异常时,进程正在使用的相关寄存器中的值压入内核栈中。比如,引起进程缺页异常的虚拟内存地址会被存放在 CR2 寄存器中。同时 CPU 还会将缺页异常的错误码 error_code 压入内核栈中。

随后内核会在 do_page_fault 函数中来处理缺页异常,该函数的参数都是内核在处理缺页异常的时候需要用到的基本信息:

c 复制代码
dotraplinkage void
do_page_fault(struct pt_regs *regs, unsigned long error_code, unsigned long address)

struct pt_regs 结构中存放的是缺页异常发生时,正在使用中的寄存器值的集合。address 表示触发缺页异常的虚拟内存地址。

error_code 是对缺页异常的一个描述,目前内核只使用了 error_code 的前六个比特位来描述引起缺页异常的具体原因,后面比特位的含义我们先暂时忽略。

P(0) : 如果 error_code 第 0 个比特位置为 0 ,表示该缺页异常是由于 CPU 访问的这个虚拟内存地址 address 背后并没有一个物理内存页与之映射而引起的,站在进程页表的角度来说,就是 CPU 访问的这个虚拟内存地址 address 在进程四级页表体系中对应的各级页目录项或者页表项是空的(页目录项或者页表项中的 P 位为 0 )。

如果 error_code 第 0 个比特位置为 1,表示 CPU 访问的这个虚拟内存地址背后虽然有物理内存页与之映射,但是由于访问权限不够而引起的缺页异常(保护异常),比如,进程尝试对一个只读的物理内存页进行写操作,那么就会引起写保护类型的缺页异常。

R/W(1) : 表示引起缺页异常的访问类型是什么 ? 如果 error_code 第 1 个比特位置为 0,表示是由于读访问引起的。置为 1 表示是由于写访问引起的。

**注意:**该标志位只是为了描述是哪种访问类型造成了本次缺页异常,这个和前面提到的访问权限没有关系。比如,进程尝试对一个可写的虚拟内存页进行写入,访问权限没有问题,但是该虚拟内存页背后并未有物理内存与之关联,所以也会导致缺页异常。这种情况下,error_code 的 P 位就会设置为 0,R/W 位就会设置为 1 。

U/S(2):表示缺页异常发生在用户态还是内核态,error_code 第 2 个比特位设置为 0 表示 CPU 访问内核空间的地址引起的缺页异常,设置为 1 表示 CPU 访问用户空间的地址引起的缺页异常。

RSVD(3):这里用于检测页表项中的保留位(Reserved 相关的比特位)是否设置,这些页表项中的保留位都是预留给内核以后的相关功能使用的,所以在缺页的时候需要检查这些保留位是否设置,从而决定近一步的扩展处理。设置为 1 表示页表项中预留的这些比特位被使用了。设置为 0 表示页表项中预留的这些比特位还没有被使用。

I/D(4):设置为 1 ,表示本次缺页异常是在 CPU 获取指令的时候引起的。

PK(5):设置为 1,表示引起缺页异常的虚拟内存地址对应页表项中的 Protection 相关的比特位被设置了。

error_code 比特位的含义定义在文件 /arch/x86/include/asm/traps.h 中:

c 复制代码
/*
 * Page fault error code bits:
 *
 *   bit 0 ==	 0: no page found	1: protection fault
 *   bit 1 ==	 0: read access		1: write access
 *   bit 2 ==	 0: kernel-mode access	1: user-mode access
 *   bit 3 ==				1: use of reserved bit detected
 *   bit 4 ==				1: fault was an instruction fetch
 *   bit 5 ==				1: protection keys block access
 */
enum x86_pf_error_code {
	X86_PF_PROT	=		1 << 0,
	X86_PF_WRITE	=		1 << 1,
	X86_PF_USER	=		1 << 2,
	X86_PF_RSVD	=		1 << 3,
	X86_PF_INSTR	=		1 << 4,
	X86_PF_PK	=		1 << 5,
};

2. 内核处理缺页中断的入口 ------ do_page_fault

经过上一小节的介绍我们知道,缺页中断产生的根本原因是由于 CPU 访问的这段虚拟内存背后没有物理内存与之映射,表现的具体形式主要有三种:

  1. 虚拟内存对应在进程页表体系中的相关各级页目录或者页表是空的,也就是说这段虚拟内存完全没有被映射过。

  2. 虚拟内存之前被映射过,其在进程页表的各级页目录以及页表中均有对应的页目录项和页表项,但是其对应的物理内存被内核 swap out 到磁盘上了。

  3. 虚拟内存虽然背后映射着物理内存,但是由于对物理内存的访问权限不够而导致的保护类型的缺页中断。比如,尝试去写一个只读的物理内存页。

虽然缺页中断产生的原因多种多样,内核也会根据不同的缺页原因进行不同的处理,但不管怎么说,一切的起点都是从 CPU 访问虚拟内存开始的,既然提到了虚拟内存,我们就不得不回顾一下进程虚拟内存空间的布局:

在 64 位体系结构下,进程虚拟内存空间总体上分为两个部分,一部分是 128T 的用户空间,地址范围为:0x0000 0000 0000 0000 - 0x0000 7FFF FFFF FFFF 。但实际上,Linux 内核是用 TASK_SIZE_MAX 来定义用户空间的末尾的,也就是说 Linux 内核是使用 TASK_SIZE_MAX 来分割用户虚拟地址空间与内核虚拟地址空间的

c 复制代码
#define TASK_SIZE_MAX  task_size_max()

#define task_size_max()  ((_AC(1,UL) << __VIRTUAL_MASK_SHIFT) - PAGE_SIZE)

#define __VIRTUAL_MASK_SHIFT 47

#define PAGE_SHIFT  12
#define PAGE_SIZE  (_AC(1,UL) << PAGE_SHIFT)

TASK_SIZE_MAX 的计算逻辑首先是将 1 左移 47 位得到的地址是 0x0000800000000000,然后减去一个 PAGE_SIZE (4K),就是 0x00007FFFFFFFF000,所以实际上,64 位体系结构的 Linux 内核中,进程用户空间实际可用的虚拟地址范围是:0x0000 0000 0000 0000 - 0x0000 7FFF FFFF F000

进程虚拟内存空间的另一部分则是 128T 的内核空间,虚拟地址范围为:0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF。由于在内核空间的一开始包含了 8T 的地址空洞,所以内核空间实际可用的虚拟地址范围是:0xFFFF 8800 0000 0000 - 0xFFFF FFFF FFFF FFFF

既然进程虚拟内存地址范围有用户空间与内核空间之分,那么当 CPU 访问虚拟内存地址时产生的缺页中断也要区分下是用户空间产生的缺页还是内核空间产生的缺页。

c 复制代码
static int fault_in_kernel_space(unsigned long address)
{
    /*
     * On 64-bit systems, the vsyscall page is at an address above
     * TASK_SIZE_MAX, but is not considered part of the kernel
     * address space.
     */
    if (IS_ENABLED(CONFIG_X86_64) && is_vsyscall_vaddr(address))
        return false;
    // 在进程虚拟内存空间中,TASK_SIZE_MAX 以上的虚拟地址均属于内核空间
    return address >= TASK_SIZE_MAX;
}

当引起缺页中断的虚拟内存地址 address 是在 TASK_SIZE_MAX 之上时,表示该缺页地址是属于内核空间的,内核的缺页处理程序 __do_page_fault 就要进入 do_kern_addr_fault 分支去处理内核空间的缺页中断。

当引起缺页中断的虚拟内存地址 address 是在 TASK_SIZE_MAX 之下时,表示该缺页地址是属于用户空间的,内核则进入 do_user_addr_fault 分支处理用户空间的缺页中断。

c 复制代码
static noinline void
__do_page_fault(struct pt_regs *regs, unsigned long hw_error_code,
        unsigned long address)
{
    // mmap_sem 是进程虚拟内存空间 mm_struct 的读写锁
    // 内核这里将 mmap_sem 预取到 cacheline 中,并标记为独占状态( MESI 协议中的 X 状态)
    prefetchw(&current->mm->mmap_sem);

    // 这里判断引起缺页异常的虚拟内存地址 address 是属于内核空间的还是用户空间的
    if (unlikely(fault_in_kernel_space(address)))
        // 如果缺页异常发生在内核空间,则由 vmalloc_fault 进行处理
        // 这里使用 unlikely 的原因是,内核对内存的使用通常是高优先级的而且使用比较频繁,所以内核空间一般很少发生缺页异常。
        do_kern_addr_fault(regs, hw_error_code, address);
    else
        // 缺页异常发生在用户态
        do_user_addr_fault(regs, hw_error_code, address);
}
NOKPROBE_SYMBOL(__do_page_fault);

进程工作在内核空间,就相当于你工作在你们公司的核心部门,负责的是公司的核心业务,公司所有的资源都会向核心部门倾斜,可以说是要什么给什么。

进程在内核空间工作也是一样的道理,由于内核负责的是整个系统最为核心的任务,基本上系统中所有的资源都会向内核倾斜,物理内存资源也是一样。内核对内存的申请优先级是最高的,使用频率也是最频繁的。

所以在为内核分配完虚拟内存之后,都会立即分配物理内存,而且是申请多少给多少,最大程度上优先保证内核的工作稳定进行。因此通常在内核中,缺页中断一般很少发生,这也是在上面那段内核代码中,用 unlikely 修饰 fault_in_kernel_space 函数的原因。

而进程工作在用户空间,就相当于你工作在你们公司的非核心部门,负责的是公司的边缘业务,公司没有那么多的资源提供给你,你在工作中需要申请的资源,公司不会马上提供给你,而是需要延迟到没有这些资源你的工作就无法进行的时候(你真正必须使用的时候),公司迫不得已才会把资源分配给你。也就是说,你用到什么的时候才会给你什么,而不是像你在核心部门那样,要什么就给你什么。

比如,笔者在前面两篇文章中为大家介绍的 mmap 内存映射,就是工作在进程用户地址空间中的文件映射与匿名映射区,进程在使用 mmap 申请内存的时候,内核仅仅只是为进程在文件映射与匿名映射区分配一段虚拟内存,重要的物理内存资源不会马上分配,而是延迟到进程真正使用的时候,才会通过缺页中断 __do_page_fault 进入到 do_user_addr_fault 分支进行物理内存资源的分配。

内核空间中的缺页异常主要发生在进程内核虚拟地址空间中 32T 的 vmalloc 映射区,这段区域的虚拟内存地址范围为:0xFFFF C900 0000 0000 - 0xFFFF E900 0000 0000。内核中的 vmalloc 内存分配接口就工作在这个区域,它用于将那些不连续的物理内存映射到连续的虚拟内存上。

3. 内核态缺页异常处理 ------ do_kern_addr_fault

do_kern_addr_fault 函数的工作主要就是处理内核虚拟内存空间中 vmalloc 映射区里的缺页异常,这一部分内容,笔者会在 vmalloc_fault 函数中进行介绍。

c 复制代码
static void
do_kern_addr_fault(struct pt_regs *regs, unsigned long hw_error_code,
           unsigned long address)
{
    // 该缺页的内核地址 address 在内核页表中对应的 pte 不能使用保留位(X86_PF_RSVD = 0)
    // 不能是用户态的缺页中断(X86_PF_USER = 0)
    // 且不能是保护类型的缺页中断 (X86_PF_PROT = 0)
    if (!(hw_error_code & (X86_PF_RSVD | X86_PF_USER | X86_PF_PROT))) {
        // 处理 vmalloc 映射区里的缺页异常
        if (vmalloc_fault(address) >= 0)
            return;
    }
}  

读到这里,大家可能会有一个疑惑,作者你刚刚不是才说了吗,工作在内核就相当于工作在公司的核心部门,要什么资源公司就会给什么资源,在内核空间申请虚拟内存的时候,都会马上分配物理内存资源,而且申请多少给多少。

既然物理内存会马上被分配,那为什么内核空间中的 vmalloc 映射区还会发生缺页中断呢 ?

事实上,内核空间里 vmalloc 映射区中发生的缺页中断与用户空间里文件映射与匿名映射区以及堆中发生的缺页中断是不一样的。

进程在用户空间中无论是通过 brk 系统调用在堆中申请内存还是通过 mmap 系统调用在文件与匿名映射区中申请内存,内核都只是在相应的虚拟内存空间中划分出一段虚拟内存来给进程使用。

当进程真正访问到这段虚拟内存地址的时候,才会产生缺页中断,近而才会分配物理内存,最后将引起本次缺页的虚拟地址在进程页表中对应的全局页目录项 pgd,上层页目录项 pud,中间页目录 pmd,页表项 pte 都创建好,然后在 pte 中将虚拟内存地址与物理内存地址映射起来。

而内核通过 vmalloc 内存分配接口在 vmalloc 映射区申请内存的时候,首先也会在 32T 大小的 vmalloc 映射区中划分出一段未被使用的虚拟内存区域出来,我们暂且叫这段虚拟内存区域为 vmalloc 区,这一点和前面文章介绍的 mmap 非常相似,只不过 mmap 工作在用户空间的文件与匿名映射区,vmalloc 工作在内核空间的 vmalloc 映射区。

内核空间中的 vmalloc 映射区就是由这样一段一段的 vmalloc 区组成的,每调用一次 vmalloc 内存分配接口,就会在 vmalloc 映射区中映射出一段 vmalloc 虚拟内存区域,而且每个 vmalloc 区之间隔着一个 4K 大小的 guard page(虚拟内存),用于防止内存越界,将这些非连续的物理内存区域隔离起来。

和 mmap 不同的是,vmalloc 在分配完虚拟内存之后,会马上为这段虚拟内存分配物理内存,内核会首先计算出由 vmalloc 内存分配接口映射出的这一段虚拟内存区域 vmalloc 区中包含的虚拟内存页数,然后调用伙伴系统依次为这些虚拟内存页分配物理内存页。

3.1 vmalloc

下面是 vmalloc 内存分配的核心逻辑,封装在 __vmalloc_node_range 函数中:

c 复制代码
/**
 * __vmalloc_node_range - allocate virtually contiguous memory
 * Allocate enough pages to cover @size from the page level
 * allocator with @gfp_mask flags.  Map them into contiguous
 * kernel virtual space, using a pagetable protection of @prot.
 *
 * Return: the address of the area or %NULL on failure
 */
void *__vmalloc_node_range(unsigned long size, unsigned long align,
            unsigned long start, unsigned long end, gfp_t gfp_mask,
            pgprot_t prot, unsigned long vm_flags, int node,
            const void *caller)
{
    // 用于描述 vmalloc 虚拟内存区域的数据结构,同 mmap 中的 vma 结构很相似
    struct vm_struct *area;
    // vmalloc 虚拟内存区域的起始地址
    void *addr;
    unsigned long real_size = size;
    // size 为要申请的 vmalloc 虚拟内存区域大小,这里需要按页对齐
    size = PAGE_ALIGN(size);
    // 因为在分配完 vmalloc 区之后,马上就会为其分配物理内存
    // 所以这里需要检查 size 大小不能超过当前系统中的空闲物理内存
    if (!size || (size >> PAGE_SHIFT) > totalram_pages())
        goto fail;

    // 在内核空间的 vmalloc 动态映射区中,划分出一段空闲的虚拟内存区域 vmalloc 区出来
    // 这里虚拟内存的分配过程和 mmap 在用户态文件与匿名映射区分配虚拟内存的过程非常相似,这里就不做过多的介绍了。
    area = __get_vm_area_node(size, align, VM_ALLOC | VM_UNINITIALIZED |
                vm_flags, start, end, node, gfp_mask, caller);
    if (!area)
        goto fail;
    // 为 vmalloc 虚拟内存区域中的每一个虚拟内存页分配物理内存页
    // 并在内核页表中将 vmalloc 区与物理内存映射起来
    addr = __vmalloc_area_node(area, gfp_mask, prot, node);
    if (!addr)
        return NULL;

    return addr;
}

同 mmap 用 vm_area_struct 结构来描述其在用户空间的文件与匿名映射区分配出来的虚拟内存区域一样,内核空间的 vmalloc 动态映射区也有一种数据结构来专门描述该区域中的虚拟内存区,这个结构就是下面的 vm_struct。

c 复制代码
// 用来描述 vmalloc 区
struct vm_struct {
    // vmalloc 动态映射区中的所有虚拟内存区域也都是被一个单向链表所串联
    struct vm_struct    *next;
    // vmalloc 区的起始内存地址
    void            *addr;
    // vmalloc 区的大小
    unsigned long       size;
    // vmalloc 区的相关标记
    // VM_ALLOC 表示该区域是由 vmalloc 函数映射出来的
    // VM_MAP 表示该区域是由 vmap 函数映射出来的
    // VM_IOREMAP 表示该区域是由 ioremap 函数将硬件设备的内存映射过来的
    unsigned long       flags;
    // struct page 结构的数组指针,数组中的每一项指向该虚拟内存区域背后映射的物理内存页。
    struct page     **pages;
    // 该虚拟内存区域包含的物理内存页个数
    unsigned int        nr_pages;
    // ioremap 映射硬件设备物理内存的时候填充
    phys_addr_t     phys_addr;
    // 调用者的返回地址(这里可忽略)
    const void      *caller;
};

由于内核在分配完 vmalloc 虚拟内存区之后,会马上为其分配物理内存,所以在 vm_struct 结构中有一个 struct page 结构的数组指针 pages,用于指向该虚拟内存区域背后映射的物理内存页。nr_pages 则是数组的大小,也表示该虚拟内存区域包含的物理内存页个数。

在内核中所有的这些 vm_struct 均是被一个单链表串联组织的,在早期的内核版本中就是通过遍历这个单向链表来在 vmalloc 动态映射区中寻找空闲的虚拟内存区域的,后来为了提高查找效率引入了红黑树以及双向链表来重新组织这些 vmalloc 区域,于是专门引入了一个 vmap_area 结构来描述 vmalloc 区域的组织形式。

c 复制代码
struct vmap_area {
    // vmalloc 区的起始内存地址
    unsigned long va_start;
    // vmalloc 区的结束内存地址
    unsigned long va_end;
    // vmalloc 区所在红黑树中的节点
    struct rb_node rb_node;         /* address sorted rbtree */
    // vmalloc 区所在双向链表中的节点
    struct list_head list;          /* address sorted list */
    // 用于关联 vm_struct 结构
    struct vm_struct *vm;          
};

看起来和用户空间中虚拟内存区域的组织形式越来越像了,不同的是由于用户空间是进程间隔离的,所以组织用户空间虚拟内存区域的红黑树以及双向链表是进程独占的。

c 复制代码
struct mm_struct {
     struct vm_area_struct *mmap;  /* list of VMAs */
     struct rb_root mm_rb;
}

而内核空间是所有进程共享的,所以组织内核空间虚拟内存区域的红黑树以及双向链表是全局的。

c 复制代码
static struct rb_root vmap_area_root = RB_ROOT;
extern struct list_head vmap_area_list;

在我们了解了 vmalloc 动态映射区中的相关数据结构与组织形式之后,接下来我们看一看为 vmalloc 区分配物理内存的过程:

c 复制代码
static void *__vmalloc_area_node(struct vm_struct *area, gfp_t gfp_mask,
                 pgprot_t prot, int node)
{
    // 指向即将为 vmalloc 区分配的物理内存页
    struct page **pages;
    unsigned int nr_pages, array_size, i;

    // 计算 vmalloc 区所需要的虚拟内存页个数
    nr_pages = get_vm_area_size(area) >> PAGE_SHIFT;
    // vm_struct 结构中的 pages 数组大小,用于存放指向每个物理内存页的指针
    array_size = (nr_pages * sizeof(struct page *));

    // 首先要为 pages 数组分配内存
    if (array_size > PAGE_SIZE) {
        // array_size 超过 PAGE_SIZE 大小则递归调用 vmalloc 分配数组所需内存
        pages = __vmalloc_node(array_size, 1, nested_gfp|highmem_mask,
                PAGE_KERNEL, node, area->caller);
    } else {
        // 直接调用 kmalloc 分配数组所需内存
        pages = kmalloc_node(array_size, nested_gfp, node);
    }

    // 初始化 vm_struct
    area->pages = pages;
    area->nr_pages = nr_pages;

    // 依次为 vmalloc 区中包含的所有虚拟内存页分配物理内存
    for (i = 0; i < area->nr_pages; i++) {
        struct page *page;

        if (node == NUMA_NO_NODE)
            // 如果没有特殊指定 numa node,则从当前 numa node 中分配物理内存页
            page = alloc_page(alloc_mask|highmem_mask);
        else
            // 否则就从指定的 numa node 中分配物理内存页
            page = alloc_pages_node(node, alloc_mask|highmem_mask, 0);
        // 将分配的物理内存页依次存放到 vm_struct 结构中的 pages 数组中
        area->pages[i] = page;
    }
    
    atomic_long_add(area->nr_pages, &nr_vmalloc_pages);
    // 修改内核主页表,将刚刚分配出来的所有物理内存页与 vmalloc 虚拟内存区域进行映射
    if (map_vm_area(area, prot, pages))
        goto fail;
    // 返回 vmalloc 虚拟内存区域起始地址
    return area->addr;
}

在内核中,凡是有物理内存出现的地方,就一定伴随着页表的映射,vmalloc 也不例外,当分配完物理内存之后,就需要修改内核页表,然后将物理内存映射到 vmalloc 虚拟内存区域中,当然了,这个过程也伴随着 vmalloc 区域中的这些虚拟内存地址在内核页表中对应的 pgd,pud,pmd,pte 相关页目录项以及页表项的创建。

大家需要注意的是,这里的内核页表指的是内核主页表,内核主页表的顶级页目录起始地址存放在 init_mm 结构中的 pgd 属性中,其值为 swapper_pg_dir。

c 复制代码
struct mm_struct init_mm = {
   // 内核主页表
  .pgd    = swapper_pg_dir,
}

#define swapper_pg_dir init_top_pgt

内核主页表在系统初始化的时候被一段汇编代码 arch\x86\kernel\head_64.S 所创建。后续在系统启动函数 start_kernel 中调用 setup_arch 进行初始化。

正如之前文章《一步一图带你构建 Linux 页表体系》 中介绍的那样,普通进程在内核态亦或是内核线程都是无法直接访问内核主页表的,它们只能访问内核主页表的 copy 副本,于是进程页表体系就分为了两个部分,一个是进程用户态页表(用户态缺页处理的就是这部分),另一个就是内核页表的 copy 部分(内核态缺页处理的是这部分)。

在 fork 系统调用创建进程的时候,进程的用户态页表拷贝自他的父进程,而进程的内核态页表则从内核主页表中拷贝,后续进程陷入内核态之后,访问的就是内核主页表中拷贝的这部分。

这也引出了一个新的问题,就是内核主页表与其在进程中的拷贝副本如何同步呢 ? 这就是本小节,笔者想要和大家交代的主题 ------ 内核态缺页异常的处理。

3.2 vmalloc_fault

当内核通过 vmalloc 内存分配接口修改完内核主页表之后,主页表中的相关页目录项以及页表项的内容就发生了改变,而这背后的一切,进程现在还被蒙在鼓里,一无所知,此时,进程页表中的内核部分相关的页目录项以及页表项还都是空的。

当进程陷入内核态访问这部分页表的的时候,会发现相关页目录或者页表项是空的,就会进入缺页中断的内核处理部分,也就是前面提到的 vmalloc_fault 函数中,如果发现缺页的虚拟地址在内核主页表顶级全局页目录表中对应的页目录项 pgd 存在,而缺页地址在进程页表内核部分对应的 pgd 不存在,那么内核就会把内核主页表中 pgd 页目录项里的内容复制给进程页表内核部分中对应的 pgd。

事实上,同步内核主页表的工作只需要将缺页地址对应在内核主页表中的顶级全局页目录项 pgd 同步到进程页表内核部分对应的 pgd 地址处就可以了,正如上图中所示,每一级的页目录项中存放的均是其下一级页目录表的物理内存地址。

例如内核主页表这里的 pgd 存放的是其下一级 ------ 上层页目录 PUD 的起始物理内存地址 ,PUD 中的页目录项 pud 又存放的是其下一级 ------ 中间页目录 PMD 的起始物理内存地址,依次类推,中间页目录项 pmd 存放的又是页表的起始物理内存地址。

既然每一级页目录表中的页目录项存放的都是其下一级页目录表的起始物理内存地址,那么页目录项中存放的就相当于是下一级页目录表的引用,这样一来我们就只需要同步最顶级的页目录项 pgd 就可以了,后面只要与该 pgd 相关的页目录表以及页表发生任何变化,由于是引用的关系,这些改变都会立刻自动反应到进程页表的内核部分中,后面就不需要同步了。

c 复制代码
/*
 * 64-bit:
 *
 *   Handle a fault on the vmalloc area
 */
static noinline int vmalloc_fault(unsigned long address)
{
    // 分别是缺页虚拟地址 address 对应在内核主页表的全局页目录项 pgd_k ,以及进程页表中对应的全局页目录项 pgd
    pgd_t *pgd, *pgd_k;
    // p4d_t 用于五级页表体系,当前 cpu 架构体系下一般采用的是四级页表
    // 在四级页表下 p4d 是空的,pgd 的值会赋值给 p4d
    p4d_t *p4d, *p4d_k;
    // 缺页虚拟地址 address 对应在进程页表中的上层目录项 pud
    pud_t *pud;
    // 缺页虚拟地址 address 对应在进程页表中的中间目录项 pmd
    pmd_t *pmd;
    // 缺页虚拟地址 address 对应在进程页表中的页表项 pte
    pte_t *pte;

    // 确保缺页发生在内核 vmalloc 动态映射区
    if (!(address >= VMALLOC_START && address < VMALLOC_END))
        return -1;

    // 获取缺页虚拟地址 address 对应在进程页表的全局页目录项 pgd
    pgd = (pgd_t *)__va(read_cr3_pa()) + pgd_index(address);
    // 获取缺页虚拟地址 address 对应在内核主页表的全局页目录项 pgd_k
    pgd_k = pgd_offset_k(address);

    // 如果内核主页表中的 pgd_k 本来就是空的,说明 address 是一个非法访问的地址,返回 -1 
    if (pgd_none(*pgd_k))
        return -1;

    // 如果开启了五级页表,那么顶级页表就是 pgd,这里只需要同步顶级页表项就可以了
    if (pgtable_l5_enabled()) {
        // 内核主页表中的 pgd_k 不为空,进程页表中的 pgd 为空,那么就同步页表
        if (pgd_none(* )) {
            // 将主内核页表中的 pgd_k 内容复制给进程页表对应的 pgd
            set_pgd(pgd, *pgd_k);
            // 刷新 mmu
            arch_flush_lazy_mmu_mode();
        } else {
            BUG_ON(pgd_page_vaddr(*pgd) != pgd_page_vaddr(*pgd_k));
        }
    }

    // 四级页表体系下,p4d 是顶级页表项,同样也是只需要同步顶级页表项即可,同步逻辑和五级页表一模一样
    // 因为是四级页表,所以这里会将 pgd 赋值给 p4d,p4d_k ,后面就直接把 p4d 看做是顶级页表了。
    p4d = p4d_offset(pgd, address);
    p4d_k = p4d_offset(pgd_k, address);
    // 内核主页表为空,则停止同步,返回 -1 ,表示正在访问一个非法地址
    if (p4d_none(*p4d_k))
        return -1;
    // 内核主页表不为空,进程页表为空,则同步内核顶级页表项 p4d_k 到进程页表对应的 p4d 中,然后刷新 mmu
    if (p4d_none(*p4d) && !pgtable_l5_enabled()) {
        set_p4d(p4d, *p4d_k);
        arch_flush_lazy_mmu_mode();
    } else {
        BUG_ON(p4d_pfn(*p4d) != p4d_pfn(*p4d_k));
    }

    // 到这里,页表的同步工作就完成了,下面代码用于检查内核地址 address 在进程页表内核部分中是否有物理内存进行映射
    // 如果没有,则返回 -1 ,说明进程在访问一个非法的内核地址,进程随后会被 kill 掉
    // 返回 0 表示表示地址 address 背后是有物理内存映射的, vmalloc 动态映射区的缺页处理到此结束。

    // 根据顶级页目录项 p4d 获取 address 在进程页表中对应的上层页目录项 pud
    pud = pud_offset(p4d, address);
    if (pud_none(*pud))
        return -1;
    // 该 pud 指向的是 1G 大页内存
    if (pud_large(*pud))
        return 0;
     // 根据 pud 获取 address 在进程页表中对应的中间页目录项 pmd
    pmd = pmd_offset(pud, address);
    if (pmd_none(*pmd))
        return -1;
    // 该 pmd 指向的是 2M 大页内存
    if (pmd_large(*pmd))
        return 0;
    // 根据 pmd 获取 address 对应的页表项 pte
    pte = pte_offset_kernel(pmd, address);
    // 页表项 pte 并没有映射物理内存
    if (!pte_present(*pte))
        return -1;

    return 0;
}
NOKPROBE_SYMBOL(vmalloc_fault);

在我们聊完内核主页表的同步过程之后,可能很多读者朋友不禁要问,既然已经有了内核主页表,而且内核地址空间包括内核页表又是所有进程共享的,那进程为什么不能直接访问内核主页表而是要访问主页表的拷贝部分呢 ? 这样还能省去拷贝内核主页表(fork 时候)以及同步内核主页表(缺页时候)这些个开销。

之所以这样设计一方面有硬件限制的原因,毕竟每个 CPU 核心只会有一个 CR3 寄存器来存放进程页表的顶级页目录起始物理内存地址,没办法同时存放进程页表和内核主页表。

另一方面的原因则是操作页表都是需要对其进行加锁的,无论是操作进程页表还是内核主页表。而且在操作页表的过程中可能会涉及到物理内存的分配,这也会引起进程的阻塞。

而进程本身可能处于中断上下文以及竞态区中,不能加锁,也不能被阻塞,如果直接对内核主页表加锁的话,那么系统中的其他进程就只能阻塞等待了。所以只能而且必须是操作主内核页表的拷贝,不能直接操作内核主页表。

好了,该向大家交代的现在都已经交代完了,我们闲话不多说,继续本文的主题内容~~~

4. 用户态缺页异常处理 ------ do_user_addr_fault

进程用户态虚拟地址空间的布局我们现在已经非常熟悉了,在处理用户态缺页异常之前,内核需要在进程用户空间众多的虚拟内存区域 vma 之中找到引起缺页的内存地址 address 究竟是属于哪一个 vma 。如果没有一个 vma 能够包含 address , 那么就说明该 address 是一个还未被分配的虚拟内存地址,进程对该地址的访问是非法的,自然也就不用处理缺页了。

所以内核就需要根据缺页地址 address 通过 find_vma 函数在进程地址空间中找出符合 address < vma->vm_end 条件的第一个 vma 出来,也就是挨着 address 最近的一个 vma。

而缺页地址 address 可以出现在进程地址空间中的任意位置,根据 address 的分布会有下面三种情况:

第一种情况就是 address 的后面没有一个 vma 出现,也就是说进程地址空间中没有一个 vma 符合条件:address < vma->vm_end。进程访问的是一个还未分配的虚拟内存地址,属于非法地址访问,不需要处理缺页。

第二种情况就是 address 恰巧包含在一个 vma 中,这个自然是正常情况,内核开始处理该 vma 区域的缺页异常。

第三种情况是 address 不巧落在了 find_vma 的前面,也就是 address < find_vma->vm_start。这种情况自然也是非法地址访问,不需要处理缺页。

但是这里有一种特殊情况就是万一这个 find_vma 是栈区怎么办呢 ? 栈是允许扩展的但不允许收缩,如果压栈指令 push 引用了一个栈区之外的地址 address,这种异常不是由程序错误所引起的,因此缺页处理程序需要单独处理栈区的扩展。

如果 find_vma 中的 vm_flags 标记了 VM_GROWSDOWN,表示该 vma 中的地址增长方向是由高到底了,说明这个 vma 可能是栈区域,近而需要到 expand_stack 函数中判断是否允许扩展栈,如果允许的话,就将栈所属的 vma 起始地址 vm_start 扩展至 address 处。

现在我们已经校验完了 vma,并确定了缺页地址 address 是一个合法的地址,下面就可以放心地调用 handle_mm_fault 函数对这块 vma 进行缺页处理了。

c 复制代码
/* Handle faults in the user portion of the address space */
static inline
void do_user_addr_fault(struct pt_regs *regs,
            unsigned long hw_error_code,
            unsigned long address)
{
    struct vm_area_struct *vma;
    struct task_struct *tsk;
    struct mm_struct *mm;
 
    tsk = current;
    mm = tsk->mm;

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

    // 在进程虚拟地址空间查找第一个符合条件:address < vma->vm_end 的虚拟内存区域 vma
    vma = find_vma(mm, address);
    // 如果该缺页地址 address 后面没有 vma 跳转到 bad_area 处理异常
    if (unlikely(!vma)) {
        bad_area(regs, hw_error_code, address);
        return;
    }
    // 缺页地址 address 恰好落在一个 vma 中,跳转到 good_area 处理 vma 中的缺页
    if (likely(vma->vm_start <= address))
        goto good_area;
    // 上面第三种情况,vma 不是栈区,跳转到 bad_area
    if (unlikely(!(vma->vm_flags & VM_GROWSDOWN))) {
        bad_area(regs, hw_error_code, address);
        return;
    }
    // vma 是栈区,尝试扩展栈区到 address 地址处
    if (unlikely(expand_stack(vma, address))) {
        bad_area(regs, hw_error_code, address);
        return;
    }

    /*
     * Ok, we have a good vm_area for this memory access, so
     * we can handle it..
     */
good_area:
    // 处理 vma 区域的缺页异常,返回值 fault 是一个位图,用于描述缺页处理过程中发生的状况信息。
    fault = handle_mm_fault(vma, address, flags);
    // 本次缺页是否属于 VM_FAULT_MAJOR,缺页处理过程中是否发生了物理内存的分配以及磁盘 IO
    // 与其对应的是 VM_FAULT_MINOR 表示缺页处理过程中所需内存页已经存在于内存中了,只是修改页表即可。
    major |= fault & VM_FAULT_MAJOR;

    /*
     * Major/minor page fault accounting. If any of the events
     * returned VM_FAULT_MAJOR, we account it as a major fault.
     */
    if (major) {
        // 统计进程总共发生的 VM_FAULT_MAJOR 次数
        tsk->maj_flt++;
        perf_sw_event(PERF_COUNT_SW_PAGE_FAULTS_MAJ, 1, regs, address);
    } else {
        // 统计进程总共发生的 VM_FAULT_MINOR 次数
        tsk->min_flt++;
        perf_sw_event(PERF_COUNT_SW_PAGE_FAULTS_MIN, 1, regs, address);
    }

}
NOKPROBE_SYMBOL(do_user_addr_fault);

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

c 复制代码
/**
 * Page fault handlers return a bitmask of %VM_FAULT values.
 */
typedef __bitwise unsigned int vm_fault_t;

比如,位图 vm_fault_t 的第三个比特位置为 1 表示 VM_FAULT_MAJOR,置为 0 表示 VM_FAULT_MINOR。

c 复制代码
enum vm_fault_reason {
	VM_FAULT_MAJOR          = (__force vm_fault_t)0x000004,
};

VM_FAULT_MAJOR 的意思是本次缺页所需要的物理内存页还不在内存中,需要重新分配以及需要启动磁盘 IO,从磁盘中 swap in 进来。

VM_FAULT_MINOR 的意思是本次缺页所需要的物理内存页已经加载进内存中了,缺页处理只需要修改页表重新映射一下就可以了。

我们来看一个具体的例子,笔者在之前的文章 《从内核世界透视 mmap 内存映射的本质(原理篇)》中为大家介绍多个进程调用 mmap 对磁盘上的同一个文件进行共享文件映射的时候,此时在各个进程的地址空间中都只是各自分配了一段虚拟内存用于共享文件映射而已,还没有分配物理内存页。

当第一个进程开始访问这段虚拟内存映射区时,由于没有物理内存页,页表还是空的,于是产生缺页中断,内核则会在伙伴系统中分配一个物理内存页,然后将新分配的内存页加入到 page cache 中。

然后调用 readpage 激活块设备驱动从磁盘中读取映射的文件内容,用读取到的内容填充新分配的内存页,最后在进程 1 页表中建立共享映射的这段虚拟内存与 page cache 中缓存的文件页之间的关联。

由于进程 1 的缺页处理发生了物理内存的分配以及磁盘 IO ,所以本次缺页处理属于 VM_FAULT_MAJOR。

当进程 2 访问其地址空间中映射的这段虚拟内存时,由于页表是空的,也会发生缺页,但是当进程 2 进入内核中发现所映射的文件页已经被进程 1 加载进 page cache 中了,进程 2 的缺页处理只需要将这个文件页映射进自己的页表就可以了,不需要重新分配内存以及发生磁盘 IO 。这种情况就属于 VM_FAULT_MINOR。

最后需要将进程总共发生的 VM_FAULT_MAJOR 次数以及 VM_FAULT_MINOR 次数统计到进程 task_struct 结构中的相应字段中:

c 复制代码
struct task_struct {
    // 进程总共发生的 VM_FAULT_MINOR 次数
    unsigned long           min_flt;
     // 进程总共发生的 VM_FAULT_MAJOR 次数
    unsigned long           maj_flt;
}

我们可以在 ps 命令上增加 -o 选项,添加 maj_flt ,min_flt 数据列来查看各个进程的 VM_FAULT_MAJOR 次数和 VM_FAULT_MINOR 次数。

5. handle_mm_fault 完善进程页表体系

饶了一大圈,现在我们终于来到了缺页处理的核心逻辑,之前笔者提到,引起缺页中断的原因大概有三种:

  • 第一种是 CPU 访问的虚拟内存地址 address 之前完全没有被映射过,其在页表中对应的各级页目录项以及页表项都还是空的。

  • 第二种是 address 之前被映射过,但是映射的这块物理内存被内核 swap out 到磁盘上了。

  • 第三种是 address 背后映射的物理内存还在,只是由于访问权限不够引起的缺页中断,比如,后面要为大家介绍的写时复制(COW)机制就属于这一种。

下面笔者一种接一种的带大家一起梳理,我们先来看第一种情况:

由于现在正在被访问的虚拟内存地址 address 之前从来没有被映射过,所以该虚拟内存地址在进程页表中的各级页目录表中的目录项以及页表中的页表项都是空的。内核的首要任务就是先要将这些缺失的页目录项和页表项一一补齐。

笔者在之前的文章《一步一图带你构建 Linux 页表体系》 中曾为大家介绍过,在当前 64 位体系架构下,其实只使用了 48 位来描述进程的虚拟内存空间,其中用户态地址空间 128T,内核态地址空间 128T,所以我们只需要使用 48 位的虚拟内存地址就可以表示进程虚拟内存空间中的任意地址了。

而这 48 位的虚拟内存地址内又分为五个部分,它们分别是虚拟内存地址在全局页目录表 PGD 中对应的页目录项 pgd_t 的偏移,在上层页目录表 PUD 中对应的页目录项 pud_t 的偏移,在中间页目录表 PMD 中对应的页目录项 pmd_t 的偏移,在页表中对应的页表项 pte_t 的偏移,以及在其背后映射的物理内存页中的偏移。

内核中使用 unsigned long 类型来表示各级页目录中的目录项以及页表中的页表项,在 64 位系统中它们都是占用 8 字节。

c 复制代码
// 定义在内核文件:/arch/x86/include/asm/pgtable_64_types.h
typedef unsigned long pteval_t;
typedef unsigned long pmdval_t;
typedef unsigned long pudval_t;
typedef unsigned long pgdval_t;

typedef struct { pteval_t pte; } pte_t;

// 定义在内核文件:/arch/x86/include/asm/pgtable_types.h
typedef struct { pmdval_t pmd; } pmd_t;
typedef struct { pudval_t pud; } pud_t;
typedef struct { pgdval_t pgd; } pgd_t;

而各级页目录表以及页表在内核中其实本质上都是一个 4K 物理内存页,只不过这些物理内存页存放的内容比较特殊,它们存放的是页目录项和页表项。一张页目录表可以存放 512 个页目录项,一张页表可以存放 512 个页表项

c 复制代码
// 全局页目录表 PGD 可以容纳的页目录项 pgd_t 的个数
#define PTRS_PER_PGD  512
// 上层页目录表 PUD 可以容纳的页目录项 pud_t 的个数
#define PTRS_PER_PUD  512
// 中间页目录表 PMD 可以容纳的页目录项 pmd_t 的个数
#define PTRS_PER_PMD  512
// 页表可以容纳的页表项 pte_t 的个数
#define PTRS_PER_PTE  512

因此我们可以把全局页目录表 PGD 看做是一个能够存放 512 个 pgd_t 的数组 ------ pgd_t[PTRS_PER_PGD],虚拟内存地址对应在 pgd_t[PTRS_PER_PGD] 数组中的索引使用 9 个比特位就可以表示了。

在内核中使用 pgd_offset 函数来定位虚拟内存地址在全局页目录表 PGD 中对应的页目录项 pgd_t,这个过程和访问数组一模一样,事实上整个 PGD 就是一个 pgd_t[PTRS_PER_PGD] 数组。

首先我们通过 mm_struct-> pgd 获取 pgd_t[PTRS_PER_PGD] 数组的首地址(全局页目录表 PGD 的起始内存地址),然后将虚拟内存地址右移 PGDIR_SHIFT(39)位再用掩码 PTRS_PER_PGD - 1 将高位全部掩去,只保留低 9 位得到虚拟内存地址在 pgd_t[PTRS_PER_PGD] 数组中的索引偏移 pgd_index。

然后将 mm_struct-> pgd 与 pgd_index 相加就可以定位到虚拟内存地址在全局页目录表 PGD 中的页目录项 pgd_t 了。

c 复制代码
/*
 * a shortcut to get a pgd_t in a given mm
 */
#define pgd_offset(mm, address) pgd_offset_pgd((mm)->pgd, (address))

#define pgd_offset_pgd(pgd, address) (pgd + pgd_index((address)))

#define pgd_index(address) (((address) >> PGDIR_SHIFT) & (PTRS_PER_PGD - 1))

#define PGDIR_SHIFT		39
#define PTRS_PER_PGD		512

在后续即将要介绍的源码实现中,大家还会看到一个 p4d 的页目录,该页目录用于在五级页表体系下表示四级页目录。

c 复制代码
typedef unsigned long	p4dval_t;
typedef struct { p4dval_t p4d; } p4d_t;

而在四级页表体系下,这个 p4d 就不起作用了,但为了代码上的统一处理,在四级页表下,前面定位到的顶级页目录项 pgd_t 会赋值给四级页目录项 p4d_t,后续处理都会将 p4d_t 看做是顶级页目录项,这一点需要和大家在这里先提前交代清楚。

c 复制代码
static inline p4d_t *p4d_offset(pgd_t *pgd, unsigned long address)
{
    if (!pgtable_l5_enabled())
        // 四级页表体系下,p4d_t 其实就是顶级页目录项
        return (p4d_t *)pgd;
    return (p4d_t *)pgd_page_vaddr(*pgd) + p4d_index(address);
}

现在我们已经通过 pgd_offset 定位到虚拟内存地址 address 对应在全局页目录 PGD 的页目录项 pgd_t(p4d_t)了。

接下来的任务就是根据这个 p4d_t 定位虚拟内存对应在上层页目录 PUD 中的页目录项 pud_t。但在定位之前,我们需要首先判断这个 p4d_t 是否是空的,如果是空的,说明在目前的进程页表中还不存在对应的 PUD,需要马上创建一个新的出来。

而 PUD 的相关信息全部都保存在 p4d_t 里,我们可以通过 native_p4d_val 函数将顶级页目录项 p4d_t 中的值获取出来。

c 复制代码
static inline p4dval_t native_p4d_val(p4d_t p4d)
{
	return p4d.p4d;
}

在 64 位系统中,各级页目录项都是用 unsigned long 类型来表示的,共 8 个字节,64 个 bit,还记得我们之前在《一步一图带你构建 Linux 页表体系》 一文中介绍的页目录项比特位布局吗 ?

在页目录项刚刚被创建出来的时候,内核会将他们全部初始化为 0 值,如果一个页目录项中除了第 5 , 6 比特位之外剩下的比特位全都为 0 的话,则表示这个页目录项是空的。

c 复制代码
static inline int p4d_none(p4d_t p4d)
{
    // p4d_t 中除了第 5,6 比特位之外,剩余比特位如果全是 0 则表示 p4d_t 是空的
    return (native_p4d_val(p4d) & ~(_PAGE_KNL_ERRATUM_MASK)) == 0;
}
// 页目录项中第 5, 6 比特位置为 1
#define _PAGE_KNL_ERRATUM_MASK (_PAGE_DIRTY | _PAGE_ACCESSED)

如果我们通过 p4d_none 函数判断出顶级页目录项 p4d 是空的,那么就需要调用 __pud_alloc 函数分配一个新的上层页目录表 PUD 出来,然后用 PUD 的起始物理内存地址以及页目录项的初始权限位 _PAGE_TABLE 填充 p4d。

c 复制代码
/*
 * Allocate page upper directory.
 * We've already handled the fast-path in-line.
 */
int __pud_alloc(struct mm_struct *mm, p4d_t *p4d, unsigned long address)
{
    // 调用 get_zeroed_page 申请一个 4k 物理内存页并初始化为 0 值作为新的 PUD
    // new 指向新分配的 PUD 起始内存地址
    pud_t *new = pud_alloc_one(mm, address);
    if (!new)
        return -ENOMEM;
    // 操作进程页表需要加锁
    spin_lock(&mm->page_table_lock);
    // 如果顶级页目录项 p4d 中的 P 比特位置为 0 表示 p4d 目前还没有指向其下一级页目录 PUD
    // 下面需要填充 p4d
    if (!p4d_present(*p4d)) {
        // 更新 mm->pgtables_bytes 计数,该字段用于统计进程页表所占用的字节数
        // 由于这里新增了一张 PUD 目录表,所以计数需要增加 PTRS_PER_PUD * sizeof(pud_t)
        mm_inc_nr_puds(mm);
        // 将 new 指向的新分配出来的 PUD 物理内存地址以及相关属性填充到顶级页目录项 p4d 中
        p4d_populate(mm, p4d, new);
    } else  /* Another has populated it */
        // 释放新创建的 PMD
        pud_free(mm, new);

    // 释放页表锁
    spin_unlock(&mm->page_table_lock);
    return 0;
}

下面我们来看一下填充顶级页目录项 p4d 的一些细节,填充的逻辑封装在下面的 p4d_populate 函数中。

c 复制代码
static inline void p4d_populate(struct mm_struct *mm, p4d_t *p4d, pud_t *pud)
{
	set_p4d(p4d, __p4d(_PAGE_TABLE | __pa(pud)));
}

#define _KERNPG_TABLE	(_PAGE_PRESENT | _PAGE_RW | _PAGE_ACCESSED |	\
			 _PAGE_DIRTY | _PAGE_ENC)
#define _PAGE_TABLE	(_KERNPG_TABLE | _PAGE_USER)

各级页目录项以及页表项,它们的本质其实就是一块 8 字节大小,64 bits 的小内存块,内核中使用 unsigned long 类型来修饰,各级页目录项以及页表项在初始的时候,它们的这 64 个比特位全部为 0 值,所谓填充页目录项就是按照下图所示的页目录项比特位布局,根据每个比特位的具体含义进行相应的填充。

由于页目录项所承担的一项最重要的工作就是定位其下一级页目录表的起始物理内存地址,这里的下一级页目录表就是刚刚我们新创建出来的 PUD。所以第一件重要的事情就是通过 __pa(pud) 来获取 PUD 的起始物理内存地址,然后将 PUD 的物理内存地址填充到顶级页目录项 p4d 中的对应比特位上。

由于物理内存地址在内核中都是按照 4K 对齐的,所以 PUD 物理内存地址的低 12 位全部都是 0 ,我们可以利用这 12 个比特位存放一些权限标记位,页目录项在初始化时需要置为 1 的权限标记位定义在 _PAGE_TABLE 中。也就是说 _PAGE_TABLE 定义了页目录项初始权限标记位集合。

c 复制代码
#define _PAGE_BIT_PRESENT 0 /* is present */
#define _PAGE_BIT_RW  1 /* writeable */
#define _PAGE_BIT_USER  2 /* userspace addressable */
#define _PAGE_BIT_ACCESSED 5 /* was accessed (raised by CPU) */
#define _PAGE_BIT_DIRTY  6 /* was written to (raised by CPU) */


#define _PAGE_PRESENT (_AT(pteval_t, 1) << _PAGE_BIT_PRESENT)
#define _PAGE_RW (_AT(pteval_t, 1) << _PAGE_BIT_RW)
#define _PAGE_USER (_AT(pteval_t, 1) << _PAGE_BIT_USER)
#define _PAGE_ACCESSED (_AT(pteval_t, 1) << _PAGE_BIT_ACCESSED)
#define _PAGE_DIRTY (_AT(pteval_t, 1) << _PAGE_BIT_DIRTY)

我们通过 _PAGE_TABLE 和 __pa(pud) 进行或运算 ------ _PAGE_TABLE | __pa(pud),这样就可以按照上图中的比特位布局构造出一个 8 字节的 unsigned long 类型的整数了,这个整数的第 12 到 35 比特位通过 __pa(pud) 填充进来,低 12 位比特通过 _PAGE_TABLE 填充进来。

随后我们通过 __p4d 将这个刚刚构造出来的 unsigned long 整数转换成 p4d_t 类型。

c 复制代码
#define __p4d(x)	native_make_p4d(x)

static inline p4d_t native_make_p4d(pudval_t val)
{
	return (p4d_t) { val };
}

最后我们通过 set_p4d 将我们刚刚构造出来的 p4d_t 赋值给原始的 p4d_t。

c 复制代码
# define set_p4d(p4dp, p4d)		native_set_p4d(p4dp, p4d)

这样一来,缺页的虚拟内存地址对应在顶级页目录表中的页目录项 p4d_t 就被填充好了,现在它已经指向了刚刚新创建出来的 PUD,并且拥有了初始的权限位。

目前为止,我们只是完善了缺页虚拟内存地址对应在进程页表顶级页目录中的目录项 p4d_t,在四级页表体系下,我们还需要继续向下逐级的去补齐虚拟内存地址对应在其他页目录中的目录项,处理逻辑上都是一模一样的。

顶级页目录项 p4d 中包含了其下一级页目录 PUD 的相关信息,在内核中使用 pud_offset 函数来定位虚拟内存地址 address 对应在 PUD 中的页目录项 pud_t。

c 复制代码
/* Find an entry in the third-level page table.. */
static inline pud_t *pud_offset(p4d_t *p4d, unsigned long address)
{
	return (pud_t *)p4d_page_vaddr(*p4d) + pud_index(address);
}

和顶级页目录 PGD 一样,上层页目录 PUD 也可以看做是一个能够存放 512 个 pud_t 的数组 ------ pud_t[PTRS_PER_PUD] 。

c 复制代码
// 上层页目录表 PUD 可以容纳的页目录项 pud_t 的个数
#define PTRS_PER_PUD  512

内核通过 pud_index 函数将虚拟内存地址右移 PUD_SHIFT(30)位然后用掩码 PTRS_PER_PUD - 1 将高位全部掩掉,只保留低 9 位得到虚拟内存地址在上层页目录 PUD 中对应的页目录项 pud_t 的偏移 ------ pud_index。

c 复制代码
static inline unsigned long pud_index(unsigned long address)
{
	return (address >> PUD_SHIFT) & (PTRS_PER_PUD - 1);
}

#define PUD_SHIFT	30

现在我们有了 pud_index,如果我们还能够知道上层页目录表 PUD 的虚拟内存地址,两者一相加就能得到页目录项 pud_t 了。而 PUD 的物理内存地址恰好保存在刚刚填充好的顶级页目录项 p4d 中,我们可以从 p4d 中将 PUD 的物理内存地址提取出来,然后通过 __va 转换成虚拟内存地址不就行了么。

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

首先我们通过 p4d_val 将顶级页目录项 p4d 的值(8 字节,64 比特)提取出来。

c 复制代码
#define p4d_val(x)	native_p4d_val(x)

static inline p4dval_t native_p4d_val(p4d_t p4d)
{
	return p4d.p4d;
}

然后再根据页目录项中的比特位布局,将其下一级页目录表的物理内存地址截取出来。

那么如何截取呢 ? 上图中展示的页目录项比特位布局笔者是按照 36 位物理内存地址所画,事实上 Linux 内核最大可支持 52 位的物理内存地址。

c 复制代码
#define __PHYSICAL_MASK_SHIFT	52

我们将 1 左移 __PHYSICAL_MASK_SHIFT 位然后再减 1 得到 __PHYSICAL_MASK(低 52 位全部为 1)。

c 复制代码
#define __PHYSICAL_MASK		((phys_addr_t)((1ULL << __PHYSICAL_MASK_SHIFT) - 1))

然后拿 p4d_val & __PHYSICAL_MASK 就可以将 p4d_val 的高位截取掉,只保留低 52 位。

这低 52 位中包含了两个部分,一个是我们想要提取的下一级页目录表的物理内存地址,另一个则是低 12 位的权限标记位。

如果我们再能够把这低 12 位的权限标记位用掩码掩掉,就可以得到下一级页目录表的物理内存地址了。

c 复制代码
#define PAGE_SHIFT  12
#define PAGE_SIZE   (_AC(1,UL) << PAGE_SHIFT)      
#define PAGE_MASK   (~(PAGE_SIZE-1))     // 0xFFFFFFFFFFFFF000

上面的 PAGE_MASK 掩码就是用于将页目录项 p4d 的低 12 位掩掉的,我们接着在 p4d_val & __PHYSICAL_MASK 的基础上再与上 PAGE_MASK,就可以将 p4d 中保存的下一级页目录表 PUD 的物理内存地址截取出来了。

虽然我们是按照 52 位的物理内存地址截取的,但是对于 36 位的物理内存地址来说,页目录项中的低 36 位到 51 位之间的比特位都是 0 值,所以也不影响。

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

static inline p4dval_t p4d_pfn_mask(p4d_t p4d)
{
	/* No 512 GiB huge pages yet */
	return 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)

现在我们已经得到 PUD 的物理内存地址了,随后通过 __va 转换成虚拟内存地址,然后在加上 pud_index 就得到缺页虚拟内存地址在进程页表上层页目录 PUD 中对应的页目录项 pud_t 了。

在得到 pud_t 之后,内核还是需要通过 pud_none 来判断下该上层页目录项 pud_t 是否是空的,如果是空的话,就需要通过 __pmd_alloc 函数重新分配一张中间页目录表 PMD 出来,然后填充这个空的 pud_t,这里的逻辑和前面处理 p4d_t 的逻辑一模一样。

c 复制代码
// 同 p4d_none 的逻辑一样
static inline int pud_none(pud_t pud)
{
	return (native_pud_val(pud) & ~(_PAGE_KNL_ERRATUM_MASK)) == 0;
}

由于这个 PUD 是之前为了填充顶级页目录项 p4d_t 而新创建出来的,所以 PUD 这张页目录表里还全是 0 值,缺页虚拟内存地址在 PUD 中对应的目录项 pud_t 自然也是 0 值,通过 pud_none 判断自然是返回 true 。

随后内核会调用 __pmd_alloc 函数新分配一张 4K 大小的物理内存页作为 PMD , 然后用 PMD 的物理内存地址去填充这个空的 pud_t。这里的逻辑和 __pud_alloc 还是一模一样。

c 复制代码
/*
 * Allocate page middle directory.
 * We've already handled the fast-path in-line.
 */
int __pmd_alloc(struct mm_struct *mm, pud_t *pud, unsigned long address)
{
    // 调用 alloc_pages 从伙伴系统申请一个 4K 大小的物理内存页,作为新的 PMD
    pmd_t *new = pmd_alloc_one(mm, address);
    if (!new)
        return -ENOMEM;
    // 如果 pud 还未指向其下一级页目录 PMD,则需要初始化填充 pud
    if (!pud_present(*pud)) {
        mm_inc_nr_pmds(mm);
        // 将 new 指向的新分配出来的 PMD 物理内存地址以及相关属性填充到上层页目录项 pud 中
        pud_populate(mm, pud, new);
    } else  /* Another has populated it */
        pmd_free(mm, new);

    return 0;
}

填充上层页目录项 pud_t 的逻辑和之前填充顶级页目录项 p4d_t 的逻辑也是一样的。

c 复制代码
static inline void pud_populate(struct mm_struct *mm, pud_t *pud, pmd_t *pmd)
{
	set_pud(pud, __pud(_PAGE_TABLE | __pa(pmd)));
}

都是通过 PMD 的物理内存地址 __pa(pmd) 以及页目录的初始权限标记位集合 _PAGE_TABLE 来构造一个 unsigned long 类型的整数。

通过 __pud 将这个刚刚构造出来的 unsigned long 整数转换成 pud_t 类型:

c 复制代码
#define __pud(x)	native_make_pud(x)

static inline pud_t native_make_pud(pmdval_t val)
{
	return (pud_t) { val };
}

最后将 __pud 的返回值通过 set_pud 赋值给原始的上层页目录项 pud 。这样就算完成了 pud 的填充。

c 复制代码
# define set_pud(pudp, pud)		native_set_pud(pudp, pud)

static inline void native_set_pud(pud_t *pudp, pud_t pud)
{
	WRITE_ONCE(*pudp, pud);
}

中间页目录表 PMD 有了,接下来的任务就该定位缺页虚拟内存地址在进程页表 PMD 中对应的页目录项 pmd_t 了。

和前面的 PGD ,PUD 一样, PMD 也可以看做是一个能够存放 512 个 pmd_t 的数组 ------ pmd_t[PTRS_PER_PMD] 。

c 复制代码
// 中间页目录表 PMD 可以容纳的页目录项 pmd_t 的个数
#define PTRS_PER_PMD  512

内核通过 pmd_offset 函数来定位虚拟内存地址 address 对应在 PMD 中的页目录项 pmd_t。

c 复制代码
static inline pmd_t *pmd_offset(pud_t *pud, unsigned long address)
{
	return (pmd_t *)pud_page_vaddr(*pud) + pmd_index(address);
}

还是之前的套路,首先需要通过 pud_page_vaddr 从上层页目录 PUD 中的页目录项 pud_t 中提取出其下一级页目录表 PMD 的起始虚拟内存地址。

c 复制代码
static inline unsigned long pud_page_vaddr(pud_t pud)
{
	return (unsigned long)__va(pud_val(pud) & pud_pfn_mask(pud));
}

然后通过 pmd_index 获取缺页虚拟内存地址在 PMD 中的偏移,和之前的处理方式一样,首先将缺页虚拟内存地址 address 右移 PMD_SHIFT(21)位,然后和掩码 PTRS_PER_PMD - 1 相与,只保留低 9 位。

c 复制代码
static inline unsigned long pmd_index(unsigned long address)
{
	return (address >> PMD_SHIFT) & (PTRS_PER_PMD - 1);
}

#define PMD_SHIFT	21
#define PTRS_PER_PMD	512

最后用刚刚提取出的 PMD 起始虚拟内存地址 pud_page_vaddr 与 pmd_index 相加就得到我们寻找的中间页目录项 pmd_t 了。

在我们获取到 pmd_t 之后,接下来就该处理页表了,而页表是直接与物理内存页进行映射的,后续我们需要到页表项中,根据权限位的设置来解析出具体的缺页原因,然后进行针对性的缺页处理,这一部分的内容封装在 handle_pte_fault 函数中,这是我们下一小节中要介绍的内容。

而本小节中介绍的 __handle_mm_fault 的主要工作是将进程页表中的三级页目录表 PGD,PUD,PMD 补齐,然后获取到 pmd_t 就完成了,随后会把 pmd_t 送到 handle_pte_fault 函数中进行页表的处理。

在我们理解了以上内容之后,再回头来看 __handle_mm_fault 源码实现就很清晰了:

c 复制代码
static vm_fault_t __handle_mm_fault(struct vm_area_struct *vma,
        unsigned long address, unsigned int flags)
{
    // vm_fault 结构用于封装后续缺页处理用到的相关参数
    struct vm_fault vmf = {
        // 发生缺页的 vma
        .vma = vma,
        // 引起缺页的虚拟内存地址
        .address = address & PAGE_MASK,
        // 处理缺页的相关标记 FAULT_FLAG_xxx
        .flags = flags,
        // address 在 vma 中的偏移,单位也页
        .pgoff = linear_page_index(vma, address),
        // 后续用于分配物理内存使用的相关掩码 gfp_mask
        .gfp_mask = __get_fault_gfp_mask(vma),
    };
    // 获取进程虚拟内存空间
    struct mm_struct *mm = vma->vm_mm;
    // 进程页表的顶级页表地址
    pgd_t *pgd;
    // 五级页表下会使用,在四级页表下 p4d 与 pgd 的值一样
    p4d_t *p4d;
    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;
  
      ........ 省略 1G 大页缺页处理 ..........
    
    // 首先 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;

      ........ 省略 2M 大页缺页处理 ..........

    // 进行页表的相关处理以及解析具体的缺页原因,后续针对性的进行缺页处理
    return handle_pte_fault(&vmf);
}
相关推荐
钟离紫轩工程师25 分钟前
django开发流程
后端·python·django
gopher95111 小时前
go语言 结构体
开发语言·后端·golang
jimte_pro1 小时前
Linux系统接口--信号量、互斥锁、原子操作和自旋锁的区别
linux·c语言·驱动开发
没有名字的小羊1 小时前
网络通信——路由器、交换机、集线器(HUB)
linux·服务器·网络
周湘zx1 小时前
k8s下的网络通信与调度
linux·运维·云原生·容器·kubernetes
忆想不到的晖1 小时前
深入解析大语言模型的网页总结原理
后端·python·aigc
黑龙江亿林等保2 小时前
CentOS:稳定的服务器操作系统选择
linux·服务器·centos
bugtraq20212 小时前
Fyne ( go跨平台GUI )中文文档-入门(一)
开发语言·后端·golang
x66ccff2 小时前
【micro】糖果配色
linux·运维·服务器
Mutig_s2 小时前
如何理解MVCC
java·后端·mysql·面试