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

Handpc
发布于 2023-10-11 10:32
浏览
0收藏

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

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

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

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

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射-鸿蒙开发者社区

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

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射-鸿蒙开发者社区

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

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射-鸿蒙开发者社区

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

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

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射-鸿蒙开发者社区

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

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

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

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射-鸿蒙开发者社区

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

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

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

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

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

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射-鸿蒙开发者社区

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

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

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射-鸿蒙开发者社区

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

  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 页表体系 —— 详解虚拟内存如何与物理内存进行映射-鸿蒙开发者社区

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

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

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

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

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

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

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

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

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

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射-鸿蒙开发者社区

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

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射-鸿蒙开发者社区

因为上文已经说了,进程虚拟内存空间中的每一个虚拟页在页表中都会有一个 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 页表体系 —— 详解虚拟内存如何与物理内存进行映射-鸿蒙开发者社区

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

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

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

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

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

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

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

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射-鸿蒙开发者社区

而 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

内核的页表在系统初始化的时候被一段汇编代码 ​​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 页表体系 —— 详解虚拟内存如何与物理内存进行映射-鸿蒙开发者社区

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

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

一张页表里边可以容纳 1024 个 PTE,一个 PTE 可以映射 4K 的物理内存,那么一张页表就可以映射 ​​1024 * 4K = 4M​​ 

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

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射-鸿蒙开发者社区

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

一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射-鸿蒙开发者社区

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

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

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

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

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

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

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

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

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

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


文章转载自公众号:bin的技术小屋

分类
标签
已于2023-10-11 10:32:47修改
收藏
回复
举报
回复
    相关推荐