一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射

笔者之前在自己的专栏《聊聊 Linux 内核》 里通过大量的篇幅写了一个系列关于内存管理相关的文章,在这个系列文章中,笔者分别通过虚拟内存管理和物理内存管理两个角度算是把 Linux 内存管理子系统的全貌给大家呈现了出来。

但之前的文章都是以专题的形式给大家呈现,采用一种静态的方式来专项阐述虚拟内存管理和物理内存管理,而且内容庞大,知识点密集。所以笔者这次想让虚拟内存和物理内存两者一起动态联动起来,在这个联动的过程中将之前的这些静态知识点统统串联起来,形成一条内存管理的主线。方便大家日后可以拎着这条主线,串联回顾内存管理这些庞大且繁杂的内容。

经过《深入理解 Linux 虚拟内存管理》 一文的介绍我们知道,虚拟内存其实是 CPU 和操作系统使用的一个障眼法,联手给进程编织了一个假象,让进程误以为自己独占了全部的内存空间:

  • 在 32 位系统中,进程以为自己独占了 3G 的内存空间。

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第1张图片

  • 在 64 位系统中,进程以为自己独占了 128T 的内存空间。

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第2张图片

这么做的好处是,操作系统为每个进程营造出一片独立的虚拟地址空间,使得进程与进程之间相互隔离,互不干扰的,解决了多进程同时运行时产生的内存地址冲突问题。

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第3张图片

同时虚拟内存还提供了系统安全方面的保障,会对进程访问内存的行为进行相关的安全权限检查,保障了系统的稳定性和安全性。比如:

  • 有些物理内存页只允许内核来访问,进程在用户态的时候是无法访问的。
  • 虚拟内存中保存了访问其映射的物理内存相关的权限,进程只能执行规定权限范围内的访存操作。比如,上面虚拟内存空间里代码段的权限是可读,可执行,但是不可写。数据段具有可读可写的权限但是不可执行。堆则具有可读可写,可执行的权限(Java 中的字节码存储在堆中,所以需要可执行权限),栈一般是可读可写的权限,一般很少有可执行权限。而文件映射与匿名映射区存放了共享链接库,所以也需要可执行的权限。

但是当程序运行起来之后,程序中所需要的数据本质上还是保存在物理内存中的,无论操作系统对虚拟内存设计的多么精彩,最终虚拟内存空间中每一个虚拟内存地址都是要映射到物理内存空间的中某一个特定物理内存地址上的。

进程虚拟内存空间中的每一个字节都有与其对应的虚拟内存地址,同样物理内存空间中每一个字节都有与其对应的物理内存地址。

下面我们就来把舞台上的桌布拿走,一起到内核中探秘一下 CPU 和操作系统联手编织的这个障眼戏法是如何玩转起来的~~~~

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第4张图片

1. 虚拟内存如何与物理内存映射起来

《深入理解 Linux 物理内存管理》一文中,笔者在介绍物理内存管理的时候曾提到,内核会将整个物理内存空间划分为一页一页大小相同的的内存块,每个内存块大小为 4K,称为一个物理内存页。

一页大小的内存块在内核中用 struct page 结构体来进行管理,struct page 中封装了每页内存块的状态信息,比如:组织结构,使用信息,统计信息,以及与其他内核结构的关联映射信息等。

内核会为每个物理内存页 page 进行统一编号。这个编号称之为 PFN(Page Frame Number),PFN 与 struct page 是一一对应的关系并且全局唯一

然后内核会将划分出来的这些一页一页的内存块统一组织在一个全局数组 mem_map 中管理。后续虚拟内存与物理内存的映射以及调度均是以页为单位进行的。

typedef struct pglist_data {
    // NUMA 节点id
    int node_id;
    // 指向 NUMA 节点内管理所有物理页 page 的数组
    struct page *node_mem_map;
}

既然物理内存是以页为单位进行管理,而虚拟内存最终是要映射到物理内存上的,所以在虚拟内存空间中也有与之相对应的虚拟页这个概念,内存的映射是以页为单位进行的。

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第5张图片

如上图所示,在内存映射的场景中,虚拟内存页的类型总共分为以下三种:

  1. 第一种就是图中灰色方框里标注的未分配页面,进程的虚拟内存空间是非常庞大的,远远的超过物理内存空间,但这并不意味着进程可以直接随意使用虚拟内存,事实上进程对虚拟内存的使用也是需要向内核申请的。进程虚拟内存空间中的虚拟内存页在未被进程申请之前的状态就是未分配页面。
  2. 第二种就是图中紫色方框里标注的已分配未映射页面,我们在进程中可以通过动态链接库 glic 中的 malloc 接口或者直接通过系统调用 mmap 向内核申请虚拟内存,申请到的虚拟内存页此时就变为了已分配的页面。但此时的虚拟内存页只是虚拟内存,其背后并没有与物理内存映射起来,所以称为已分配未映射页面。
  3. 第三种是图中绿色方框里标注的正常页面,当进程开始读写这些已分配未映射的虚拟内存页时,在 CPU 中用于地址翻译的硬件 MMU 会产生一个缺页中断,随后内核会为其分配相应的物理内存页面,并将虚拟内存页与物理内存页映射起来。此时这些已分配未映射的虚拟内存页就变为了正常页面。从此以后,进程就可以正常读写这些虚拟内存页了。
MMU 负责将虚拟内存地址翻译为物理内存地址,笔者后面会详细介绍这个地址翻译过程。

明白了这些之后,我们再来看上面这副内存映射图,从图中我们可以读出以下几种信息:

  1. 每个进程独占全部的虚拟内存空间,比如上图中,进程 1 的虚拟内存空间(蓝色部分)和进程 2 的虚拟内存空间(黄色部分)它们都拥有属于各自的虚拟内存页1 到虚拟内存页 7 这段范围的虚拟内存。也就是说进程1 和进程 2 看到的虚拟内存空间地址范围都是一样的。
  2. 每个进程的虚拟内存空间都是相互隔离,互不干扰的,进程可以在属于自己的虚拟内存空间里随意折腾。比如上图中,进程 1 里的虚拟内存页 1 是一个未分配页面,而进程 2 里的虚拟内存页 1 却是一个正常页面,被内核映射到物理内存页 2 中。也就是说虽然每个进程拥有的虚拟内存地址空间范围是一样的,但是各自虚拟内存空间中的虚拟页可能映射的物理页不一样,使用的方式和用途也不一样。
  3. 进程所看到的连续虚拟内存,在物理内存中有可能是不连续的,比如上图中,进程 1 里的虚拟页 4 和 虚拟页 5,它们在进程 1 的虚拟内存空间中是连续的,但是它们背后映射的物理内存页却是不连续的。虚拟内存页 4 被映射到了物理内存页 1 中,虚拟内存页 5 被映射到了物理内存页 4 中。
  4. 物理内存空间中蓝色部分是进程 1 正在使用的内存(物理页 1,物理页 4,物理页 7),黄色部分是进程 2 正在使用的内存(物理页 2,物理页 3,物理页 6)。这些复杂且琐碎的内存映射细节统统由内存管理子系统进行管理,从而极大的解放了程序员的心智负担。

现在让我们把视角从进程的虚拟内存空间切换到内核中的内存管理系统中,来看一下内核是如何管理这些内存映射关系的。

谈到映射,我们自然会想到 Map 这个数据结构,那么虚拟内存与物理内存之间的映射关系如果用 Map 来表达的话,就是如下形式:

 Map<虚拟内存,物理内存>

如果我们给上面那副图加上 Map 映射关系的话,就演变成了这样:

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第6张图片

Map<虚拟内存,物理内存> 的映射关系在内核中是被一个叫做页表的东西来管理的,页表除了管理虚拟内存与物理内存之间的映射关系之外,还会有一些访问权限的管理,来控制进程对物理内存的访问权限。

由于进程是独占虚拟内存空间的,而且不同进程之间的虚拟内存空间是相互隔离的,所以每个进程也都会有属于自己的页表,来专门管理各自虚拟内存空间中的映射关系以及各自访问物理内存的权限。

好了,现在我们已经大概清楚了虚拟内存与物理内存映射的一个总体框架了,当我们有了一个全局视角之后,下面我们就来深入到细节中,来看看内核究竟如何通过一张页表来管理这些内存映射关系以及访问权限的。

2. 内核如何通过页表来管理内存映射关系

我们都知道内核对物理内存的管理是按照页为基本单位进行的,进程运行起来所需要的数据也是存储在一个一个的物理页中,既然物理内存页可以存储进程的普通数据,那么它也一定可以存储进程虚拟内存与物理内存之间的映射关系。

事实上,内核也是这么干的,内核会从物理内存空间中拿出一个物理内存页来专门存储进程里的这些内存映射关系,而这种物理内存页我们将其称之为页表,从这里可以看出页表的本质其实就是一个物理内存页。

而内核会在页表中划分出来一个个大小相等的小内存块,这些小内存块我们称之为页表项 PTE(Page Table Entry),正是这个 PTE 保存了进程虚拟内存空间中的虚拟页与物理内存页的映射关系,以及控制物理内存访问的相关权限位。

在 32 位系统中页表中的 PTE 占用 4 个字节,64 位系统中页表的 PTE 占用 8 个字节。

因为内存映射的粒度是按照页为单位进行的,所以进程虚拟内存空间中的每个虚拟页在页表中都会有一个 PTE 与之对应,而虚拟页背后映射的物理内存页的起始地址就保存在 PTE 中。

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第7张图片

