翼度科技»论坛 云主机 LINUX 查看内容

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

6

主题

6

帖子

18

积分

新手上路

Rank: 1

积分
18
本文基于内核 5.4 版本源码讨论
在前面两篇介绍 mmap 的文章中,笔者分别从原理角度以及源码实现角度带着大家深入到内核世界深度揭秘了 mmap 内存映射的本质。从整个 mmap 映射的过程可以看出,内核只是在进程的虚拟地址空间中寻找出一段空闲的虚拟内存区域 vma 然后分配给本次映射而已。
  1.     vma = vm_area_alloc(mm);
  2.     vma->vm_start = addr;
  3.     vma->vm_end = addr + len;
  4.     vma->vm_flags = vm_flags;
  5.     vma->vm_page_prot = vm_get_page_prot(vm_flags);
  6.     vma->vm_pgoff = pgoff;
复制代码

如果是文件映射的话,内核还会额外做一项工作,就是将分配出来的这段虚拟内存区域 vma 与映射文件关联映射起来。
  1. vma->vm_file = get_file(file);
  2. 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 函数中来处理缺页异常,该函数的参数都是内核在处理缺页异常的时候需要用到的基本信息:
  1. dotraplinkage void
  2. 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 中:
  1. /*
  2. * Page fault error code bits:
  3. *
  4. *   bit 0 ==         0: no page found        1: protection fault
  5. *   bit 1 ==         0: read access                1: write access
  6. *   bit 2 ==         0: kernel-mode access        1: user-mode access
  7. *   bit 3 ==                                1: use of reserved bit detected
  8. *   bit 4 ==                                1: fault was an instruction fetch
  9. *   bit 5 ==                                1: protection keys block access
  10. */
  11. enum x86_pf_error_code {
  12.         X86_PF_PROT        =                1 << 0,
  13.         X86_PF_WRITE        =                1 << 1,
  14.         X86_PF_USER        =                1 << 2,
  15.         X86_PF_RSVD        =                1 << 3,
  16.         X86_PF_INSTR        =                1 << 4,
  17.         X86_PF_PK        =                1 << 5,
  18. };
复制代码
在内核中,凡是有物理内存出现的地方,就一定伴随着页表的映射,vmalloc 也不例外,当分配完物理内存之后,就需要修改内核页表,然后将物理内存映射到 vmalloc 虚拟内存区域中,当然了,这个过程也伴随着 vmalloc 区域中的这些虚拟内存地址在内核页表中对应的 pgd,pud,pmd,pte 相关页目录项以及页表项的创建。

大家需要注意的是,这里的内核页表指的是内核主页表,内核主页表的顶级页目录起始地址存放在 init_mm 结构中的 pgd 属性中,其值为 swapper_pg_dir。
  1. #define TASK_SIZE_MAX  task_size_max()
  2. #define task_size_max()  ((_AC(1,UL) << __VIRTUAL_MASK_SHIFT) - PAGE_SIZE)
  3. #define __VIRTUAL_MASK_SHIFT 47
  4. #define PAGE_SHIFT  12
  5. #define PAGE_SIZE  (_AC(1,UL) << PAGE_SHIFT)
复制代码
内核主页表在系统初始化的时候被一段汇编代码 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 相关的页目录表以及页表发生任何变化,由于是引用的关系,这些改变都会立刻自动反应到进程页表的内核部分中,后面就不需要同步了。
  1. static int fault_in_kernel_space(unsigned long address)
  2. {
  3.     /*
  4.      * On 64-bit systems, the vsyscall page is at an address above
  5.      * TASK_SIZE_MAX, but is not considered part of the kernel
  6.      * address space.
  7.      */
  8.     if (IS_ENABLED(CONFIG_X86_64) && is_vsyscall_vaddr(address))
  9.         return false;
  10.     // 在进程虚拟内存空间中,TASK_SIZE_MAX 以上的虚拟地址均属于内核空间
  11.     return address >= TASK_SIZE_MAX;
  12. }
