【Ucore操作系统】4. 地址空间

【 0. 引言 】

背景

  • 上一章,我们分别实现了多道程序和分时多任务系统,它们的核心机制都是任务切换。由于多道程序和分时多任务系统的设计初衷不同,它们在任务切换的时机和策略也不同。有趣的一点是,任务切换机制对于应用是完全 透明 (Transparent) 的:应用可以不对内核实现该机制的策略做任何假定(除非要进行某些针对性优化),甚至可以完全不知道这机制的存在。
  • 在大多数应用(也就是应用开发者)的视角中,它们会独占一整个 CPU 和特定(连续或不连续)的内存空间。当然,通过上一章的学习,我们知道在现代操作系统中,出于公平性的考虑,我们极少会让独占CPU这种情况发生。所以应用自认为的独占CPU只是内核想让应用看到的一种 幻象 (Illusion) ,而 CPU 计算资源被 时分复用 (TDM, Time-Division Multiplexing) 的实质被内核通过恰当的抽象隐藏了起来,对应用不可见
  • 与之相对,我们目前还没有对内存管理功能进行有效的管理,仅仅是把程序放到某处的物理内存中。在内存访问方面,所有的应用都直接通过物理地址访问物理内存,这使得应用开发者需要了解繁琐的物理地址空间布局,访问内存也很不方便。在上一章中,出于任务切换的需要,所有的应用都在初始化阶段被加载到内存中并同时驻留下去直到它们全部运行结束。而且,所有的应用都 直接通过物理地址访问物理内存会带来以下问题:
    • 首先, 内核提供给应用的内存访问接口不够透明,也不好用。由于应用直接访问物理内存,这需要它在构建的时候就需要规划自己需要被加载到哪个地址运行。为了避免冲突可能还需要应用的开发者们对此进行协商,这显然是一件在今天看来不可理喻且极端麻烦的事情。
    • 其次, 内核并没有对应用的访存行为进行任何保护措施,每个应用都有整块物理内存的读写权力。即使应用被限制在 U 特权级下运行,它还是能够造成很多麻烦:比如它可以读写其他应用的数据来窃取信息或者破坏它的正常运行;甚至它还可以修改内核的代码段来替换掉原本的 trap_handler 来挟持内核执行恶意代码。总之,这造成系统既不安全、也不稳定。
    • 再次,目前应用的内存使用空间在其运行前已经限定死了, 内核不能灵活地给应用程序提供的运行时动态可用内存空间 。比如一个应用结束后,这个应用所占的空间就被释放了,但这块空间无法动态地给其它还在运行的应用使用。
  • 因此,为了防止应用胡作非为,本章将更好的管理物理内存,并提供给应用一个抽象出来的更加透明易用、也更加安全的访存接口,这就是基于分页机制的虚拟内存。站在应用程序运行的角度看,就是存在一个从“0”地址开始的非常大的可读/可写/可执行的地址空间(Address Space)
  • 实现地址空间的第一步就是实现分页机制,建立好虚拟内存和物理内存的页映射关系。此过程涉及硬件细节,不同的地址映射关系组合,相对比较复杂。总体而言,我们需要思考如下问题:
    • 硬件中物理内存的范围是什么?
    • 哪些物理内存空间需要建立页映射关系?
    • 如何建立页表使能分页机制?
    • 如何确保OS能够在分页机制使能前后的不同时间段中都能正常寻址和执行代码?
    • 页目录表(一级)的起始地址设置在哪里?
    • 二级/三级等页表的起始地址设置在哪里,需要多大空间?
    • 如何设置页目录表项的内容?
    • 如何设置其它页表项的内容?
    • 如果要让每个任务有自己的地址空间,那每个任务是否要有自己的页表?
    • 代表应用程序的任务和操作系统需要有各自的页表吗?
    • 在有了页表之后,任务和操作系统之间应该如何传递数据?
  • 如果能解决上述问题,我们就能更好地理解地址空间,虚拟地址等操作系统的抽象概念与操作系统的虚存具体实现之间的联系。

本章任务

  • 本章展现了操作系统一系列功能:
    • 通过 动态内存分配,提高了应用程序对内存的动态使用效率。
    • 通过页表的 虚实内存映射机制,简化了编译器对应用的地址空间设置。
    • 通过页表的虚实内存映射机制,加强了应用之间,应用与内核之间的内存隔离,增强了系统安全。

【 1. C 中的动态内存分配 】

  • 到目前为止,如果将我们的内核也看成一个应用,那么其中所有的变量都是被静态分配在内存中的,这样在对空闲内存的使用方面缺少灵活性。我们希望能在操作系统中提供 动态申请和释放内存 的能力,这样就可以 加强操作系统对各种以内存为基础的资源分配与管理
  • 在应用程序的视角中, 动态内存分配中的内存,其实就是操作系统管理的 “堆 (Heap)”。但现在要实现操作系统,那么就需要操作系统自身能提供动态内存分配的能力。如果要实现动态内存分配的能力,需要操作系统需要有如下功能:
    • 初始时能提供一块大内存空间作为初始的“堆”。在 没有分页机制情况下,这块空间是物理内存空间,否则就是虚拟内存空间
    • 提供在堆上 分配一块内存的函数接口。这样函数调用方就能够得到一块地址连续的空闲内存块进行读写。
    • 提供 释放内存的函数接口。能够回收内存,以备后续的内存分配请求。
    • 提供 空闲空间管理的连续内存分配算法。能够有效地管理空闲快,这样就能够动态地维护一系列空闲和已分配的内存块。
    • (可选)提供 建立在堆上的数据结构和操作。有了上述基本的内存分配与释放函数接口,就可以实现类似动态数组,动态字典等空间灵活可变的堆数据结构,提高编程的灵活性。

1.1 C语言的内存分配

  • 在使用C++语言的过程中,大家其实对new/delete的使用方法已经烂熟于心了。在C中,对动态内存的申请是采用如下的函数实现的:
void* malloc (size_t size);
void free (void* ptr);
  • 其中,malloc函数 的作用是 从堆中分配一块大小为 size 字节的空间,并返回一个指向它的指针。而后续不用的时候,将这个 指针传给 free 即可在堆中回收这块空间。我们通过返回的指针变量来间接访问堆上的空间,而无法直接进行 访问。
  • 事实上,我们在程序中能够 直接 看到的变量都是被静态分配在栈或者全局数据段上的,它们大小在编译期已知比如这里 一个指针类型的大小就可以等于计算机可寻址空间的位宽。这样的它们却可以作为背后一块大小在编译期无法确定的空间的代表,这是一件非常有趣的 事情。

1.2 kalloc 中的动态内存分配

  • 同一个页的地址而言它对应的物理内存是连续的。但是, 连续的虚拟地址空间不一定对应着连续的物理地址空间,因此我们需要一个数据结构来存储哪些物理内存是可用的。对于这种给不连续的情况,我们采用链表的数据结构,将空闲的每个PAGE大小的物理内存空间作为listnode来进行内存的管理。这些新增的代码在kalloc.c之中。
  • 我们采用链表结构记录空闲的物理地址。因此当应用程序申请一段动态内存的时候,只需要把链表头所指向地址拿出即可。
// os/kalloc.c
struct linklist {
    struct linklist *next;
};

struct {
    struct linklist *freelist;
} kmem;
  • 注意,我们的管理仅仅在页这个粒度进行,所以所有的地址必须是 PAGE_SIZE 对齐的。
// os/kalloc.c: 页面分配
void *
kalloc(void)
{
    struct linklist *l;
    l = kmem.freelist;
    kmem.freelist = l->next;
    return (void*)l;
}