而进程虚拟内存空间中的每一个字节都有一个虚拟内存地址来表示,格式为:页表内偏移 + 物理内存页内偏移

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第8张图片

因为上文已经说了,进程虚拟内存空间中的每一个虚拟页在页表中都会有一个 PTE 与之对应,专门用来存储该虚拟页背后映射的物理内存页的起始地址。

上述虚拟内存地址格式中的 页表内偏移 就是专门用来定位虚拟内存页在页表中的 PTE 的,因为页表本质其实还是一个物理内存页,而一个物理内存页里边的内存肯定都是连续的,每个 PTE 的尺寸又是相同的,所以我们可以把页表看做一个数组,PTE 看做数组里的元素,在一个数组里定位元素,我们直接通过元素的索引 index 就可以定位了。这个索引 index 就是 页表内偏移

这样一来,给定一个虚拟内存地址,内核会先从这个虚拟内存地址中提取出 页表内偏移 ,然后根据 页表起始地址 + 页表内偏移 * sizeof(PTE) 就能获取到该虚拟内存地址所在虚拟页在页表中对应的 PTE 了。

这里大家可能会有一个疑问,页表内偏移我们可以从虚拟内存地址中获取,那这个页表起始地址我们该从哪里获取呢 ?

进程的虚拟内存空间在内核中是用 struct mm_struct 结构来描述的,每个进程都有自己独立的虚拟内存空间,而进程的虚拟内存到物理内存的映射也是独立的,为了保证每个进程里内存映射的独立进行,所以每个进程都会有独立的页表,而页表的起始地址就存放在 struct mm_struct 结构中的 pgd 属性中。

事实上,mm_struct->pgd 存放的是进程的顶级页表的起始地址,而为了让大家清晰的理解整个内存映射的过程,所以笔者在本小节中只讨论单级页表的情形,在这里单级页表的语义就是顶级页表。
struct mm_struct {
  // 当前进程顶级页表的起始地址
  pgd_t * pgd;
}

而进程的顶级页表起始地址 pgd 又是在什么时候被内核设置进去的呢?

很显然这个设置的时机是在进程被创建出来的时候,当我们使用 fork 系统调用创建进程的时候,内核在 _do_fork 函数中会通过 copy_process 将父进程的所有资源拷贝到子进程中,这其中也包括父进程的虚拟内存空间。