复制代码
在我们聊完内核主页表的同步过程之后,可能很多读者朋友不禁要问,既然已经有了内核主页表,而且内核地址空间包括内核页表又是所有进程共享的,那进程为什么不能直接访问内核主页表而是要访问主页表的拷贝部分呢 ? 这样还能省去拷贝内核主页表(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 进行缺页处理了。
  1. static noinline void
  2. __do_page_fault(struct pt_regs *regs, unsigned long hw_error_code,
  3.         unsigned long address)
  4. {
  5.     // mmap_sem 是进程虚拟内存空间 mm_struct 的读写锁
  6.     // 内核这里将 mmap_sem 预取到 cacheline 中,并标记为独占状态( MESI 协议中的 X 状态)
  7.     prefetchw(&current->mm->mmap_sem);
  8.     // 这里判断引起缺页异常的虚拟内存地址 address 是属于内核空间的还是用户空间的
  9.     if (unlikely(fault_in_kernel_space(address)))
  10.         // 如果缺页异常发生在内核空间,则由 vmalloc_fault 进行处理
  11.         // 这里使用 unlikely 的原因是,内核对内存的使用通常是高优先级的而且使用比较频繁,所以内核空间一般很少发生缺页异常。
  12.         do_kern_addr_fault(regs, hw_error_code, address);
  13.     else
  14.         // 缺页异常发生在用户态
  15.         do_user_addr_fault(regs, hw_error_code, address);
  16. }
  17. NOKPROBE_SYMBOL(__do_page_fault);
复制代码
handle_mm_fault 函数会返回一个 unsigned int 类型的位图 vm_fault_t,通过这个位图可以简要描述一下在整个缺页异常处理的过程中究竟发生了哪些状况,方便内核对各种状况进行针对性处理。
  1. static void
  2. do_kern_addr_fault(struct pt_regs *regs, unsigned long hw_error_code,
  3.            unsigned long address)
  4. {
  5.     // 该缺页的内核地址 address 在内核页表中对应的 pte 不能使用保留位(X86_PF_RSVD = 0)
  6.     // 不能是用户态的缺页中断(X86_PF_USER = 0)
  7.     // 且不能是保护类型的缺页中断 (X86_PF_PROT = 0)
  8.     if (!(hw_error_code & (X86_PF_RSVD | X86_PF_USER | X86_PF_PROT))) {
  9.         // 处理 vmalloc 映射区里的缺页异常
  10.         if (vmalloc_fault(address) >= 0)
  11.             return;
  12.     }
  13. }  
复制代码
比如,位图 vm_fault_t 的第三个比特位置为 1 表示 VM_FAULT_MAJOR,置为 0 表示 VM_FAULT_MINOR。
  1. /**
  2. * __vmalloc_node_range - allocate virtually contiguous memory
  3. * Allocate enough pages to cover @size from the page level
  4. * allocator with @gfp_mask flags.  Map them into contiguous
  5. * kernel virtual space, using a pagetable protection of @prot.
  6. *
  7. * Return: the address of the area or %NULL on failure
  8. */
  9. void *__vmalloc_node_range(unsigned long size, unsigned long align,
  10.             unsigned long start, unsigned long end, gfp_t gfp_mask,
  11.             pgprot_t prot, unsigned long vm_flags, int node,
  12.             const void *caller)
  13. {
  14.     // 用于描述 vmalloc 虚拟内存区域的数据结构,同 mmap 中的 vma 结构很相似
  15.     struct vm_struct *area;
  16.     // vmalloc 虚拟内存区域的起始地址
  17.     void *addr;
  18.     unsigned long real_size = size;
  19.     // size 为要申请的 vmalloc 虚拟内存区域大小,这里需要按页对齐
  20.     size = PAGE_ALIGN(size);
  21.     // 因为在分配完 vmalloc 区之后,马上就会为其分配物理内存
  22.     // 所以这里需要检查 size 大小不能超过当前系统中的空闲物理内存
  23.     if (!size || (size >> PAGE_SHIFT) > totalram_pages())
  24.         goto fail;
  25.     // 在内核空间的 vmalloc 动态映射区中,划分出一段空闲的虚拟内存区域 vmalloc 区出来
  26.     // 这里虚拟内存的分配过程和 mmap 在用户态文件与匿名映射区分配虚拟内存的过程非常相似,这里就不做过多的介绍了。
  27.     area = __get_vm_area_node(size, align, VM_ALLOC | VM_UNINITIALIZED |
  28.                 vm_flags, start, end, node, gfp_mask, caller);
  29.     if (!area)
  30.         goto fail;
  31.     // 为 vmalloc 虚拟内存区域中的每一个虚拟内存页分配物理内存页
  32.     // 并在内核页表中将 vmalloc 区与物理内存映射起来
  33.     addr = __vmalloc_area_node(area, gfp_mask, prot, node);
  34.     if (!addr)
  35.         return NULL;
  36.     return addr;
  37. }
复制代码
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 结构中的相应字段中:
  1. // 用来描述 vmalloc 区
  2. struct vm_struct {
  3.     // vmalloc 动态映射区中的所有虚拟内存区域也都是被一个单向链表所串联
  4.     struct vm_struct    *next;
  5.     // vmalloc 区的起始内存地址
  6.     void            *addr;
  7.     // vmalloc 区的大小
  8.     unsigned long       size;
  9.     // vmalloc 区的相关标记
  10.     // VM_ALLOC 表示该区域是由 vmalloc 函数映射出来的
  11.     // VM_MAP 表示该区域是由 vmap 函数映射出来的
  12.     // VM_IOREMAP 表示该区域是由 ioremap 函数将硬件设备的内存映射过来的
  13.     unsigned long       flags;
  14.     // struct page 结构的数组指针,数组中的每一项指向该虚拟内存区域背后映射的物理内存页。
  15.     struct page     **pages;
  16.     // 该虚拟内存区域包含的物理内存页个数
  17.     unsigned int        nr_pages;
  18.     // ioremap 映射硬件设备物理内存的时候填充
  19.     phys_addr_t     phys_addr;
  20.     // 调用者的返回地址(这里可忽略)
  21.     const void      *caller;
  22. };
复制代码
我们可以在 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 字节。
  1. struct vmap_area {
  2.     // vmalloc 区的起始内存地址
  3.     unsigned long va_start;
  4.     // vmalloc 区的结束内存地址
  5.     unsigned long va_end;
  6.     // vmalloc 区所在红黑树中的节点
  7.     struct rb_node rb_node;         /* address sorted rbtree */
  8.     // vmalloc 区所在双向链表中的节点
  9.     struct list_head list;          /* address sorted list */
  10.     // 用于关联 vm_struct 结构
  11.     struct vm_struct *vm;         
  12. };
复制代码
而各级页目录表以及页表在内核中其实本质上都是一个 4K 物理内存页,只不过这些物理内存页存放的内容比较特殊,它们存放的是页目录项和页表项。一张页目录表可以存放 512 个页目录项,一张页表可以存放 512 个页表项
  1. struct mm_struct {
  2.      struct vm_area_struct *mmap;  /* list of VMAs */
  3.      struct rb_root mm_rb;
  4. }
复制代码
因此我们可以把全局页目录表 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 了。
  1. static struct rb_root vmap_area_root = RB_ROOT;
  2. extern struct list_head vmap_area_list;
复制代码
在后续即将要介绍的源码实现中,大家还会看到一个 p4d 的页目录,该页目录用于在五级页表体系下表示四级页目录。
  1. static void *__vmalloc_area_node(struct vm_struct *area, gfp_t gfp_mask,
  2.                  pgprot_t prot, int node)
  3. {
  4.     // 指向即将为 vmalloc 区分配的物理内存页
  5.     struct page **pages;
  6.     unsigned int nr_pages, array_size, i;
  7.     // 计算 vmalloc 区所需要的虚拟内存页个数
  8.     nr_pages = get_vm_area_size(area) >> PAGE_SHIFT;
  9.     // vm_struct 结构中的 pages 数组大小,用于存放指向每个物理内存页的指针
  10.     array_size = (nr_pages * sizeof(struct page *));
  11.     // 首先要为 pages 数组分配内存
  12.     if (array_size > PAGE_SIZE) {
  13.         // array_size 超过 PAGE_SIZE 大小则递归调用 vmalloc 分配数组所需内存
  14.         pages = __vmalloc_node(array_size, 1, nested_gfp|highmem_mask,
  15.                 PAGE_KERNEL, node, area->caller);
  16.     } else {
  17.         // 直接调用 kmalloc 分配数组所需内存
  18.         pages = kmalloc_node(array_size, nested_gfp, node);
  19.     }
  20.     // 初始化 vm_struct
  21.     area->pages = pages;
  22.     area->nr_pages = nr_pages;
  23.     // 依次为 vmalloc 区中包含的所有虚拟内存页分配物理内存
  24.     for (i = 0; i < area->nr_pages; i++) {
  25.         struct page *page;
  26.         if (node == NUMA_NO_NODE)
  27.             // 如果没有特殊指定 numa node,则从当前 numa node 中分配物理内存页
  28.             page = alloc_page(alloc_mask|highmem_mask);
  29.         else
  30.             // 否则就从指定的 numa node 中分配物理内存页
  31.             page = alloc_pages_node(node, alloc_mask|highmem_mask, 0);
  32.         // 将分配的物理内存页依次存放到 vm_struct 结构中的 pages 数组中
  33.         area->pages[i] = page;
  34.     }
  35.    
  36.     atomic_long_add(area->nr_pages, &nr_vmalloc_pages);
  37.     // 修改内核主页表,将刚刚分配出来的所有物理内存页与 vmalloc 虚拟内存区域进行映射
  38.     if (map_vm_area(area, prot, pages))
  39.         goto fail;
  40.     // 返回 vmalloc 虚拟内存区域起始地址
  41.     return area->addr;
  42. }
复制代码
而在四级页表体系下,这个 p4d 就不起作用了,但为了代码上的统一处理,在四级页表下,前面定位到的顶级页目录项 pgd_t 会赋值给四级页目录项 p4d_t,后续处理都会将 p4d_t 看做是顶级页目录项,这一点需要和大家在这里先提前交代清楚。
  1. struct mm_struct init_mm = {
  2.    // 内核主页表
  3.   .pgd    = swapper_pg_dir,
  4. }
  5. #define swapper_pg_dir init_top_pgt
复制代码
现在我们已经通过 pgd_offset 定位到虚拟内存地址 address 对应在全局页目录 PGD 的页目录项 pgd_t(p4d_t)了。

接下来的任务就是根据这个 p4d_t 定位虚拟内存对应在上层页目录 PUD 中的页目录项 pud_t。但在定位之前,我们需要首先判断这个 p4d_t 是否是空的,如果是空的,说明在目前的进程页表中还不存在对应的 PUD,需要马上创建一个新的出来。
而 PUD 的相关信息全部都保存在 p4d_t 里,我们可以通过 native_p4d_val 函数将顶级页目录项 p4d_t 中的值获取出来。
  1. /*
  2. * 64-bit:
  3. *
  4. *   Handle a fault on the vmalloc area
  5. */
  6. static noinline int vmalloc_fault(unsigned long address)
  7. {
  8.     // 分别是缺页虚拟地址 address 对应在内核主页表的全局页目录项 pgd_k ,以及进程页表中对应的全局页目录项 pgd
  9.     pgd_t *pgd, *pgd_k;
  10.     // p4d_t 用于五级页表体系,当前 cpu 架构体系下一般采用的是四级页表
  11.     // 在四级页表下 p4d 是空的,pgd 的值会赋值给 p4d
  12.     p4d_t *p4d, *p4d_k;
  13.     // 缺页虚拟地址 address 对应在进程页表中的上层目录项 pud
  14.     pud_t *pud;
  15.     // 缺页虚拟地址 address 对应在进程页表中的中间目录项 pmd
  16.     pmd_t *pmd;
  17.     // 缺页虚拟地址 address 对应在进程页表中的页表项 pte
  18.     pte_t *pte;
  19.     // 确保缺页发生在内核 vmalloc 动态映射区
  20.     if (!(address >= VMALLOC_START && address < VMALLOC_END))
  21.         return -1;
  22.     // 获取缺页虚拟地址 address 对应在进程页表的全局页目录项 pgd
  23.     pgd = (pgd_t *)__va(read_cr3_pa()) + pgd_index(address);
  24.     // 获取缺页虚拟地址 address 对应在内核主页表的全局页目录项 pgd_k
  25.     pgd_k = pgd_offset_k(address);
  26.     // 如果内核主页表中的 pgd_k 本来就是空的,说明 address 是一个非法访问的地址,返回 -1
  27.     if (pgd_none(*pgd_k))
  28.         return -1;
  29.     // 如果开启了五级页表,那么顶级页表就是 pgd,这里只需要同步顶级页表项就可以了
  30.     if (pgtable_l5_enabled()) {
  31.         // 内核主页表中的 pgd_k 不为空,进程页表中的 pgd 为空,那么就同步页表
  32.         if (pgd_none(* )) {
  33.             // 将主内核页表中的 pgd_k 内容复制给进程页表对应的 pgd
  34.             set_pgd(pgd, *pgd_k);
  35.             // 刷新 mmu
  36.             arch_flush_lazy_mmu_mode();
  37.         } else {
  38.             BUG_ON(pgd_page_vaddr(*pgd) != pgd_page_vaddr(*pgd_k));
  39.         }
  40.     }
  41.     // 四级页表体系下,p4d 是顶级页表项,同样也是只需要同步顶级页表项即可,同步逻辑和五级页表一模一样
  42.     // 因为是四级页表,所以这里会将 pgd 赋值给 p4d,p4d_k ,后面就直接把 p4d 看做是顶级页表了。
  43.     p4d = p4d_offset(pgd, address);
  44.     p4d_k = p4d_offset(pgd_k, address);
  45.     // 内核主页表为空,则停止同步,返回 -1 ,表示正在访问一个非法地址
  46.     if (p4d_none(*p4d_k))
  47.         return -1;
  48.     // 内核主页表不为空,进程页表为空,则同步内核顶级页表项 p4d_k 到进程页表对应的 p4d 中,然后刷新 mmu
  49.     if (p4d_none(*p4d) && !pgtable_l5_enabled()) {
  50.         set_p4d(p4d, *p4d_k);
  51.         arch_flush_lazy_mmu_mode();
  52.     } else {
  53.         BUG_ON(p4d_pfn(*p4d) != p4d_pfn(*p4d_k));
  54.     }
  55.     // 到这里,页表的同步工作就完成了,下面代码用于检查内核地址 address 在进程页表内核部分中是否有物理内存进行映射
  56.     // 如果没有,则返回 -1 ,说明进程在访问一个非法的内核地址,进程随后会被 kill 掉
  57.     // 返回 0 表示表示地址 address 背后是有物理内存映射的, vmalloc 动态映射区的缺页处理到此结束。
  58.     // 根据顶级页目录项 p4d 获取 address 在进程页表中对应的上层页目录项 pud
  59.     pud = pud_offset(p4d, address);
  60.     if (pud_none(*pud))
  61.         return -1;
  62.     // 该 pud 指向的是 1G 大页内存
  63.     if (pud_large(*pud))
  64.         return 0;
  65.      // 根据 pud 获取 address 在进程页表中对应的中间页目录项 pmd
  66.     pmd = pmd_offset(pud, address);
  67.     if (pmd_none(*pmd))
  68.         return -1;
  69.     // 该 pmd 指向的是 2M 大页内存
  70.     if (pmd_large(*pmd))
  71.         return 0;
  72.     // 根据 pmd 获取 address 对应的页表项 pte
  73.     pte = pte_offset_kernel(pmd, address);
  74.     // 页表项 pte 并没有映射物理内存
  75.     if (!pte_present(*pte))
  76.         return -1;
  77.     return 0;
  78. }
  79. NOKPROBE_SYMBOL(vmalloc_fault);
复制代码
在 64 位系统中,各级页目录项都是用 unsigned long 类型来表示的,共 8 个字节,64 个 bit,还记得我们之前在《一步一图带你构建 Linux 页表体系》 一文中介绍的页目录项比特位布局吗 ?

在页目录项刚刚被创建出来的时候,内核会将他们全部初始化为 0 值,如果一个页目录项中除了第 5 , 6 比特位之外剩下的比特位全都为 0 的话,则表示这个页目录项是空的。
  1. /* Handle faults in the user portion of the address space */
  2. static inline
  3. void do_user_addr_fault(struct pt_regs *regs,
  4.             unsigned long hw_error_code,
  5.             unsigned long address)
  6. {
  7.     struct vm_area_struct *vma;
  8.     struct task_struct *tsk;
  9.     struct mm_struct *mm;
  10.     tsk = current;
  11.     mm = tsk->mm;
  12.        .............. 省略 ..............
  13.     // 在进程虚拟地址空间查找第一个符合条件:address < vma->vm_end 的虚拟内存区域 vma
  14.     vma = find_vma(mm, address);
  15.     // 如果该缺页地址 address 后面没有 vma 跳转到 bad_area 处理异常
  16.     if (unlikely(!vma)) {
  17.         bad_area(regs, hw_error_code, address);
  18.         return;
  19.     }
  20.     // 缺页地址 address 恰好落在一个 vma 中,跳转到 good_area 处理 vma 中的缺页
  21.     if (likely(vma->vm_start <= address))
  22.         goto good_area;
  23.     // 上面第三种情况,vma 不是栈区,跳转到 bad_area
  24.     if (unlikely(!(vma->vm_flags & VM_GROWSDOWN))) {
  25.         bad_area(regs, hw_error_code, address);
  26.         return;
  27.     }
  28.     // vma 是栈区,尝试扩展栈区到 address 地址处
  29.     if (unlikely(expand_stack(vma, address))) {
  30.         bad_area(regs, hw_error_code, address);
  31.         return;
  32.     }
  33.     /*
  34.      * Ok, we have a good vm_area for this memory access, so
  35.      * we can handle it..
  36.      */
  37. good_area:
  38.     // 处理 vma 区域的缺页异常,返回值 fault 是一个位图,用于描述缺页处理过程中发生的状况信息。
  39.     fault = handle_mm_fault(vma, address, flags);
  40.     // 本次缺页是否属于 VM_FAULT_MAJOR,缺页处理过程中是否发生了物理内存的分配以及磁盘 IO
  41.     // 与其对应的是 VM_FAULT_MINOR 表示缺页处理过程中所需内存页已经存在于内存中了,只是修改页表即可。
  42.     major |= fault & VM_FAULT_MAJOR;
  43.     /*
  44.      * Major/minor page fault accounting. If any of the events
  45.      * returned VM_FAULT_MAJOR, we account it as a major fault.
  46.      */
  47.     if (major) {
  48.         // 统计进程总共发生的 VM_FAULT_MAJOR 次数
  49.         tsk->maj_flt++;
  50.         perf_sw_event(PERF_COUNT_SW_PAGE_FAULTS_MAJ, 1, regs, address);
  51.     } else {
  52.         // 统计进程总共发生的 VM_FAULT_MINOR 次数
  53.         tsk->min_flt++;
  54.         perf_sw_event(PERF_COUNT_SW_PAGE_FAULTS_MIN, 1, regs, address);
  55.     }
  56. }
  57. NOKPROBE_SYMBOL(do_user_addr_fault);
复制代码
如果我们通过 p4d_none 函数判断出顶级页目录项 p4d 是空的,那么就需要调用 __pud_alloc 函数分配一个新的上层页目录表 PUD 出来,然后用 PUD 的起始物理内存地址以及页目录项的初始权限位 _PAGE_TABLE 填充 p4d。
  1. /**
  2. * Page fault handlers return a bitmask of %VM_FAULT values.
  3. */
  4. typedef __bitwise unsigned int vm_fault_t;
复制代码
下面我们来看一下填充顶级页目录项 p4d 的一些细节,填充的逻辑封装在下面的 p4d_populate 函数中。
  1. enum vm_fault_reason {
  2.         VM_FAULT_MAJOR          = (__force vm_fault_t)0x000004,
  3. };
复制代码
各级页目录项以及页表项,它们的本质其实就是一块 8 字节大小,64 bits 的小内存块,内核中使用 unsigned long 类型来修饰,各级页目录项以及页表项在初始的时候,它们的这 64 个比特位全部为 0 值,所谓填充页目录项就是按照下图所示的页目录项比特位布局,根据每个比特位的具体含义进行相应的填充。

由于页目录项所承担的一项最重要的工作就是定位其下一级页目录表的起始物理内存地址,这里的下一级页目录表就是刚刚我们新创建出来的 PUD。所以第一件重要的事情就是通过 __pa(pud) 来获取 PUD 的起始物理内存地址,然后将 PUD 的物理内存地址填充到顶级页目录项 p4d 中的对应比特位上。
由于物理内存地址在内核中都是按照 4K 对齐的,所以 PUD 物理内存地址的低 12 位全部都是 0 ,我们可以利用这 12 个比特位存放一些权限标记位,页目录项在初始化时需要置为 1 的权限标记位定义在 _PAGE_TABLE 中。也就是说 _PAGE_TABLE 定义了页目录项初始权限标记位集合。
  1. struct task_struct {
  2.     // 进程总共发生的 VM_FAULT_MINOR 次数
  3.     unsigned long           min_flt;
  4.      // 进程总共发生的 VM_FAULT_MAJOR 次数
  5.     unsigned long           maj_flt;
  6. }
复制代码
在我们清楚了以上背景知识之后,再来看 handle_pte_fault 的缺页处理逻辑就很清晰了:
  1. // 定义在内核文件:/arch/x86/include/asm/pgtable_64_types.h
  2. typedef unsigned long pteval_t;
  3. typedef unsigned long pmdval_t;
  4. typedef unsigned long pudval_t;
  5. typedef unsigned long pgdval_t;
  6. typedef struct { pteval_t pte; } pte_t;
  7. // 定义在内核文件:/arch/x86/include/asm/pgtable_types.h
  8. typedef struct { pmdval_t pmd; } pmd_t;
  9. typedef struct { pudval_t pud; } pud_t;
  10. typedef struct { pgdval_t pgd; } pgd_t;
复制代码
7. do_anonymous_page 处理匿名页缺页


在本文的第五小节中,我们完成了各级页目录的补齐填充工作,但是现在最后一级页表还没有着落,所以在处理缺页之前,我们需要调用 pte_alloc 继续把页表补齐了。
  1. // 全局页目录表 PGD 可以容纳的页目录项 pgd_t 的个数
  2. #define PTRS_PER_PGD  512
  3. // 上层页目录表 PUD 可以容纳的页目录项 pud_t 的个数
  4. #define PTRS_PER_PUD  512
  5. // 中间页目录表 PMD 可以容纳的页目录项 pmd_t 的个数
  6. #define PTRS_PER_PMD  512
  7. // 页表可以容纳的页表项 pte_t 的个数
  8. #define PTRS_PER_PTE  512
复制代码
首先我们通过 pmd_none 判断缺页地址 address 在进程页表中间页目录 PMD 中对应的页目录项 pmd 是否是空的,如果 pmd 是空的,说明此时还不存在一级页表,这样一来,就需要调用 __pte_alloc 来分配一张页表,然后用页表的 pfn 以及初始权限位 _PAGE_TABLE 来填充 pmd。
  1. /*
  2. * a shortcut to get a pgd_t in a given mm
  3. */
  4. #define pgd_offset(mm, address) pgd_offset_pgd((mm)->pgd, (address))
  5. #define pgd_offset_pgd(pgd, address) (pgd + pgd_index((address)))
  6. #define pgd_index(address) (((address) >> PGDIR_SHIFT) & (PTRS_PER_PGD - 1))
  7. #define PGDIR_SHIFT                39
  8. #define PTRS_PER_PGD                512
复制代码

现在我们已经有了一级页表,但是页表中的 pte 还都是空的,接下来就该用这个空的 pte 来映射物理内存页了。
首先我们通过 alloc_zeroed_user_highpage_movable 来分配一个物理内存页出来,关于物理内存详细的分配过程,感兴趣的读者可以看下笔者的这篇文章——《深入理解 Linux 物理内存分配全链路实现》
这个物理内存页就是为缺页地址 address 映射的物理内存了,随后我们通过 mk_pte 利用物理内存页 page 的 pfn 以及缺页内存区域 vma 中记录的页属性 vma->vm_page_prot 填充一个新的页表项 entry 出来。
entry 这里只是一个临时的值,后续会将 entry 的值设置到真正的 pte 中。
  1. typedef unsigned long        p4dval_t;
  2. typedef struct { p4dval_t p4d; } p4d_t;
复制代码

如果缺页内存地址 address 所在的虚拟内存区域 vma 是可写的,那么我们就通过 pte_mkwrite 和 pte_mkdirty 将临时页表项 entry 的 R/W(1) 比特位和D(6) 比特位置为 1 。表示该页表项背后映射的物理内存页 page 是可写的,并且标记为脏页。
  1. static inline p4d_t *p4d_offset(pgd_t *pgd, unsigned long address)
  2. {
  3.     if (!pgtable_l5_enabled())
  4.         // 四级页表体系下,p4d_t 其实就是顶级页目录项
  5.         return (p4d_t *)pgd;
  6.     return (p4d_t *)pgd_page_vaddr(*pgd) + p4d_index(address);
  7. }
复制代码
注意,此时缺页内存地址 address 在页表中的 pte 还是空的,我们还没有设置呢,目前只是先将值初始化到临时的页表项 entry 中,下面才到设置真正的 pte 的时候。
调用 pte_offset_map_lock,首先获取 address 在一级页表中的真正 pte,然后将一级页表锁定。
  1. static inline p4dval_t native_p4d_val(p4d_t p4d)
  2. {
  3.         return p4d.p4d;
  4. }
复制代码
按理说此时获取到的 pte 应该是空的,如果 pte 不为空,说明已经有其他线程把缺页处理好了,pte 已经被填充了,那么本次缺页处理就该停止,不能在往下走了,直接跳转到 release 处,释放页表锁,释放新分配的物理内存页 page。
  1. static inline int p4d_none(p4d_t p4d)
  2. {
  3.     // p4d_t 中除了第 5,6 比特位之外,剩余比特位如果全是 0 则表示 p4d_t 是空的
  4.     return (native_p4d_val(p4d) & ~(_PAGE_KNL_ERRATUM_MASK)) == 0;
  5. }
  6. // 页目录项中第 5, 6 比特位置为 1
  7. #define _PAGE_KNL_ERRATUM_MASK (_PAGE_DIRTY | _PAGE_ACCESSED)
复制代码
如果 pte 为空,说明此时没有其他线程对缺页进行并发处理,我们可以接着处理缺页。
进程使用到的常驻内存等相关统计信息保存在 task->rss_stat 字段中:
  1. /*
  2. * Allocate page upper directory.
  3. * We've already handled the fast-path in-line.
  4. */
  5. int __pud_alloc(struct mm_struct *mm, p4d_t *p4d, unsigned long address)
  6. {
  7.     // 调用 get_zeroed_page 申请一个 4k 物理内存页并初始化为 0 值作为新的 PUD
  8.     // new 指向新分配的 PUD 起始内存地址
  9.     pud_t *new = pud_alloc_one(mm, address);
  10.     if (!new)
  11.         return -ENOMEM;
  12.     // 操作进程页表需要加锁
  13.     spin_lock(&mm->page_table_lock);
  14.     // 如果顶级页目录项 p4d 中的 P 比特位置为 0 表示 p4d 目前还没有指向其下一级页目录 PUD
  15.     // 下面需要填充 p4d
  16.     if (!p4d_present(*p4d)) {
  17.         // 更新 mm->pgtables_bytes 计数,该字段用于统计进程页表所占用的字节数
  18.         // 由于这里新增了一张 PUD 目录表,所以计数需要增加 PTRS_PER_PUD * sizeof(pud_t)
  19.         mm_inc_nr_puds(mm);
  20.         // 将 new 指向的新分配出来的 PUD 物理内存地址以及相关属性填充到顶级页目录项 p4d 中
  21.         p4d_populate(mm, p4d, new);
  22.     } else  /* Another has populated it */
  23.         // 释放新创建的 PMD
  24.         pud_free(mm, new);
  25.     // 释放页表锁
  26.     spin_unlock(&mm->page_table_lock);
  27.     return 0;
  28. }
复制代码
由于这里我们新分配一个匿名内存页用于缺页处理,所以相关 rss_stat 统计信息 —— task->rss_stat.count[MM_ANONPAGES] 要加 1 。
  1. static inline void p4d_populate(struct mm_struct *mm, p4d_t *p4d, pud_t *pud)
  2. {
  3.         set_p4d(p4d, __p4d(_PAGE_TABLE | __pa(pud)));
  4. }
  5. #define _KERNPG_TABLE        (_PAGE_PRESENT | _PAGE_RW | _PAGE_ACCESSED |        \
  6.                          _PAGE_DIRTY | _PAGE_ENC)
  7. #define _PAGE_TABLE        (_KERNPG_TABLE | _PAGE_USER)