// os/kalloc.c: 页面释放
void *
kfree(void *pa)
{
    struct linklist *l;
    l = (struct linklist*)pa;
    l->next = kmem.freelist;
    kmem.freelist = l;
}
  • 那么我们的内核有那些空闲内存需要管理呢?
    事实上,qemu 已经规定了内核需要管理的内存范围,可以参考这里,具体来说,需要软件管理的内存为 [0x80000000, 0x88000000),其中,rustsbi 使用了 [0x80000000, 0x80200000) 的范围,其余都是内核使用。来看看 kmem 的初始化:
    我们在main函数中会执行kinit,它会初始化从ekernel到PHYSTOP的所有物理地址作为空闲的物理地址。freerange中调用的kfree函数以页为单位向对应内存中填入垃圾数据(全1),并把初始化好的一个页作为新的空闲listnode插入到链表首部。
// os/kalloc.c

// ekernel 为链接脚本定义的内核代码结束地址,PHYSTOP = 0x88000000
void
kinit()
{
    freerange(ekernel, (void*)PHYSTOP);
}

// kfree [pa_start, pa_end)
void
freerange(void *pa_start, void *pa_end)
{
    char *p;
    p = (char*)PGROUNDUP((uint64)pa_start);
    for(; p + PGSIZE <= (char*)pa_end; p += PGSIZE)
        kfree(p);
}
  • 注意,C语言之中要求进行内存回收,也就是malloc以及free要成对出现。但是我们的OS中不强制要求这一点,也就是如果测例本身未在申请动态内存后显式地调用free来释放内存,OS无需帮助它释放内存。

【 2. 地址空间 】

  • 直到现在,我们的操作系统给应用看到的是一个非常原始的物理内存空间,可以简单地理解为一个可以随便访问的大数组。为了限制应用访问内存空间的范围并给操作系统提供内存管理的灵活性,计算机硬件引入了各种 内存保护/映射硬件机制,如: RISC-V的基址-边界翻译和保护机制、x86的分段机制、RISC-V/x86/ARM都有的 分页机制,它们的共同之处在于 CPU访问的数据和指令内存地址是虚地址,需要进行转换形成合法的物理地址或产生非法的异常 。为了用好这种硬件机制,操作系统需要升级自己的能力。
  • 操作系统为了更好地管理这两种形式的内存,并给应用程序提供统一的访问接口,即应用程序不需要了解虚拟内存和物理内存的区别的,操作系统提出了 地址空间 Address Space 抽象,并在内核中建立虚实地址空间的映射机制,给应用程序提供一个虚拟的内存环境
  • 本节将结合操作系统的发展历程回顾来介绍 地址空间 Address Space 抽象的实现策略 是如何变化的。

2.1 虚拟地址和地址空间

2.1.1 地址虚拟化出现之前

  • 我们之前介绍过,在最早整套硬件资源只用来执行单个裸机应用的时候,并不存在真正意义上的操作系统,而只能算是一种应用 函数库。那个时候,物理内存的一部分用来保存函数库的代码和数据,余下的部分都交给应用来使用。从功能上可以将应用 占据的内存分成几个段:代码段、全局数据段、堆和栈等。当然,由于就只有这一个应用,它想如何调整布局都是它自己的 事情。从内存使用的角度来看,批处理系统和裸机应用很相似:批处理系统的每个应用也都是独占内核之外的全部内存空间, 只不过当一个应用出错或退出之后,它所占据的内存区域会被清空,而序列中的下一个应用将自己的代码和数据放置进来。 这个时期,内核提供给应用的访存视角是一致的,因为它们确实会在运行过程中始终独占一块固定的内存区域,每个应用开发者 都基于这一认知来规划程序的内存布局。
  • 后来,为了降低等待 I/O 带来的无意义的 CPU 资源损耗,多道程序出现了。而为了提升用户的交互式体验,提高生产力,分时 多任务系统诞生了。它们的特点在于:应用开始多出了一种“暂停”状态,这可能来源于它主动 yield 交出 CPU 资源,或是在 执行了足够长时间之后被内核强制性换出。当应用处于暂停状态的时候,它驻留在内存中的代码、数据该何去何从呢? 曾经有一种 做法是每个应用仍然和在批处理系统中一样独占内核之外的整块内存,当暂停的时候,内核负责 将它的代码、数据保存在磁盘或 硬盘中,然后把即将换入的应用保存在磁盘上的代码、数据恢复到内存,这些都做完之后才能开始执行新的应用。不过,由于这种做法需要大量读写内存和外部存储设备,而它们的 速度都比 CPU 慢上几个数量级,这导致任务切换的开销过大, 甚至完全不能接受。
  • 既然如此,就只能像我们在第三章中的做法一样,限制每个应用的最大可用内存空间小于物理内存的容量,这样 就可以同时把多个应用的数据驻留在内存中。在任务切换的时候只需完成任务上下文保存与恢复即可,这只是在内存的帮助下保存、 恢复少量通用寄存器,甚至无需访问外存,这从很大程度上降低了任务切换的开销。在本章的引言中介绍过第三章中操作系统的做法对应用程序开发带了一定的困难。从应用开发的角度看,需要应用程序决定自己会被加载到哪个物理地址运行,需要直接访问真实的 物理内存。这就要求应用开发者对于硬件的特性和使用方法有更多了解,产生额外的学习成本,也会为应用的开发和调试带来不便。从 内核的角度来看,将直接访问物理内存的权力下放到应用会使得它 难以对应用程序的访存行为进行有效管理,已有的特权级机制亦无法 阻止很多来自应用程序的恶意行为

2.1.2 加一层抽象加强内存管理

  • 为了解决这种困境,抽象仍然是最重要的指导思想。在这里,抽象意味着内核要负责将物理内存管理起来,并为上面的应用提供 一层抽象接口,从之前的失败经验学习,这层抽象需要达成下面的设计目标:

    • 透明 :应用开发者可以不必了解底层真实物理内存的硬件细节,且在非必要时也不必关心内核的实现策略, 最小化他们的心智负担;
    • 高效 :这层抽象至少在大多数情况下不应带来过大的额外开销;
    • 安全 :这层抽象应该有效检测并阻止应用读写其他应用或内核的代码、数据等一系列恶意行为。
  • 最终,到目前为止仍被操作系统内核广泛使用的抽象被称为 地址空间 (Address Space) 。某种程度上讲,可以将它看成 一块巨大但并不一定真实存在的内存。在每个应用程序的视角里,操作系统分配给应用程序一个范围有限(但其实很大),独占的连续地址空间(其中有些地方被操作系统限制不能访问,如内核本身占用的虚地址空间等),因此应用程序可以在划分给它的地址空间中随意规划内存布局,它的 各个段也就可以分别放置在地址空间中它希望的位置(当然是操作系统允许应用访问的地址)。 应用同样可以使用一个地址作为索引来读写自己地址空间的数据,就像用物理地址作为索引来读写物理内存上的数据一样,这种地址被称为 虚拟地址 (Virtual Address) 。当然,操作系统要达到 地址空间 抽象的设计目标,需要有计算机硬件的支持,这就是计算机组成原理课上讲到的 MMU 和 TLB 等硬件机制。

  • 从此, 应用能够直接看到并访问的内存就只有操作系统提供的地址空间,且它的任何一次访存使用的地址都是虚拟地址,无论取指令来执行还是读写 栈、堆或是全局数据段都是如此。事实上,特权级机制被拓展,使得应用不再具有通过物理地址直接访问物理内存的能力。应用所处的执行环境在安全方面被进一步强化,形成了用户态特权级和地址空间的二维安全措施。

  • 由于每个应用独占一个地址空间,里面只含有自己的各个段,于是它可以随意规划 各个段的分布而无需考虑和其他应用冲突;同时,它完全无法窃取或者破坏其他应用的数据,毕竟那些段在其他应用的地址空间 内,鉴于 应用只能通过虚拟地址读写它自己的地址空间 ,这是它没有能力去访问的。这是 地址空间 抽象对应用程序执行的安全性和稳定性的一种保障。

  • 我们知道应用的数据终归还是存在物理内存中的,那么虚拟地址如何形成地址空间,虚拟地址空间如何转换为物理内存呢?操作系统可以设计巧妙的数据结构来表示地址空间。但如果完全由操作系统来完成转换每次处理器地址访问所需的虚实地址转换,那开销就太大了。这就需要 扩展硬件功能来加速地址转换过程(回忆 计算机组成原理 课上讲的 MMU 和 TLB )。

