操作系统构建Part7:页表

7.1 前言

        上一章读者们学习了dummyxv6的物理内存初始化流程,本章将进入新的章节,本章主要介绍虚拟内存和物理内存的概念,然后介绍虚拟内存和物理内存的映射流程,最后会引入页表的概念,以及dummyxv6如何实现页表功能。

7.2 虚拟内存与物理内存简介

        在上一章中,读者们深入研究了dummyxv6的物理内存初始化流程,了解了如何将可用的物理内存组织成一个单向链表。这一过程为内核提供了对物理内存的基本管理能力。然而,仅仅管理物理内存是不够的,为了进一步提升系统的效率、安全性和灵活性,现代操作系统引入了虚拟内存的概念。本节将详细介绍虚拟内存和物理内存的基本概念,以及它们如何协同工作。
        物理内存是计算机硬件中实际存在的内存,通常指RAM(随机存取存储器)。物理内存的大小是有限的,由硬件决定(如8GB、16GB等),它直接由CPU访问,并且读写速度非常快。然而在多任务系统中,对每个应用程序而言,它们自然是希望自己看上去独占了硬件资源,并且不用去理会如何与其他应用进程协作、共享同一台设备的内存。
        为了解决这一问题,现代操作系统引入了虚拟内存的概念。虚拟内存是操作系统提供的一种抽象机制,它为每个进程提供了一个独立的、连续的地址空间。可以将虚拟内存想象成一个巨大的虚拟仓库,每个进程都有自己的独立仓库,而物理内存则是实际存储货物的有限空间。虚拟内存的大小通常远大于物理内存,由操作系统和硬件共同管理。虚拟内存的地址是逻辑地址,需要通过映射机制转换为物理地址,才能访问实际的物理内存。这种映射机制提供了内存保护和隔离,提高了系统的安全性和稳定性。
        虚拟内存的实现依赖于硬件(如MMU,内存管理单元)和操作系统(如页表管理)。那么,虚拟内存和物理内存是如何映射的呢?操作系统又是如何通过页表来管理这种映射的呢?这些问题将在后续的小节中详细解答。
        通过本节的介绍,读者了解到虚拟内存和物理内存是内存管理的两个核心概念,它们共同协作以提高系统的效率、安全性和灵活性。接下来,笔者将深入探讨虚拟内存和物理内存的映射机制,以及dummyxv6如何实现页表功能。

7.3 为什么需要虚拟内存?

        在上一节中,笔者介绍了虚拟内存和物理内存的基本概念。现在,大家先来一起看一下,现代操作系统中,虚拟内存有哪些作用:

  • 解决物理内存限制:物理内存(RAM)是计算机硬件中实际存在的内存,其大小由硬件决定,通常是有限的(如8GB、16GB等)。在多任务操作系统中,多个进程同时运行时,物理内存往往很快耗尽。例如,一个大型应用程序可能需要数GB的内存来运行,而系统中可能同时运行多个这样的程序。如果没有虚拟内存机制,系统将很快耗尽物理内存,导致程序无法启动或运行缓慢。 虚拟内存通过将部分数据存储在磁盘上(称为交换空间或页面文件),并在需要时动态加载到物理内存中,从而扩展了可用的内存空间。这样,即使物理内存有限,系统也可以运行比物理内存更大的程序,提高了系统的整体效率。
  • 提供内存隔离和保护:在多任务操作系统中,多个进程同时运行,每个进程都需要访问内存。如果没有内存隔离机制,一个进程可能会意外或恶意地访问其他进程的内存,导致数据泄露或系统崩溃。虚拟内存通过为每个进程提供一个独立的、连续的地址空间,确保了进程之间的内存隔离。 操作系统通过页表管理虚拟内存和物理内存之间的映射关系,确保每个进程只能访问属于自己的内存区域。这种隔离机制不仅提高了系统的安全性,还防止了进程之间的相互干扰,提高了系统的稳定性。
  • 简化内存管理:虚拟内存为每个进程提供了一个连续的地址空间,无论物理内存是否连续。这使得程序员可以像操作一个连续的内存空间一样编写代码,而无需担心物理内存的碎片化问题。操作系统负责将虚拟地址映射到物理地址,程序员无需关心这些底层细节。 例如,一个程序可能需要分配一块较大的内存来存储数据。在物理内存中,这块内存可能需要分成多个不连续的块来分配,这会增加编程的复杂性。而虚拟内存机制使得程序员可以像操作一个连续的内存块一样进行分配和访问,简化了内存管理。
  • 支持动态内存分配:虚拟内存支持动态内存分配,允许程序在运行时根据需要分配和释放内存。这使得程序可以更灵活地管理内存资源,提高内存的利用率。例如,一个程序可能在运行时根据用户输入动态分配内存,而虚拟内存机制可以确保这些内存分配请求得到满足。 操作系统通过页表管理虚拟内存的分配和释放,确保内存的动态分配不会影响其他进程的正常运行。这种动态内存分配机制不仅提高了内存的利用率,还使得程序可以更灵活地适应不同的运行环境。
  • 提高系统性能:虽然虚拟内存将部分数据存储在磁盘上,但操作系统通过智能的页面置换算法,确保最常用的数据始终保留在物理内存中。当一个进程访问虚拟内存时,操作系统会检查所需的数据是否在物理内存中。如果不在,操作系统会从磁盘加载所需的数据到物理内存中,这个过程称为页面置换。 页面置换算法(如最近最少使用算法LRU)可以有效减少页面置换的频率,提高系统的整体性能。虽然页面置换会带来一定的性能开销,但通过合理的算法设计,这种开销可以被最小化,从而在有限的物理内存下实现高效的内存管理。

        通过以上几点,读者可以清楚地看到虚拟内存不仅解决了物理内存的限制问题,还提供了内存隔离和保护、简化了内存管理、支持动态内存分配,并提高了系统的整体性能。这些特性使得虚拟内存成为现代计算机系统中不可或缺的一部分。
        dummyxv6并没有实现所有与虚拟内存相关的功能,而是聚焦于虚拟内存和物理内存映射,这个最核心的机制。