复制代码
随后调用 page_add_new_anon_rmap 建立匿名页的反向映射关系,关于匿名页的反向映射笔者已经在之前的文章 ——  《深入理解 Linux 物理内存管理》 中详细介绍过了,感兴趣的朋友可以回看下。
反向映射建立好之后,调用 lru_cache_add_active_or_unevictable 将匿名内存页加入到 LRU 活跃链表中。
最后调用 set_pte_at 将之间我们临时填充的页表项 entry 赋值给缺页 address 真正对应的 pte。
  1. #define _PAGE_BIT_PRESENT 0 /* is present */
  2. #define _PAGE_BIT_RW  1 /* writeable */
  3. #define _PAGE_BIT_USER  2 /* userspace addressable */
  4. #define _PAGE_BIT_ACCESSED 5 /* was accessed (raised by CPU) */
  5. #define _PAGE_BIT_DIRTY  6 /* was written to (raised by CPU) */
  6. #define _PAGE_PRESENT (_AT(pteval_t, 1) << _PAGE_BIT_PRESENT)
  7. #define _PAGE_RW (_AT(pteval_t, 1) << _PAGE_BIT_RW)
  8. #define _PAGE_USER (_AT(pteval_t, 1) << _PAGE_BIT_USER)
  9. #define _PAGE_ACCESSED (_AT(pteval_t, 1) << _PAGE_BIT_ACCESSED)
  10. #define _PAGE_DIRTY (_AT(pteval_t, 1) << _PAGE_BIT_DIRTY)