2.1.3 增加硬件加速虚实地址转换

在这里插入图片描述

  • 开始回顾一下 计算机组成原理 课。如上图所示,当应用取指或者执行 一条访存指令的时候,它都是在以虚拟地址为索引读写自己的地址空间。此时, CPU 中的 内存管理单元 (MMU, Memory Management Unit) 自动将这个虚拟地址进行 地址转换 (Address Translation) 变为一个物理地址, 也就是物理内存上这个应用的数据真实被存放的位置。也就是说,在 MMU 的帮助下,应用对自己地址空间的读写才能被实际转化为 对于物理内存的访问。
  • 事实上, 每个应用的地址空间都可以看成一个从虚拟地址到物理地址的映射。可以想象对于不同的应用来说,该映射可能是不同的, 即 MMU 可能会将来自不同两个应用地址空间的相同虚拟地址翻译成不同的物理地址。要做到这一点,就需要 硬件提供一些寄存器 ,软件可以对它进行设置来控制 MMU 按照哪个应用的地址空间进行地址转换。于是,将应用的数据放到物理内存并进行管理,而 在任务切换的时候需要将控制 MMU 选用哪个应用的地址空间进行映射的那些寄存器也一并进行切换,则是作为软件部分的内核需 要完成的工作。
  • 回过头来,在介绍内核对于 CPU 资源的抽象——时分复用的时候,我们曾经提到它为应用制造了一种每个应用独占整个 CPU 的 幻象,而隐藏了多个应用分时共享 CPU 的实质。而地址空间也是如此, 应用也只能看到它独占整个地址空间的幻象,而 藏在背后的实质仍然是多个应用共享物理内存,它们的数据分别存放在内存的不同位置
  • 地址空间只是一层抽象接口,它有很多种具体的实现策略。对于不同的实现策略来说,操作系统内核如何规划应用数据放在物理内存的位置, 而 MMU 又如何进行地址转换也都是不同的。下面我们简要介绍几种曾经被使用的策略,并探讨它们的优劣。

2.2 分段内存管理

2.2.1 等量分配

在这里插入图片描述

  • 曾经的一种做法如上图所示:每个应用的地址空间大小限制为一个固定的常数 bound ,也即 每个应用的可用虚拟地址区间 均为 [0,bound) 。随后,以这个大小为单位,将物理内存除了内核预留空间之外的部分划分为若干 个大小相同的 插槽 (Slot) ,每个应用的所有数据都被内核放置在其中一个插槽中,对应于物理内存上的一段连续物理地址 区间,假设其起始物理地址为 base ,则由于二者大小相同,这个区间实际为 [base,base+bound) 。因此地址转换很容易完成,只需检查一下虚拟地址不超过地址空间 的大小限制(此时需要借助特权级机制通过异常来进行处理),然后做一个线性映射,将虚拟地址加上 base 就得到了数据实际所在的物理地址。
  • 可以看出,这种实现极其简单:MMU 只需要 base,bound 两个寄存器,在地址转换进行比较或加法运算即可;而内核只需要在任务切换的同时切换 base 寄存器(由于 bound是一个常数),内存 管理方面它只需考虑一组插槽的占用状态,可以用一个 位图 (Bitmap) 来表示,随着应用的新增和退出对应置位或清空。
  • 然而,它的问题在于:浪费的内存资源过多。注意到应用地址空间预留了一部分,它是用来让栈得以向低地址增长,同时允许堆 往高地址增长(支持应用运行时进行动态内存分配)。每个应用的情况都不同,内核只能按照在它能力范围之内的消耗内存最多 的应用的情况来统一指定地址空间的大小,而其他内存需求较低的应用根本无法充分利用内核给他们分配的这部分空间,但这部分空间又是一个完整的插槽的一部分,也不能再交给其他应用使用,这种在地址空间内部无法被充分利用的空间被称为 内碎片 (Internal Fragment) ,它限制了系统同时共存的应用数目。如果应用的需求足够多样化,那么内核无论如何设置 应用地址空间的大小限制也不能得到满意的结果。这就是固定参数的弊端:虽然实现简单,但不够灵活。

2.2.2 按需分配

  • 为了解决这个问题,一种分段管理的策略开始被使用,如下图所示:
    在这里插入图片描述
  • 注意到 内核开始以更细的粒度,也就是应用地址空间中的一个逻辑段作为单位来安排应用的数据在物理内存中的布局。对于每个段来说,从它在某个应用地址空间中的虚拟地址到它被实际存放在内存中的物理地址中间都要经过一个不同的线性映射,于是 MMU 需要用一对不同的 base/bound 进行区分。这里由于每个段的大小都是不同的,我们也不再能仅仅 使用一个 bound 进行简化。当任务切换的时候,这些对寄存器也需要被切换。
  • 简单起见,我们这里忽略一些不必要的细节。比如应用在以虚拟地址为索引访问地址空间的时候,它如何知道该地址属于哪个段,从而硬件可以使用正确的一对 base/bound 寄存器进行合法性检查和完成实际的地址转换。这里只关注 分段管理是否解决了内碎片带来的内存浪费问题。注意到每个段都只会在内存中占据一块与它实际所用到的大小相等的空间。堆 的情况可能比较特殊,它的大小可能会在运行时增长,但是那需要应用通过系统调用向内核请求。也就是说这是一种 按需分配,而 不再是内核在开始时就给每个应用分配一大块很可能用不完的内存。由此,不再有内碎片了。
  • 尽管内碎片被消除了,但内存浪费问题并没有完全解决。这是因为每个段的大小都是不同的(它们可能来自不同的应用,功能 也不同),内核就需要使用更加通用、也更加复杂的连续内存分配算法来进行内存管理,而不能像之前的插槽那样以一个比特 为单位。顾名思义,连续内存分配算法就是每次需要分配一块连续内存来存放一个段的数据。 随着一段时间的分配和回收,物理内存还剩下一些相互不连续的较小的可用连续块,其中有一些只是两个已分配内存块之间的很小的间隙,它们自己可能由于空间较小,已经无法被 用于分配,被称为 外碎片 (External Fragment)

举个例子,在内存上,分配三个操作系统分配的用于装载进程的内存区域A、B和C。假设,三个内存区域都是相连的,故而三个内存区域不会产生外部碎片。现在假设B对应的进程执行完毕了操作系统随即收回了B,这个时候A和C中间就有一块空闲区域了。

  • 如果这时再想分配一个比较大的块, 就需要将这些不连续的外碎片“拼起来”,形成一个大的连续块。然而这是一件开销很大的事情,涉及到极大的内存读写开销。具体而言,这需要移动和调整一些已分配内存块在物理内存上的位置,才能让那些小的外碎片能够合在一起,形成一个大的空闲块。如果连续内存分配算法 选取得当,可以尽可能减少这种操作。课上所讲到的那些算法,包括 first-fit/worst-fit/best-fit 或是 buddy system,其具体表现取决于实际的应用需求,各有优劣。
  • 那么,分段内存管理带来的外碎片和连续内存分配算法比较复杂的 问题可否被解决呢?