7.4 dummyxv6的用户进程内存布局

        理解用户进程的内存布局,是理解虚拟内存空间的重要一环。第6章详细介绍了dummyxv6的kernel内存布局,本节要介绍的是dummyxv6的用户进程内存布局,dummyxv6是仿制麻省理工的开源教学级操作系统xv6,因此它的用户进程内存布局和官方xv6一样,如图1所示。虽然到目前为止,本系列还没详细介绍用户进程的数据结构和功能实现,但是先从内存布局角度了解用户进程还是很重要的。用户进程内存布局是虚拟内存空间,每个用户进程的内存布局都一样。 image图1
        读者可以看到,图1划分了若干个信息段,包括用户代码和数据段(user text and data segment)、用户栈(user stack)、堆空间(heap)、trapframe段和trampoline段。现在开始分别对它们进行解析:

  • 用户代码段和数据段(user text and data):
    • 用户代码段存储了用户进程的可执行代码。这个段通常是只读的,以防止程序意外修改其指令。在dummyxv6中,代码段是用户进程的起始部分,包含了程序的指令集。从虚拟内存空间的角度看,它起始于地址0x0,结束于数据段地址线。
    • 数据段存储了用户进程的全局变量和静态变量。数据段的大小在进程启动时确定,并且在进程的生命周期内保持不变。数据段是用户进程的静态数据存储区域。
  • 用户栈(user stack):用户栈是用户进程用于存储函数调用栈、局部变量和函数参数的内存区域。栈是向下增长的,即从高地址向低地址扩展。栈的大小通常有限,用于支持函数调用和返回。
  • 堆空间(heap):堆空间是用户进程用于动态内存分配的内存区域。堆是向上增长的,即从低地址向高地址扩展。用户进程可以通过系统调用(如sbrk)来动态调整堆的大小。堆空间的大小可以根据进程的需要动态扩展或收缩。
  • trapframe段 (陷阱帧):trapframe是每个用户进程独立的一个内存区域,用于保存进程在陷入内核时的上下文信息(如寄存器状态)。当用户进程发生系统调用、中断或异常时,CPU会将当前的寄存器状态保存到Trapframe中,以便在返回用户态时恢复现场。
  • trampoline (陷阱跳板):trampoline是用户进程共享的内存区域,实际上是操作系统代码段的一部分。它的作用是提供一个安全的跳板,用于在用户态和内核态之间切换。当用户进程需要陷入内核时,CPU会通过trampoline跳转到内核代码执行。trampoline的设计是为了确保用户进程无法直接修改内核代码,同时提供一个高效的切换机制。

        总的来说,trapframe和trampoline是操作系统内核,在发生异常、中断时使用的代码段和数据段,其余的空间皆是用户进程自己使用的代码和数据空间。

