使用Rust编写操作系统 - 3.2 - 内存分页实现
本文将展示如何在内核中实现对内存分页的支持。我们首先将探讨使内核可以访问物理页表帧的各种技术,并讨论它们各自的优缺点。然后,实现地址转换函数和创建新映射函数。
这个博客是在GitHub上公开开发的。如果你有任何问题或疑问,请在那里开一个issue。你也可以在底部留言。这篇文章的完整源代码可以在post-09分支中找到。
介绍
上一篇文章介绍了分页的概念,通过与分段进行比较来引入分页,解释了分页和页表的工作原理,然后介绍了x86_64
的4级页表设计。我们发现bootloader已经为内核设置了页表层次结构,这意味着我们的内核已经在虚拟地址上运行。这有助于提高安全性,因为非法内存访问并不能修改任意物理内存,只会导致页面错误异常。
前文结尾我们留下一个问题——无法从内核访问页表——这是因为页表存储在物理内存中,而内核已经运行在虚拟地址上。本文将在这一点上深入,探讨使内核能够访问页表帧的不同方法。我们将讨论每种方法的优缺点,然后为内核确定一种适合的方法。
要实现该方法,首先,我们将需要bootloader的支持,因此需要对其进行配置。然后,我们将实现一个遍历页表层次结构的函数,以将虚拟地址转换为物理地址。最后,我们将学习如何在页表中创建新的映射,以及如何找到未使用的内存帧来创建新的页表。
访问页表的方法
从内核访问页表并不像看起来那样简单。为理解这个问题,让我们回顾前文的示例,4级页表层次结构:
这里的关键是每个页表条目都会存储下一张表的物理地址。这样做既避免了对这些地址再进行转换——会对性能造成不利影响——也避免了地址转换陷入无限递归。
对我们来说,问题在于无法直接通过内核访问物理地址,因为内核仍运行在虚拟地址上。例如,当我们访问地址4 KiB
时,我们访问的是虚拟地址4 KiB
,而不是存储第4级页表的物理地址4 KiB
。当我们要访问物理地址4KiB
时,我们只能通过映射到它的某个虚拟地址来进行访问。
因此,为了访问页表帧,我们需要将一些虚拟页映射到这些帧上。创建映射的方法有很多,而这些方法都允许我们访问任意页表帧。
恒等映射
一个简单的解决方案是对所有页表进行恒等映射:
在上图中,我们看到了很多恒等映射的页面表帧。如此,页表的物理地址也同时是有效的虚拟地址,我们因此可以轻松地从CR3寄存器开始访问到所有级别的页表。
但是,它会使虚拟地址空间变得混乱,并使得找到更大尺寸的连续存储区域变得更加困难。例如,假设我们要在上图中创建一个大小为1000KiB的虚拟内存区域,比如用于内存映射文件。我们无法从28KiB
开始该区域,因为它会与已经映射的页面1004KiB
发生冲突。因此,我们必须进一步寻找,直到找到足够大的连续未映射区域,例如1008KiB
。这是与分段类似的碎片问题。
同样,这会使得创建新的页表变得更加困难,因为我们需要为新表找到大小相称且尚未使用的物理帧。例如,假设我们为内存映射文件保留了从1008KiB开始的1000KiB
虚拟内存区域,于是便不能再使用物理地址在1000KiB
和2008KiB
之间的任何帧,因为无法对其进行恒等映射。
固定偏移量映射
为了避免使虚拟地址空间变的混乱,我们可以为页表映射划分单独的内存区域。因此,我们不再恒等映射页表帧,而将页表帧以固定偏移量映射到虚拟地址空间中。例如,偏移量可以是10 TiB
:
通过将10TiB..(10TiB+物理内存大小)
范围内的虚拟内存地址专门用于页表映射,我们避免了恒等映射的冲突问题。只有在虚拟地址空间寻址空间远大于物理内存大小时,才可以为虚拟地址空间保留如此巨大的区域。不过这个大小在x86_64上很容易达到,因为48位虚拟地址地址的寻址空间为256TiB。
该方法仍然有一个缺点,就是每当我们创建一个新的页表时,都需要创建一个新的映射。另外,该方法不允许访问其他地址空间的页表,这在创建新进程时很有用。
完整物理内存映射
要来解决这些问题,我们不再仅映射页表帧,而是映射完整物理内存:
这种方法允许内核访问任意物理内存,包括其他地址空间的页表帧。保留的虚拟内存范围与以上一节相同,不同之处在于虚拟内存不再包含未映射的页面(译注:即虚拟内存空间将全部映射到物理帧)。
这种方法的缺点是需要额外的页表来存储物理内存的映射。这些页表需要存储在某个地方,因此它们会用掉一部分物理内存,这在内存较小的设备上可能是个问题。
不过,在x86_64上我们可以使用2MiB的巨页进行映射,而不是默认的4KiB页面。这样,映射32GiB内存,仅需要1个3级表和32个2级表(译注:一个2级表包含512个2MiB的巨页条目,即一个2级表可映射1GiB)总共132KiB的空间用于存储页表(译注:每页表512条目,每条目占8B空间,即每页表占4KiB空间,一共占(1+32)*4KiB=132KiB)。而且巨页还可以提升缓存效率,因为巨页在转换后备缓冲区(TLB)中使用的条目更少。
临时映射
对于物理内存量很小的设备,我们只能在需要访问时才临时映射页表帧。为了能够创建临时映射,我们只需要一个恒等映射的1级页表:
图中的1级表控制虚拟地址空间的前2MiB(译注:512个大小为4KiB的虚拟页面)。这是因为它可以通过从CR3寄存器开始并跟随4级、3级和2级页表中的第0个条目来访问。索引为8
的条目将地址32KiB
上的虚拟页映射到地址32KiB上
的物理帧,也就是恒等映射了1级表本身。图中使用32KiB
处的水平箭头表明了此恒等映射。
通过写入恒等映射的1级表,我们的内核最多可以创建511个临时映射(512减去恒等映射所需的条目)。在上面的示例中,内核创建了两个临时映射:
- 将1级表的第0个条目映射到地址为
24KiB
的帧,便创建了一个虚拟的临时映射,将0KiB
处的虚拟页映射到2级页表所在的物理帧,如虚线箭头所示。 - 将1级表的第9个条目映射到地址为
4KiB
的帧,便创建了一个虚拟的临时映射,将36KiB
处的虚拟页映射到4级页表所在的物理帧,如虚线箭头所示。
现在,内核可以通过写入0KiB
页面来访问第2级页表,以及通过写入36KiB
页面来访问第4级页表。
使用临时映射访问任意页表帧的过程为:
- 在恒等映射的1级表中搜索未使用条目。
- 将该条目映射到我们想要访问的页表所在的物理帧。
- 通过映射到该条目的虚拟页面访问目标帧。
- 将该条目设置回未使用状态,从而删除本次临时映射。
这种方法重复使用相同的512个虚拟页面来创建映射,因此仅需要4KiB的物理内存。缺点是它有点麻烦,特别是因为新的映射可能需要修改多个级别的页表,这意味着我们需要将上述过程重复多次。
递归页表
另一个根本不需要附加页表的有趣方法是递归映射页表。这种方法的思路是将4级页表的某些条目映射到4级表本身。如此可以有效地保留一部分虚拟地址空间,并将所有当前和将来的页表帧映射到该空间。
让我们通过一个例子来理解该方法是如何工作的:
这与本文开头示例的唯一区别是,4级表中索引511处的附加条目映射到了4级表本身的4KiB
物理帧。
通过让CPU在转换中跟踪此条目,它不会到达3级表,而又回到这一4级表。这类似于调用自身的递归函数,因此此表称为递归页表。重要的是,CPU假定4级表中的每个条目都指向3级表,因此现在将4级表视为3级表。这之所以可行,是因为所有级别的表在x86_64上的布局都完全相同。
通过在开始实际转换之前访问一次或多次递归项,我们可以有效地减少CPU遍历的级别数。例如,如果我们只访问一次递归条目,然后进入3级表,则CPU会认为3级表是2级表。更进一步,它将2级表视为1级表,将1级表视为映射的帧。这意味着我们现在可以读写1级页表,因为CPU认为它是映射的帧。下图说明了5个翻译步骤:
类似的,在开始转换之前,我们可以两次访问递归项,以将遍历的级别数减少为两个:
让我们逐步观察改操作:首先,CPU访问4级表上的递归条目,并认为自己已到达3级表。然后,它再次访问递归条目,并认为自己已到达2级表。但实际上,CPU仍然位于4级表中。当CPU现在访问另一个条目时,它将进入在3级表,但认为自己已经在1级表上了。因此,当下一个条目指向2级表时,CPU认为它指向映射的帧,这使我们可以读写2级表。
访问3级和4级表的工作方式相同。为了访问3级表,我们重复访问了3次递归项,使CPU认为它已经在1级表中了。然后,我们访问另一个条目并到达第3级表,CPU将其视为映射帧。要访问4级表本身,我们只需访问递归项四次,直到CPU将4级表本身视为映射帧(下图中的蓝色)。
你可能需要花一些时间来理解这个思路,但是在实践中却非常有效。
在下面的小节中,我们将解释如何构造虚拟地址以一次或多次访问递归项。我们不会在实现中使用递归分页,因此你可以跳过这一节继续阅读后文。
计算地址(选读)
可以看到在实际地址转换前通过一次或多次递归地访问条目来访问所有级别的页表。由于访问这四个级别的表的索引是直接从虚拟地址中派生的,因此我们需要为此方法构造特殊的虚拟地址。请记住,页表索引是通过以下方式从虚拟地址中派生的:
假设我们要访问映射特定页面的1级页表。如上所述,这意味着在继续执行4级,3级和2级索引之前,我们必须访问一次递归项。为此,我们将地址的每个块向右移动一个块,并将原始4级索引设置为递归索引:
为了访问2级表,我们将每个索引块向右移动两个块,并将原始4级索引和原始3级索引的块都设置为递归索引:
通过将每个块向右移动三个块并对原始4级,3级和2级地址块使用递归索引,便可以访问3级页表:
最后,我们可以通过将每个块向右移动四个块并使用除偏移量以外的所有地址块作为递归索引来访问4级表:
现在就可以计算全部四个级别的页表的虚拟地址了。我们甚至可以通过将其索引乘以8(页面表条目的大小)来计算精确指向特定页面表条目的地址。
下表总结了用于访问不同级别页表帧的地址结构:
用于访问 | 虚拟地址结构(八进制) |
---|---|
页 | 0o_SSSSSS_AAA_BBB_CCC_DDD_EEEE |
1级页表项 | 0o_SSSSSS_RRR_AAA_BBB_CCC_DDDD |
2级页表项 | 0o_SSSSSS_RRR_RRR_AAA_BBB_CCCC |
3级页表项 | 0o_SSSSSS_RRR_RRR_RRR_AAA_BBBB |
4级页表项 | 0o_SSSSSS_RRR_RRR_RRR_RRR_AAAA |
其中AAA
是4级索引,BBB
是3级索引,CCC
是2级索引,DDD
是映射帧的1级索引,而EEEE
是映射帧的偏移量。RRR
是递归条目的索引。当索引(三位数,译注:9位二进制数转换为3位八进制数)转换为偏移量(四位数,译注:12位二进制偏移地址转换为4位八进制数)时,可以通过将其乘以8(页表项的大小,译注:八进制下乘八相当于左移一位)来完成。有了这样的偏移量,结果地址就直接指向相应的页表条目。
SSSSSS
是符号扩展位,这意味着它们都是第47位的副本。这是对x86_64架构上有效地址的特殊要求。我们在上一篇文章中对此进行了解释。
之所以使用八进制表示地址,是因为每个八进制字符表示三个位,这使我们可以清楚地区分不同页表级别的9位索引。对于每个字符代表四个位的十六进制来说是不可能的。
用Rust代码实现
要在Rust代码中构造这样的地址,可以使用位运算:
1 | // 希望访问的指定页表的虚拟地址 |
上面的代码假定递归映射条目的索引为0o777
,即最后一个4级条目511。不过目前情况并非如此,因此代码尚无法工作。请参阅下文,了解如何告诉bootloader设置递归映射。
除了手动执行按位运算之外,还可以使用x86_64
crate的RecursivePageTable
类型,该类型为各种页表操作提供安全的抽象。例如,以下代码展示了如何将虚拟地址转换为其映射的物理地址:
1 | use x86_64::structures::paging::{Mapper, Page, PageTable, RecursivePageTable}; |
与上一段代码相同,运行此代码需要有效的递归映射。使用这种映射,可以像上一个代码示例中那样计算给定的level_4_table_addr
。
递归分页是一种有趣的技术,也展示了页表中的单个映射功能有多强大。它相对容易实现,只需要极少的设置(即设置一个递归项),因此它作为我们的第一个分页实验确实是一个不错选择。
不过,它也有一些缺点:
- 该方法会占用大量虚拟内存空间(512GiB,译注:即占用4级页表一个条目)。不过48位虚拟地址空间较大,这也不算是一个大问题,但这可能会导致次优的缓存行为。
- 该方法仅允许轻松访问当前活动的地址空间。通过更改递归项,仍然可以访问其他地址空间,但是需要临时映射才能切换回去。我们在重新映射内核(已过时)一文中描述了如何执行此操作。
- 该方法在很大程度上依赖于x86的页表格式,可能无法在其他架构中使用。
Bootloader支持
所有这些方法在初始化时都需要对其页表进行修改。例如,需要创建物理内存的映射,或者需要递归映射4级表的条目。目前的问题是我们还没有能够访问页表的方法,因此也无法创建这些必要的的映射。
这意味着我们需要bootloader的帮助,该程序会创建内核运行的页表。bootloader可以访问页表,因此它可以创建我们需要的任何映射。在当前的实现中,bootloader
crate支持上面提到的两种方法,并可以通过cargo功能进行控制:
map_physical_memory
特性可以将整个物理内存映射到虚拟地址空间中的某处。因此,内核可以访问所有物理内存,于是我们可以实现映射完整物理内存中的方法。- 使用
recursive_page_table
特性,bootloader将递归映射4级页表的一个条目。这允许内核按照递归页表部分中的描述访问页面表。
我们为内核选择第一种方法,因为它简单,平台独立且功能更强大(还允许访问非页表帧)。为了启用所需的bootloader支持,我们将map_physical_memory
特性添加到了bootloader
的依赖项中:
1 | [dependencies] |
启用此特性后,bootloader会将完整的物理内存映射到一些未使用的虚拟地址范围。为了将希望使用的虚拟地址范围告诉内核,引导加载程序会传递一个引导信息结构体。
引导信息
bootloader
crate定义了一个BootInfo
结构体,包含传递给内核的所有信息。该结构体仍处于早期阶段,因此在更新为将来与语义版本不兼容的bootloader版本时,可能会造成损坏。启用map_physical_memory
特性后,它将包含memory_map
和physical_memory_offset
两个字段:
memory_map
字段包含可用物理内存的概述。该字段告诉内核系统中有多少可用物理内存,以及哪些内存区域是为诸如VGA硬件之类的设备所保留的。可以从BIOS或UEFI固件查询内存映射,但查询只能在启动过程的早期。也正是由于这个原因,内存映射必须由bootloader提供,因为内核无法在之后检索该映射。在下文中,我们将需要内存映射。physical_memory_offset
字段包含物理内存映射到虚拟地址的起始地址。通过将此偏移量添加到物理地址,即可获得相应的虚拟地址。这使我们可以从内核访问任意物理内存。
bootloader将BootInfo
结构体以_start
函数的&'static BootInfo
参数的形式传递给内核。我们尚未在该函数中声明此参数,按照下面的方式修改:
1 | use bootloader::BootInfo; |
在前面的文章中,我们一直都缺少该参数也并没有造成什么问题,因为x86_64调用约定在CPU寄存器中传递了第一个参数。因此,若不声明该参数,只会使得参数被忽略。但是,如果我们不小心使用了错误的参数类型,那将会造成问题,因为编译器并不知道我们入口点函数的正确类型签名。
entry_point
宏
由于_start
函数是从bootloader外部调用的,因此不会检查该函数的签名。这意味着我们可以让该函数接受任意参数也不产生任何编译错误,但是函数将无法运行或在运行时导致未定义的行为。
为了确保入口点函数始终具有bootloader期望的正确签名,bootloader
crate提供了entry_point
宏,这个宏提供了类型检查的方式来将Rust函数定义为入口点。让我们使用此宏重写入口点函数:
1 | use bootloader::{BootInfo, entry_point}; |
我们不再需要使用extern "C"
或no_mangle
修饰入口点,因为该宏为我们在底层定义了真正_start
入口点。现在,kernel_main
函数就是一个普通的的Rust函数,因此我们可以为其选择一个任意名称。重要的是对它进行类型检查,以便在我们使用错误的函数签名时(例如通过添加参数或更改参数类型)产生编译错误。
让我们在lib.rs
中做出相同的更改:
1 |
|
由于入口点仅在测试模式下使用,因此我们为本次修改的条目均添加#[cfg(test)]
属性。此外,为了避免与main.rs
的kernel_main
混淆,我们也为测试入口点指定了不同的名称的函数test_kernel_main
。目前暂时不使用BootInfo
参数,于是我们在参数名称前添加_
前缀以消除未使用某变量的编译警告。
实现
现在,我们可以访问物理内存了,也终于可以开始实现页表代码了。第一步,我们要看一下内核正在运行的当前活动页表。第二步,我们将创建一个转换函数,该函数返回给定虚拟地址所映射的物理地址。最后一步,我们将尝试修改页表以创建新的映射。
在开始之前,我们为代码创建一个新的memory
模块:
1 | pub mod memory; |
再为该模块创建一个空的src/memory.rs
文件。
访问页表
在上一篇文章的末尾,我们试图观察内核运行的页表,但是由于无法访问CR3
寄存器指向的物理帧而失败。现在我们将接着上一篇文章,通过创建一个active_level_4_table
函数来返回对活动4级页表的引用:
1 | use x86_64::{ |
首先,我们从CR3
寄存器中读取活动4级表的物理帧。然后,我们获取其物理起始地址,将其转换为u64
,再为其添加physical_memory_offset
,以获取映射页表帧的虚拟地址。最后,我们通过as_mut_ptr
方法将虚拟地址转换为*mut PageTable
裸指针,再为该指针非安全地创建&mut PageTable
引用。创建&mut
引用而非&
引用,是因为我们将在下文对页面表进行修改。
这里不需要使用非安全块,因为Rust会将unsafe fn
函数体当做一个大型unsafe
块来对待。这会使代码更加危险,可能稍不注意就会在前几行中意外引入非安全操作。这也使发现非安全操作变得更加困难。目前有一个RFC提出对此行为的修改。
现在,我们可以使用此函数来打印4级表的条目:
1 | fn kernel_main(boot_info: &'static BootInfo) -> ! { |
首先,我们将BootInfo
结构体重的physical_memory_offset
字段转换为VirtAddr
,再传给active_level_4_table
函数。然后,使用iter
函数迭代页表条目,并使用enumerate
函数为每个元素添加遍历索引i
。我们仅打印非空条目,因为全部512个条目无法一起显示在屏幕上。
运行可以看到以下输出:
我们看到了多个非空条目,它们都映射到不同的3级表。因为内核代码、内核堆栈、物理内存映射和引导信息都会使用单独的内存区域。
为了进一步遍历页表并查看第3级表,我们可以将条目的映射帧再次转换为虚拟地址:
1 | use x86_64::structures::paging::PageTable; |
至于查看2级和1级表,只需对3级和2级条目重复该过程即可。你可以想象,这很快就会变得非常冗长,因此我们在这里不显示完整的代码。
手动遍历页表很有趣,因为它有助于了解CPU如何执行转换。但是,大多数时候我们只对给定虚拟地址的映射物理地址感兴趣,因此让我们为其创建一个函数。
转换地址
为了将虚拟地址转换为物理地址,我们必须遍历全部四级页表,直到到达映射的帧为止。让我们创建一个执行此转换的函数:
1 | use x86_64::PhysAddr; |
再将该函数传递给安全函数translate_addr_inner
,以限制非安全操作的范围。前面提到,Rust会将unsafe fn
函数体当做一个大型unsafe
块来对待。因此,通过调用私有安全函数,可以再次明确每个不安全操作。
其中的私有函数包含实际的实现细节:
1 | /// `translate_addr`调用的私有函数 |
我们不选择复用active_level_4_table
函数,而是再次从CR3
寄存器中读取4级帧。这样做可以简化此原型实现。不用担心,我们将在稍后创建一个更好的解决方案。
VirtAddr
结构体已经提供了用于计算进入四个级别页表的索引的方法。我们将这些索引存储在一个小的数组中,之后便可以使用for
循环遍历页表。在循环之外,我们记录最后被访问的frame
,以便稍后计算其物理地址。该帧在迭代时指向页表帧,并在最后一次迭代后(即在访问1级条目之后)指向映射的帧。
在循环内部,我们再次使用physical_memory_offset
将帧转换为页表引用。然后,我们读取当前页表的条目,并使用PageTableEntry::frame
函数检索映射的帧。如果条目未映射到帧,则返回None
。如果条目映射到2MiB或1GiB的巨页,则产生panic。
让我们通过转换一些地址来测试转换函数:
1 | fn kernel_main(boot_info: &'static BootInfo) -> ! { |
运行可以看到以下输出:
如预期的那样,恒等映射的地址0xb8000
转换为相同的物理地址。代码页和栈页转换为一些随机的物理地址,这取决于bootloader如何为内核创建初始映射。值得注意的是,转换后的最后12位(译注:在图中的十六进制体现为最后3位)始终保持不变,这是合理的,因为这些位是页面偏移量,而不是被转换地址的一部分。
由于可以通过加上physical_memory_offset
来访问每个物理地址,因此physical_memory_offset
地址本身的转换应指向物理地址0
。但是,转换失败,因为该映射使用巨页来提高效率,但我们的实现尚不支持巨页。
使用OffsetPageTable
将虚拟地址转换为物理地址是OS内核中的常见任务,因此x86_64
crate为其提供了一种抽象。该实现已经支持巨页和除translate_addr
之外的其他几个页表函数,因此下文将使用该抽象进行操作,而不是在我们自己的实现中手动添加对巨页的支持。
该抽象基于两个trait,它们定义了各种页表映射函数:
Mapper
trait的泛型约束为PageSize
,它提供操作页面的函数。例如:translate_page
用于将给定页面转换为其相应大小的帧,map_to
函数在页表中创建新的映射。Translate
trait提供了适用于多种页面大小的函数,例如translate_addr
或普通translate
。
trait仅定义了接口,并未提供任何实现。x86_64
crate当前提供三种类型,这些类型按照不同需求实现了这些trait。OffsetPageTable
类型假定完整的物理内存以某个偏移量全部映射到虚拟地址空间。MappedPageTable
更加灵活一些:它只假定每个页表帧均被映射到了一个位于虚拟地址空间中的可计算地址。最后,可以使用RecursivePageTable
类型通过递归页表访问页表帧。
对我们来说,bootloader将完整的物理内存映射到附加physical_memory_offset
偏移量的虚拟地址,因此我们可以使用OffsetPageTable
类型。要初始化该类型,我们在内存模块中创建一个新的init
函数:
1 | use x86_64::structures::paging::OffsetPageTable; |
该函数将physical_memory_offset
作为参数,新建并返回一个具有'static
生命周期的OffsetPageTable
实例。这意味着该实例在内核的完整运行时始终保持有效。在函数主体中,我们首先调用active_level_4_table
函数获取4级页表的可变引用。然后,我们将此引用作为第一个参数传递给OffsetPageTable::new
函数。我们使用physical_memory_offset
变量作为第二个参数传递给new
函数,该参数期望得到虚拟地址映射到物理地址的起点(译注:即虚拟地址映射到物理地址时附加的偏移量)。
从现在开始,仅应从init
函数调用active_level_4_table
函数,因为当多次调用它时,很容使可变的引用产生多个别名,而这可能会导致未定义的行为。因此,应通过删除pub
关键字来使该函数变为私有。
现在,我们可以使用Translate::translate_addr
方法来代替我们自己的memory::translate_addr
函数。只需要在kernel_main
中做几行更改:
1 | fn kernel_main(boot_info: &'static BootInfo) -> ! { |
这里需要导入Translate
trait以使用它提供的translate_addr
方法。
此时运行,我们会看到与以前相同的转换结果,不同之处在于巨页也可以转换了:
不出所料,0xb8000
、代码地址和栈地址的转换结果与我们自己实现的转换函数相同。此外,我们现在看到虚拟地址physical_memory_offset
映射到物理地址0x0
。
通过使用MappedPageTable
类型的转换功能,我们就没必要自己实现对巨页的支持了。另外,还可以访问其他页面函数——如map_to
——我们将在下一节中使用。
现在,我们不再需要前面手动实现的memory::translate_addr
和memory::translate_addr_inner
函数了,因此可以将它们删除。
创建新映射
到目前为止,我们仅查看了页表,还从未修改页表。让我们通过为先前未映射的页面创建一个新的映射来试着修改页表。
该操作将使用Mapper
trait的map_to
函数来实现,让我们首先看一下这个函数。文档说明该函数有四个参数,分别是:想要映射的页面,该页面应映射到的帧,要为该页表项设置的标志,以及frame_allocator
。这里需要帧分配函数,是因为映射给定页面时,可能需要创建新的页表,而这个过程需要新给页表分配未使用的帧。
一个示例函数create_example_mapping
第一步是创建一个新的create_example_mapping
函数,该函数将给定的虚拟页面映射到VGA文本缓冲区的物理帧0xb8000
。我们选择该帧是因为它使我们能够轻松测试映射是否被正确创建:我们只需要对新映射的页面进行写入,就可以在屏幕上观察到写入是否成功。
create_example_mapping
函数如下所示:
1 | use x86_64::{ |
除了需要被映射的page
之外,该函数还需要OffsetPageTable
实例的可变引用和一个frame_allocator
。frame_allocator
参数使用impl Trait
语法约束该泛型参数必须实现FrameAllocator
trait。而该trait又约束了其泛型参数必须实现PageSize
trait,以便同时支持4KiB标准页和2MiB/1GiB巨页。我们只想创建一个4KiB映射,因此我们将泛型参数设置为Size4KiB
。
map_to
为非安全方法,而调用者必须确保该帧未被使用。两次映射同一帧会导致未定义行为,例如,当两个不同的&mut
引用指向同一物理内存位置时。在我们的例子中,确实也二次映射了已被映射的VGA文本缓冲区帧,因此打破了所需的安全条件。不过,create_example_mapping
函数只是一个临时测试函数,在后文中将被删除,也无伤大雅。我们在该行上添加了FIXME
注释,以提醒我们这种不安全用法。
除了page
和unused_frame
外,map_to
方法还使用了一组用于映射的标志,和一个frame_allocator
的引用,稍后将对此分配器进行说明。设置PRESENT
是因为所有有效条目都需要该标志,而WRITABLE
标志则使映射的页面可写入。关于所有可用的标志的列表,请参见上一篇文章的页表格式一节。
map_to
函数可能会失败,因此它将返回一个Result
。这只是示例用代码,不需要高鲁棒性,因此我们在发生panic时仅使用expect
应付。调用成功时该函数将返回MapperFlush
类型,该类型提供了一种调用其flush
方法即可从转换后备缓冲区(TLB)中刷新新映射页面的简便方法。像Result
一样,该类型也使用#[must_use]
属性在我们意外忘记调用flush
方法时发出警告。
一个假的FrameAllocator
为了能够调用create_example_mapping
,首先需要创建一个实现了FrameAllocator
trait的类型。如上所述,如果map_to
需要新的帧,则该trait就负责为新页表分配帧。
让我们先从简单的情况入手:假设我们不需要创建新的页表。在这种情况下,这个帧分配器始终返回None
就足够了。下面的代码创建了一个EmptyFrameAllocator
来测试我们的映射函数:
1 | /// 一个始终返回`None`的帧分配器 |
FrameAllocator
的实现是非安全的,因为实现者必须保证分配器仅分配未使用的帧。否则,可能会发生不确定的行为,例如,当两个虚拟页面映射到同一物理帧上时。而我们的EmptyFrameAllocator
只返回None
,因此并不出现生这种问题。
选择一个虚拟页面
现在,我们有一个简单的帧分配器,可以将其传递给create_example_mapping
函数了。不过这个分配器始终返回None
,所以它也就只能用在创建映射时并不需要额外的页表帧的情况了。为了了解何时需要额外页表帧以及何时不需要额外页表帧,让我们看一个示例:
图的左侧为虚拟地址空间,右侧为物理地址空间,中间为页表。页表如虚线所示,存储在物理存储帧中。虚拟地址空间在地址0x803fe00000
中包含一个映射的页面,以蓝色标记。为了将此页面转换为其所在的帧,CPU遍历全部4级页表,直到到达地址为36KiB
的帧。
此外,图中以红色显示VGA文本缓冲区的物理帧。我们的目标是使用create_example_mapping
函数将先前未映射的虚拟页面映射到此帧。不过由于EmptyFrameAllocator
始终返回None
,我们自然希望创建映射时不会用到该类型来分配额外的帧。而这取决于我们为映射选择的虚拟页面。
图中以黄色标记了虚拟地址空间中的两个候选页面,一个位于地址0x803fdfd000
,就在被映射页(蓝色)之前3页。该地址的4级和3级页表索引与蓝页相同(译注:索引为别为1、0),但2级和1级索引却不同(参阅上一篇文章,译注:索引分别为126、125)。2级表中的索引不同意味着此页面使用了不同的1级表。如果我们选择该页作为我们示例中的映射,那么由于1级表尚不存在,就需要创建该表,也就需要一个额外的未使用的物理帧。而位于地址0x803fe02000
的另一个候选页面则不存在此问题,因为它使用与蓝色页面相同的1级页表(译注:索引分别为1、0、127、2)。因此,所有必需的页表已经存在。
总之,创建新映射的难度取决于我们要映射的虚拟页面。在最简单的情况下,该页面的1级页表已经存在,我们只需要写入一个条目即可。在最困难的情况下,该页面所在的内存区域中尚不存在3级表,因此我们需要首先创建新的3级表、2级表、1级表。
为了让create_example_mapping
函数能够使用EmptyFrameAllocator
类型,我们需要选择一个所有页表均已存在的页面。要找到这样的页面,我们可以利用bootloader会将自身加载到虚拟地址空间的第一个兆字节中这一行为。这意味着该区域的所有页面都存在一个有效的1级表。因此,我们可以在此内存区域中选择任何未使用的页面作为示例映射,比如选择地址为0
的页面。通常,该页面应保持未使用状态,以确保解引用空指针会导致页面错误,因此我们知道bootloader将该地址保留为未映射状态。
创建映射
至此,我们准备好了create_example_mapping
函数所需的所有参赛,现在可以修改kernel_main
函数,以将映射位于虚拟地址0
的页面。由于我们会将页面映射到VGA文本缓冲区的帧上,那么也应该也能够通过该页面写在屏幕上。实现如下:
1 | fn kernel_main(boot_info: &'static BootInfo) -> ! { |
我们首先使用mapper
和frame_allocator
的可变引用作为参数,调用create_exmaple_mapping
函数,来为位于虚拟地址0
处的页面创建映射。这会将该页面映射到VGA文本缓冲区的帧上,因此我们应该能够在屏幕上看到对其进行的任何写入。
然后,我们将页面转换为裸指针,并向偏移量400
处写入一个值。我们不在页面开头进行写入,因为VGA缓冲区的首会直接被下一个println
移出屏幕。写入值0x_f021_f077_f065_f04e
代表字符串”New!“。在白色背景上。正如我们在“VGA文本模式”一文中所了解的那样,对VGA缓冲区的写操作应该是易失性的,因此我们使用write_volatile
方法。
在QEMU中运行将看到以下输出:
屏幕上的”New!“是通过页面0
写入的,这意味着我们成功在页表中创建了新的映射。
仅因为负责虚拟地址0
处页面的1级页表已存在,所以创建该映射才起作用。当我们尝试为尚不存在1级表的页面进行映射时,map_to
函数将失败,因为它试图从EmptyFrameAllocator
分配帧以创建新的页面表。当我们尝试映射页面0xdeadbeaf000
而不是页面0
时,就会看到这种情况:
1 | fn kernel_main(boot_info: &'static BootInfo) -> ! { |
运行会出现带有以下错误信息的panic:
1 | panicked at 'map_to failed: FrameAllocationFailed', /…/result.rs:999:5 |
要映射没有1级页表的页面,我们需要创建一个适当的FrameAllocator
。但是,我们如何知道哪些帧未被使用,以及到底有多少物理内存可用呢?
分配帧
为了创建新的页表,我们需要创建一个适当的帧分配器。为此,我们使用memory_map
,它曾作为BootInfo
结构体的一部分传给bootloader:
1 | use bootloader::bootinfo::MemoryMap; |
该结构体有两个字段:一个对bootloader传递的内存映射的'static
引用,以及一个跟踪分配器应返回的下一帧的编号的字段next
。
如我们在引导信息一节中所介绍的那样,内存映射由BIOS/UEFI
固件提供。它只能在引导过程早期被查询,因此bootloader已经为我们调用了相应的函数。存储器映射由MemoryRegion
结构体列表组成,结构体包含每个存储区域的起始地址、长度和类型(例如未使用,保留等)。
init
函数使用给定的内存映射初始化BootInfoFrameAllocator
。next
字段初始化为0
,且该值会随着每个帧的分配而增长,以避免两次返回相同的帧。由于我们不知道内存映射的可用帧是否已在其他地方使用,因此init
函数必须标记为unsafe
才能要求调用者提供额外的保证。
写一个usable_frames
方法
在实现FrameAllocator
trait之前,我们先添加一个辅助方法,以将内存映射转换为能够返回可用帧的迭代器:
1 | use bootloader::bootinfo::MemoryRegionType; |
该函数使用组合多个迭代器的方法,将初始MemoryMap
转换为返回可用物理帧的迭代器:
- 首先,我们调用
iter
方法将内存映射转换为返回MemoryRegions
的迭代器。 - 然后,我们使用
filter
方法跳过任何保留区域或别的不可用的区域。bootloader会为其创建的所有映射更新内存映射,因此内核使用的帧(代码,数据或栈)或用于存储引导信息的帧就已经被标记为InUse
或类似的标志。因此,我们可以确定Usable
帧不会在其他地方使用。 - 之后,我们使用
map
组合器和Rust的range语法将内存区域的迭代器转换为地址范围的迭代器。 - 接下来,我们使用
flat_map
将地址范围转换为帧起始地址的迭代器,在这个过程中同时使用step_by
每隔4096字节选择一个地址。这是因为页面大小为4096字节(即4 KiB),每隔4096选择一个地址便得到了每个帧的起始地址。Bootloader页面会对齐所有可用的内存区域,因此我们在这里不需要任何用于对齐或舍入的代码。之所以使用flat_map
而非map
,是因为想要得到Iterator<Item = u64>
而非Iterator<Item = Iterator<Item = u64>>
。(译注:上一步得到的迭代器是Iterator<Item = Range<u64>>
,因此只有使用flat_map
将内外迭代器都打开才能操作其中的u64
)。 - 最后,我们将起始地址转换为
PhysFrame
类型,以构造Iterator<Item = PhysFrame>
。
该函数的返回类型使用impl Trait
特性。这样,我们可以指定返回值某种实现了Iterator
trait特性且其中迭代元素类型为PhysFrame
的类型,而无需指明具体的返回类型。注意,我们无法指明返回值的具体类型,因为它依赖于一个匿名的闭包。
实现FrameAllocator
trait
现在我们可以实现FrameAllocator
trait了:
1 | unsafe impl FrameAllocator<Size4KiB> for BootInfoFrameAllocator { |
首先使用usable_frames
方法从内存映射中获取可用帧的迭代器。然后,使用Iterator::nth
函数获取索引为 self.next
的帧(从而跳过第(self.next-1)
帧)。在返回该帧之前,将self.next
增加一,以便在下一次调用时返回下一个帧。
这种实现方式并不是十分理想,因为在每次分配帧时该函数都会重新创建usable_frame
分配器。最好直接将迭代器存储为结构体的一个字段,然后,我们将不需要再调用nth
方法,而是只需在每次分配时调用next
。不过,这种方法的问题在于,目前还无法在结构体字段中存储impl Trait
类型。不过在未来某一天,当Rust的named existential types完全实现时,这个方法也许会变得可行。
使用BootInfoFrameAllocator
现在,我们可以修改kernel_main
函数,以传入BootInfoFrameAllocator
实例代替原来的EmptyFrameAllocator
实例了:
1 | fn kernel_main(boot_info: &'static BootInfo) -> ! { |
使用引导信息帧分配器后映射成功,我们再次看到了白底黑字的”New!“出现在屏幕上。在后台,map_to
方法通过以下方式创建了缺少的页表:
- 使用传入的
frame_allocator
分配未使用的帧。 - 将帧初始化为全零以创建一个新的空页表。
- 将更高级别的表的条目映射到该帧。
- 使用下一级表继续执行。
虽然我们的create_example_mapping
函数只是一些示例代码,但至少现在已经能够为任意页面创建新的映射了。这对于在以后的文章中进行内存的分配或多线程的实现时至关重要。
不过目前,我们应该先删掉create_example_mapping
函数,以避免上述的意外调用导致的未定义的行为。
小结
在这篇文章中,我们学习了访问页表物理帧的各种技术,包括恒等映射、完整物理内存映射、临时映射和递归页表等。接下来我们选择了映射完整的物理内存,因为它简单、可移植且功能强大。
如果没有页表的访问权限,我们就无法映射内核中的物理内存,因此我们需要bootloader的支持。bootloader
crate支持通过可选的cargo特性创建我们所期望的映射。而我们所需的引导信息将以&BootInfo
参数的形式从入口点函数中传递给内核。
在代码实现的过程中,我们首先通过手动遍历页表的方式实现了地址转换函数,然后转而使用x86_64
crate的MappedPageTable
类型代替我们手写的代码。此外,我们还学习了如何在页表中创建新的映射,以及如何在bootloader传入的内存映射之上创建必要的FrameAllocator
。
下期预告
下一篇文章将为我们的内核创建一个堆内存区域,这将使我们能够分配内存并使用各种集合类型。
支持本项目
创建和维护这个博客和相关库是一项繁重的工作,但我真的很喜欢。通过支持我,您可以让我在新内容、新功能和持续维护上投入更多时间。
支持我的最好方式是在GitHub上赞助我,因为他们不收取任何中间费用。如果你喜欢其他平台,我也有Patreon和Donorbox账户。后者是最灵活的,因为它支持多种货币和一次性捐款。
感谢您的支持!