2.3 分页内存管理

  • 问题背景:内碎片和外碎片
    仔细分析一下可以发现,段的大小不一是外碎片产生的根本原因。之前我们把应用的整个地址空间连续放置在物理内存中,在 每个应用的地址空间大小均相同的情况下,只需利用类似位图的数据结构维护一组插槽的占用状态,从逻辑上 分配和回收都是以一个固定的比特为单位,自然也就不会存在外碎片了。但是这样粒度过大,不够灵活,又在地址空间内部产生了内碎片
  • 解决方法:分页内存管理
    若要结合二者的优点的话,就需要 内核始终以一个同样大小的单位来在物理内存上放置应用地址空间中的数据,这样内核就可以 使用简单的插槽式内存管理,使得内存分配算法比较简单且不会产生外碎片;同时,这个 单位的大小要足够小,从而其内部没有 被用到的内碎片的大小也足够小,尽可能提高内存利用率,这便是我们将要介绍的 分页内存管理
    在这里插入图片描述
  • 如上图所示, 内核以页为单位进行物理内存管理,每个应用的地址空间可以被分成若干个(虚拟)页面 (Page) ,可用的物理内存也同样可以被分成若干个(物理)页帧 (Frame)
    虚拟页面和物理页帧的大小相同,每个虚拟页面中的数据实际上都存储在某个物理页帧上
  • 分段内存管理和分页内存管理的区别
    相比分段内存管理,分页内存管理的粒度更小,应用地址空间中的每个逻辑段都由多个虚拟页面组成,而 每个虚拟页面在地址转换的过程中都使用一个不同的线性映射,而不是在分段内存管理中每个逻辑段都使用一个相同的线性映射
  • 页号、页表
    • 为了方便实现虚拟页面到物理页帧的地址转换,我们给每个虚拟页面和物理页帧一个编号,分别称为 虚拟页号 (VPN, Virtual Page Number) 物理页号 (PPN, Physical Page Number)
    • 每个应用都有一个不同的 页表 (Page Table) ,里面记录了该应用地址空间中的每个虚拟页面映射到物理内存中的哪个物理页帧,即数据实际 被内核放在哪里。我们可以用页号来代表二者,因此如果 将页表看成一个键值对,其键的类型为虚拟页号,值的类型则为物理 页号
    • 流程:当 MMU 进行地址转换的时候, MMU首先找到给定的虚拟地址所在的虚拟页面的页号,然后查当前应用的页表根据虚拟页号找到物理页号,最后按照虚拟地址在它所在的虚拟页面中的相对位置相应给物理页号对应的物理页帧的起始地址加上一个偏移量,这就得到了实际访问的物理地址
  • 物理地址的权限
    在页表中通过虚拟页号不仅能查到物理页号,还能得到一组 rwx 保护位,它限制了应用对转换得到的物理地址对应的内存的使用方式。 最典型的如 rwx , r 表示当前应用可以读该内存; w 表示当前应用可以写该内存; x 则表示当前应用 可以从该内存取指令用来执行。一旦违反了这种限制则会触发异常被内核捕获到。通过适当的设置,可以检查一些应用明显的 错误:比如应用修改自己本应该只读的代码段,或者从数据段取指令来执行。
  • 当一个应用的地址空间比较大的时候,页表里面的项数会很多(事实上每个虚拟页面都应该对应页表中的一项,上图中我们已经 省略掉了那些未被使用的虚拟页面),导致它的容量极速膨胀,已经不再是像之前那样数个寄存器便可存下来的了,CPU 内也没有 足够的硬件资源能够将它存下来。因此它 只能作为一种被内核管理的数据结构放在内存中,但是 CPU 也会直接访问它来查页表, 这也就需要内核和硬件之间关于页表的内存布局达成一致。
  • 由于分页内存管理既简单又灵活,它逐渐成为了主流,RISC-V 架构也使用了这种策略。后面我们会基于这种机制,自己来动手从物理内存抽象出应用的地址空间来。

2.4 C的内存布局

  • 在memory_layout.h之中我们展示了内存的布局。
    其中前两项在上一节已经介绍过了。下面的MAXVA其实并不是SV39中最大的虚拟地址(39位全为1)。我们设置它为(1<<38)是因为VA的高位填充是根据39位来进行的。为了方便全部填充0,就不考虑大于(1<<38)的虚拟地址。 在我们的框架之中,va不可能大于MAXVA。大家可以看到,我们指定TRAMPOLINE和TRAPFRAME在va的最高位,这是为什么呢?大家可以自行思考一下,我们将在下面解释。
// the kernel expects there to be RAM
// for use by the kernel and user pages
// from physical address 0x80000000 to PHYSTOP.
#define KERNBASE 0x80200000L
#define PHYSTOP (0x80000000 + 128*1024*1024) // 128M

// map the trampoline page to the highest address,
// in both user and kernel space.

// one beyond the highest possible virtual address.
// MAXVA is actually one bit less than the max allowed by
// Sv39, to avoid having to sign-extend virtual addresses
// that have the high bit set.
#define MAXVA (1L << (9 + 9 + 9 + 12 - 1))

#define USER_TOP (MAXVA)
#define TRAMPOLINE (USER_TOP - PGSIZE)
#define TRAPFRAME (TRAMPOLINE - PGSIZE)

#define USTACK_BOTTOM (0x0)

【 3. SV39多级页表机制:内容介绍 】

  • 在上一小节中我们已经简单介绍了分页的内存管理策略,现在我们尝试在 RV64 架构提供的 SV39 分页机制的基础上完成内核中的软件对应实现。由于内容过多,我们将分成两个小节进行讲解。本节主要讲解在RV64架构下的虚拟地址与物理地址的访问属性(可读,可写,可执行等),组成结构(页号,帧号,偏移量等),访问的空间范围等;以及我们在OS中如何进行页表的处理。

3.1 内存控制相关的CSR寄存器

  • 默认情况下 MMU 未被使能,此时无论 CPU 位于哪个特权级,访存的地址都会作为一个物理地址交给对应的内存控制单元来直接 访问物理内存
    我们可以 通过修改 S 特权级的一个名为 satp 的 CSR 来启用分页模式,在这之后 S 和 U 特权级的访存 地址会被视为一个虚拟地址,它需要经过 MMU 的地址转换变为一个物理地址,再通过它来访问物理内存;而 M 特权级的访存地址,我们可设定是内存的物理地址。(注解:M 特权级的访存地址被视为一个物理地址还是一个需要经历和 S/U 特权级相同的地址转换的虚拟地址取决于硬件配置,在这里我们不会进一步探讨。)
    在这里插入图片描述
  • 上图是 RV64 架构下 satp 的字段分布。当 MODE 设置为 0 的时候,代表所有访存都被视为物理地址;而设置为 8 的时候,SV39 分页机制被启用, 所有 S/U 特权级的访存被视为一个 39 位的虚拟地址,它们需要先经过 MMU 的地址转换流程, 如果顺利的话,则会 变成一个 56 位的物理地址来访问物理内存;否则则会触发异常,这体现了该机制的内存保护能力。
  • 虚拟地址和物理地址都是字节地址,39 位的虚拟地址可以用来访问理论上最大 512GiB( 2 39 = 2 9 ⋅ 2 30 = 512 G 2^{39}=2^9\cdot 2^{30}=512G 239=29230=512G) 的地址空间, 而 56 位的物理地址在理论上甚至可以访问一块大小比这个地址空间的还高出几个数量级的物理内存。但是实际上 无论是 虚拟地址还是物理地址,真正有意义、能够通过 MMU 的地址转换或是 CPU 内存控制单元的检查的地址仅占其中的很小 一部分 ,因此它们的理论容量上限在目前都没有实际意义。