7.5 虚拟内存与物理内存的映射

        应用进程使用的是虚拟内存空间,相应的,应用程序的可执行文件中,里面的指令使用的也是虚拟内存地址,因此计算机系统需要一种机制来将应用程序内的虚拟内存地址转化为真实的物理内存地址。在开始正式讨论虚拟内存如何转换成物理内存之前,先来看看虚拟内存空间如何映射到物理内存空间。图2展示了这种关系,连续的虚拟内存空间,在物理内存空间中未必是连续的,并且中间可能夹杂着其他用户进程使用的物理内存空间[1]
image图2
        图中有一部分内存映射到了磁盘里,这是因为虚拟内存空间远大于实际的物理内存空间,如果每个进程的虚拟内存段都要加载到物理内存中,那么物理内存将远远不够用。好在用户进程总会有经常使用的虚拟内存段,那些不经常使用的内存段,在物理内存不够用时,由操作系统内核将其从物理内存中淘汰,并存入磁盘,下次要使用的时候再从磁盘中读出,加载到物理内存中。
        前面说的虚拟内存段也好,物理内存段也好,实际上就是将内存按页分割。第六章也介绍过,dummyxv6内核一次分配的内存空间就是一页。在dummyxv6中,一页是4KB,图2中的每个映射,都对应一页虚拟内存到一页物理内存的映射。接下来要讲的虚拟内存地址转化为物理内存地址,也是页对齐的。
        执行应用进程指令的是CPU,而指令中操作的内存地址又是虚拟地址,那么CPU如何将这些虚拟地址转换成物理地址呢?答案是现代CPU是通过一个被称为MMU(Memory Management Unnit)的单元来实现这个功能的。如图3所示,CPU首先会将虚拟内存地址传入MMU,然后MMU返回物理地址给CPU,CPU再到物理内存中访问对应的数据。
image图3
        MMU如何知道虚拟内存和物理内存是怎么映射的?实际上在内核初始化阶段,或者应用进程被创建时,会将一块物理内存的地址保存到CPU的一个寄存器中,而这个地址指向的内存块,记录着虚拟内存和物理内存的映射关系。这个寄存器在RISC-V架构的处理器中叫做satp,MMU收到虚拟内存后,会去satp寄存器指向的物理内存中寻找与输入虚拟地址对应的物理地址。这里就引出了一个新的问题了,CPU每次访问虚拟内存,都要经过MMU去satp寄存器指向的内存块中寻找其对应的物理地址,这个过程相对于直接访问CPU内部的缓存来说,就显得非常耗时了。为此,现代处理器架构中,往往又带了一个叫做TLB(translation lookaside buffer),这块缓存可以视作是satp指向内存块的mini版,图4展示了一个从TLB中查找物理内存地址的例子[2]
        图中的page offset是页内偏移的意思,不管是虚拟地址还是物理地址,一般被分为两个部分,低12位为页内偏移,高于12位的部分为页编码(虚拟地址就是虚拟页编码,物理地址就是物理页编码)。由于虚拟内存和物理内存是按页映射的,因此它们的地址也是按页对齐的,也就是说它们的地址低12位一定都是0。页内偏移量是不需要映射的,MMU先根据虚拟页编码找到对应的物理页编码,再加上物理偏移量,就能在物理内存中找到要访问的内存地址。
image图4
        MMU访问过的虚拟内存,会将它以及其对应的物理内存存到TLB中。虽然TLB很小,但是经常访问的虚拟内存地址,MMU就不用再去satp寄存器指向的内存中查找了,而是直接访问更近的TLB,并将物理地址直接返回给CPU。当然,不常用的映射关系会从TLB中清除。
        这里需要注意的是,每个用户进程都有自己独立的映射内存,而虚拟内存的地址空间往往又很大,直接给内核和每个用户进程分配一块很大的物理内存,去存这些映射信息,显得非常不现实,分大了怕浪费,分小了怕不够用,那么操作系统是怎么管理这个映射内存的呢?接下来要说的页表就是解决这个问题的。

7.6 页表

        前文提过,当TLB不命中时(MMU在TLB中找不到与虚拟内存映射的物理内存),MMU会到satp寄存器指向的内存块中查找。从逻辑上看,读者可以将这块内存视作是一个查找表,如图5所示。