复制代码
到这里我们才算是真正把进程的页表体系给补齐了。

在明白以上内容之后,我们回过头来看在 do_anonymous_page 匿名页缺页处理的逻辑就很清晰了:
  1. 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 标记为可写,脏页。  static inline p4d_t *p4d_offset(pgd_t *pgd, unsigned long address)
  2. {
  3.     if (!pgtable_l5_enabled())
  4.         // 四级页表体系下,p4d_t 其实就是顶级页目录项
  5.         return (p4d_t *)pgd;
  6.     return (p4d_t *)pgd_page_vaddr(*pgd) + p4d_index(address);
  7. }    // 锁定一级页表,并获取 address 在页表中对应的真实 pte    vmf->pte = pte_offset_map_lock(vma->vm_mm, vmf->pmd, vmf->address,            &vmf->ptl);    // 是否有其他线程在并发处理缺页static inline int p4d_none(p4d_t p4d)
  8. {
  9.     // p4d_t 中除了第 5,6 比特位之外,剩余比特位如果全是 0 则表示 p4d_t 是空的
  10.     return (native_p4d_val(p4d) & ~(_PAGE_KNL_ERRATUM_MASK)) == 0;
  11. }
  12. // 页目录项中第 5, 6 比特位置为 1
  13. #define _PAGE_KNL_ERRATUM_MASK (_PAGE_DIRTY | _PAGE_ACCESSED)    // 增加 进程 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。
  1. # define set_p4d(p4dp, p4d)                native_set_p4d(p4dp, p4d)
复制代码
在 vma->vm_ops 中有个重要的函数 fault,在 ext4 文件系统中的实现是:ext4_filemap_fault 函数。
  1. /* Find an entry in the third-level page table.. */
  2. static inline pud_t *pud_offset(p4d_t *p4d, unsigned long address)
  3. {
  4.         return (pud_t *)p4d_page_vaddr(*p4d) + pud_index(address);
  5. }
复制代码
vma->vm_ops->fault 函数就是专门用于处理文件映射区缺页的,本小节要介绍的文件页的缺页处理的核心就是依赖这个函数完成的。
我们知道 mmap 进行文件映射的时候只是单纯地建立了虚拟内存与文件之间的映射关系,此时并没有物理内存分配。当进程对这段文件映射区进行读取操作的时候,会触发缺页,然后分配物理内存(文件页),这一部分逻辑在下面的 do_read_fault 函数中完成,它主要处理的是由于对文件映射区的读取操作而引起的缺页情况。
而 mmap 文件映射又分为私有文件映射与共享文件映射两种映射方式,而私有文件映射的核心特点是读共享的,当任意进程对私有文件映射区发生写入操作时候,就会发生写时复制 COW,这一部分逻辑在下面的 do_cow_fault 函数中完成。
对共享文件映射区进行的写入操作而引起的缺页,内核放在 do_shared_fault 函数中进行处理。
  1. // 上层页目录表 PUD 可以容纳的页目录项 pud_t 的个数
  2. #define PTRS_PER_PUD  512
复制代码
8.1 do_read_fault 处理读操作引起的缺页

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

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

除此之外,内核还会考虑到进程访问内存的空间局部性,所以内核除了会映射本次缺页需要的文件页之外,还会将其相邻的文件页读取到 page cache 中,然后将这些相邻的文件页映射到对应的 pte 中。这一部分预先提前映射的逻辑在 map_pages 函数中实现。
  1. /* Find an entry in the third-level page table.. */
  2. static inline pud_t *pud_offset(p4d_t *p4d, unsigned long address)
  3. {
  4.         return (pud_t *)p4d_page_vaddr(*p4d) + pud_index(address);
  5. }
复制代码
如果不满足预先提前映射的条件,那么内核就只会专注处理映射本次缺页所需要的文件页。
首先通过上面的 fault 函数,当映射文件所在文件系统是 ext4 时,该函数的实现为 ext4_filemap_fault,该函数只负责获取本次缺页所需要的文件页。
当获取到文件页之后,内核会调用 finish_fault 函数,将文件页映射到缺页地址 address 在进程页表中对应的 pte 中,do_read_fault 函数处理就完成了,不过需要注意的是,对于私有文件映射的话,此时的这个 pte 还是只读的,多进程之间读共享,当任意进程尝试写入的时候,会发生写时复制。
  1. static inline unsigned long p4d_page_vaddr(p4d_t p4d)
  2. {
  3.         return (unsigned long)__va(p4d_val(p4d) & p4d_pfn_mask(p4d));
  4. }
复制代码
__do_fault 函数底层会调用到  vma->vm_ops->fault,在 ext4 文件系统中对应的实现是 ext4_filemap_fault。
  1. #define p4d_val(x)        native_p4d_val(x)
  2. static inline p4dval_t native_p4d_val(p4d_t p4d)
  3. {
  4.         return p4d.p4d;
  5. }
复制代码
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 中。
  1. #define __PHYSICAL_MASK_SHIFT        52
复制代码
  1. #define __PHYSICAL_MASK                ((phys_addr_t)((1ULL << __PHYSICAL_MASK_SHIFT) - 1))
复制代码
文件页现在有了,接下来内核就会调用 finish_fault 将文件页映射到 pte 中。
  1. #define PAGE_SHIFT  12
  2. #define PAGE_SIZE   (_AC(1,UL) << PAGE_SHIFT)      
  3. #define PAGE_MASK   (~(PAGE_SIZE-1))     // 0xFFFFFFFFFFFFF000
复制代码
alloc_set_pte 将之前我们准备好的文件页,映射到缺页地址 address 在进程页表对应的 pte 中。
  1. static inline unsigned long p4d_page_vaddr(p4d_t p4d)
  2. {
  3.     return (unsigned long)__va(p4d_val(p4d) & p4d_pfn_mask(p4d));
  4. }
  5. static inline p4dval_t p4d_pfn_mask(p4d_t p4d)
  6. {
  7.         /* No 512 GiB huge pages yet */
  8.         return PTE_PFN_MASK;
  9. }
  10. /* Extracts the PFN from a (pte|pmd|pud|pgd)val_t of a 4KB page */
  11. #define PTE_PFN_MASK                ((pteval_t)PHYSICAL_PAGE_MASK)
  12. #define PHYSICAL_PAGE_MASK        (((signed long)PAGE_MASK) & __PHYSICAL_MASK)
复制代码
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,所以这些修改也都不会回写到磁盘文件中,这就是私有文件映射的核心特点。
  1. // 同 p4d_none 的逻辑一样
  2. static inline int pud_none(pud_t pud)
  3. {
  4.         return (native_pud_val(pud) & ~(_PAGE_KNL_ERRATUM_MASK)) == 0;
  5. }
复制代码
8.3 do_shared_fault 处理对共享文件映射区写入引起的缺页

上小节我们介绍的 do_cow_fault 函数处理的场景是,当我们采用 mmap 进行私有文件映射之后,立即对虚拟映射区进行写入操作之后的缺页处理逻辑。
如果我们调用 mmap 对文件进行共享文件映射之后,然后立即对虚拟映射区进行写入操作,这背后的缺页处理逻辑又是怎样的呢 ?
其实和之前的文件缺页处理逻辑的核心流程都差不多,不同的是由于这里我们进行的共享文件映射,所以多个进程中的虚拟文件映射区都会映射到 page cache 中的文件页上,由于没有写时复制,所以进程对文件页的修改都会直接反映到 page cache 中,近而后续会回写到磁盘文件上。
由于共享文件映射涉及到脏页回写,所以在共享文件映射的缺页处理场景中,为了防止数据的丢失会额外有一些文件系统日志的记录工作。
  1. /*
  2. * Allocate page middle directory.
  3. * We've already handled the fast-path in-line.
  4. */
  5. int __pmd_alloc(struct mm_struct *mm, pud_t *pud, unsigned long address)
  6. {
  7.     // 调用 alloc_pages 从伙伴系统申请一个 4K 大小的物理内存页,作为新的 PMD
  8.     pmd_t *new = pmd_alloc_one(mm, address);
  9.     if (!new)
  10.         return -ENOMEM;
  11.     // 如果 pud 还未指向其下一级页目录 PMD,则需要初始化填充 pud
  12.     if (!pud_present(*pud)) {
  13.         mm_inc_nr_pmds(mm);
  14.         // 将 new 指向的新分配出来的 PMD 物理内存地址以及相关属性填充到上层页目录项 pud 中
  15.         pud_populate(mm, pud, new);
  16.     } else  /* Another has populated it */
  17.         pmd_free(mm, new);
  18.     return 0;
  19. }