3.2 地址格式与组成

在这里插入图片描述

  • 我们采用分页管理,单个页面的大小设置为 4KiB 即 2 12 2^{12} 212 个字节,每个虚拟页面和物理页帧都对齐到这个页面大小,也就是说 虚拟/物理地址区间 [0,4KiB) 为第0个虚拟页面/物理页帧,而 [4KiB,8KiB) 为第1个,以此类推。
    4KiB 需要用 12 位字节地址来表示,因此虚拟地址和物理地址都被分成两部分:
    • 它们的低 12 位即 [11:0] 被称为 页内偏移 (Page Offset)
      页内偏移描述一个地址指向的字节在它所在页面中的相对位置。
    • 虚拟地址的高 27 位即 [38:12] 为它的 虚拟页号 VPN
      物理地址的高 44 位即 [55:12] 为它的 物理页号 PPN
      页号可以用来定位一个虚拟/物理地址 属于哪一个虚拟页面/物理页帧。
  • 地址转换流程
    地址转换是以页为单位进行的,在地址转换的前后地址的页内偏移部分不变。可以认为 MMU 只是从虚拟地址中取出 27 位虚拟页号, 在页表中查到其对应的物理页号(如果存在的话),最后将得到的44位的物理页号与虚拟地址的12位页内偏移依序拼接到一起就变成了56位的物理地址

注解:RV64 架构中虚拟地址为何只有 39 位?

  • 在 64 位架构上虚拟地址长度确实应该和位宽一致为 64 位,但是在启用 SV39 分页模式下,只有低 39 位是真正有意义的SV39 分页模式规定 64 位虚拟地址的 [63:39] 这高 25 位必须和第 38 位相同,否则 MMU 会直接认定它是一个 不合法的虚拟地址。通过这个检查之后 MMU 再取出低 39 位尝试将其转化为一个 56 位的物理地址。
  • 也就是说,所有 2 64 2^{64} 264 个虚拟地址中,只有最低的 256GiB (当第 38 位为0时)以及最高的 256GiB (当第 38 位为 1 时)是可能通过 MMU 检查的。当我们写软件代码的时候,一个 地址的位宽毋庸置疑就是 64 位,我们要清楚可用的只有最高和最低这两部分,尽管它们已经巨大的超乎想象了;而本节中 我们专注于介绍 MMU 的机制,强调MMU 看到的真正用来地址转换的虚拟地址只有 39 位。

3.3 多级页表原理

3.3.1 线性表实现

  • 页表的一种最简单的实现是线性表,也就是按照地址从低到高、输入的虚拟页号从 0 开始递增的顺序依次在内存中放置每个虚拟页号对应的页表项 (我们之前提到过页表的容量过大无法保存在 CPU 中)。由于 每个页表项的大小是 8 字节,我们只要知道第一个页表项(对应虚拟页号 0 )被放在的物理地址 base_addr ,就能 直接计算出每个输入的虚拟页号对应的页表项所在的位置。如下图所示:
    在这里插入图片描述
  • 事实上,对于虚拟页号 i,如果页表(每个应用都有一个页表,这里指其中某一个)的起始地址为 base_addr,则这个虚拟页号对应的页表项可以在物理地址 base_addr+8i 处找到, 这使得 MMU 的实现和内核的软件控制都变得非常简单。然而遗憾的是,这远远超出了我们的物理内存限制:由于虚拟页号有 2 27 2^{27} 227 种,每个虚拟页号对应一个 8 字节的页表项,则每个页表都需要消耗掉 1GiB( 2 27 ⋅ 2 3 = 2 30 = 1 G 2^{27}\cdot 2^3=2^{30}=1G 22723=230=1G) 内存,应用的数据还需要保存在内存的其他位置,这就使得 每个应用要吃掉 1GiB 以上的内存。作为对比,K210 开发板目前只有 8MiB 的内存,因此从空间占用角度来说,这种线性表实现是完全不可行的。
  • 线性表的问题在于:线性表保存了所有虚拟页号对应的页表项,但是高达 512GiB 的地址空间中真正会被应用 使用到的只是其中极小的一个子集(本教程中的应用内存使用量约在数十~数百 KiB 量级),也就导致有意义并能在页表中查到实际的物理页号的虚拟页号在 2 27 2^{27} 227 中也只是很小的一部分。由此线性表的绝大部分空间 其实都是被浪费掉的。

3.3.2 字典树实现

  • 背景
    那么如何进行优化呢?核心思想就在于 按需分配 ,也就是说:有多少合法的虚拟页号,我们就维护一个多大的映射,并为此使用多大的内存用来保存映射。这是因为,每个应用的地址空间最开始都是空的,或者说所有的虚拟页号均不合法,那么这样的页表自然不需要占用任何内存, MMU 在地址转换的时候无需关心页表的内容而是将所有的虚拟页号均判为不合法即可。而在后面,内核已经决定好了一个应用的各逻辑段存放位置之后, 内核负责从零开始以虚拟页面为单位来让该应用的地址空间的某些部分变得合法,反映在该应用的页表上也就是一对对映射顺次被插入进来,自然页表所占据的内存大小也就逐渐增加
  • 这种思想在计算机科学中得到了广泛应用,为了方便接下来的说明,我们可以举一道数据结构的题目作为例子:
    • 设想我们要维护 一个字符串的多重集,集合中所有的字符串的字符集均为 α = { a , b , c } \alpha=\{a,b,c\} α={a,b,c},长度均为一个给定的常数 n,该字符串集合一开始为空集。我们要支持两种操作,第一种是将一个字符串插入集合,第二种是查询一个字符串在当前 的集合中出现了多少次。简单起见,假设 n = 3 n=3 n=3 。那么我们可能会建立这样一棵 字典树 (Trie) :
    • 字典树由若干个节点(图中用椭圆形来表示)组成,从逻辑上而言每个节点代表一个可能的字符串前缀。每个节点的存储内容 都只有三个指针,对于蓝色的非叶节点来说,它的三个指针各自指向一个子节点;而对于绿色的叶子节点来说,它的三个指针不再指向 任何节点,而是具体保存一种可能的长度为 n 的字符串的计数。这样,对于题目要求的两种操作,我们只需根据输入的字符串中的每个字符在字典树上自上而下对应走出一步,最终就能够找到字典树中维护的它的计数。之后我们可以将其直接返回或者加一。

在这里插入图片描述

  • 注意到如果某些字符串自始至终没有被插入,那么一些节点没有存在的必要。反过来说一些节点是由于我们插入了一个以它对应的字符串为前缀的字符串才被分配出来的。如下图所示:
    • 一开始仅存在一个根节点 root。在我们插入字符串 acb 的过程中,我们只需要分配 a 和 ac 两个节点。 注意 ac 是一个叶节点,它的 b 指针不再指向另外一个节点而是保存字符串 acb 的计数。 此时我们无法访问到其他未分配的节点,如根节点的 b/c 或是 a 节点的 a/b 均为空指针。 如果后续再插入一个字符串,那么 至多分配两个新节点 ,因为如果走的路径上有节点已经存在,就无需重复分配了。 这可以说明, 字典树中节点的数目(或者说字典树消耗的内存)是随着插入字符串的数目逐渐线性增加的。