long _do_fork(unsigned long clone_flags,
       unsigned long stack_start,
       unsigned long stack_size,
       int __user *parent_tidptr,
       int __user *child_tidptr,
       unsigned long tls)
{
              ......... 省略 ..........
     struct pid *pid;
     struct task_struct *p;

              ......... 省略 ..........
    // 拷贝父进程的所有资源
     p = copy_process(clone_flags, stack_start, stack_size,
         child_tidptr, NULL, trace, tls, NUMA_NO_NODE);

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

copy_process 函数开始拷贝父进程中的所有资源到子进程中:

static __latent_entropy struct task_struct *copy_process(
     unsigned long clone_flags,
     unsigned long stack_start,
     unsigned long stack_size,
     int __user *child_tidptr,
     struct pid *pid,
     int trace,
     unsigned long tls,
     int node)
{

    struct task_struct *p;
    // 为进程创建 task_struct 结构
    p = dup_task_struct(current, node);

        ....... 初始化子进程 ...........

        ....... 开始拷贝父进程资源  .......      

    // 拷贝父进程的虚拟内存空间以及页表
    retval = copy_mm(clone_flags, p);

        ......... 省略拷贝父进程的其他资源 .........

    // 分配 CPU
    retval = sched_fork(clone_flags, p);
    // 分配 pid
    pid = alloc_pid(p->nsproxy->pid_ns_for_children);

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

copy_mm 函数负责处理子进程虚拟内存空间的初始化工作,它会调用 dup_mm 函数,最终在 dup_mm 函数中将父进程虚拟内存空间的所有内容包括父进程的相关页表全部拷贝到子进程中,其中就包括了为子进程分配顶级页表起始地址 pgd。

static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
{
    ...... 省略 ........
    
    mm = dup_mm(tsk, current->mm);
    
    ...... 省略 ........
}

/**
 * Allocates a new mm structure and duplicates the provided @oldmm structure
 * content into it.
 */
static struct mm_struct *dup_mm(struct task_struct *tsk,
    struct mm_struct *oldmm)
{
     // 子进程虚拟内存空间,此时还是空的
     struct mm_struct *mm;
     int err;
     // 为子进程申请 mm_struct 结构
     mm = allocate_mm();
     if (!mm)
        goto fail_nomem;
     // 将父进程 mm_struct 结构里的内容全部拷贝到子进程 mm_struct 结构中
     memcpy(mm, oldmm, sizeof(*mm));
     // 为子进程分配顶级页表起始地址并赋值给 mm_struct->pgd
     if (!mm_init(mm, tsk, mm->user_ns))
        goto fail_nomem;
     // 拷贝父进程的虚拟内存空间中的内容以及页表到子进程中
     err = dup_mmap(mm, oldmm);
     if (err)
        goto free_pt;

     return mm;
}

最后内核会在 mm_init 函数中调用 mm_alloc_pgd,并在 mm_alloc_pgd 函数中通过调用 pgd_alloc 为子进程分配其独立的顶级页表起始地址,赋值给子进程 struct mm_struct 结构中的 pgd 属性。

static struct mm_struct *mm_init(struct mm_struct *mm, struct task_struct *p,
    struct user_namespace *user_ns)
{
    .... 初始化子进程的 mm_struct 结构 ......
    
    // 为子进程分配顶级页表起始地址 pgd
    if (mm_alloc_pgd(mm))
        goto fail_nopgd;

}

static inline int mm_alloc_pgd(struct mm_struct *mm)
{
    // 内核为子进程分配好其顶级页表起始地址之后
    // 赋值给子进程 mm_struct 结构中的 pgd 属性
    mm->pgd = pgd_alloc(mm);
    if (unlikely(!mm->pgd))
        return -ENOMEM;
    return 0;
}

到现在为止,一个进程就算是被完整的创建出来了,它拥有了自己独立的页表(页表内容和父进程一模一样),同时也拥有了属于自己的顶级页表起始地址 pgd,但是这里大家需要特别注意一点的就是进程的 struct mm_struct 结构中的这个 pgd 现在还只是顶级页表的虚拟内存地址,还无法被 CPU 直接使用。

当这个进程被调度到某个 CPU 之上时,内核就会调用 context_switch 来对进程上下文进行切换,切换的内容主要包括:

  1. 进程虚拟内存空间的切换。
  2. 寄存器以及进程栈的切换。
/*
 * context_switch - switch to the new MM and the new thread's register state.
 */
static __always_inline struct rq *
context_switch(struct rq *rq, struct task_struct *prev,
           struct task_struct *next, struct rq_flags *rf)
{
    ........ 省略 ,,,,,,,,,,

    if (!next->mm) {                                // to kernel

        ........ 内核线程的切换 ,,,,,,,,,,

    } else {                                        // to user
        ........ 用户进程的切换 ,,,,,,,,,,

        membarrier_switch_mm(rq, prev->active_mm, next->mm);
        // 切换进程虚拟内存空间
        switch_mm_irqs_off(prev->active_mm, next->mm, next);
    }

    // 切换 CPU 上下文和进程栈
    switch_to(prev, next, prev);
    barrier();
    return finish_task_switch(prev);
}

和本小节主题相关的是 switch_mm_irqs_off 函数,它主要负责对进程虚拟内存空间进行切换,其中就包括了调用 load_new_mm_cr3 函数将进程顶级页表起始地址 mm_struct-> pgd 中的虚拟内存地址通过 __sme_pa 宏 转换为物理内存地址,并将 pgd 的物理内存地址加载到 cr3 寄存器中。

void switch_mm_irqs_off(struct mm_struct *prev, struct mm_struct *next,
            struct task_struct *tsk)
{
      // 通过 __sme_pa 将 pgd 的虚拟内存地址转换为物理内存地址
      // 并加载到 cr3 寄存器中
      load_new_mm_cr3(next->pgd, new_asid, true);
}
cr3 寄存器中存放的是当前进程顶级页表 pgd 的物理内存地址,不能是虚拟内存地址。

进程的上下文在内核中完成切换之后,现在 cr3 寄存器中保存的就是当前进程顶级页表的起始物理内存地址了,当 CPU 通过下图所示的虚拟内存地址访问进程的虚拟内存时,CPU 首先会从 cr3 寄存器中获取到当前进程的顶级页表起始地址,然后从虚拟内存地址中提取出虚拟内存页对应 PTE 在页表内的偏移,通过 页表起始地址 + 页表内偏移 * sizeof(PTE) 这个公式定位到虚拟内存页在页表中所对应的 PTE。

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第9张图片

而虚拟内存页背后所映射的物理内存页的起始地址就保存在该 PTE 中,随后 CPU 继续从上图所示的虚拟内存地址中提取后半部分——物理内存页内偏移,并通过 物理内存页起始地址 + 物理内存页内偏移 就定位到了该物理内存页中一个具体的物理字节上。

好了,现在我们已经梳理清楚了内核如何通过页表来完成进程的虚拟内存与物理内存之间的映射关系了,并在这个基础上,我们又近一步了解了 CPU 如何通过虚拟内存访问其背后映射的物理内存的整个过程。

但是这里笔者还要和大家特别强调的一点的是:当用户进程被 CPU 调度起来,访问进程虚拟内存的时候,上述的虚拟内存地址与物理内存地址转换的过程都是在用户态进行的,正常的内存访问无需进入内核态。

除非 CPU 访问的虚拟内存页面类型是:

  1. 未分配页面。
  2. 已分配未映射页面。
  3. 以映射,但是由于内存紧张的原因,该虚拟内存页映射的物理内存页被置换到磁盘上了。

以上三种虚拟内存页有一个共同的特征就是它们背后的物理内存页均不在内存中,要么是没有映射,要么是被置换到磁盘上。当 CPU 访问这些虚拟内存页面的时候,就会产生缺页中断,随后进入内核态为其分配物理内存页面,填充物理内存页面中的内容,最后在页表中建立映射关系。之后的内存访问均是在用户态中进行。

通过前边文章 《深入理解 Linux 虚拟内存管理》 的介绍,我们知道,进程的整个虚拟内存空间分为两个部分,一个是用户态虚拟内存空间,一个是内核态虚拟内存空间。

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第10张图片

而 CPU 无论是在用户态还是在内核态,访问的均是虚拟内存地址,不管是用户空间的虚拟内存地址还是内核空间的虚拟内存地址最终都是要与物理内存进行映射的,而通过前边的介绍我们也知道了,虚拟内存与物理内存的映射关系是通过页表来管理的。

所以页表也就分为了两个部分:

  1. 进程用户态页表:主要负责管理进程用户态虚拟内存空间到物理内存的映射关系。
  2. 内核态页表:主要负责管理内核态虚拟内存空间到物理内存的映射关系,这一部分主要供内核使用。

和进程用户态虚拟内存空间一样,内核态虚拟内存空间也有一个 struct mm_struct 结构来描述:

struct mm_struct init_mm = {
  .mm_rb    = RB_ROOT,
  .pgd    = swapper_pg_dir,
  .mm_users  = ATOMIC_INIT(2),
  .mm_count  = ATOMIC_INIT(1),
  .mmap_sem  = __RWSEM_INITIALIZER(init_mm.mmap_sem),
  .page_table_lock =  __SPIN_LOCK_UNLOCKED(init_mm.page_table_lock),
  .mmlist    = LIST_HEAD_INIT(init_mm.mmlist),
  .user_ns  = &init_user_ns,
  INIT_MM_CONTEXT(init_mm)
};

从这里我们可以看到内核空间的顶级页表起始地址 pgd 叫做 swapper_pg_dir,定义在文件 arch/x86/include/asm/pgtable_64.h 中:

#define swapper_pg_dir init_top_pgt

内核的页表在系统初始化的时候被一段汇编代码 arch\x86\kernel\head_64.S所创建。后续内核虚拟内存空间的创建以及内核页表的初始化工作是在系统启动函数 start_kernel 中调用 setup_arch 完成。

asmlinkage __visible void __init start_kernel(void)
{
    ........ 省略 ........
    // 创建内核虚拟内存空间,初始化内核页表
    setup_arch(&command_line);

    ........ 省略 ........
}
void __init setup_arch(char **cmdline_p)
{
    // 初始化内核页表
    clone_pgd_range(swapper_pg_dir     + KERNEL_PGD_BOUNDARY,
            initial_page_table + KERNEL_PGD_BOUNDARY,
            KERNEL_PGD_PTRS);
    // 将内核顶级页表起始地址转换为物理地址,并加载到 cr3 寄存器中
    load_cr3(swapper_pg_dir);
    // 刷新 TLB 页表缓存
    __flush_tlb_all();
}

这里我们又看到了熟悉的 cr3 寄存器,无论是进程页表也好还是内核页表也好,再被 CPU 访问之前都必须先加载到 cr3 寄存器中。

现在内核页表已经被创建和初始化好了,但是对于处于内核态的进程以及内核线程来说并不能直接访问这个内核页表,它们只能访问内核页表的 copy 副本,进程的页表分为两个部分,一个是进程用户态页表,另一个就是内核页表的 copy 部分。

前边我们介绍 fork 系统调用在创建子进程的时候,会拷贝父进程的所有资源,当拷贝父进程的虚拟内存空间的时候,内核会通过 pgd_alloc 函数为子进程创建顶级页表 pgd,其实这里还有一项重要的工作,笔者在前边没有讲,那就是在 pgd_alloc 函数中还会调用 pgd_ctor,这个 pgd_ctor 函数的主要工作就是将内核页表拷贝到进程页表中。

static inline int mm_alloc_pgd(struct mm_struct *mm)
{
    // 内核为子进程分配好其顶级页表起始地址之后
    // 赋值给子进程 mm_struct 结构中的 pgd 属性
    mm->pgd = pgd_alloc(mm);
    if (unlikely(!mm->pgd))
        return -ENOMEM;
    return 0;
}

pgd_t *pgd_alloc(struct mm_struct *mm)
{
    pgd_t *pgd;
    // 为子进程分配顶级页表
    pgd = _pgd_alloc();
    if (pgd == NULL)
        goto out;

    mm->pgd = pgd;

    ...... 根据配置,与初始化子进程页表 .....
    // 拷贝内核页表到子进程中
    pgd_ctor(mm, pgd);

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

当进程通过系统调用切入到内核态之后,就会使用内核页表的这部分 copy 副本,来访问内核空间虚拟内存映射的物理内存。当进程页表中内核部分的拷贝副本与主内核页表不同步时,进程在内核态就会发生缺页中断,随后会同步主内核页表到进程页表中,这里又是延时拷贝在内核中的一处应用。

内核线程有一点和普通的进程不同,内核线程只能运行在内核态,而在内核态中,所有进程看到的虚拟内存空间全部都是一样的,所以对于内核线程来说并不需要为其单独的定义 mm_struct 结构来描述内核虚拟内存空间,内核线程的 struct task_struct 结构中的 mm 属性指向 null,内核线程之间调度是不涉及地址空间切换的,从而避免了无用的 TLB 缓存以及 CPU 高速缓存的刷新。

 struct task_struct {
    // 对于内核线程来说,它并没有自己的地址空间
    // 因为它始终工作在内核空间中,所有进程看到的都是一样的
    struct mm_struct  *mm;
}

但是内核线程依然需要访问内核空间中的虚拟内存,也就是说内核线程仍然需要内核页表,但是它又没有自己的地址空间,那该怎么办呢?

内核这里做了一个非常巧妙的处理,当一个内核线程被调度时,它会发现自己的虚拟地址空间为 null,虽然它不会访问用户态的内存,但是它会访问内核内存,聪明的内核会将调度之前的上一个用户态进程的虚拟内存空间 mm_struct 直接赋值给内核线程 task_struct->active_mm 中 。

 struct task_struct {
    // 内核线程的 active_mm 指向前一个进程的地址空间
    // 普通进程的 active_mm 指向 null
    struct mm_struct *active_mm;
}

因为内核线程不会访问用户空间的内存,它仅仅只会访问内核空间的内存,所以直接复用上一个用户态进程页表的内核部分就可以避免为内核线程分配 mm_struct 和相关页表的开销,以及避免内核线程之间调度时地址空间的切换开销。

好了,在本小节中,笔者通过一张单级页表的例子,带着大家分别从进程用户态和内核态的角度阐述了页表是如何表达虚拟内存与物理内存之间的映射关系的。在我们清楚了页表这个概念之后,下面笔者准备继续带大家去看一下页表的演化过程,那么在这这前,我们先来分析下单级页表有哪些不足,近而导致进程的页表体系需要向前演进。

3. 单级页表的不足

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第11张图片

经过上小节内容的介绍我们知道,页表的本质其实就是一个物理内存页,一张页表 4K 大小,下面我们以 32 位系统来举例说明,在 32 位系统中,页表中的一个 PTE 占用 4B 大小,所以一张页表可以容纳 1024 个 PTE。

在进程中虚拟内存与物理内存的映射是以页为单位的,进程虚拟内存空间中的一个虚拟内存页映射物理内存空间的一个物理内存页,这种映射关系以及访存权限都保存在 PTE 中,所以进程中的一个虚拟内存页对应页表中的一个 PTE,一个 PTE 能够映射 4K 的物理内存(一个物理内存页)。

一张页表里边可以容纳 1024 个 PTE,一个 PTE 可以映射 4K 的物理内存,那么一张页表就可以映射 1024 * 4K = 4M 大小的物理内存 ,而页表本质上是一个物理内存页(4K大小),所以内核需要用额外的 4K 大小的物理内存去映射 4M 的物理内存。

假设我们现在系统中有 4G 的物理内存,一张页表能够映射 4M 大小的物理内存,而为了映射这 4G 的物理内存,我们需要 1024 张页表,一张页表占用 4K 物理内存,所以为了映射 4G 的物理内存,我们额外需要 4M 的物理内存(1024张页表)来映射。

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第12张图片

更要命的是这 4M 物理内存(1024张页表)还必须是连续的,因为页表是单级的,而页表相当于是 PTE 的数组,进程虚拟内存空间中的一个虚拟内存页对应一个 PTE,而 PTE 在页表这个数组中的索引 index 就保存在虚拟内存地址中,内核通过页表的起始地址加上这个索引 index 才能定位到虚拟内存页对应的 PTE,近而通过 PTE 定位到映射的物理内存页。

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第13张图片

如果这 4M 物理内存(1024张页表)不是连续的,那么我们就无法通过访问数组的方式定位 PTE 了。而系统经过长时间运行之后,由于内存碎片的原因,是很难找到这么大一片连续的物理内存的。

大家需要注意的是,这 4M 的连续物理内存还只是一个进程所需要的,因为进程的虚拟内存空间都是独立的,页表也是独立的,一个进程就需要额外的 4M 连续物理内存(1024张页表)来支持进程内独立的内存映射关系。假如在系统中跑上 100 个进程,那总共就需要额外的 400M 连续的物理内存。这对于一个只有 4G 物理内存,单级页表的系统来说,无疑是巨大的开销和浪费。

在进程启动的时候就为它分配 4M 的页表这确实是比较大的开销,这一点是没错的,但是为什么说是一种浪费呢?

如果进程一启动就立马会访问全部的 4G 物理内存,那么的确需要在一开始就为进程分配 4M 的连续物理内存来存放页表,那这一点开销无论多么大都是必须的,不能省的,否则进程将无法运行。

但程序的局部性原理告诉我们,进程在运行之后,对于内存的访问不会一下子就要访问全部的内存,相反进程对于内存的访问会表现出明显的倾向性,更加倾向于访问最近访问过的数据以及热点数据附近的数据。

程序局部性原理表现为:时间局部性和空间局部性。时间局部性是指如果程序中的某条指令一旦执行,则不久之后该指令可能再次被执行;如果某块数据被访问,则不久之后该数据可能再次被访问。空间局部性是指一旦程序访问了某个存储单元,则不久之后,其附近的存储单元也将被访问。

所以无论一个进程在实际运行过程中总共需要占用的内存资源有多大,根据程序局部性原理,在某一段时间内,进程真正需要的物理内存其实是很少的一部分,我们只需要为每个进程分配很少的物理内存就可以保证进程的正常执行运转。

既然在某一个特定的时刻,进程只需要很少的物理内存就可以正常运转,那么进程虚拟内存与物理内存之间的映射关系相应也会很少,根本就不需要 4M 的物理内存来保存映射关系。

我们完全可以在进程初始状态下,创建一个最小集的页表,当进程实际确实需要的时候,我们再来创建相应具体的页表,这又是延时分配思想在内核中的另一处应用。

那么内核是如何做到的呢?接下来我们就需要向多级页表演进了~~~~

4. 多级页表的演进

在开始为大家介绍多级页表之前,笔者这里还是要和大家不断强化几个核心概念,这些概念非常重要,这关系到大家是否能从本质上理解多级页表的设计。

  1. 页表本质上还是一个物理内存页,只不过这个物理内存页比较特殊,里面存放的是 PTE,保存虚拟内存与物理内存的映射关系。既然它是一个普通的物理内存页,那么也会参与内核的调度,既会被内核 swap in 以及 swap out,也会被缓存在 CPU 高速缓存中加速访问。
  2. 在 32 位系统中,一个 PTE 占用 4 个字节,可以映射 4K 的物理内存,一张页表本身占用 4K 的物理内存,可以映射 4M 的物理内存。

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第14张图片

  1. 定位虚拟内存页在进程页表中对应的 PTE 是通过数组的访问方式进行的,虚拟内存地址中包含了其对应的 PTE 在页表中的偏移(页表数组中的 index),所以这就要求每一级页表都必须是连续的,比如上小节中介绍的单级页表,这 1024 张页表必须是连续的物理内存(4M 大小)。
  2. 进程对系统内存的访问具有明显的局部性,在任意时刻,我们只需要为进程分配很少的内存就能保证进程的正常运行。

在强化了这些核心概念之后,我们继续沿着上小节中介绍的单级页表的思路往下捋,接下来我们还是以 4G 的物理内存为例,根据局部性原理我们知道,进程在启动之后的任意时刻都不可能一下子就要访问全部的 4G 物理内存,但是我们需要给进程提供寻址 4G 物理内存的能力,也就是说你先别管我访问不访问,反正 4G 物理内存的寻址能力我是需要的。

“让社会的不良风气吹进来,我可以不收,但你们不能不送”——范德彪

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第15张图片

所以在单级页表的情况下,我们必须要为进程额外分配 4M 的连续物理内存来存放 1024 张页表,不管进程访问不访问,这 4M 的开销是不能省的。

那么现在我们在拿出一个 4K 的物理内存页作为页表,然后将这个页表放在单级页表的前面,组成一个二级页表的体系,情况会变成什么样呢?

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第16张图片

之前笔者不断地和大家强调过,页表的本质是一个物理内存页,页表是 PTE 的数组,而 PTE 的本质是指向其映射的一个物理内存页,既然 PTE 可以指向一个普通的物理内存页,那么它也可以指向一个页表。

根据这个思路,二级页表中的一个 PTE 本质上指向的还是一个物理内存页,只不过这个物理内存页比较特殊,它是一张页表(一级页表),一级页表是用来映射真正的物理内存的,一张一级页表可以映射 4M 物理内存。

这也就是说二级页表中的一个 PTE 就可以映射 4M 物理内存,同样的道理,二级页表中也包含了 1024 个 PTE,所以一张二级页表就可以映射 4G 的物理内存。

虽说二级页表和一级页表本质上都是一样的,它们都是一个物理内存页,但是我们习惯上将二级页表叫做页目录表,用来做一级页表的索引,就好像书中的目录一样,二级页表中的 PTE 我们习惯上称为做页目录项 (Page Directory Entry, PDE)。

因为一张页目录表就可以映射 4G 的物理内存了,所以在二级页表的情况下,我们只需要在进程启动的时候额外为它分配 4K 的连续物理内存就可以了,这相比单级页表下,需要为每个进程额外分配 4M 的连续物理内存节省了非常多宝贵的内存资源。

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第17张图片

但进程运行起来肯定会访问内存对吧,要访问内存就需要有映射,在运行过程中光有一张页目录表肯定是不够的,根据程序局部性原理,进程在运行中的任意时刻,只会访问很小一块的内存,比如这时进程需要访问 4K 的物理内存(一个物理内存页),在二级页表情况下,内核会本着你访问多少,我映射多少的原则来进行内存映射,下面我们来一起看看二级页表下的映射过程并与一级页表对比下内存消耗。

当前系统中,进程只有一张页目录表,页目录表里的 PDE 没有映射任何东西,这时进程需要访问一个物理内存页,而对物理内存页的映射任务主要是在一级页表的 PTE 中,所以现在首要的任务就是建立一张一级页表出来,并用页目录表索引起来。

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第18张图片

在二级页表的情况下,内核只需要一张 4K 的页目录表和一张 4K 的一级页表总共 8K 的内存就可以支持进程访问一个 4K 物理页面了,而根据程序的空间局部性原理,在不久的将来,进程只会访问与该物理内存页临近的页面,所以事实上,即使进程访问 4M 的内存,依然只需要一张 4K 的页目录表和一张 4K 的一级页表就可以满足了。

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第19张图片

当进程需要访问下一个 4M 的物理内存时,这时候第一个一级页表已经映射满了,那就需要再创建第二张页表用来映射下一个 4M 的物理内存,当然了,第二张页表依然需要索引在页目录表的 PDE 中。

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第20张图片

这时候内核就需要一张页目录表和两张一级页表共 12K 额外的物理内存来映射,这依然比单级页表的 4M 连续物理内存开销小很多。

同理,随着进程一个 4M 接着一个 4M 物理内存的访问,在极端的情况下整个页目录表都被映射满了,这时候内核就需要 4K(页目录表)+ 4M(1024张一级页表)的额外内存来保存映射关系了,这种情况下看起来会比单级页表下的 4M 内存开销大了那么一点点,但这种属于极端情况,非常少见,极大部分情况下还是比单级页表开销少很多很多的。

而且在二级页表体系下,上面极端情况中的这 1024 张一级页表不需要是连续的,因为我们只需要保证顶级页表(这里指页目录表)是连续的就可以了,通过页目录表中的 PDE 可以唯一索引到一张一级页表的起始物理内存地址,而页表内肯定是连续的 4K 物理内存,所以依然可以通过数组的方式索引到一级页表中的 PTE,近而找到其映射的物理内存页面。

除此之外二级页表体系还有一个优势,就是当内存紧张的时候,那些不经常使用的一级页表可以被 swap out 到磁盘中,当进程再次访问到该页表映射的物理内存时,内核在将页表从磁盘中 swap in 到内存中。当然了,顶级页表(这里指页目录表)必须是常驻内存的,不允许 swap 。

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第21张图片

既然页表的本质是一个物理内存页,那么同理,进程经常访问的那些页表也会被缓存到 CPU 高速缓存中加速下一次的访问速度。

在本小节中我们主要揭露多级页表的本质,除了二级页表之外,根据同样的道理,也会有三级页表,四级页表,Linux 内核甚至还支持五级页表,无论页表有多少级,但是都逃脱不了本小节中介绍的本质。本质的原理我们清楚了之后,下面我们就来看下多级页表具体的工作过程吧~~~

4.1 二级页表

现在我们对单级页表体系下的虚拟内存的寻址过程已经非常熟悉了,那么多级页表体系下的虚拟内存寻址过程也是一样的,都逃脱不了前边为大家介绍的页表本质。在引入二级页表寻址过程之前,我们在来回顾下单级页表寻址的本质核心逻辑:

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第22张图片

  1. 首先无论是几级页表,它们通过虚拟内存寻址的本质就是定位虚拟内存页对应在页表中的 PTE,然后通过 PTE 找到其映射的具体物理内存页。
  2. 页表的本质是一个物理内存页,其中包含了 1024 个 PTE,每个 PTE 可以映射一个具体的物理内存页,PTE 中保存了物理内存页的起始地址,进程地址空间中的一个虚拟内存页对应页表中的一个 PTE。因为在内核中是按照页为单位进行内存映射的。
  3. 在单级页表体系下,前面提到的 1024 张一级页表背后是通过连续的 4M 物理内存保存的,既然是连续的,那么我们可以把单级页表看做一个大的 PTE 数组,只要我们知道了单级页表的起始物理内存地址以及虚拟内存页对应 PTE 在单级页表(1024 张一级页表)中的 index,那么就可以定位到 PTE 了。
这里的单级页表起始物理内存地址指的就是 1024 张一级页表中,第一张页表的起始物理内存地址。

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第23张图片

  1. cr3 寄存器保存了顶级页表的起始物理内存地址,顶级页表随着进程的创建而创建,保存在进程 mm_struct->pgd,当进程被 CPU 调度的时候,会伴随着进程上下文切换,其中就会将 mm_struct->pgd 转换为物理内存地址并加载到 cr3 寄存器中。
这里的顶级页表指的就是单级页表(1024 张一级页表)
  1. 无论在几级页表体系下,进程虚拟内存空间中的虚拟内存地址格式在设计上总共分为两大部分,一部分是用来定位虚拟内存页在页表中对应的 PTE 偏移,另一部分是用来定位物理内存页中要访问的具体字节。

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第24张图片

  1. 单级页表的起始物理内存地址(保存在 cr3 寄存器中)有了,虚拟内存页在单级页表中对应 PTE 的偏移(保存在虚拟内存地址中)有了,通过公式 页表起始地址 + 页表内偏移 * sizeof(PTE) 就可以定位到虚拟内存页对应的 PTE 了,而 PTE 中保存了映射物理内存页的起始地址,在加上虚拟内存地址中保存的物理内存页内偏移,这样就可以定位到虚拟内存地址对应的物理字节了。

从单级页表演进到二级页表之后,虚拟内存寻址的底层逻辑还是一样的,只不过现在的顶级页表变成了页目录表(Page Directory), cr3 寄存器现在存放的是页目录表的起始物理内存地址。

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第25张图片

通过虚拟内存地址定位 PTE 的步骤由原来的一步变成了现在的两步,因为我们多加了一级页目录表,所以现在需要首先定位页目录表中的 PDE,然后通过 PDE 定位到具体的页表,近而找到页表中的 PTE。

所以在二级页表体系下的虚拟内存地址的格式也就发生了变化,单级页表下虚拟内存地址中只需要保存页表中的 PTE 偏移即可,二级页表下虚拟内存地址还需要多保存一份页目录表中 PDE 的偏移。

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第26张图片

二级页表应用在 32 位系统中,相应的虚拟内存地址由 32 位 bit 组成,在 32 位系统中页目录表中的 PDE 和页表中的 PTE 均占用 4 个字节。而前边我们也介绍过了,页目录表和页表的本质其实就是一个物理内存页,它们分别占用 4K 大小。

因此一张页目录表中有 1024 个 PDE,要寻址这 1024 个 PDE 用 10 个 bit 就可以了,所以在上图中的虚拟内存地址中的 页目录表中 PDE 偏移 部分占用 10 个 bit 位。

同样的道理,一张页表中有 1024 个 PTE, 要寻址这个 1024 个 PTE 也是需要 10 个 bit,上图中虚拟内存地址中的 一级页表中 PTE 偏移 部分也需要占用 10 个 bit 位。

这样一来我们就可以通过虚拟内存地址中的前 10 个 bit 定位到页目录表中的 PDE ,而 PDE 指向的是一级页表的起始物理内存地址,然后我们通过接下来的 10 个 bit 就可以定位到页表中的 PTE,而 PTE 指向的是虚拟内存页最终映射的物理内存页的起始地址。

现在我们找到物理内存页了,那么如何在物理内存页中找到我们要访问的字节呢 ?这就需要上图虚拟内存地址中的最后一部分 物理内存页内偏移了,因为一个物理内存页占用 4K 大小,我们用 12 位 bit 就可以寻址内存页中的任意字节了。

这样加起来,刚好可以组成一个 32 位的虚拟内存地址,在我们清楚了二级页表下的虚拟内存地址格式之后,接下来我们就来看下二级页表体系下的寻址过程:

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第27张图片

  1. 当 CPU 访问进程虚拟内存空间中的一个地址时,会先从 cr3 寄存器中拿出页目录表的起始物理内存地址,然后从虚拟内存地址中解析出前 10 bit 的内容作为页目录表中 PDE 的偏移,通过公式 页目录表起始地址 + 页目录表内偏移 * sizeof(PDE) 就可以定位到该虚拟内存页在页目录表中的 PDE 了。
  2. PDE 中保存了其指向的一级页表的起始物理内存地址,我们在从虚拟内存地址中解析出下一个 10 bit 作为页表中 PTE 的偏移,然后通过公式 页表起始地址 + 页表内偏移 * sizeof(PTE) 就能定位到虚拟内存页在一级页表中的 PTE 了。
  3. PTE 中保存了最终映射的物理内存页的起始地址,最后我们从虚拟内存地址中解析出最后 12 个 bit,最终定位到虚拟内存地址对应的物理字节上。

现在二级页表下虚拟内存寻址的完整过程笔者就为大家介绍完了,那么我们提到了这么次的 PDE,PTE,它们内部到底长什么样子呢?我们接着往下看~~~

4.1.1. 32 位页表项 PTE

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第28张图片

在进程的虚拟内存空间中,每一个虚拟内存页在页表中都有一个 PTE 与之对应,在 32 位系统中,每个 PTE 占用 4 个字节大小,其中保存了虚拟内存页背后映射的物理内存页的起始地址,以及进程访问物理内存的一些权限标识位。

PTE 在内核中是用 unsigned long 类型描述的,在 32 位系统中占用 4 个字节:

typedef unsigned long    pteval_t;

typedef struct { pteval_t pte; } pte_t;

下面是 PTE 中 32 bit (4 字节) 的布局格式:

image

由于内核将整个物理内存划分为一页一页的单位,每个物理内存页大小为 4K,所以物理内存页的起始地址都是按照 4K 对齐的,也就导致物理内存页的起始地址的后 12 位全部是 0,我们只需要在 PTE 中存储物理内存地址的高 20 位就可以了,剩下的低 12 位可以用来标记一些权限位。下面是 PTE 权限位的含义:

P(0) 表示该 PTE 映射的物理内存页是否在内存中,值为 1 表示物理内存页在内存中驻留,值为 0 表示物理内存页不在内存中,可能被 swap 到磁盘上了。当 PTE 中的 P 位为 0 时,上图中的其他权限位将变得没有意义,这种情况下其他 bit 位存放物理内存页在磁盘中的地址。当物理内存页需要被 swap in 的时候,内核会在这里找到物理内存页在磁盘中的位置。

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第29张图片

当我们通过上述虚拟内存寻址过程找到其对应的 PTE 之后,首先会检查它的 P 位,如果为 0 直接触发缺页中断(page fault),随后进入内核态,由内核的缺页异常处理程序负责将映射的物理页面换入到内存中。

R/W(1) 表示进程对该物理内存页拥有的读,写权限,值为 1 表示进程对该物理页拥有读写权限,值为 0 表示进程对该物理页拥有只读权限,进程对只读页面进行写操作将触发 page fault (写保护中断异常),用于写时复制(Copy On Write, COW)的场景。

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

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

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

U/S(2) 值为 0 表示该物理内存页面只有内核才可以访问,值为 1 表示用户空间的进程也可以访问。

PCD(4) 是 Page Cache Disabled 的缩写,表示 PTE 指向的这个物理内存页中的内容是否可以被缓存再 CPU CACHE 中,值为 1 表示 Disabled,值为 0 表示 Enabled。

PWT(3) 同样也是和 CPU CACHE 相关的控制位,Page Write Through 的缩写,值为 1 表示 CPU CACHE 中的数据发生修改之后,采用 Write Through 的方式同步回物理内存页中。值为 0 表示采用 Write Back 的方式同步回物理内存页。

当 CPU 修改了高速缓存中的数据之后,这些修改后的缓存内容同步回内存的方式有两种:

  1. Write Back:CPU 修改后的缓存数据不会立马同步回内存,只有当 cache line 被替换时,这些修改后的缓存数据才会被同步回内存中,并覆盖掉对应物理内存页中旧的数据。
  2. Write Through:CPU 修改高速缓存中的数据之后,会立刻被同步回物理内存页中。

A(5) 表示 PTE 指向的这个物理内存页最近是否被访问过,1 表示最近被访问过(读或者写访问都会设置为 1),0 表示没有。该 bit 位被硬件 MMU 设置,由操作系统重置。内核会经常检查该比特位,以确定该物理内存页的活跃程度,不经常使用的内存页,很可能就会被内核 swap out 出去。

D(6) 主要针对文件页使用,当 PTE 指向的物理内存页是一个文件页时,进程对这个文件页写入了新的数据,这时文件页就变成了脏页,对应的 PTE 中 D 比特位会被设置为 1,表示文件页中的内容与其背后对应磁盘中的文件内容不同步了。关于脏页的详细描述,可以回看下笔者之前的这篇文章 《从 Linux 内核角度探秘 JDK NIO 文件读写本质》

PAT(7) 表示是否支持 PAT(Page Attribute Table) , PAT 的相关内容和本文主题无关,这里就不做过多的介绍了。

G(8) 设置为 1 表示该 PTE 是全局的,该标志位表示 PTE 中保存的映射关系是否是全局的,什么意思呢,一般来说进程都有各自独立的虚拟内存空间,进程的页表也是独立的 ,CPU 每次访问进程虚拟内存地址的时候都需要进行地址翻译(上一小节介绍的寻址过程),为了加速地址翻译的速度,避免每次遍历页表,CPU 会把经常被访问到的 PTE 缓存在一个 TLB 的硬件缓存中,由于 TLB 中缓存的是当前进程相关的 PTE,所以操作系统每次在切换进程的时候,都会重新刷新 TLB 缓存。

而有一些 PTE 是所有进程共享的,比如说内核虚拟内存空间中的映射关系,所有进程进入内核态看到的都是一样的。所以会将这些全局共享的 PTE 中的 G 比特位置为 1 ,这样在每次进程切换的时候,就不会 flush 掉 TLB 缓存的那些共享的全局 PTE(比如内核地址的空间中使用的 PTE),从而在很大程度上提升了性能。

以上介绍的这些 PTE 相关的权限比特位定义在内核文件/arch/x86/include/asm/pgtable_types.h 中:

#define _PAGE_BIT_PRESENT    0    /* is present */
#define _PAGE_BIT_RW        1    /* writeable */
#define _PAGE_BIT_USER        2    /* userspace addressable */
#define _PAGE_BIT_PWT        3    /* page write through */
#define _PAGE_BIT_PCD        4    /* page cache disabled */
#define _PAGE_BIT_ACCESSED    5    /* was accessed (raised by CPU) */
#define _PAGE_BIT_DIRTY        6    /* was written to (raised by CPU) */
#define _PAGE_BIT_PAT        7    /* on 4KB pages */
#define _PAGE_BIT_GLOBAL    8    /* Global TLB entry PPro+ */

// 从 PTE 中提取相应比特位的掩码
#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_PWT    (_AT(pteval_t, 1) << _PAGE_BIT_PWT)
#define _PAGE_PCD    (_AT(pteval_t, 1) << _PAGE_BIT_PCD)
#define _PAGE_ACCESSED    (_AT(pteval_t, 1) << _PAGE_BIT_ACCESSED)
#define _PAGE_DIRTY    (_AT(pteval_t, 1) << _PAGE_BIT_DIRTY)
#define _PAGE_PSE    (_AT(pteval_t, 1) << _PAGE_BIT_PSE)
#define _PAGE_GLOBAL    (_AT(pteval_t, 1) << _PAGE_BIT_GLOBAL)

image

除此之外,内核还定义了一系列的方法,用于操作 PTE 的相关权限比特位,这些方法三个一组,分别用于相应权限比特位的查询,设置,清除操作,定义在内核文件 /arch/x86/include/asm/pgtable.h 中:

// 查询 PTE 指向的物理内存页是否在内存中
static inline int pte_present(pte_t a)

// 查询内存页是否可写
static inline int pte_write(pte_t pte)
// 设置内存页可写
static inline pte_t pte_mkwrite(pte_t pte)
// 禁止读写
static inline pte_t pte_wrprotect(pte_t pte)

// 查询 PTE 是否是 global 
static inline int pte_global(pte_t pte)
static inline pte_t pte_mkglobal(pte_t pte)
static inline pte_t pte_clrglobal(pte_t pte)

// 查询 PTE 指向的内存页是否是脏页
static inline int pte_dirty(pte_t pte)
static inline pte_t pte_mkclean(pte_t pte)
static inline pte_t pte_mkdirty(pte_t pte)

// 查询 PTE 指向的内存页是否最近被访问过
static inline int pte_young(pte_t pte)
static inline pte_t pte_mkold(pte_t pte)
static inline pte_t pte_mkyoung(pte_t pte)

4.1.2. 32 位页目录项 PDE

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第30张图片

同 PTE 一样,PDE 在 32 位系统中也是用 unsigned long 类型来描述的,同样也是占用 4 个字节大小。

typedef unsigned long    pgdval_t;

PDE 是用来指向一级页表的起始物理内存地址的,而页表的本质是一个物理内存页(4K 大小),因此页表的起始内存地址也是按照 4K 对齐的,后 12 位全部为 0 ,我们可以继续用 PDE 的低 12 位来标记页目录项的权限位:

image

这里和页表中 PTE 的权限位不同的是,PDE 中的第 6 个比特位脏页标记位没有了,因为 PDE 指向的是一级页表,页表并不是一个文件页,所以脏页标记在这里就没有意义了。

还有就是 PDE 中的第 8 比特位,Global 全局标记位也没有了,因为 TLB 缓存的 PTE 而不是 PDE,所以不需要设置 Global 标记来防止进程切换导致 TLB flush。

最后一个不同的是 PDE 中的第 7 比特位由 PTE 中的 PAT 标记变为了 PS 标记位。那么这个 PS 比特位在这里是干什么用的呢?

当 PS 标记为 0 的时候,PDE 的映射关系确实如本小节第一张图中所示,PDE 指向一级页表的起始内存地址,这种情况下,PDE 的作用确实是我们前边介绍的页目录项的作用。

但是当 PS 标记为 1 的时候,PDE 就会被内核拿来当做 PTE 使用,不过这个 ”PTE“ 比较特殊,其特殊之处在于该 PDE 会指向一个大页内存,这个物理内存页不是普通的 4K 大小,而是 4M 大小。

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第31张图片

笔者在前面的小节中曾介绍过,在二级页表体系下,页目录表中的一个 PDE 可以映射的物理内存空间是 4M ,既然这样,PDE 也可以直接指向一张 4M 的内存大页。为什么内核还需要支持大页内存呢?

我们都知道 Linux 管理内存的最小单位是 page,每个 page 描述 4K 大小的物理内存,但在一些内存敏感的使用场景中,用户往往期望使用一些巨型大页。

因为这些巨型页要比普通的 4K 内存页要大很多,所以遇到缺页中断的情况就会相对减少,由于减少了缺页中断所以性能会更高。

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

还有一个使用巨型页受益场景就是,当一个内存占用很大的进程(比如 Redis)通过 fork 系统调用创建子进程的时候,会拷贝父进程的相关资源,其中就包括父进程的页表,由于巨型页使用的页表项少,所以拷贝的时候性能会提升不少。

既然 PS 标记为 1 的情况下,PDE 指向的是一个 4M 的物理大页内存,这种情况下内核就把 PDE 当做一个特殊的 ”PTE“ 使用了,所以 PDE 中的比特位布局又发生了变化,不过大部分还是和 PTE 一样的。

image

不过这里笔者还是要向大家特殊说明一下,第 13 到 31 比特位的作用,粗略的从总体来讲这个范围的比特位确实是用来保存 4M 大页的起始内存地址的,所以笔者直接在 31:13 范围内的比特位直接标记成 4M 大页的起始内存地址。

但是进一步细分来说,其实 4M 内存大页的起始地址都是按照 4M 对齐的,也就是说 4M 大页的起始内存地址的后 22 位全部为 0 ,我们只需要用 10 个比特位就可以标记了,事实上,4M 大页的起始内存地址在内核中就是使用 31:22 范围内的比特标记的,剩下的比特用来做内存地址的扩展使用,不过这个和本文主旨无关,笔者就直接忽略了。

和 PTE 一样,PDE 的相关权限比特位也定义在内核文件:/arch/x86/include/asm/pgtable_types.h 中:

#define _PAGE_BIT_PSE        7    /* 4 MB page */
// 从 PDE 中提取 PS 比特位掩码
#define _PAGE_PSE    (_AT(pdeval_t, 1) << _PAGE_BIT_PSE)

我们可以通过如下位运算,从 PDE 中提取 PS 比特位来确定该 PDE 指向的是一级页表还是 4M 大页。

native_pde_val(pde) & _PAGE_PSE

4.2 四级页表

在 32 位系统中,内核主要采用二级页表体系来进行虚拟内存寻址,但是到了 64 位系统中,二级页表明显就不够用了,因为二级页表最多只能映射 4G 的物理内存空间,而 64 位系统中,进程的虚拟寻址空间是巨大的,进程的用户态需要寻址 128T 的虚拟内存空间,内核态也有 128T 的虚拟内存空间。

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第32张图片

为了能够寻址这么大的虚拟内存空间,内核在 64 位系统中引入了四级页表体系,当我们清楚了二级页表的虚拟寻址过程,四级页表就很简单了,不就是多引入了两级页目录么,前面小节介绍的多级页表的本质还是不变的。

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第33张图片

64 位系统中的四级页表相比 32 位系统中的二级页表来说,多引入了两个层级的页目录,分别是四级页表和三级页表,四级页表体系完整的映射关系如下图所示:

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第34张图片

但是在内核中一般不这么叫,内核中称上图中的四级页表为全局页目录 PGD(Page Global Directory),PGD 中的页目录项叫做 pgd_t,PGD 是四级页表体系下的顶级页表,保存在进程 struct mm_struct 结构中的 pgd 属性中,在进程调度上下文切换的时候,由内核通过 load_new_mm_cr3 方法将 pgd 中保存的顶级页表虚拟内存地址转换物理内存地址,随后加载到 cr3 寄存器中,从而完成进程虚拟内存空间的切换。

上图中的三级页表在内核中称之为上层页目录 PUD(Page Upper Directory),PUD 中的页目录项叫做 pud_t 。

二级页表在这里也改了一个名字叫做中间页目录 PMD(Page Middle Directory),PMD 中的页目录项叫做 pmd_t,最底层的用来直接映射物理内存页面的一级页表,名字不变还叫做页表(Page Table)

由于在四级页表体系下,又多引入了两层页目录(PGD,PUD),所以导致其通过虚拟内存地址定位 PTE 的步骤又增加了两步,首先需要定位顶级页表 PGD 中的页目录项 pgd_t,pgd_t 指向的 PUD 的起始内存地址,然后在定位 PUD 中的页目录项 pud_t,后面的流程就和二级页表一样了。

因此 64 位的虚拟内存地址格式也就随着发生了变化:

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第35张图片

32 位系统中的页目录表,页表和 64 位系统中的页目录表,页表在内核中都是使用一个普通 4K 的物理内存页存储映射关系的,不同的是 64 位系统中的页表中的 PTE 以及页目录表(PGD,PUD,PMD)中的 PDE 都是占用 8 个字节,在内核中都是使用 unsigned long 类型描述:

// 定义在内核文件:/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;
内核这里使用 struct 结构来包裹 unsigned long 类型的目的是要确保这些页目录项以及页表项只能被专门的辅助函数访问,不能直接访问。

一张页表 4K 大小,页表中的一个 PTE 占用 8 个字节,所以在 64 位系统中一张页表只能包含 512 个 PTE,在内核中使用 PTRS_PER_PTE 常量来表示一张页表中可以容纳的 PTE 个数,用 PAGE_SHIFT 常量表示一个物理内存页的大小:2^PAGE_SHIFT。

/*
 * entries per page directory level
 */
#define PTRS_PER_PTE    512

/* PAGE_SHIFT determines the page size */
#define PAGE_SHIFT        12

要寻址页表中这 512 个 PTE,我们用 9 个 bit 就可以了,因此上图虚拟内存地址中的 一级页表中的 PTE 偏移 占用 9 个 bit 位。而一个 PTE 可以映射 4K 大小的物理内存(一个物理内存页),所以在 64 位的四级页表体系下,一张一级页表可以映射的物理内存空间大小为 2M 大小。

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第36张图片

一张中间页目录 PMD 也是 4K 大小,PMD 中的页目录项 pmd_t 也是占用 8 个字节,所以一张 PMD 中只能容纳 512 个 pmd_t,内核中使用 PTRS_PER_PMD 常量来表示 PMD 中可以容纳多少个页目录项 pmd_t。因次 64 位虚拟内存地址中的 PMD中的页目录偏移 使用 9 个 bit 就可以表示了。

/*
 * PMD_SHIFT determines the size of the area a middle-level
 * page table can map
 */
#define PMD_SHIFT    21
#define PTRS_PER_PMD    512

而一个 pmd_t 指向一张一级页表,所以一个 pmd_t 可以映射的物理内存为 2M,内核中使用 PMD_SHIFT 常量来表示一个 pmd_t 可以映射的物理内存范围:2^PMD_SHIFT。一张 PMD 可以映射 1G 的物理内存。

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第37张图片

同理我们知道,一张上层页目录 PUD 中可以容纳 512 个页目录项 pud_t,内核中使用 PTRS_PER_PUD 常量来表示 PUD 中可以容纳多少个页目录项 pud_t。 64 位虚拟内存地址中的 PUD中的页目录偏移 也是使用 9 个 bit 就可以表示了。

/*
 * 3rd level page
 */
#define PUD_SHIFT    30
#define PTRS_PER_PUD    512

内核中使用 PUD_SHIFT 常量来表示一个 pud_t 可以映射的物理内存范围:2^PUD_SHIFT,一个 pud_t 指向一张 PMD,因此可以映射 1G 的物理内存。一张 PUD 可以映射 512G 的物理内存。

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第38张图片

一样的道理,顶级页目录 PGD 中可以容纳的页目录 pgd_t 个数 PTRS_PER_PGD = 512, 64 位虚拟内存地址中的 PGD中的页目录偏移 也是使用 9 个 bit 就可以表示了,一个 pgd_t 可以映射的物理内存为 2^PGDIR_SHIFT = 512 G。一张 PGD 可以映射的物理内存为 256 T,可以说是非常非常巨大了。

/*
 * 4th level page in 5-level paging case
 */
#define PGDIR_SHIFT        39
#define PTRS_PER_PGD        512

通过以上内容介绍,我们就得到了 64 位虚拟内存地址中的比特位布局情况:

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第39张图片

  • PAGE_SHIFT 用来表示页表中的一个 PTE 可以映射的物理内存大小(4K)。
  • PMD_SHIFT 用来表示 PMD 中的一个页目录项 pmd_t 可以映射的物理内存大小(2M)。
  • PUD_SHIFT 用来表示 PUD 中的一个页目录项 pud_t 可以映射的物理内存大小(1G)。
  • PGD_SHIFT 用来表示 PGD 中的一个页目录项 pgd_t 可以映射的物理内存大小(512G)。

这些 XXX_SHIFT 常量在内核中除了可以表示对应页目录项映射的物理内存大小之外,还可以从一个 64 位虚拟内存地址中获取其在对应页目录中的偏移。

比如我们现在需要从一个 64 位虚拟内存地址中获取它在 PGD 中的偏移,可以讲虚拟内存地址右移 PGD_SHIFT 位来得到:

#define pgd_index(address) ( address >> PGDIR_SHIFT) 

然后我们可以通过 PGD 的起始内存地址加上 pgd_index 就可以得到虚拟内存地址在 PGD 中的页目录项 pgd_t 了。

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

同样的道理,我们可以将虚拟内存地址右移 PUD_SHIFT 位,并用掩码 PTRS_PER_PUD - 1 掩掉高 9 位 , 只保留低 9 位,就可以得到虚拟内存地址在 PUD 中的偏移了:

PTRS_PER_PUD - 1 转换为二进制是 9 个 1,用来截取最低的 9 个比特位。
static inline unsigned long pud_index(unsigned long address)
{
    return (address >> PUD_SHIFT) & (PTRS_PER_PUD - 1);
}

我们通过 pgd_t 获取 PUD 的起始内存地址 + pud_index 得到虚拟内存地址对应的 pud_t:

/* Find an entry in the third-level page table.. */
static inline pud_t *pud_offset(pgd_t *pgd, unsigned long address)
{
    return (pud_t *)pgd_page_vaddr(*pgd) + pud_index(address);
}

根据相同的计算逻辑,我们可以通过 pmd_offset 函数获取虚拟内存地址在 PMD 中的页目录项 pmd_t:

/* Find an entry in the second-level page table.. */
static inline pmd_t *pmd_offset(pud_t *pud, unsigned long address)
{
    return (pmd_t *)pud_page_vaddr(*pud) + pmd_index(address);
}

static inline unsigned long pmd_index(unsigned long address)
{
    return (address >> PMD_SHIFT) & (PTRS_PER_PMD - 1);
}

通过 pte_offset_kernel 函数可以获取虚拟内存地址在一级页表中的 PTE:

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

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

现在我们已经清楚了内核如何通过一系列的 XXX_offset 方法从虚拟内存地址中提取对应页目录以及页表中的偏移了,有了这些基础之后,接下来我们就来看一下四级页表体系的寻址过程:

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第40张图片

  1. 首先 MMU 会从 cr3 寄存器中获取顶级页目录 PGD 的起始内存地址,然后通过 pgd_index 从虚拟内存地址中截取 PGD 中的页目录项偏移,这样就定位到了具体的一个 pgd_t。
  2. pgd_t 中保存的是 PMD 的起始内存地址,通过 pud_index 可以从虚拟内存地址中截取 PUD 中的页目录项偏移,从而确定虚拟内存地址在 PUD 中的页目录项 pud_t。
  3. 同样的道理,根据 pud_t 中保存的 PMD 其实内存地址,在加上通过 pmd_index 获取到的 PMD 中的页目录项偏移,定位到虚拟内存地址在 PMD 中的页目录项 pmd_t。
  4. 后面的寻址流程就和二级页表一样了,pmd_t 指向具体页表的起始内存地址,通过 pte_index 截取虚拟内存地址在 一级页表中的 PTE 偏移,最终定位到一个具体的 PTE 中,PTE 指向的正是虚拟内存地址映射的物理内存页面,然后通过虚拟内存地址中的低 12 位(物理内存页内偏移),最终确定到一个具体的物理字节上。

4.2.1. 64 位页表项

在 64 位系统中,页表中 PTE 在内核中使用 unsigned long 类型描述,占用 8 个字节:

typedef unsigned long   pteval_t;
typedef struct { pteval_t pte; } pte_t;

这 64 位的 PTE 布局如下:

image

这里我们以 36 位物理内存地址(最多 52 位)为例进行说明,首先物理内存页的起始内存地址都是按照 4K 对齐的,所以 36 位物理内存地址的低 12 位全部为 0 ,和 32 位的 PTE 一样,内核可以用这低 12 位来描述 PTE 的权限位,其中 0 到 8 之间的比特位,在 32 位 PTE 和 64 位 PTE 中含义都是一样的,这里笔者不在赘述。

R(11) 这里的 R 表示 restart,该比特位主要用于 HLAT paging,当遍历到 R 位是 1 的 PTE 时,MMU 会重新从 CR3 寄存器开始遍历页表,这里我们只做简单了解。

本例中的物理内存地址是 36 位的,由于物理内存页都是 4K 对齐的,低 12 位全都是 0 ,因此我们只需要在 PTE 中存储物理内存地址的高 24 位即可,这部分存储在 PTE 中的第 12 到 35 比特位。

Reserved(51:36) 这些是预留位,全部设置为 0 。Protection(62:59) 这 4 个比特位用于对物理内存页的访问进行控制。

XD(63) 该比特位是 64 位 PTE 中新增的,32 位 PTE 中是没有的,值为 1 表示该 PTE 所映射的物理内存页面中的数据是可以被执行的。

4.2.2. 64 位页目录项

在 64 位系统中使用的四级页表体系中一共包含了三个层级的页目录,它们分别为:全局页目录 PGD(Page Global Directory),上层页目录 PUD(Page Upper Directory),PMD(Page Middle Directory)。

这三种类型的页目录中的页目录项 PDE 在内核中也是使用 unsigned long 类型来描述的,在 64 位系统中占用 8 个字节:

typedef unsigned long   pmdval_t;
typedef unsigned long   pudval_t;
typedef unsigned long   pgdval_t;

typedef struct { pmdval_t pmd; } pmd_t;
typedef struct { pudval_t pud; } pud_t;
typedef struct { pgdval_t pgd; } pgd_t;

64 位 PDE 中的比特位布局如下图所示:

image

当 64 位 PDE 的 PS(7) 比特位为 0 时,该 PDE 指向的是其下一级页目录或者页表的起始内存地址。

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第41张图片

当 64 位 PDE 的 PS(7) 比特位为 1 时,该 PDE 指向的就是一个内存大页,对于 PMD 中的页目录项 pmd_t 而言,它指向的是一张 2M 大小的物理内存大页。

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第42张图片

对于 PUD 中的页目录项 pud_t 而言,它指向的是一张 1G 大小的物理内存大页。

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第43张图片

当 64 位 PDE 的 PS(7) 比特位为 1 时,这些页目录项 PDE 就被当做了一个特殊的 ”PTE“ 对待了,因此 PDE 中的比特位布局又就变成了 64 位 PTE 中的样子了。

image

为了表述严谨,这里笔者需要特殊说明的一点是,方便让大家容易理解,笔者将第 12 到 35 比特位直接标注为了存储大页内存的地址,但事实上,大页内存的地址并不需要这么多位来存储,因为大页中的内存容量比较大,所以大页个数相对较少,它们的起始内存地址不会特别高,使用小于 24 位的比特就可以存放了,多出来的比特位被用作其他目的,但是这些都和本文主旨无关,笔者就直接忽略掉了。

内核当然也会提供一系列的辅助函数来对页目录进行操作:

  • pgd_alloc,pud_alloc,pmd_alloc 负责创建初始化对应的页目录。
  • mk_pgd,mk_pud,mk_pmd,mk_pte 用于创建相应页目录项和页表项,并初始化上述比特位。
  • 以及提供相关 pgd_xxx,pud_xxx,pmd_xxx 等形式的辅助函数,用于对相关比特位的增删改查操作。

5. CPU 的整个寻址过程

本文的重点是要为大家完整清晰地构建出内核中的页表体系,给大家解释清楚虚拟内存是如何与物理内存映射起来的,当我们理解了这些之后,在本小节中,笔者准备带大家一起探秘下,当 CPU 访问一个进程虚拟内存空间中的某个虚拟内存地址之后,操作系统背后到底发生了什么。

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第44张图片

经过本文前边内容的介绍,上图中的这个四级页表的遍历过程,我们已经非常的清楚了,我们可以明显的体会到整个地址翻译的过程需要的步骤还是比较多的,而 CPU 访问内存的操作是非常非常频繁的,如果我们采用内核这种软件的方式对页表进行遍历,效率会非常的差。

而采用一种专门的硬件来对软件进行加速,无疑是一种最简单,最直接有效的优化手段,于是在 CPU 中引入了一个专门对页表进行遍历的地址翻译硬件 MMU(Memory Management Unit),有了 MMU 硬件的加持整个地址翻译的过程就非常的快了。

事实上,上图中展示的四级页表的整个遍历操作均是在 MMU 中进行的:

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第45张图片

经过前边的内容我们知道,这些页目录,页表的本质其实在内核看来都是一张普通的 4K 大小的物理内存页,而物理内存页中经常被访问到的内存数据都是缓存在 CPU 的高速缓存 L1 ,L2,L3 CACHE 中的,这样可以利用局部性原理加速 CPU 对内存的访问。

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第46张图片

所以页目录表和页表中那些经常被 MMU 遍历到的页目录项 PDE,页表项 PTE 均会缓存在 CPU 的 CACHE 中,这样 MMU 就可以直接从 CPU 高速缓存中获取 PDE , PTE 了,近一步加速了整个地址翻译的过程。

当 MMU 拿到一个 CPU 正在访问的虚拟内存地址之后, MMU 首先会从 CR3 寄存器中获取顶级页目录表 PGD 的起始内存地址,然后从虚拟内存地址中提取出 pgd_index,从而定位到 PGD 中的一个页目录项 pdg_t,MMU 首先会从 CPU 的高速缓存中去获取这个 pgd_t,如果 pgd_t 经常被访问到,那么此时它已经存在于高速缓存中了,MMU 直接可以进行下一级页目录的地址翻译操作,避免了慢速的内存访问。

同样的道理,在 MMU 经过层层的页目录遍历之后,终于定位到了一级页表中的 PTE,MMU 也是先会从 CPU 高速缓存中去获取 PTE,如果 PTE 不在高速缓存中,MMU 才会去内存中去获取。获取到 PTE 之后,MMU 就得到了虚拟内存地址背后映射的物理内存地址了。

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第47张图片

在我们引入 MMU 之后,虽然加快了整个页表遍历的过程,但是 CPU 每访问一个虚拟内存地址,MMU 还是需要查找一次 PTE,即便是最好的情况,MMU 也还是需要到 CPU 高速缓存中去找一下的,即便这样开销已经很小了,但是我们还是想近一步降低这个访问 CPU CACHE 的开销,让 CPU 访存性能达到极致,那么该怎么办呢?

既然 MMU 每次都需要查找一次 PTE,那么我们能不能在 MMU 中引入一层硬件缓存,这样 MMU 可以把查找到的 PTE 缓存在硬件中,下次再需要的时候直接到硬件缓存中拿现成的 PTE 就可以了,这样一来,CPU 的访存效率又被近一步加快了。

这个 MMU 中的硬件缓存就叫做 TLB(Translation Lookaside Buffer),TLB 是一个非常小的,虚拟寻址的硬件缓存,专门用来缓存被 MMU 翻译之后的热点 PTE。当我们引入 TLB 之后,整个寻址过程就又有了一些新的变化:

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第48张图片

当 CPU 将要访问的虚拟内存地址送到 MMU 中翻译时,MMU 首先会在 TLB 中通过虚拟内存寻址查找其对应的 PTE 是否缓存在 TLB 中,如果 cache hit ,那么 MMU 就可以直接获得现成的 PTE,避免了漫长的地址翻译过程。

如果 cache miss,那么 MMU 就需要重新遍历页表,然后获取 PTE 的内存地址,从 CPU 高速缓存或者内存中去查找 PTE,慢速路径下获取到 PTE 之后,MMU 会将 PTE 缓存到 TLB 中,加快下一次获取 PTE 的速度。

当 MMU 获取到 PTE 之后,就可以从 PTE 中拿到物理内存页的起始地址了,在加上虚拟内存地址的低 12 位(物理内存页内偏移)这样就获取到了虚拟内存地址映射的物理内存地址了。

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第49张图片

那么当 MMU 拿到我们最终要访问的物理内存地址之后,又该怎么办呢?

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第50张图片

  • 当 MMU 获取到最终的物理内存地址,首先会根据物理内存地址到 CPU 高速缓存中去查找数据,如果 cache hit,整个访存操作快速结束。
  • 如果 cache miss,那么 MMU 会将物理内存地址放到系统总线上传输,随后 IO bridge 会将系统总线上传输的地址信号传递到存储总线上。
  • 内存中的存储控制器感受到存储总线上的地址信号之后,会将物理内存地址从存储总线上读取出来。并根据物理内存地址定位到具体的存储器模块,随后解析物理内存地址从 DRAM 芯片中取出对应物理内存地址里的数据。

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射_第51张图片

关于 DRAM 芯片的具体访问细节,感兴趣的读者朋友可以回看下笔者之前文章 《一步一图带你深入理解 Linux 虚拟内存管理》 的 ”8. 到底什么是物理内存地址“ 小节。
  • 存储控制器将读取到的数据放到存储总线传输上,随后 IO bridge 将存储总线上的数据信号转换为系统总线上的数据信号,然后继续沿着系统总线传递。
  • CPU 芯片感受到系统总线上的数据信号之后,将数据从系统总线上读取出来并拷贝到寄存器中,随后通过 ALU 完成计算。

总结

本文笔者通过页表体系这条主线脉络,为大家串讲了一下之前介绍的虚拟内存管理以及物理内存管理的相关内容,在我们回顾完虚拟内存管理和物理内存管理之后,随后我们引出了虚拟内存如何与物理内存进行映射这个问题,并在这个过程中为大家揭露了页表的本质。

在我们清楚了页表的本质之后,笔者又沿着页表体系的演进这条主线,对单级页表,二级页表,四级页表展开了介绍,其中花了一定的篇幅为大家详细的介绍了 32 位和 64 位页表项以及页目录想的比特位布局,让大家真真实实的看到了页表项和页目录项到底长什么样子。

在这个基础之上,笔者又对虚拟内存地址格式的组成进行了详细的剖析,并深入到内核中,带着大家梳理了内核是如何从虚拟内存地址中提取对应页目录以及页表中的偏移的,在这些基础之上详细介绍了页表的整个遍历过程。

在本文的最后,笔者带大家又梳理了一遍 CPU 寻址的完整过程,对前边的知识内容做一个串联回顾。

到这里页表相关的知识内容,笔者就为大家介绍完了,感谢大家的收看,我们下篇文章见~~

你可能感兴趣的:(一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射)