image图5
        虽然这样很好理解,但是实际上,这种方式的可操作性并不强,比方说这块物理内存要分配多大才够用?不够用的时候怎么办,再让内核分配新的内存,也会导致映射表内存不连续。不够用的时候又浪费内存。而且这个映射表包括内核在内,还有每个应用进程都是单独一份,将会消耗大量的内存。面对这样的问题,读者们自然而然地能想到,每个进程的虚拟内存到物理内存的映射表,按需分配就好了,如果你这么想,那么想到点子上了,不过怎么做才能即做到按需分配,又能保持完整的映射功能呢?接下来要介绍的多级页表就做到了这点。

7.6.1 什么是页表?

        页表就是记录虚拟内存和物理内存映射的数据结构,图5所展示的方式,本质上也是一种页表,只是它一般被认为是单级页表。多级页表采用了树结构,xv6采用了3级结构。dummyxv6作为官方xv6的仿制品,也继承了这一特性。

7.6.2 为什么需要页表?

        为什么需要页表,正如前文提到过,页表是实现虚拟地址到物理地址映射的关键结构。使用多级页表是为了尽可能节约物理内存。

7.6.3 页表的结构

        多级页表中的每一个节点,都是一个数据页,也就是一块4KB的物理内存块。在dummyxv6中,每个节点包含512个PTE(page table entry)的数据,每个PTE是64位的(也就是8个字节),这样算内存大小刚好是4KB。PTE指向下一级页表的物理地址,并且保存了它的属性信息(比如是否创建、读写权限、执行权限等)。内核也好,应用进程也好,都有他们自己独有的页表。
        要理解页表,首先要理解虚拟内存地址的结构,虚拟地址一般被分为两个部分,VPN(Virtual Page Number)和Page Offset。VPN可以理解为虚拟空间的页编码,也就是把虚拟空间按页切割,然后给每个页一个编码,用来标识它们。Page Offset即是页内偏移量,VPN找到对应的页内存,再加上Page Offset就能找到最终的内存地址。

Virtual Address:0x00004105
+----------------+------------+
|   VPN:0x00004  |Offset:0x105|
+----------------+------------+

        与虚拟内存空间一样,物理内存空间也是按页切分,每个页也有一个页编码用来作为它们的ID,物理页编码被称为PPN(Physical Page Number)。页表记录的则是VPN到PPN的映射。dummyxv6采用的是RISC-V的Sv39模式,也就是说地址的有效位为39个比特,其中低12位为页内偏移值,高27位为VPN或者PPN。
        在Sv39模式中,页表一共被分为3级,分别是Level 2(L2)、Level 1(L1)和Level 0(L0)。每个Level占页编码的9个位,它们的值将作为页表节点中,查找下一级页表节点的索引值。如图6所示[3],页表中的根节点的地址,保存在satp寄存器中。
image图6
        现在结合图6来解释MMU查找虚拟地址对应的物理地址的步骤:

  • 页表树的根节点地址存在satp寄存器中,当MMU开始对虚拟地址查找其对应的物理地址时,会从satp寄存器中获取根节点的地址,并且以L2这9个比特表示的值为索引,在根节点中查找与下一级页表关联的PTE。
  • PTE包含PPN和表示下一级页表的页属性的值–Flag,当Flag中的Valid比特被置为0时,表示L1级页没有被创建,此时MMU查找失败。当Flag中的Valid比特被设置为1时,表示L1级页表存在,因此要取出PPN的值,并向左移12个比特之后,得到L1级页表的物理地址。
  • 以虚拟地址中的L1段的值,作为L1级页表的索引,找到对应的PTE,并用类似上一步的做法判断L0级页表是否存在,如果不存在MMU查找失败,如果存在则根据其PPN计算出L0级页表的物理地址。
  • 用同样的办法,从L0级页表中查找虚拟地址L0段所指向的PTE,判断PTE的Flag值,如果Valid值为0,则MMU查找失败,如果Valid值为1,则表示目标物理页存在。此时根据最后找到的PTE,找出PPN,将其转成目标物理地址。最后取出虚拟地址的Offset值拼到最后找的PPN中,得到最终的物理地址。
            以上就是MMU在TLB缓存不命中时,进行的页表查找(page table walk)的逻辑,本质上就是去内存中查找虚拟地址对应的物理地址。这里需要注意的是,MMU并不负责页表的创建,它只负责查找,找得到就返回物理地址,找不到就向CPU发出缺页(page fault)异常,然后交给内核处理这个请求,在dummyxv6中,内核直接终止触发缺页异常的进程。页表的创建与管理主要由内核负责。