在这里插入图片描述

  • 读者可能很好奇,为何在这里要用相当一部分篇幅来介绍字典树呢?事实上 SV39 分页机制等价于一颗字典树。 27 位的虚拟页号可以看成一个长度 n = 3 n=3 n=3 的字符串,字符集为 α = { 0 , 1 , 2 , . . . , 511 } \alpha = \{0,1,2,...,511\} α={0,1,2,...,511} ,因为每一位字符都由 9 个比特( 2 9 = 512 2^9=512 29=512)组成。而我们也不再维护所谓字符串的计数,而是要找到字符串(虚拟页号)对应的页表项。
    • 因此,每个叶节点都需要保存 2 9 = 512 2^9=512 29=512 个 8 字节的页表项,一共正好 2 9 ⋅ 2 3 = 2 11 = 4 K i B 2^9\cdot 2^3=2^{11}=4KiB 2923=211=4KiB ,可以直接放在一个物理页帧内。而对于非叶节点来说,从功能上它只需要保存 512 个指向下级节点的指针即可,不过我们就像叶节点那样也保存 512 个页表项,这样所有的节点都可以被放在一个物理页帧内,它们的位置可以用一个 物理页号来代替。当想从一个非叶节点向下走时,只需找到当前字符对应的页表项的物理页号字段,它就指向了下一级节点的位置, 这样非叶节点中转的功能也就实现了。
    • 每个节点的内部是一个线性表,也就是将这个节点起始物理地址加上字符对应的偏移量就找到了 指向下一级节点的页表项(对于非叶节点)或是能够直接用来地址转换的页表项(对于叶节点)。这种页表实现被称为 多级页表 (Multi-Level Page-Table) 。由于 SV39 中虚拟页号被分为三级页索引 (Page Index) ,因此这是一种三级页表。
  • 非叶节点的页表项标志位含义和叶节点相比有一些不同:
    • 当 V 为 0 的时候,代表当前指针是一个空指针,无法走向下一级节点,即该页表项对应的虚拟地址范围是无效的;
    • 只有当V 为1 且 R/W/X 均为 0 时,表示是一个合法的页目录表项,其包含的指针会指向下一级的页表。
    • 注意: 当V 为1 且 R/W/X 不全为 0 时,表示是一个合法的页表项,其包含了虚地址对应的物理页号
    • 在这里我们给出 SV39 中的 R/W/X 组合的含义:
      在这里插入图片描述

拓展:大页 (Huge Page)
本教程中并没有用到大页的知识,这里只是作为拓展,不感兴趣的读者可以跳过。

  • 事实上正确的说法应该是:只要 R/W/X 不全为 0 就会停下来,直接从当前的页表项中取出物理页号进行最终的地址转换。 如果这一过程并没有发生在多级页表的最深层,那么在地址转换的时候并不是直接将物理页号和虚拟地址中的页内偏移接 在一起得到物理地址,这样做会有问题:由于有若干级页索引并没有被使用到,即使两个虚拟地址的这些级页索引不同, 还是会最终得到一个相同的物理地址,导致冲突。
  • 我们需要重新理解将物理页号和页内偏移“接起来”这一行为,它的本质是将物理页号对应的物理页帧的起始物理地址和 页内偏移进行求和,前者是将物理页号左移上页内偏移的位数得到,因此看上去恰好就是将物理页号和页内偏移接在一起。 但是如果在从多级页表往下走的中途停止,未用到的页索引会和虚拟地址的 12 位页内偏移一起形成一个 位数更多的页内偏移,也就对应于一个大页,在转换物理地址的时候,其算法仍是上述二者求和,但那时便不再是简单的 拼接操作。
  • 在 SV39 中,如果使用了一级页索引就停下来,则它可以涵盖虚拟页号的前 9 位为某一固定值的所有虚拟地址, 对应于一个 1GiB 的大页;如果使用了二级页索引就停下来,则它可以涵盖虚拟页号的前 18 位为某一固定值的所有虚拟地址,对应于一个 2MiB 的大页。以同样的视角,如果使用了 所有三级页索引才停下来,它可以涵盖虚拟页号为某一个固定值的所有虚拟地址,自然也就对应于一个大小为 4KiB 的虚拟页面。
  • 使用大页的优点在于,当地址空间的大块连续区域的访问权限均相同的时候,可以直接映射一个大页,从时间上避免了大量 页表项的索引和修改,从空间上降低了所需节点的数目。但是,从内存分配算法的角度,这需要内核支持从物理内存上分配 三种不同大小的连续区域( 4KiB 或是另外两种大页),便不能使用更为简单的插槽式管理。权衡利弊 之后,本书全程只会以 4KiB 为单位进行页表映射而不会使用大页特性。
  • 那么 SV39 多级页表相比线性表到底能节省多少内存呢?这里直接给出结论:设某个应用地址空间实际用到的区域总大小为 S 字节,则地址空间对应的多级页表消耗内存为 S 512 \frac{S}{512} 512S 左右。下面给出了详细分析,对此 不感兴趣的读者可以直接跳过。

分析 SV39 多级页表的内存占用

  • 我们知道,多级页表的总内存消耗取决于节点的数目,每个节点则需要一个大小为
    4KiB 物理页帧存放。不妨设某个应用地址空间中的实际用到的总空间大小为 S 字节,则多级页表所需的内存至少有这样两个上界:
    • 每映射一个 4KiB 的虚拟页面,最多需要新分配两个物理页帧来保存新的节点,加上初始就有一个根节点, 因此消耗内存不超过 4 K i B × ( 1 + 2 S 4 K i B = 4 K i B + 2 S ) 4KiB\times (1+2\frac{S}{4KiB}=4KiB+2S) 4KiB×(1+24KiBS=4KiB+2S)
    • 考虑已经映射了很多虚拟页面,使得根节点的 512 个孩子节点都已经被分配的情况,此时最坏的情况是每次映射 都需要分配一个不同的最深层节点,加上根节点的所有孩子节点并不一定都被分配,从这个角度来讲消耗内存不超过 4 K i B × ( 1 + 512 + S 4 K i B = 4 K i B + 2 M i B + S ) 4KiB\times (1+512+\frac{S}{4KiB}=4KiB+2MiB+S) 4KiB×(1+512+4KiBS=4KiB+2MiB+S)
  • 虽然这两个上限都可以通过刻意构造一种地址空间的使用来达到,但是它们看起来很不合理,因为它们均大于 S,也就是 元数据比数据还大。其实,真实环境中一般不会有如此极端的使用方式,更加贴近 实际的是下面一种上限:即除了根节点的一个物理页帧之外,地址空间中的每个实际用到的大小为 T 字节的 连续 区间 会让多级页表额外消耗不超过 4 K i B × ( [ T 2 M i B ] + [ T 1 G i B ] ) 4KiB\times([\frac{T}{2MiB}]+[\frac{T}{1GiB}]) 4KiB×([2MiBT]+[1GiBT]) 的内存。这是因为,括号中的两项分别对应为了映射这段连续区间所需要新分配的最深层和次深层节点的数目,前者每连续映射 2MiB 才会新分配一个,而后者每连续映射 1GiB 才会新分配一个。由于后者远小于前者, 可以将后者忽略,最后得到的结果近似于 T 512 \frac{T}{512} 512T。而一般情况下我们对于地址空间的使用方法都是在其中 放置少数几个连续的逻辑段,因此当一个地址空间实际使用的区域大小总和为 S 字节的时候,我们可以认为为此多级页表 消耗的内存在 S 512 \frac{S}{512} 512S 左右。相比线性表固定消耗 1GiB 的内存,这已经相当可以 接受了。
  • 上面主要是对一个固定应用的多级页表进行了介绍。在一个多任务系统中,可能同时存在多个任务处于运行/就绪状态,它们的多级页表在内存中共存,那么 MMU 应该如何知道当前做地址转换的时候要查哪一个页表呢?
    回到 satp CSR 的布局 , CSR寄存器 的 PPN 字段指的就是多级页表根节点所在的物理页号。因此, 每个应用的地址空间就可以用包含了它多级页表根节点所在物理页号 的 satp CSR 代表。在我们切换任务的时候, satp 也必须被同时切换。
  • 最后的最后,我们给出 SV39 地址转换的全过程图示来结束多级页表原理的介绍:
    在这里插入图片描述