复制代码
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。
  1. static inline void pud_populate(struct mm_struct *mm, pud_t *pud, pmd_t *pmd)
  2. {
  3.         set_pud(pud, __pud(_PAGE_TABLE | __pa(pmd)));
  4. }
复制代码
现在父子进程拥有了一模一样的地址空间,页表是一样的,页表中的 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 。
  1. #define __pud(x)        native_make_pud(x)
  2. static inline pud_t native_make_pud(pmdval_t val)
  3. {
  4.         return (pud_t) { val };
  5. }
复制代码
现在子进程处理完了,下面我们再来看当父进程发生写入操作的时候会发生什么 ?
首先和子进程一样,现在父进程页表中的相关 pte 仍然是只读的,访问这段虚拟内存地址依然会产生写保护类型的缺页中断,和子进程不同的是,此时父进程 pte 中指向的原有物理内存页 old_page 的引用计数已经变为 1 了,说明父进程是独占的,复用原来的 old_page 即可,不必进行写时复制,只是简单的将父进程页表中的相关 pte 改为可写就行了。
  1. # define set_pud(pudp, pud)                native_set_pud(pudp, pud)
  2. static inline void native_set_pud(pud_t *pudp, pud_t pud)
  3. {
  4.         WRITE_ONCE(*pudp, pud);
  5. }
复制代码
理解了上面的核心内容,我们再来看 do_wp_page 的处理逻辑就很清晰了:
  1. // 中间页目录表 PMD 可以容纳的页目录项 pmd_t 的个数
  2. #define PTRS_PER_PMD  512
复制代码
10. do_swap_page 处理 swap 缺页异常

如果在遍历进程页表的时候发现,虚拟内存地址 address 对应的页表项 pte 不为空,但是 pte 中第 0 个比特位置为 0 ,则表示该 pte 之前是被物理内存映射过的,只不过后来被内核 swap out 出去了。

我们需要的物理内存页不在内存中反而在磁盘中,现在我们就需要将物理内存页从磁盘中 swap in 进来。但在 swap in 之前内核需要知道该物理内存页的内容被保存在磁盘的什么位置上。
笔者在之前文章《一步一图带你构建 Linux 页表体系》 中的第 4.2.1 小节中详细介绍了 64 位页表项 pte 的比特位布局,以及各个比特位的含义。
  1. static inline pmd_t *pmd_offset(pud_t *pud, unsigned long address)
  2. {
  3.         return (pmd_t *)pud_page_vaddr(*pud) + pmd_index(address);
  4. }
复制代码

64 位的 pte 主要用来表示物理内存页的地址以及相关的权限标识位,但是当物理内存页不在内存中的时候,这些比特位就没有了任何意义。我们何不将这些已经没有任何意义的比特位利用起来,在物理内存页被 swap out 到磁盘上的时候,将物理内存页在磁盘上的位置保存在这些比特位中。本质上还利用的是之前 pte 中的那 64 个比特,为了区别 swap 的场景,内核使用了一个新的结构体  swp_entry_t 来包装。
  1. static inline unsigned long pud_page_vaddr(pud_t pud)
  2. {
  3.         return (unsigned long)__va(pud_val(pud) & pud_pfn_mask(pud));
  4. }
复制代码

swap in 的首要任务就是先要从进程页表中将这个 swp_entry_t 读取出来,然后从 swp_entry_t 中解析出内存页在 swap 交换区中的位置,根据磁盘位置信息将内存页的内容读取到内存中。由于产生了新的物理内存页,所以就要创建新的 pte 来映射这个物理内存页,然后将新的 pte 设置到页表中,替换原来的 swp_entry_t。
这里笔者需要为大家解释的第一个问题就是 —— 这个 swp_entry_t 究竟是长什么样子 的,它是如何保存 swap 交换区相关位置信息的 ?
10.1 交换区的布局及其组织结构

要明白这个,我们就需要先了解一下 swap 交换区(swap area)的布局,swap 交换区共有两种类型,一种是 swap 分区(swap partition),另一种是 swap 文件(swap file)。
swap partition 可以认为是一个没有文件系统的裸磁盘分区,分区中的磁盘块在磁盘中是连续分布的。
swap file 可以认为是在某个现有的文件系统上,创建的一个定长的普通文件,专门用于保存匿名页被 swap 出来的内容。背后的磁盘块是不连续的。
Linux 系统中可以允许多个这样的 swap 交换区存在,我们可以同时使用多个交换区,也可以为这些交换区指定优先级,优先级高的会被内核优先使用。这些交换区都可以被灵活地添加,删除,而不需要重启系统。多个交换区可以分散在不同的磁盘设备上,这样可以实现硬件的并行访问。
在使用交换区之前,我们可以通过 mkswap 首先创建一个交换区出来,如果我们创建的是 swap partition,则在 mkswap 命令后面直接指定分区的设备文件名称即可。
  1. static inline unsigned long pmd_index(unsigned long address)
  2. {
  3.         return (address >> PMD_SHIFT) & (PTRS_PER_PMD - 1);
  4. }
  5. #define PMD_SHIFT        21
  6. #define PTRS_PER_PMD        512
复制代码
如果我们创建的是 swap file,则需要额外先使用 dd 命令在现有文件系统中创建出一个定长的文件出来。比如下面通过 dd 命令从 /dev/zero 中拷贝创建一个 /swapfile 文件,大小为 4G。
  1. static vm_fault_t __handle_mm_fault(struct vm_area_struct *vma,
  2.         unsigned long address, unsigned int flags)
  3. {
  4.     // vm_fault 结构用于封装后续缺页处理用到的相关参数
  5.     struct vm_fault vmf = {
  6.         // 发生缺页的 vma
  7.         .vma = vma,
  8.         // 引起缺页的虚拟内存地址
  9.         .address = address & PAGE_MASK,
  10.         // 处理缺页的相关标记 FAULT_FLAG_xxx
  11.         .flags = flags,
  12.         // address 在 vma 中的偏移,单位也页
  13.         .pgoff = linear_page_index(vma, address),
  14.         // 后续用于分配物理内存使用的相关掩码 gfp_mask
  15.         .gfp_mask = __get_fault_gfp_mask(vma),
  16.     };
  17.     // 获取进程虚拟内存空间
  18.     struct mm_struct *mm = vma->vm_mm;
  19.     // 进程页表的顶级页表地址
  20.     pgd_t *pgd;
  21.     // 五级页表下会使用,在四级页表下 p4d 与 pgd 的值一样
  22.     p4d_t *p4d;
  23.     vm_fault_t ret;
  24.     // 获取 address 在全局页目录表 PGD 中对应的目录项 pgd
  25.     pgd = pgd_offset(mm, address);
  26.     // 在四级页表下,这里只是将 pgd 赋值给 p4d,后续均已 p4d 作为全局页目录项
  27.     p4d = p4d_alloc(mm, pgd, address);
  28.     if (!p4d)
  29.         return VM_FAULT_OOM;
  30.     // 首先 p4d_none 判断全局页目录项 p4d 是否是空的
  31.     // 如果 p4d 是空的,则调用 __pud_alloc 分配一个新的上层页目录表 PUD,然后填充 p4d
  32.     // 如果 p4d 不是空的,则调用 pud_offset 获取 address 在上层页目录 PUD 中的目录项 pud
  33.     vmf.pud = pud_alloc(mm, p4d, address);
  34.     if (!vmf.pud)
  35.         return VM_FAULT_OOM;
  36.   
  37.       ........ 省略 1G 大页缺页处理 ..........
  38.    
  39.     // 首先 pud_none 判断上层页目录项 pud 是不是空的
  40.     // 如果 pud 是空的,则调用 __pmd_alloc 分配一个新的中间页目录表 PMD,然后填充 pud
  41.     // 如果 pud 不是空的,则调用 pmd_offset 获取 address 在中间页目录 PMD 中的目录项 pmd
  42.     vmf.pmd = pmd_alloc(mm, vmf.pud, address);
  43.     if (!vmf.pmd)
  44.         return VM_FAULT_OOM;
  45.       ........ 省略 2M 大页缺页处理 ..........
  46.     // 进行页表的相关处理以及解析具体的缺页原因,后续针对性的进行缺页处理
  47.     return handle_pte_fault(&vmf);
  48. }
复制代码
然后使用 mkswap 命令创建 swap file :
  1. static inline int pmd_none(pmd_t pmd)
  2. {
  3.         unsigned long val = native_pmd_val(pmd);
  4.         return (val & ~_PAGE_KNL_ERRATUM_MASK) == 0;
  5. }
复制代码
当 swap partition 或者 swap file 创建好之后,我们通过 swapon 命令来初始化并激活这个交换区。
  1. #define pte_offset_map(dir, address) pte_offset_kernel((dir), (address))
  2. static inline pte_t *pte_offset_kernel(pmd_t *pmd, unsigned long address)
  3. {
  4.         return (pte_t *)pmd_page_vaddr(*pmd) + pte_index(address);
  5. }
  6. static inline unsigned long pte_index(unsigned long address)
  7. {
  8.         return (address >> PAGE_SHIFT) & (PTRS_PER_PTE - 1);
  9. }
  10. #define PAGE_SHIFT   12
  11. // 页表可以容纳的页表项 pte_t 的个数
  12. #define PTRS_PER_PTE  512
复制代码
当前系统中各个交换区的情况,我们可以通过 cat /proc/swaps 或者 swapon -s 命令产看:

交换区在内核中使用 struct swap_info_struct 结构体来表示,系统中众多的交换区被组织在一个叫做 swap_info 的数组中,数组中的最大长度为 MAX_SWAPFILES,MAX_SWAPFILES 在内核中是一个常量,一般指定为 32,也就是说,系统中最大允许 32 个交换区存在。
  1. unsigned long mmap_region(struct file *file, unsigned long addr,
  2.         unsigned long len, vm_flags_t vm_flags, unsigned long pgoff,
  3.         struct list_head *uf)
  4. {
  5.                   ........ 省略 ........
  6.     // 文件映射
  7.     if (file) {
  8.         // 将文件与虚拟内存映射起来
  9.         vma->vm_file = get_file(file);
  10.         // 这一步中将虚拟内存区域 vma 的操作函数 vm_ops 映射成文件的操作函数(和具体文件系统有关)
  11.         // ext4 文件系统中的操作函数为 ext4_file_vm_ops
  12.         // 从这一刻开始,读写内存就和读写文件是一样的了
  13.         error = call_mmap(file, vma);
  14.         if (error)
  15.             goto unmap_and_free_vma;
  16.         addr = vma->vm_start;
  17.         vm_flags = vma->vm_flags;
  18.     }  else {
  19.         // 这里处理私有匿名映射
  20.         // 将  vma->vm_ops 设置为 null,只有文件映射才需要 vm_ops 这样才能将内存与文件映射起来
  21.         vma_set_anonymous(vma);
  22.     }
  23. }