7.6.4 内核的页表查找函数

        既然MMU不负责页表的构建,那么这个活只能由操作系统来构建了。现在先来看一个简单的例子来理解这个过程。假设现在内核要创建一个应用进程,该应用进程的页表还未创建。现在要为该应用进程创建页表,并且将一个起始地址为0x0的虚拟内存页映射到一个真实的物理内存页上,此时,内核先从空闲的物理内存链表中,取出一个物理内存页,如图7所示,假设它现在的地址为0x8048000。此时这个物理内存页既是该应用进程的页表树根节点。
image图7
        接着对0x0这个虚拟地址进行解析,现在对其按照RISC-V的Sv39模式进行切分,得到如下所示的结果。

0x0虚拟地址二进制值:
+---------+---------+---------+------------+
|    L2   |    L1   |    L0   | Page Offset|
+---------+---------+---------+------------+
|000000000|000000000|000000000|000000000000|
+---------+---------+---------+------------+
| 9 bits  | 9 bits  | 9 bits  |   12 bits  |
+---------+---------+---------+------------+

        首先取出虚拟地址0x0的L2段,它的十进制值为0,此时直接到第一个页表偏移0字节的位置查找L1级页表关联的PTE,如图8所示:
image图8
        由于PTE是0,说明L1级页表没有创建,此时内核从物理内存链表中再取出一个新的物理内存页,其地址为0x8049000,并将其物理地址向右移12位,得到0x8049,这个值就是该进程L1级页表的PPN,因为L1级页表已经创建,所以要在L2级页表刚刚被找到的PTE里标记,设置PTE_V比特(既将Valid比特设置为1),得到图9的结果。
image图9
        接下来从虚拟地址0x0中取出L1段,它的值还是0,此时到0x8049000指向的物理页中查找PTE,得到的还是0,因此内核再次分配一个新的物理页,并在L1级页表对应的PTE处赋上相应的值,如图10所示。
image图10
        现在取出虚拟地址的L0段,并且将其作为索引,到0x8050000指向的物理内存页上查找PTE,此时由于PTE是0,所以内核需要创建一个物理内存页,用来表示该应用进程0x0虚拟地址指向的内存页,得到图11的结果。
image图11
        最后当应用程序访问0x0的内存时,在MMU的TLB缓存不命中的情况下,直接访问刚刚创建的页表,找到虚拟地址0x0对应的物理地址0x805100,并且加上offset 0,就得到最终要访问的内存地址了。另外,只有该应用进程在运行时,其页表树根节点的地址0x8048000会写入satp寄存器中。
        上面这个例子,为读者提供了页表创建,以及映射物理地址的全流程,前面创建L2、L1和L0级页表的逻辑流程,是由内核虚拟内存管理模块的walk函数实现,而将给应用进程使用的物理页映射到L0级页表的操作,是由虚拟内存管理模块的mappages函数实现。以下是walk函数的实现:

// kernel/vm.c
// walk returns a pointer to the PTE in the pagetable for the virtual address.
1 pte_t* walk(pagetable_t pagetable, uint64_t va, int alloc) {
2     pte_t* pte;
3 
4     for (int level = 2; level > 0; level--) {
5         pte = &pagetable[PTX(va, level)];
6         // If the PTE is valid, just use it.
7         if (*pte & PTE_V) {
8             pagetable = (pagetable_t)PTE2PA(*pte);
9         } else 
10        // If the PTE is invalid, allocate a new page table.
11        {
12            if (!alloc || (pagetable = (pagetable_t)kalloc()) == 0)
13                return 0;
14            memset(pagetable, 0, PGSIZE);
15            *pte = PA2PTE((uint64_t)pagetable) | PTE_V;
16        }
17    }
18
19    return &pagetable[PTX(va, 0)];
20}

        现在先来对walk函数内出现的类型和宏进行解释和说明:

  • pte_t类型:这是uint64_t的别名,用来表示PTE用的。
  • pagetable_t类型:这是一个uint64_t* 类型,这个类型的变量指向一个页表的起始地址。
  • PTX宏:用于取出虚拟地址的对应Level段的值,结合图6,比如代码中level的值为2,那么就取出图6中虚拟地址L2段的9个位,作为当前页表查找PTE的索引。
  • PTE2PA宏:这个宏将PTE转成物理地址。前文有简要介绍过,一个PTE包含PPN和Flag段,其中PPN占44个比特位,而Flag占10个比特位,获取物理地址的方式就是对PTE先向右移10位,清除Flag位,再向左移12位,就得到一个页对齐的物理地址了。
  • PA2PTE宏:这个宏将物理地址转成PTE,由于这里的物理地址一般是要页对齐的,因此其低12位必然是0(2^12=4096),所以将物理地址先向右移12位,再向左移10位。PTE最右边的10位用来标记下一级内存页的属性。
  • PTE_* 宏:结合图6,每个PTE有10个位标记下一级内存页的属性,第0个比特表示是否存在,它的宏对应图6中的第0个比特位表示,所以用PTE_V。以此类推,表示可读,可写,可执行的宏分别是PTE_R、PTE_W、PTE_X。

        现在回过头来讨论walk函数,它的实现逻辑和MMU进行页表查找很类似。只是它多了一个参数alloc,当alloc为0时,就和MMU实现页表查找没什么差别,只是返回0表示找不到虚拟地址L0段指向的PTE。当alloc为1时,如果虚拟地址对应level段的页表没被创建,那么walk函数就会创建它,并且将创建好的内存页标记为Valid,表示PTE关联的内存是存在的。walk函数最终是将虚拟地址L0段指向的页表PTE返回。这里需要注意的是,传入walk函数的页表变量,是页表树的根节点。