【 4. SV39多级页表机制:OS 实现 】

  • 本节我们将讲述OS是如何实现页表的支持的。在深入本章的内容之前,大家一定要牢记, 完成虚拟地址查询页表或TLB转换成物理地址的过程是由硬件,也就是CPU来完成的(MMU)。我们在 框架之中实现的地址转换函数是为了我们在某些函数中自己计算虚拟地址到物理地址转换使用的
  • OS操作系统负责对页表进行建立、更改等处理。
    真正在程序运行时,CPU对指令、数据虚拟地址会十分机械地按照下面讲述的方法使用os创建好的页表进行地址转换。

4.1 地址相关的数据结构抽象

  • 正如本章第一小节所说,在分页内存管理中,地址转换的核心任务在于如何维护虚拟页号到物理页号的映射——也就是页表。我们对页表的操作集中在vm.c文件之中,首先是为了实现页表,我们新增的类型的定义:
// os/types.h

typedef uint64 pte_t;
typedef uint64 pde_t;
typedef uint64 *pagetable_t;// 512 PTEs
  • 页表项
    第一小节中我们提到,在页表中以虚拟页号作为索引不仅能够查到物理页号,还能查到一组保护位,它控制了应用对地址空间每个虚拟页面的访问权限。但实际上还有更多的标志位,物理页号和全部的标志位以某种固定的格式保存在一个结构体中,它被称为 页表项 (PTE, Page Table Entry) ,是利用虚拟页号在页表中查到的结果。
    在这里插入图片描述
  • 上图为 SV39 分页模式下的页表项,其中 [53:10] 这 44 位是物理页号,最低的 8 位 [7:0] 则是标志位,它们的含义如下(请注意,为方便说明,下文我们用 页表项的对应虚拟页面 来表示索引到 一个页表项的虚拟页号对应的虚拟页面):
    • V(Valid) :仅当 V(Valid) 位为 1 时,页表项才是合法的;
    • R/W/X :分别控制索引到这个页表项的对应虚拟页面是否允许读/写/取指;
    • U :控制索引到这个页表项的对应虚拟页面是否在 CPU 处于 U 特权级的情况下是否被允许访问;
    • G :我们暂且不理会;
    • A(Accessed) :记录自从页表项上的这一位被清零之后,页表项的对应虚拟页面是否被访问过;
    • D(Dirty) :记录自从页表项上的这一位被清零之后,页表项的对应虚拟页表是否被修改过。
  • 由于pte页表项只有54位,每一个页表项我们用一个8字节的无符号整型来记录就已经足够。

4.1.1 页表实现va–>pa的转换过程

  • 下面我们通过来解读我们OS的walk函数了解SV39的页表读取机制。
    walk函数 模拟了CPU进行MMU的过程。它的参数分别是页表,待转换的虚拟地址va,以及如果没有对应的物理地址时是否分配物理地址。
:linenos:

// os/vm.c

pte_t *
walk(pagetable_t pagetable, uint64 va, int alloc) {
    if (va >= MAXVA)
        panic("walk");

    for (int level = 2; level > 0; level--) {
        pte_t *pte = &pagetable[PX(level, va)];
        if (*pte & PTE_V) {
            pagetable = (pagetable_t) PTE2PA(*pte);
        } else {
            if (!alloc || (pagetable = (pde_t *) kalloc()) == 0)
                return 0;
            memset(pagetable, 0, PGSIZE);
            *pte = PA2PTE(pagetable) | PTE_V;
        }
    }
    return &pagetable[PX(0, va)];
}
  • SV39的转换是由3级页表结构完成。在riscv.h之中定义的宏函数PX完成了每一级从va转换到pte的过程:
:linenos:

#define PXMASK 0x1FF// 9 bits
#define PGSHIFT 12// bits of offset within a page
#define PXSHIFT(level) (PGSHIFT + (9 * (level)))
#define PX(level, va) ((((uint64)(va)) >> PXSHIFT(level)) & PXMASK)
  • 可以看到,每一次我们只需要截取队va高27位中对应级别的9位即可。一开始截取最高9位,接着是中间的9位和低9位。这9位我们如何使用呢?SV39中要求我们把页表的44位和虚拟地址对应的9位*8直接拼接在一起做为pte的地址。页表的高44位(也就是页号)拼接上12位的0实际上就是pagetable指向的物理地址。我们可以计算得到一个4096大小的页表之中有4096/8=512个页表项。因此我们得到的这9位实际上就是pte在这一页之中的偏移,也就是其下标了。
  • 得到了页表项之后,我们使用PTE2PA函数将该页表项的高44位(也就是下一个页表的页号)取出和12个0拼接(通过左移和右移可以轻松实现),就得到了下一级页表的起始物理地址了。接着重复这样的操作,直到最后一个pte解析出来,就可以返回最后一个pte了(循环并没有处理最后一级)。最后一个pte中记录了物理地址的物理页号PPN,将它直接和虚拟地址的12位offset拼接就得到了对应的物理地址pa。
  • 整个过程中要注意随时通过PTE的标志位判断每一级的pte是否是有效的(V位)。如果无效则需要kalloc分配一个新的页表,并初始化该pte在其中的位置。如果alloc参数=0或者已经没有空闲的内存了(这个情况在lab8之前不会遇到),那么遇到中途V=0的pte整个walk过程就会直接退出。当然这是OS的写法,如果CPU在MMU的时候遇到这种情况就会直接报异常了。
  • walk函数是我们比较底层的一个函数,但也是所有遍历页表进行地址转换函数的基础。我们还实现了两个转换函数:
:linenos:

// Look up a virtual address, return the physical page,
// or 0 if not mapped.
// Can only be used to look up user pages.
// Use `walk`
uint64 walkaddr(pagetable_t pagetable, uint64 va);

// Look up a virtual address, return the physical address.
// Use `walkaddr`
uint64 useraddr(pagetable_t pagetable, uint64 va);
  • 大家可以自行阅读。注意walkaddr函数没有考虑偏移量,因此在使用的时候请首先考虑useraddr函数。

4.2 页表的建立过程

  • 无论是CPU进行MMU,还是我们自己walk实现va到pa的转换需要的页表都是需要OS来生成的。相关函数也是本章练习涉及到的主要函数。
// Create PTEs for virtual addresses starting at va that refer to
// physical addresses starting at pa. va and size might not
// be page-aligned. Returns 0 on success, -1 if walk() couldn't
// allocate a needed page-table page.
int mappages(pagetable_t pagetable, uint64 va, uint64 size, uint64 pa, int perm);