复制代码
由于交换区是有优先级的,所以内核又会按照优先级高低,将交换区组织在一个叫做 swap_avail_heads  的双向链表中。
  1. static int ext4_file_mmap(struct file *file, struct vm_area_struct *vma)
  2. {
  3.         ........ 省略 ........
  4.         
  5.       vma->vm_ops = &ext4_file_vm_ops;
  6.       
  7.         ........ 省略 ........   
  8. }
复制代码
swap_info_struct 结构用于描述单个交换区中的各种信息:
  1. static inline void vma_set_anonymous(struct vm_area_struct *vma)
  2. {
  3.         vma->vm_ops = NULL;
  4. }
复制代码
而在每个交换区 swap area 内部又会分为很多连续的  slot (槽),每个 slot 的大小刚好和一个物理内存页的大小相同都是 4K,物理内存页在被 swap out 到交换区时,就会存放在 slot 中。
交换区中的这些 slot 会被组织在一个叫做 swap_map 的数组中,数组中的索引就是 slot 在交换区中的 offset (这个位置信息很重要),数组中的值表示该 slot 总共被多少个进程同时引用。
什么意思呢 ? 比如现在系统中一共有三个进程同时共享一个物理内存页(内存中的概念),当这个物理内存页被 swap out 到交换区上时,就变成了 slot (内存页在交换区中的概念),现在物理内存页没了,这三个共享进程就只能在各自的页表中指向这个 slot,因此该 slot 的引用计数就是 3,对应在数组 swap_map 中的值也是 3 。

交换区中的第一个 slot 用于存储交换区的元信息,比如交换区对应底层各个磁盘块的坏块列表。因此笔者将其标注了红色,表示不能使用。
swap_map 数组中的值表示的就是对应 slot 被多少个进程同时引用,值为 0 表示该 slot 是空闲的,下次 swap out 的时候首先查找的就是空闲 slot 。 查找范围就是 lowest_bit 到 highest_bit 之间的 slot。当查找到空闲 slot 之后,就会将整个物理内存页回写到这个 slot 中。
  1. static inline bool vma_is_anonymous(struct vm_area_struct *vma)
  2. {
  3.         return !vma->vm_ops;
  4. }
复制代码
但是这里会有一个问题就是交换区面向的是整个系统,而系统中会有很多进程,如果多个进程并发进行 swap 的时候,swap_map 数组就会面临并发操作的问题,这样一来就不得不需要一个全局锁来保护,但是这也导致了多个 CPU 只能串行访问,大大降低了并发度。
那怎么办呢 ? 想想 JDK 中的 ConcurrentHashMap,将锁分段呗,这样可以将锁竞争分散开来,大大提升并发度。
内核会将 swap_map 数组中的这些 slot,按照常量 SWAPFILE_CLUSTER 指定的个数,256 个 slot 分为一个 cluster。
  1.     // pte 是空的,表示缺页地址 address 还从来没有被映射过,接下来就要处理物理内存的映射
  2.     if (!vmf->pte) {
  3.         // 判断缺页的虚拟内存地址 address 所在的虚拟内存区域 vma 是否是匿名映射区
  4.         if (vma_is_anonymous(vmf->vma))
  5.             // 处理匿名映射区发生的缺页
  6.             return do_anonymous_page(vmf);
  7.         else
  8.             // 处理文件映射区发生的缺页
  9.             return do_fault(vmf);
  10.     }
复制代码
每个 cluster 中包含一把 spinlock_t 锁,如果 cluster 是空闲的,那么 swap_cluster_info 结构中的 data 指向下一个空闲的 cluster,如果 cluster 不是空闲的,那么 data 保存的是该 cluster 中已经分配的 slot 个数。
  1. #define _PAGE_BIT_PRESENT 0 /* is present */
  2. #define _PAGE_PRESENT (_AT(pteval_t, 1) << _PAGE_BIT_PRESENT)
复制代码
这样一来 swap_map 数组中的这些独立的 slot,就被按照以 cluster 为单位重新组织了起来,这些 cluster 被串联在 cluster_info 链表中。
为了进一步利用 cpu cache,以及实现无锁化查找 slot,内核会给每个 cpu 分配一个 cluster —— percpu_cluster,cpu 直接从自己的 cluster 中查找空闲 slot,近一步提高了 swap out 的吞吐。
当 cpu 自己的 percpu_cluster 用尽之后,内核则会调用 swap_alloc_cluster 函数从 free_clusters 中获取一个新的 cluster。
  1. static inline int pte_present(pte_t a)
  2. {
  3.         return pte_flags(a) & (_PAGE_PRESENT | _PAGE_PROTNONE);
  4. }
复制代码