7.6.5 设置映射关系

        上一节给出了一个虚拟内存映射到物理内存,在页表遍历的过程中补全页表,并最终实现映射的例子。上一节也介绍了页表遍历的函数–walk,本节将介绍最终实现映射的函数–mappages,以下是它的实现:

// kernel/vm.c
1 int mappages(pagetable_t pagetable, uint64_t va, uint64_t size, uint64_t pa, int perm) {
2     if (va % PGSIZE != 0)
3         panic("mappages:va is not page align");
4     
5     if (pa % PGSIZE != 0)
6         panic("mappages:pa is not page align");
7     
8     if (size % PGSIZE != 0)
9         panic("mappages:size is not page align");
10
11    uint64_t a, last;
12    pte_t* pte;
13
14    a = va;
15    last = va + size;
16
17    // printf("pagetable:0x%x -------------\n", (uint64_t)pagetable);
18    for (; a < last; a += PGSIZE, pa += PGSIZE) {
19        // printf("map pa:0x%x\n", pa);
20
21        if ((pte = walk(pagetable, a, 1)) == 0)
22            return 0;
23
24        if (*pte & PTE_V)
25            panic("mapages: remap");
26
27        *pte = PA2PTE(pa) | perm | PTE_V;
28    }
29
30    return 1;
31}

        与前面给的例子不一样,这个函数将连续的虚拟内存空间和连续的物理内存空间进行映射。参数pagetable即是页表树根节点,va是起始虚拟地址。size是需要被映射的内存总量,它是4096的倍数。pa则是物理地址。perm意为permission,即是被映射的内存页的访问权限(读者可以回顾一下图6中,PTE的Flag各个比特位的描述)。
        第2行到第9行代码,则是进行参数校验,虚拟地址变量va、映射内存大小size以及物理地址pa,均要页对齐。
        第11行到第15行代码,则是计算出循环的起止位置。
        第18行到第28行代码,则是不断通过walk函数,找到L0级页表的PTE,并且赋值对应的物理地址,最后实现映射关联。

7.6.6 内核虚拟内存初始化

        在完成了最核心的两个函数,walk和mappages函数的介绍之后,接下来就是介绍内核的初始化流程了。初始化的逻辑全部在kvminit内实现。这里kvm的含义是kernel virtual memory的意思。现在来看一下kvminit函数的实现:

// kernel/vm.c
// kvminit initializes the kernel's page table.
1 void kvminit() {
2     kvmmake();
3 }

        逻辑很简单,就是调用了kvmmake函数,接下来看一下kvmmake函数做了哪些事情:

// kernel/vm.c
1  void kvmmake() {
2      kpgtbl = (pagetable_t)kalloc();
3      if (kpgtbl == 0) {
4          panic("kvmmake:fail to kalloc");
5      }
6      memset(kpgtbl, 0, PGSIZE);
7  
8      // uart registers
9      kvmmap(kpgtbl, UART0, UART0, PGSIZE, PTE_R | PTE_W);
10 
11     // kernel text
12     kvmmap(kpgtbl, KERNEL_BASE, KERNEL_BASE, (uint64_t)etext - KERNEL_BASE, PTE_R | PTE_X);
13 
14     // kernel data
15     kvmmap(kpgtbl, (uint64_t)etext, (uint64_t)etext, PHYSTOP - (uint64_t)etext, PTE_R | PTE_W);
16 
17     // trampoline
18     kvmmap(kpgtbl, TRAMPOLINE, (uint64_t)trampoline, PGSIZE, PTE_R | PTE_X);
19 
20     proc_mapstack(kpgtbl);
21 }

        第2行到第6行代码,为内核创建了根页表,并对它进行初始化。         第9行代码,将UART0地址映射到UART0上,本质上是用回原来的地址,只是增加了权限设置,这使得页表功能开启之后,CPU能够正常读写这部分内存。
        第12行代码,将内核代码段的物理地址,映射到内核代码段的物理地址上,并且标记为只读和可执行。
        第15行代码,将从数据段开始到物理内存上限的内存,按页切分,并且映射到内核页表上,注意这里只是将etext到PHYSTOP之间的物理地址,映射到内核页表上,并不以为着这些内存已经被分配。
        第18行代码,对trampoline的虚拟地址映射到内核对应的物理内存地址上。trampoline位于内核的代码段内,主要用于用户模式向内核模式切换,以及中断发生时,做状态切换用的。
        第20行代码,dummyxv6支持同时运行64个进程,每个进程均有属于自己的内核栈,每个内核栈均是内核调用kalloc函数创建的物理内存页。

7.6.7 设置satp寄存器

        在内核完成虚拟内存初始化之后,就会设置satp寄存器了,既将kvmmake函数开辟的kpgtbl内存页的地址,设置过去。代码如下所示:

// kernel/vm.c
1 void kvmhartinit() {
2     sfence_vma();
3 
4     w_satp(MAKE_SATP((uint64_t)kpgtbl));
5 
6     sfence_vma();
7 }

        第2行和第6行代码,是清理TLB缓存用的,保证内核不会访问到残存数据。
        第4行代码,则是将页表地址设置到satp寄存器中,并且告诉处理器按照Sv39模式运行。MAKE_SATP宏的定义如下所示:

// kernel/riscv.h
// sv39 scheme
#define MAKE_SATP(pagetable) ((uint64_t)(pagetable >> 12) | (8L << 60L))

        这个宏将物理地址向右移12位,获得PPN。然后拼上数值8向左移动60位,这个主要是用于设置地址模式的,最高4位为8表示使用Sv39模式[4]

7.7 用户进程用的虚拟内存

        本节将介绍dummyxv6给用户进程创建页表映射使用的函数。

7.7.1 创建页表根节点

        前文提到过,每个应用进程都有自己独立的页表树,为了构建这样的树,首先需要有一个接口用来创建根节点,uvmcreate函数就是做这件事情的,现在来看一下它的实现逻辑。

// kernel/vm.c
// uvmcreate creates a new pagetable for a user process as its root pagetable.
1 uint64_t uvmcreate() {
2     pagetable_t pagetable;
3     pagetable = (pagetable_t)kalloc();
4     if (pagetable == 0)
5         return 0;
6     memset(pagetable, 0, PGSIZE);
7     return (uint64_t)pagetable;
8 }

        函数的逻辑很简单,就是开辟一个物理内存页,作为页表树的根节点,完成初始化后返回。

7.7.2 扩展内存

        一个用户进程被创建时,除了要给他创建一个页表作为页表树的根节点,此外还需要按用户进程实际需要的内存大小,为其开辟相应的物理内存,并且完成虚拟内存到物理内存的映射。

// kernel/vm.c
1  int uvmalloc(pagetable_t pagetable, uint64_t oldsz, uint64_t newsz, int perm) {
2      if (newsz <= oldsz)
3          return oldsz; 
4      
5      for (uint64_t a = oldsz; a < PROUNDUP(newsz); a += PGSIZE) {
6          uint64_t pa = (uint64_t)kalloc();
7          if (pa == 0) {
8              uvmdealloc(pagetable, a, oldsz);
9              return 0;
10         }
11         memset((void*)pa, 0, PGSIZE);
12         if (!mappages(pagetable, a, PGSIZE, pa, perm)) {
13             uvmdealloc(pagetable, a, oldsz);
14             kfree((void*)pa);
15             return 0;
16         }
17     }
18 
19     return newsz;
20 }

        uvmalloc函数中,pagetable参数一般是由uvmcreate创建的页表。oldsz和newsz需要页对齐,并且newsz要大于oldsz,回顾一下第7.4节关于dummyxv6用户进程的内存空间,它们的虚拟内存地址是从0开始的,所以oldsz其实就是其截止虚拟内存边界地址,newsz是新的虚拟内存边界地址,uvmmalloc函数的作用则是将旧边界到新边界的这一段虚拟内存页,匹配对应的物理内存页,并在页表上完成映射关系。每个页会被设置为perm属性。
        如果不能一次性开辟足够的物理内存匹配虚拟内存空间,那么uvmalloc函数会将已经开辟的内存还回去,并且解绑相对应的映射。