// Remove npages of mappings starting from va. va must be
// page-aligned. The mappings must exist.
// Optionally free the physical memory.
void uvmunmap(pagetable_t pagetable, uint64 va, uint64 npages, int do_free);
  • 以上是建立新映射和取消映射的函数,mappages 在 pagetable 中建立 [va, va + size) 到 [pa, pa + size) 的映射,页表属性为perm,uvmunmap 则取消一段映射,do_free 控制是否 kfree 对应的物理内存(比如这是一个共享内存,那么第一次 unmap 就不 free,最后一个 unmap 肯定要 free)。
  • mappages的perm是用于控制页表项的flags的。请注意它具体指向哪几位,这将极大地影响页表的可用性。因为CPU进行MMU的时候一旦权限出错,比如CPU在U态访问了flag之中U=0的页表项是会直接报异常的。

4.3 启用页表后的跨页表操作

  • 一旦启用了页表之后,U态的测例程序就开始全部使用虚拟地址了。这就意味着它传给OS的指针参数也是虚拟地址,我们无法直接去读虚拟地址,而是要将它使用对应进程的页表转换成物理地址才能读取。
  • 为了方便大家,我们预先准备了几个跨页表进行字符串数据交换的函数。
:linenos:

// Copy from kernel to user.
// Copy len bytes from src to virtual address dstva in a given page table.
// Return 0 on success, -1 on error.
int copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len);

// Copy from user to kernel.
// Copy len bytes to dst from virtual address srcva in a given page table.
// Return 0 on success, -1 on error.
int copyin(pagetable_t pagetable, char *dst, uint64 srcva, uint64 len);

// Copy a null-terminated string from user to kernel.
// Copy bytes to dst from virtual address srcva in a given page table,
// until a '\0', or max.
// Return 0 on success, -1 on error.
int copyinstr(pagetable_t pagetable, char *dst, uint64 srcva, uint64 max);
  • 用于与指定页表进行数据交换,copyout 可以向页表中写东西,后续用于 sys_read,也就是给用户传数据,copyin 用户接受用户的 buffer,也就是从用户哪里读数据。 注意,用户在启用了虚拟内存之后,用户 syscall 给出的指针是不能直接用的,因为与内核的映射不一样,会读取错误的物理地址,使用指针必须通过 useraddr 转化,当然,更加推荐的是 copyin/out 接口,否则很可能损坏内存数据,同时,copyin/out 接口处理了虚存跨页的情况,useraddr 则需要手动判断并处理。跨页会在测例文件bin比较大的时候出现。如果你的程序出现了完全De不出来的BUG,可能就是跨页+使用了错误的接口导致的。

4.4 内核页表

  • 开启页表之后,内核也需要进行映射处理。但是我们这里可以直接进行一一映射,也就是va经过MMU转换得到的pa就是va本身(但是转换过程还是会执行!)。内核需要能访问到所有的物理内存以处理频繁的操作不同进程内存的需求。内核页表建立过程在main函数之中调用。
:linenos:

#define PTE_V (1L << 0)     // valid
#define PTE_R (1L << 1)
#define PTE_W (1L << 2)
#define PTE_X (1L << 3)
#define PTE_U (1L << 4)     // 1 -> user can access

#define KERNBASE (0x80200000)
extern char e_text[];     // kernel.ld sets this to end of kernel code.
extern char trampoline[];

pagetable_t kvmmake(void) {
    pagetable_t kpgtbl;
    kpgtbl = (pagetable_t) kalloc();
    memset(kpgtbl, 0, PGSIZE);
    kvmmap(kpgtbl, KERNBASE, KERNBASE, (uint64) e_text - KERNBASE, PTE_R | PTE_X);
    kvmmap(kpgtbl, (uint64) e_text, (uint64) e_text, PHYSTOP - (uint64) e_text, PTE_R | PTE_W);
    kvmmap(kpgtbl, TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);
    return kpgtbl;
}

void kvmmap(pagetable_t kpgtbl, uint64 va, uint64 pa, uint64 sz, int perm)
{
    if (mappages(kpgtbl, va, sz, pa, perm) != 0)
        panic("kvmmap");
}

4.5 用户页表的加载

  • 用户的加载逻辑在 loader.c 中,其中唯一逻辑变化较大的就是 bin_loader 函数,请结合注释理解这个函数:
:linenos:

pagetable_t bin_loader(uint64 start, uint64 end, struct proc *p)
{
    // pg 代表根页表地址
    pagetable_t pg;
    // 根页表大小恰好为 1 个页
    pg= (pagetable_t)kalloc();
    if (pg == 0) {
        errorf("uvmcreate: kalloc error");
        return 0;
    }
    // 注意 kalloc() 分配的页为脏页,这里需要先清空。
    memset(pagetable, 0, PGSIZE);
    // 映射 trapoline(也就是 uservec 和 userret 的代码),注意这里的权限!
    if (mappages(pagetable, TRAMPOLINE, PAGE_SIZE, (uint64)trampoline,
            PTE_R | PTE_X) < 0) {
        kfree(pagetable);
        errorf("uvmcreate: mappages error");
        return 0;
    }
    // 映射 trapframe(中断帧),注意这里的权限!
    if (mappages(pg, TRAPFRAME, PGSIZE, (uint64)p->trapframe,
            PTE_R | PTE_W) < 0) {
        panic("mappages fail");
    }

    // 接下来映射用户实际地址空间,也就是把 physics address [start, end)
    // 映射到虚拟地址 [BASE_ADDRESS, BASE_ADDRESS + length)

    // riscv 指令有对齐要求,同时,如果不对齐直接映射的话会把部分内核地址映射到用户态,很不安全
    // ch5我们就不需要这个限制了。
    if (!PGALIGNED(start)) {
        // Fix in ch5
        panic("user program not aligned, start = %p", start);
    }
    end = PGROUNDUP(end);
    // 实际的 map 指令。
    uint64 length = end - start;
    if (mappages(pg, BASE_ADDRESS, length, start,
            PTE_U | PTE_R | PTE_W | PTE_X) != 0) {
        panic("mappages fail");
    }
    p->pagetable = pg;
    // 接下来 map user stack, 注意这里的虚存选择,想想为何要这样?
    uint64 ustack_bottom_vaddr = BASE_ADDRESS + length + PAGE_SIZE;
    mappages(pg, ustack_bottom_vaddr, USTACK_SIZE, (uint64)kalloc(),
        PTE_U | PTE_R | PTE_W | PTE_X);
    p->ustack = ustack_bottom_vaddr;
    // 设置 trapframe
    p->trapframe->epc = BASE_ADDRESS;
    p->trapframe->sp = p->ustack + USTACK_SIZE;
    // exit 的时候会 unmap 页表中 [BASE_ADDRESS, max_page * PAGE_SIZE) 的页
    p->max_page = PGROUNDUP(p->ustack + USTACK_SIZE - 1) / PAGE_SIZE;
    return pg;
}
  • 这里大家也要注意,每一个测例进程都有一套自己的页表。因此在进程切换或者异常中断处理返回U态的时候需要设置satp的值为其对应的值才能使用正确的页表。具体的实现其实之前几章已经先做好了。
  • 我们需要重点关注一下trapframe 和 trampoline 代码的位置。在前面两节我们看到了memory_layout文件。这两块内存用户特权级切换,必须用户态和内核态都能访问。所以它们在内核和用户页表中都有 map,注意所有 kalloc() 分配的内存内核都能访问,这是因为我们已经预先设置好页表了。
#define USER_TOP (MAXVA)
#define TRAMPOLINE (USER_TOP - PGSIZE)
#define TRAPFRAME (TRAMPOLINE - PGSIZE)
  • 8
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

MR_Promethus

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值