现在交换区的整体布局笔者就为大家介绍完了,可能大家这里有一点还是会比较困惑 —— 你说来说去,这个 slot 到底是个啥 ?
哈哈,大家先别急,我们现在已经对进程的虚拟内存空间非常熟悉了,这里我们把交换区 swap_info_struct 与进程的内存空间 mm_struct 放到一起一对比就很清楚了。
首先进程虚拟内存空间中的虚拟内存别管说的如何天花乱坠,说到底还是要保存在真实的物理内存中的,虚拟内存与物理内存通过页表来关联起来。
同样的道理,别管交换区布局的如何天花乱坠,swap out 出来的数据说到底还是要保存在真实的磁盘中的,而交换区中是按照 slot 为单位进行组织管理的,磁盘中是按照磁盘块来组织管理的,大小都是 4K 。
交换区中的 slot 就好比于虚拟内存空间中的虚拟内存,都是虚拟的概念,物理内存页与磁盘块才是真实本质的东西。
虚拟内存是连续的,但其背后映射的物理内存可能是不连续,交换区中的 slot 也都是连续的,但磁盘中磁盘块的扇区地址却不一定是连续的。页表可以将不连续的物理内存映射到连续的虚拟内存上,内核也需要一种机制,将不连续的磁盘块映射到连续的 slot 中。
当我们使用 swapon 命令来初始化激活交换区时,内核会扫描交换区中各个磁盘块的扇区地址,以确定磁盘块与扇区的对应关系,然后搜集扇区地址连续的磁盘块,将这些连续的磁盘块组成一个块组,slot 就会一个一个的映射到这些块组上,块组之间的扇区地址是不连续的,但是 slot 是连续的。
slot 与连续的磁盘块组的映射关系保存在 swap_extent 结构中:
  1. static inline unsigned long p4d_page_vaddr(p4d_t p4d)
  2. {
  3.     return (unsigned long)__va(p4d_val(p4d) & PTE_PFN_MASK;
  4. }
  5. /* Extracts the PFN from a (pte|pmd|pud|pgd)val_t of a 4KB page */
  6. #define PTE_PFN_MASK        ((pteval_t)PHYSICAL_PAGE_MASK)
  7. #define PHYSICAL_PAGE_MASK  (((signed long)PAGE_MASK) & __PHYSICAL_MASK)
复制代码
由于一个块组内的磁盘块都是连续的,slot 本来又是连续的,所以 swap_extent 结构中只需要保存映射到该块组内第一个 slot 的编号 (start_page),块组内第一个磁盘块在磁盘上的块号,以及磁盘块个数就可以了。
虚拟内存页类比 slot,物理内存页类比磁盘块,这里的 swap_extent 可以看做是虚拟内存区域 vma,进程的虚拟内存空间正是由一段一段的 vma 组成,这些 vma 被组织在一颗红黑树上。
交换区也是一样,它是由一段一段的 swap_extent 组成,同样也会被组织在一颗红黑树上。我们可以通过 slot 在交换区中的 offset,在这颗红黑树中快速查找出 slot 背后对应的磁盘块。
  1. #define PTE_FLAGS_MASK                (~PTE_PFN_MASK)
  2. static inline pteval_t pte_flags(pte_t pte)
  3. {
  4.         return native_pte_val(pte) & PTE_FLAGS_MASK;
  5. }
  6. static inline pteval_t native_pte_val(pte_t pte)
  7. {
  8.         return pte.pte;
  9. }
复制代码

现在交换区内部的样子,我们已经非常清楚了,有了这些背景知识之后,我们在回过头来看本小节最开始提出的问题 —— swp_entry_t 到底长什么样子。
10.2 一睹 swp_entry_t 真容


匿名内存页在被内核 swap out 到磁盘上之后,内存页中的内容保存在交换区的 slot 中,在 swap in 的场景中,内核需要根据 swp_entry_t 里的信息找到这个 slot,进而找到其对应的磁盘块,然后从磁盘块中读取出被 swap out 出去的内容。
这个就和交换区的布局有很大的关系,首先系统中存在多个交换区,这些交换区被内核组织在 swap_info 数组中。
  1. unsigned long mmap_region(struct file *file, unsigned long addr,
  2.         unsigned long len, vm_flags_t vm_flags, unsigned long pgoff,
  3.         struct list_head *uf)
  4. {
  5.                   ........ 省略 ........
  6.     // 文件映射
  7.     if (file) {
  8.         // 将文件与虚拟内存映射起来
  9.         vma->vm_file = get_file(file);
  10.         // 这一步中将虚拟内存区域 vma 的操作函数 vm_ops 映射成文件的操作函数(和具体文件系统有关)
  11.         // ext4 文件系统中的操作函数为 ext4_file_vm_ops
  12.         // 从这一刻开始,读写内存就和读写文件是一样的了
  13.         error = call_mmap(file, vma);
  14.         if (error)
  15.             goto unmap_and_free_vma;
  16.         addr = vma->vm_start;
  17.         vm_flags = vma->vm_flags;
  18.     }  else {
  19.         // 这里处理私有匿名映射
  20.         // 将  vma->vm_ops 设置为 null,只有文件映射才需要 vm_ops 这样才能将内存与文件映射起来
  21.         vma_set_anonymous(vma);
  22.     }
  23. }
复制代码
我们首先需要知道匿名内存页到底被 swap out 到哪个交换区里了,所以 swp_entry_t 里必须包含交换区在 swap_info 数组中的索引,而这个索引正是 swap_info_struct 结构中的 type 字段。
  1. #define _PAGE_PROTNONE        (_AT(pteval_t, 1) << _PAGE_BIT_PROTNONE)
  2. #define _PAGE_BIT_PROTNONE        _PAGE_BIT_GLOBAL
  3. #define _PAGE_BIT_GLOBAL        8
复制代码
在确定了交换区的位置后,我们需要知道匿名页被 swap out 到交换区中的哪个 slot 中,所以 swp_entry_t 中也必须包含 slot 在交换区中的 offset,这个 offset 就是 swap_info_struct 结构里 slot 所在 swap_map 数组中的下标。
  1. static inline int pte_protnone(pte_t pte)
  2. {
  3.         return (pte_flags(pte) & (_PAGE_PROTNONE | _PAGE_PRESENT))
  4.                 == _PAGE_PROTNONE;
  5. }
复制代码
所以总结下来 swp_entry_t 中需要包含以下三种信息:
第一, swp_entry_t 需要标识该页表项是一个 pte 还是 swp_entry_t,因为它俩本质上是一样的,都是 unsigned long 类型的无符号整数,是可以相互转换的。
  1. static inline bool vma_is_accessible(struct vm_area_struct *vma)
  2. {
  3.         return vma->vm_flags & (VM_READ | VM_EXEC | VM_WRITE);
  4. }
复制代码
第 0 个比特位置 1 表示是一个 pte,背后映射的物理内存页存在于内存中。如果第 0 个比特位置 0 则表示该 pte 背后映射的物理内存页已经被 swap out 出去了,那么它就是一个 swp_entry_t,指向内存页在交换区中的位置。
第二,swp_entry_t 需要包含被 swap 出去的匿名页所在交换区的索引 type,第 2 个比特位到第 7 个比特位,总共使用 6 个比特来表示匿名页所在交换区的索引。
第三,swp_entry_t 需要包含匿名页所在 slot 的位置 offset,第 8 个比特位到第 57 个比特位,总共 50 个比特来表示匿名页对应的 slot 在交换区的 offset 。
  1.   if (pte_protnone(vmf->orig_pte) && vma_is_accessible(vmf->vma))
  2.         return do_numa_page(vmf);
复制代码
内核提供了宏 __swp_type 用于从  swp_entry_t 中将匿名页所在交换区编号提取出来,还提供了宏 __swp_offset 用于从 swp_entry_t 中将匿名页所在 slot 的 offset 提取出来。
  1. echo 0 > /proc/sys/kernel/numa_balancing
  2. sysctl -w kernel.numa_balancing=0
复制代码
而 swap partition 是一个没有文件系统的裸磁盘分区,其背后的磁盘块都是连续分布的,所以对于 swap partition 来说,slot 与磁盘块是直接映射的,我们获取到 slot 的 offset 之后,在乘以一个固定的偏移 2 ^ PAGE_SHIFT - 9 跳过用于存储交换区元信息的 swap header ,就可以直接获得磁盘块了。
这里有点像 《深入理解 Linux 虚拟内存管理》 一文中提到的内核虚拟内存空间中的直接映射区,虚拟内存与物理内存都是直接映射的,通过虚拟内存地址减去一个固定的偏移直接就可以获得物理内存地址了。
  1. static inline unsigned long
  2. copy_one_pte(struct mm_struct *dst_mm, struct mm_struct *src_mm,
  3.         pte_t *dst_pte, pte_t *src_pte, struct vm_area_struct *vma,
  4.         unsigned long addr, int *rss)
  5. {
  6.     /*
  7.      * If it's a COW mapping, write protect it both
  8.      * in the parent and the child
  9.      */
  10.     if (is_cow_mapping(vm_flags) && pte_write(pte)) {
  11.         // 设置父进程的 pte 为只读
  12.         ptep_set_wrprotect(src_mm, addr, src_pte);
  13.         // 设置子进程的 pte 为只读
  14.         pte = pte_wrprotect(pte);
  15.     }
  16.     // 获取 pte 中映射的物理内存页(此时父子进程共享该页)
  17.     page = vm_normal_page(vma, addr, pte);
  18.     // 物理内存页的引用计数 + 1
  19.     get_page(page);
  20. }
复制代码
swap_readpage 是内核 swap 机制的最底层实现,直接和磁盘打交道,负责搭建磁盘与内存之间的桥梁。虽然直接调用 swap_readpage 可以基本完成 swap in 的目的,但在某些特殊情况下会导致 swap 的性能非常糟糕。
比如下图所示,假设当前系统中存在三个进程,它们共享引用了同一个物理内存页 page。

当这个被共享的 page 被内核 swap out 到交换区之后,三个共享进程的页表会发生如下变化:

当 进程1 开始读取这个共享 page 的时候,由于 page 已经 swap out 到交换区了,所以会发生 swap 缺页异常,进入内核通过 swap_readpage 将共享  page 的内容从磁盘中读取进内存,此时三个进程的页表结构变为下图所示:

现在共享 page 已经被 进程1 swap in 进来了,但是 进程2 和 进程 3 是不知道的,它们的页表中还储存的是 swp_entry_t,依然指向 page 所在交换区的位置。
按照之前的逻辑,当 进程2 以及 进程3 开始读取这个共享 page 的时候,其实 page 已经在内存了,但是它们此刻感知不到,因为 进程2 和 进程3 的页表中存储的依然是 swp_entry_t,还是会产生 swap 缺页中断,重新通过 swap_readpage 读取交换区中的内容,这样一来就产生了额外重复的磁盘 IO。
除此之外,更加严重的是,由于 进程2 和 进程3 的 swap 缺页,又会产生两个新的内存页用来存放从 swap_readpage 中读取进来的交换区数据。
产生了重复的磁盘 IO 不说,还产生了额外的内存消耗,并且这样一来,三个进程对内存页就不是共享的了。
还有一种极端场景是一个进程试图读取一个正在被 swap out 的 page ,由于 page 正在被内核 swap out,此时进程页表指向该 page 的 pte 已经变成了 swp_entry_t。
进程在这个时候访问 page 的时候,还是会产生 swap 缺页异常,进程试图 swap in 这个正在被内核 swap out 的 page,但是此时 page 仍然还在内存中,只不过是正在被内核刷盘。
而按照之前的 swap in 逻辑,进程这里会调用 swap_readpage 从磁盘中读取,产生额外的磁盘 IO 以及内存消耗不说,关键是此刻 swap_readpage 出来的数据都不是完整的,这肯定是个大问题。
内核为了解决上面提到的这些问题,因此引入了一个新的结构 —— swap cache 。
10.3 swap cache

有了 swap cache 之后,情况就会变得大不相同,我们在回过头来看第一个问题 —— 多进程共享内存页。
进程1 在 swap in 的时候首先会到 swap cache 中去查找,看看是否有其他进程已经把内存页 swap in 进来了,如果  swap  cache 中没有才会调用 swap_readpage 从磁盘中去读取。
当内核通过 swap_readpage 将内存页中的内容从磁盘中读取进内存之后,内核会把这个匿名页先放入 swap cache 中。进程 1 的页表将原来的 swp_entry_t 填充为 pte 并指向 swap cache 中的这个内存页。

由于进程1 页表中对应的页表项现在已经从 swp_entry_t 变为 pte 了,指向的是 swap  cache 中的内存页而不是 swap 交换区,所以对应 slot 的引用计数就要减 1 。
还记得我们之前介绍的 swap_map 数组吗 ?slot 被进程引用的计数就保存在这里,现在这个 slot 在 swap_map 数组中保存的引用计数从 3 变成了 2 。表示还有两个进程也就是 进程2 和 进程3 仍在继续引用这个 slot 。

当进程2 发生 swap 缺页中断的时候进入内核之后,也是首先会到 swap cache 中查找是否现在已经有其他进程把共享的内存页 swap in 进来了,内存页 page 在 swap cache 的索引就是页表中的 swp_entry_t。由于这三个进程共享的同一个内存页,所以三个进程页表中的 swp_entry_t 都是相同的,都是指向交换区的同一位置。
由于共享内存页现在已经被 进程1 swap in 进来了,并存放在 swap cache 中,所以 进程2 通过 swp_entry_t 一下就在 swap cache 中找到了,同理,进程 2 的页表也会将原来的 swp_entry_t 填充为 pte 并指向 swap cache 中的这个内存页。slot 的引用计数减 1。

现在这个 slot 在 swap_map 数组中保存的引用计数从 2 变成了 1 。表示只有 进程3 在引用这个 slot 了。
当 进程3 发生 swap 缺页中断的之后,内核还是先通过 swp_entry_t 到 swap cache 中去查找,找到之后,将 进程 3 页表原来的 swp_entry_t 填充为 pte 并指向 swap cache 中的这个内存页,slot 的引用计数减 1。
现在 slot 的引用计数已经变为 0 了,这意味着所有共享该内存页的进程已经全部知道了新内存页的地址,它们的 pte 已经全部指向了新内存页,不在指向 slot 了,此时内核便将这个内存页从 swap cache 中移除。

针对第二个问题 —— 进程试图 swap in 这个正在被内核 swap out 的 page,内核的处理方法也是一样,内核在 swap out 的时候首先会在交换区中为这个 page 分配 slot 确定其在交换区的位置,然后通过之前文章 《深入理解 Linux 物理内存管理》
介绍的匿名页反向映射机制找到所有引用该内存页的进程,将它们页表中的 pte 修改为指向 slot 的 swp_entry_t。
然后将匿名页 page 先是放入到 swap cache 中,慢慢地通过 swap_writepage 回写。当匿名页被完全回写到交换区中时,内核才会将 page 从 swap cache 中移除。

如果当内核正在回写的过程中,不巧有一个进程又要访问该内存页,同样也会发生 swap 缺页中断,但是由于此时没有回写完成,内存页还保存在 swap cache 中,内核通过进程页表中的 swp_entry_t 一下就在 swap cache 中找到了,避免了再次发生磁盘 IO,后面的过程就和第一个问题一样了。
上述查找 swap cache 的过程。内核封装在 __read_swap_cache_async 函数里,在 swap in 的过程中,内核会首先调用这里查看 swap cache 是否已经缓存了内存页,如果没有,则新分配一个内存页并加入到 swap cache 中,最后才会调用 swap_readpage 从磁盘中将所需内容读取到新内存页中。
  1.     // 判断本次缺页是否为写时复制引起的
  2.     if (vmf->flags & FAULT_FLAG_WRITE) {
  3.         // 这里说明 vma 是可写的,但是 pte 被标记为不可写,说明是写保护类型的中断
  4.         if (!pte_write(entry))
  5.             // 进行写时复制处理,cow 就发生在这里
  6.             return do_wp_page(vmf);
  7.     }
复制代码
前面我们提到,Linux 系统中同时允许多个交换区存在,内核将这些交换区组织在 swap_info 数组中。
  1. unsigned long mmap_region(struct file *file, unsigned long addr,
  2.         unsigned long len, vm_flags_t vm_flags, unsigned long pgoff,
  3.         struct list_head *uf)
  4. {
  5.                   ........ 省略 ........
  6.     // 文件映射
  7.     if (file) {
  8.         // 将文件与虚拟内存映射起来
  9.         vma->vm_file = get_file(file);
  10.         // 这一步中将虚拟内存区域 vma 的操作函数 vm_ops 映射成文件的操作函数(和具体文件系统有关)
  11.         // ext4 文件系统中的操作函数为 ext4_file_vm_ops
  12.         // 从这一刻开始,读写内存就和读写文件是一样的了
  13.         error = call_mmap(file, vma);
  14.         if (error)
  15.             goto unmap_and_free_vma;
  16.         addr = vma->vm_start;
  17.         vm_flags = vma->vm_flags;
  18.     }  else {
  19.         // 这里处理私有匿名映射
  20.         // 将  vma->vm_ops 设置为 null,只有文件映射才需要 vm_ops 这样才能将内存与文件映射起来
  21.         vma_set_anonymous(vma);
  22.     }
  23. }
复制代码
内核会为系统中每一个交换区分配一个 swap cache,被内核组织在一个叫做 swapper_spaces 的数组中。交换区的 swap cache 在 swapper_spaces 数组中的索引也是 swp_entry_t 中存储的 type 信息,通过 swp_type  来提取。
  1. #define pte_alloc(mm, pmd) (unlikely(pmd_none(*(pmd))) && __pte_alloc(mm, pmd))
复制代码
这里我们可以看到,交换区的 swap cache 和文件的 page cache 一样,都是 address_space 结构来描述的,而对于 swap file 来说,因为它本质上是文件系统里的一个文件,所以 swap file 既有 swap cache 也有 page cache 。
这里大家需要区分 swap file 的 swap cache 和 page cache,前面在介绍 swap_readpage 函数的时候,笔者也提过,swap file 的 page cache 在 swap 的场景中是不会缓存内存页的,内核只是利用 page cache 相关的操作函数 ——  address_space->a_ops ,从 swap file 所在的文件系统中读取或者写入匿名页,匿名页是不会加入到 page cache 中的。
而交换区是针对整个系统来说的,系统中会存在很多进程,当发生 swap 的时候,系统中的这些进程会对同一个 swap cache 进行争抢,所以为了近一步提高 swap 的并行度,内核会将一个交换区中的 swap cache 分裂多个出来,将竞争的压力分散开来。
这样一来,一个交换就演变出多个 swap cache 出来,swapper_spaces 数组其实是一个 address_space 结构的二维数组。每个 swap cache 能够管理的匿名页个数为 2^SWAP_ADDRESS_SPACE_SHIFT 个,涉及到的内存大小为 4K * SWAP_ADDRESS_SPACE_PAGES —— 64M。
  1. static inline void pmd_populate(struct mm_struct *mm, pmd_t *pmd,
  2.                 struct page *pte)
  3. {
  4.     // 通过页表 page 获取对应的 pfn
  5.     unsigned long pfn = page_to_pfn(pte);
  6.     // 将页表 page 的 pfn 以及初始权限位 _PAGE_TABLE 填充到 pmd 中
  7.     set_pmd(pmd, __pmd(((pteval_t)pfn << PAGE_SHIFT) | _PAGE_TABLE));
  8. }
复制代码
当我们通过 swapon 命令来初始化并激活一个交换区的时候,内核会在 init_swap_address_space 函数中为交换区初始化 swap cache。
  1. int __pte_alloc(struct mm_struct *mm, pmd_t *pmd)
  2. {
  3.     spinlock_t *ptl;
  4.     // 调用 get_zeroed_page 申请一个 4k 物理内存页并初始化为 0 值作为新的 页表
  5.     // new 指向新分配的 页表 起始内存地址
  6.     pgtable_t new = pte_alloc_one(mm);
  7.     if (!new)
  8.         return -ENOMEM;
  9.     // 锁定中间页目录项 pmd
  10.     ptl = pmd_lock(mm, pmd);
  11.     // 如果 pmd 是空的,说明此时 pmd 并未指向页表,下面就需要用新页表 new 来填充 pmd
  12.     if (likely(pmd_none(*pmd))) {  
  13.         // 更新 mm->pgtables_bytes 计数,该字段用于统计进程页表所占用的字节数
  14.         // 由于这里新增了一张页表,所以计数需要增加 PTRS_PER_PTE * sizeof(pte_t)
  15.         mm_inc_nr_ptes(mm);
  16.         // 将 new 指向的新分配出来的页表 page 的 pfn 以及相关初始权限位填充到 pmd 中
  17.         pmd_populate(mm, pmd, new);
  18.         new = NULL;
  19.     }
  20.     spin_unlock(ptl);
  21.     return 0;
  22. }
  23. // 页表可以容纳的页表项 pte_t 的个数
  24. #define PTRS_PER_PTE  512
复制代码
这里我们可以看到,对于 swap cache 来说,内核会将 address_space-> a_ops 初始化为 swap_aops。
  1. #define mk_pte(page, pgprot)   pfn_pte(page_to_pfn(page), (pgprot))
复制代码
10.4 swap 预读


现在我们已经清楚了当进程虚拟内存空间中的某一段 vma 发生 swap 缺页异常之后,内核的 swap in 核心处理流程。但是整个完整的 swap 流程还没有结束,内核还需要考虑内存访问的空间局部性原理。
当进程访问某一段内存的时候,在不久之后,其附近的内存地址也将被访问。对应于本小节的 swap 场景来说,当进程地址空间中的某一个虚拟内存地址 address 被访问之后,那么其周围的虚拟内存地址在不久之后,也会被进程访问。
而那些相邻的虚拟内存地址,在进程页表中对应的页表项也都是相邻的,当我们处理完了缺页地址 address 的 swap 缺页异常之后,如果其相邻的页表项均是 swp_entry_t,那么这些相邻的 swp_entry_t 所指向交换区的内容也需要被内核预读进内存中。
这样一来,当 address 附近的虚拟内存地址发生 swap 缺页的时候,内核就可以直接从 swap cache 中读到了,避免了磁盘 IO,使得 swap in 可以快速完成,这里和文件的预读机制有点类似。
swap 预读在 Linux 内核中由 swapin_readahead 函数负责,它有两种实现方式:
第一种是根据缺页地址 address 周围的虚拟内存地址进行预读,但前提是它们必须属于同一个 vma,这个逻辑在 swap_vma_readahead 函数中完成。
第二种是根据内存页在交换区中周围的磁盘地址进行预读,但前提是它们必须属于同一个交换区,这个逻辑在 swap_cluster_readahead 函数中完成。
  1.   if (vma->vm_flags & VM_WRITE)
  2.         entry = pte_mkwrite(pte_mkdirty(entry));
复制代码
在本小节介绍的 swap 缺页场景中,内核是按照缺页地址周围的虚拟内存地址进行预读的。在函数 swap_vma_readahead 的开始,内核首先调用 swap_ra_info 方法来计算本次需要预读的页表项集合。
预读的最大页表项个数由 page_cluster 决定,但最大不能超过 2 ^ SWAP_RA_ORDER_CEILING。
  1. #define pte_offset_map_lock(mm, pmd, address, ptlp) \
  2. ({                          \
  3.     // 获取 pmd 映射的一级页表锁
  4.     spinlock_t *__ptl = pte_lockptr(mm, pmd);   \
  5.     // 获取 pte
  6.     pte_t *__pte = pte_offset_map(pmd, address);    \
  7.     *(ptlp) = __ptl;                \
  8.     // 锁定一级页表
  9.     spin_lock(__ptl);               \
  10.     __pte;                      \
  11. })
复制代码
这样一来,经过 swap_vma_readahead 预读之后,缺页内存地址 address 周围的页表项所指向的内存页就全部被加载到 swap cache 中了。

当进程下次访问 address 周围的内存地址时,虽然也会发生 swap 缺页异常,但是内核直接从 swap cache 中就可以读取到了,避免了磁盘 IO。
10.5 还原 do_swap_page 完整面貌


当我们明白了前面介绍的这些背景知识之后,再回过头来看内核完整的 swap in 过程就很清晰了

  • 首先内核会通过 pte_to_swp_entry 将进程页表中的 pte 转换为 swp_entry_t
  • 通过 lookup_swap_cache 根据 swp_entry_t 到 swap cache 中查找是否已经有其他进程将内存页 swap 进来了。
  • 如果 swap cache 没有对应的内存页,则调用 swapin_readahead 启动预读,在这个过程中,内核会重新分配物理内存页,并将这个物理内存页加入到 swap cache 中,随后通过 swap_readpage 将交换区的内容读取到这个内存页中。
  • 现在我们需要的内存页已经 swap in 到内存中了,后面的流程就和普通的缺页处理一样了,根据 swap in 进来的内存页地址重新创建初始化一个新的 pte,然后用这个新的 pte,将进程页表中原来的  swp_entry_t 替换掉。
  • 为新的内存页建立反向映射关系,加入 lru active list 中,最后 swap_free 释放交换区中的资源。
  1.     if (!pte_none(*vmf->pte))
  2.         goto release;
复制代码
总结


本文我们介绍了 Linux 内核如何通过缺页中断将进程页表从 0 到 1 一步一步的完整构建出来。从进程虚拟内存空间布局的角度来讲,缺页中断主要分为两个方面:

  • 内核态缺页异常处理 —— do_kern_addr_fault,这里主要是处理 vmalloc 虚拟内存区域的缺页异常,其中涉及到主内核页表与进程页表内核部分的同步问题。
  • 用户态缺页异常处理 —— do_user_addr_fault,其中涉及到的主内容是如何从 0 到 1 一步一步构建完善进程页表体系。
总体上来讲引起缺页中断的原因分为两大类:

  • 第一类是缺页虚拟内存地址背后映射的物理内存页不在内存中
  • 第二类是缺页虚拟内存地址背后映射的物理内存页在内存中。
第一类缺页中断的原因涉及到三种场景:

  • 缺页虚拟内存地址 address 在进程页表中间页目录对应的页目录项 pmd_t 是空的。
  • 缺页地址 address 对应的 pmd_t 虽然不是空的,页表也存在,但是 address 对应在页表中的 pte 是空的。
  • 虚拟内存地址 address 在进程页表中的页表项 pte 不是空的,但是其背后映射的物理内存页被内核 swap out 到磁盘上了。
第二类缺页中断的原因涉及到两种场景:

  • NUMA Balancing。
  • 写时复制了(Copy On Write, COW)。
最后我们介绍了内核整个 swap in 的完整过程,其中涉及到的重要内容包括交换区的布局以及在内核中的组织结构,swap cache 与 page cache 之间的区别,swap 预读机制。
好了,今天的内容到这里就结束了,感谢大家的收看,我们下篇文章见~~~~

来源:https://www.cnblogs.com/binlovetech/p/17918733.html
免责声明:由于采集信息均来自互联网,如果侵犯了您的权益,请联系我们【E-Mail:cb@itdo.tech】 我们会及时删除侵权内容,谢谢合作!

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x

举报 回复 使用道具