7.7.3 释放内存

        有开辟内存就有释放内存,应用进程的内存可以通过uvmdealloc函数来实现,而uvmdealloc又是通过调用uvmunmap函数来释放内存和解绑映射关系的。

// kernel/vm.c
1 int uvmunmap(pagetable_t pagetable, uint64_t va, uint64_t size, int do_free) {
2     uint64_t a, last;
3     pte_t* pte;
4 
5     if (va % PGSIZE != 0)
6         panic("uvmunmap:va is not page align");
7     
8     if (size % PGSIZE != 0)
9         panic("uvmunmap:size is not page align");
10    
11    a = va;
12    last = va + size;
13
14    for (; a < last; a += PGSIZE) {
15        if ((pte = walk(pagetable, a, 0)) == 0)
16            panic("uvmunmap: walk");
17        if (*pte & PTE_V) {
18            if (do_free) {
19                uint64_t pa = PTE2PA(*pte);
20                kfree((void*)pa);
21            }
22            *pte = 0;
23        }
24    }
25
26    return 1;
27}

        uvmunmap函数,只需要传入根节点页表–pagetable,以及要解绑的起始虚拟地址–va,以及从起始虚拟地址开始要解绑size个字节(size是页对齐的),最后do_free变量决定释放释放解绑的物理内存页。接下来看看uvmdealloc函数的实现:

// kernel/vm.c
1 int uvmdealloc(pagetable_t pagetable, uint64_t oldsz, uint64_t newsz) {
2     if (oldsz <= newsz)
3         return oldsz;
4 
5     uvmunmap(pagetable, newsz, oldsz - newsz, 1);
6     
7     return newsz;
8 }

        uvmdealloc函数逻辑很简单,oldsz是原来的虚拟内存截止地址,newsz是新的虚拟内存截止地址,newsz一定比oldsz小,所以这个函数执行的是内存收缩。它调用了uvmunmap函数,从新的更低的地址线开始,一直释放和解绑,直到newsz到oldsz这一段的内存都被解绑和释放为止。

7.7.4 回收整个页表

        最后要介绍的则是回收应用进程的整个页表,uvmfree函数,释放虚拟地址从0到sz对应的所有物理内存,并释放应用进程的所有页表。

// kernel/vm.c
1 void uvmfree(pagetable_t pagetable, uint64_t sz) {
2     uvmdealloc(pagetable, sz, 0);
3     freewalk(pagetable);
4 }

        而释放所有页表的函数则如下所示:

// kernel/vm.c
1 int freewalk(pagetable_t pagetable) {
2     for (int i = 0; i < 512; i++) {
3         pte_t* pte = &pagetable[i];
4         if ((*pte & PTE_V) && ((*pte & (PTE_R | PTE_W | PTE_X | PTE_U)) == 0)) {
5             pagetable_t child = (pagetable_t)PTE2PA(*pte);
6             freewalk(child);
7             pagetable[i] = 0;
8         } else if (*pte & PTE_V) {
9             panic("freewalk: not a leaf");
10        }
11    }
12    kfree((void*)pagetable);
13    return 1;
14}

        freewalk函数从根节点开始,遍历每一个PTE,如果PTE有效,则递归调用下去,这样做的目的是先释放叶子节点,再逐层往上释放。

7.8 结束语

        到这里,关于dummyxv6所有的内容就介绍完毕,希望对读者有帮助,我们下一章再见。

Reference

[1] Virtual memory
[2] How is Virtual Memory Translated to Physical Memory?
[3] 《xv6-book-riscv-rev1.pdf》 3.1 Paging Hardware
[4] 《riscv-privileged-20211203》 4.1.11 Supervisor Address Translation and Protection (satp) Register