内存管理

  • 一、实验目的
  • 二、实验内容
    • 分页介绍
      • 内存保护
      • 分段
        • 外部碎片
      • 分页
        • 内部碎片
        • 页表
        • 多级页表
      • 在x86_64上分页
        • 示例翻译
        • 页表格式
        • 转换表缓冲区(TLB)
      • 实施
        • 页面错误
        • 访问页表
      • 小结
    • 分页实现
      • 访问页表
        • 恒等映射
        • 以固定偏移量映射
        • 映射整个物理内存
        • 临时映射
        • 递归页表
      • 引导加载器支持
        • 引导信息
        • entry_point宏
      • 实现
        • 访问页表
        • 地址转换
        • 使用OffsetPageTable
        • 创建一个新映射
        • 分配帧
      • 总结

一、实验目的

分页内存管理是内存管理的基本方法之一。本实验的目的在于全面理解分页式内存管理的基本方法以及访问页表,完成地址转换等方法。

二、实验内容

分页介绍

内存保护

操作系统的一项主要任务对一个进程的内存区域进行保护,不允许其他进程访问。例如,你的网页浏览器不应该干扰你的文本编辑器。为了实现这一目标,有不同的方法,x86上,硬件支持两种不同的内存保护:分页和分段

分段

我们要将虚拟内存地址映射到相对应的物理地址,通过段表来实现
每个地址通过段表来实现,段表每个条目都有段基地址和段界限。
段基地址包含该段在内存的开始的物理地址,段界限指定该段长度。

第一个实例的段偏移量为100,因此将其虚拟地址0-150转换为物理地址100-250。第二个实例有偏移量300,它将其虚拟地址0-150转换为物理地址300-450。

外部碎片

即使有足够的空闲内存可用,也无法将程序的第三个实例映射到没有重叠的虚拟内存中。问题是我们需要连续内存和不能使用的小块自由块。

解决外部碎片的一种方法是紧缩,将内存可使用部分紧缩在一起。

现在有足够的连续空间来存储我们程序的第三个实例。

分页

分页的实现:将物理内存分为固定大小的块,称为帧(frame);将虚拟内存分为同样大小的块,称为页(page);
每个页都单独映射到一个帧。当执行进程时,把程序所有的页放入物理内存中任何可用的帧中。

在这个例子中,我们的页大小和帧大小都是50个字节。每个页面分别映射到一个帧,因此一个连续的虚拟内存区域可以映射到非连续的物理帧。从而解决了外部碎片的问题。

内部碎片

分页虽然没有外部碎片,但是有内部碎片。
原因是:分配是以帧为单位,如果所要求的内存不是页的整数倍,最后一个帧用不完。每个进程平均有半页的内存碎片。

页表

我们每一页映射到哪一个帧上,这些信息储存在页表上。

如图所示,每个程序都有自己的页表。在每次访问内存时,CPU从寄存器(页表)中读取表指针,并查找表中被访问页的映射帧。
如果页表较小,可以将页表作为一组寄存器,如图所示。如果页表过大,需要将页表储存在内存里。
其中flag标志位r/w标志该页面即可读又可写。

多级页表

逻辑(虚拟)地址空间很大,页表本身很大。将页表划分成更小部分,一般用两级分页算法,将页表再分页。

如图所示就是一个二级页表

也可以使用三级,四级等更高的层次。

在x86_64上分页

对64位体系结构,我们使用四级页表,页面大小为4KB

我们看到每个页表索引由9位组成,这是有意义的,因为每个表都有2^9 = 512个条目。最低的12位是4KB页面中的偏移量(2^12字节=4 KB)。位48到64被丢弃,这意味着x86_64实际上不是64位,因为它只支持48位地址。

即使丢弃了48到64位,也不能将其设置为任意值。相反,这个范围内的所有位都必须是位47的副本,这样才能保持地址的唯一性,并允许像5级页面表这样的未来扩展。

示例翻译

上面的页面表层次结构映射两页(蓝色)。从页面表索引中,我们可以推断出这两个页面的虚拟地址是0x803FE7F000和0x803FE00000。让我们看看当程序试图从地址读取时会发生什么0x803FE7F5CE。首先,我们将地址转换为二进制,并确定该地址的页表索引和页偏移量:

使用这些索引,我们现在可以遍历页面表层次结构来确定地址的映射框架:

首先,我们将读取第4级表的地址。CR3寄存器。

  • 4级索引是1,所以我们查看该表的索引1的条目,它告诉我们3级表存储在地址16 KB上。
  • 我们从该地址加载第3级表,并查看索引0的条目,它将我们指向24 KB处的第2级表。
  • 2级索引为511,因此我们查看该页面的最后一个条目,以找到第1级表的地址。
  • 通过1级表中带有索引127的条目,我们最终发现页面被映射到框架12 KB,或十六进制中的0x3000。
  • 最后一步是将页面偏移量添加到帧地址(基地址),以获得物理地址0x3000+0x5ce=0x35ce。

第1级表中页的权限为r意思是只读。硬件强制执行这些权限,如果我们试图写入该页,则会引发异常。

一个四级页表最多有512512512=2^27个实例

页表格式

X86_64体系结构上的页表基本上是由512个条目组成的数组。在Rust语法中:

#[repr(align(4096))]
pub struct PageTable {entries: [PageTableEntry; 512],
}

不用添加
每个条目为8个字节(64位),格式如下:

我们看到只有12-51位被用来存储物理帧地址,其余的位被用作标志,或者可以被操作系统自由使用。

让我们仔细看看可用的标志:

  • 这个present标志区分映射页和未映射页。它可以用于在主内存满时将页面临时交换到磁盘。当随后访问该页时,一个特殊的异常称为页面故障发生这种情况,操作系统可以通过从磁盘重新加载丢失的页,然后继续执行程序来作出反应。
  • 这个writable和no execute标志控制页的内容分别是可写的还是包含可执行指令的。
  • 这个accessed和dirty当对页进行读或写时,CPU会自动设置标志。操作系统可以利用这些信息,例如,决定自上次保存到磁盘以来哪些页面要交换,或者页面内容是否被修改。
  • 这个write through caching和disable cache标志允许单独控制每个页面的缓存。
  • 这个user accessible标志使页面对用户空间代码可用,否则只有在CPU处于内核模式时才能访问它。此功能可用于使系统调用在运行用户空间程序时保持内核映射速度更快。但是,幽灵尽管如此,漏洞还是允许用户空间程序读取这些页面。
  • 这个global向硬件发出信号,表明一个页面在所有地址空间中都可用,因此不需要从地址空间交换机上的转换缓存(参见下面关于TLB的部分)中删除。此标志通常与已清除的标记一起使用。user accessible标志将内核代码映射到所有地址空间。
  • 这个huge page标志允许通过允许级别2或级别3页表的条目直接指向映射的框架来创建较大大小的页。在此位设置下,页面大小将增加因子512,对于第2级条目,将增加到2MiB=5124KiB,对于第3级条目,页面大小甚至会增加到1 GiB=5122 MiB。使用较大页面的优点是需要更少的翻译缓存行和更少的页表。

这个x86_64机箱提供的类型为页表以及他们条目所以我们不需要自己创造这些结构。

转换表缓冲区(TLB)

TLB与页表一起使用:TLB只包含页表的一小部分条目。当CPU产生逻辑地址,其页号提交给TLB,如果找到页号,那么就得到帧号。如果页码不在TLB中(TLB失效),那么访问页表,得到帧号,访问内存。

内核在修改页面表时必须手动更新TLB。为此,有一个特殊的cpu指令称为invlpg(“失效页”),它从TLB中移除指定页的转换,以便在下一次访问中再次从页表中加载它。替换策略有最少使用替换(LRU),随机替换等。
但是,TLB中的有些条目是固定的,通常内核代码的条目是固定的。

每次修改页表时,TLB就必须刷新,确保下一个进程不会使用错误的地址转换。否则,TLB可能包含老的条目,这些条目包含上一个进程留下来的无效物理地址。

实施

我们的内核已经有分页机制了。 我们添加的引导加载程序已经设置了一个4级分页层次结构,它将我们内核的每个页面映射到一个物理帧。 bootloader程序执行此操作是因为在x86_64上的64位模式下必须进行分页。

这意味着我们在内核中使用的每个内存地址都是一个虚拟地址。 访问地址为0xb8000的VGA缓冲区能用,这是因为bootloader程序已经将该内存页映射到本身了,这意味着它将虚拟页0xb8000映射到物理帧0xb8000。

分页使我们的内核已经相对安全,因为每个超出范围的内存访问都会导致页面错误异常,而不是写入随机的物理内存。 引导加载程序甚至为每个页面设置了正确的访问权限,这意味着只有包含代码的页面是可执行的,只有数据页面是可写的。

页面错误

让我们尝试通过访问内核之外的一些内存来导致页面错误。 首先,我们创建一个页面错误处理程序并在我们的IDT中注册它,以便我们看到page fault exception而不是通用的double fault:


CR2寄存器由CPU在页面错误时自动设置,并包含导致页面错误的虚拟地址。 我们使用x86_64 crate 的Cr2 :: read函数来读取和打印它。
这个PageFaultErrorCodeType提供有关导致页错误的内存访问类型的更多信息,例如,它是由读操作还是写操作引起的。因此,我们也把它打印出来。如果不解决页面错误,我们就不能继续执行,因此我们输入一个hlt_loop最后。

现在,我们可以尝试访问内核之外的一些内存:

当我们运行它时,我们看到我们的页面错误处理程序被调用:

这个CR2寄存器包含0xdeadbeaf,是我们试图访问的地址。错误代码通过CAUSED_BY_WRITE错误是在执行写操作时发生的。
它可以通过未设置的位来告诉我们更多的信息。例如,事实上PROTECTION_VIOLATION未设置标志意味着由于目标页不存在而发生了页面错误。

CR2寄存器确实包含0xdeadbeaf,我们试图访问的地址。
我们看到当前指令指针是0x2031b2,所以我们可以知道这个地址指向一个代码页。 代码页由引导加载程序以只读方式映射,因此从该地址读取有效但写入会导致页面错误。 您可以通过将0xdeadbeaf指针更改为0x2031b2来尝试此操作:

注释掉最后一行,我们可以看出读操作成功了,但写操作会导致一个页面异常。

我们看到“阅读有效”打印消息,这表示读取操作没有导致任何错误。但是,而不是“写作成功”消息,说明出现了页面错误。这一次,除了所导致的_by_WITE标志之外,还设置了保护_违章标志,这表明页面已经存在,但不允许对其进行操作。在这种情况下,不允许写入页,因为代码页被映射为只读。

访问页表

让我们看一下定义内核映射方式的页面表:

x86_64的Cr3::read函数从CR3寄存器返回当前活动的4级页表。 它返回PhysFrame和Cr3Flags类型的元组。 我们只对PhysFrame感兴趣,所以我们忽略了元组的第二个元素。

当我们运行它时,我们看到以下输出:

Level 4 page table at: PhysAddr(0x1000)

因此,当前活动的4级页表存储在物理内存中的地址0x1000处,如PhysAddr包装器类型所示。现在的问题是:我们如何从内核访问该表?

当分页处于活动状态时,无法直接访问物理内存,因为程序可以轻松地绕过内存保护并访问其他程序的内存。因此,访问该表的唯一方法是通过一些映射到地址0x1000处的物理帧的虚拟页面。
为页表帧创建映射的这个问题是一个普遍问题,因为内核需要定期访问页表,例如在为新线程分配堆栈时。

小结

这部分介绍了两种内存保护技术:分段和分页。 前者使用可变大小的内存区域并且受到外部碎片的影响,后者使用固定大小的页面,并允许对访问权限进行更细粒度的控制。

分页存储具有一个或多个级别的页表中的页面的映射信息。x86_64体系结构使用4级页表,页面大小为4KiB。 硬件自动遍历页表并在转译后备缓冲区(TLB)中缓存生成的转译规则。 此缓冲区不是透明的,需要在页表更改时手动刷新。

我们了解到我们的内核已经在分页之上运行,并且非法内存访问会导致页面错误异常。 我们尝试访问当前活动的页表,但我们只能访问4级表,因为页表存储了我们无法直接从内核访问的物理地址。

分页实现

访问页表

从我们的内核访问页表并不像看起来那样容易。 为了理解这个问题,让我们再次看一下前面的示例4级页面表层次结构:

这里重要的是每个页面条目都存储下一张表的物理地址。 这避免了再次为这些地址运行地址转换,否则将对性能造成不利影响,并很容易导致无限的转换循环。

对我们来说,问题在于我们无法直接从内核访问物理地址,因为我们的内核也运行在虚拟地址之上。例如,当我们访问地址 4 KiB 时,我们访问的是虚拟地址 4 KiB,而不是存储第4级页表的 物理 地址 4 KiB。 当我们想访问物理地址 4 KiB 时,我们只能通过一些映射到它的虚拟地址来进行访问。

因此,为了访问页表帧,我们需要将一些虚拟页映射到它们。 创建这些映射的方法有很多,所有这些方法都允许我们访问任意页表帧。

恒等映射

一个简单的解决方案是对所有页表进行恒等映射:

在此示例中,我们看到了很多被恒等映射的页表帧。这样一来,页表的物理地址也是有效的虚拟地址,因此我们可以轻松地从CR3寄存器访问所有级别的页表。

但是,它会使虚拟地址空间变得混乱,并使得找到大尺寸的连续内存区域变得更加困难。例如,假设我们要在上面的图形中创建一个大小为 1000 KiB 的虚拟内存区域,例如用于内存映射文件。我们无法在 28 KiB 处开始该区域,因为它会与 1004KiB 处已映射的页碰撞。因此,我们必须继续寻找,直到找到足够大的未映射区域,例如 1008 KiB 。这是与分段类似的碎片问题。

同样,这使创建新的页表变得更加困难,因为我们需要找到其对应的页尚未使用的物理帧。例如,假设我们为内存映射文件保留了从1008 KiB 开始的虚拟 1000 KiB 内存区域。现在我们不能再使用物理地址在1000 KiB和2008 KiB之间的任何帧,因为我们无法对其进行恒等映射。

以固定偏移量映射

为了避免弄乱虚拟地址空间,我们可以为页表映射使用单独的内存区域。我们不再对页表帧进行恒等映射,而是将其从一个有固定偏移量的虚拟地址空间开始映射。例如,偏移量可以是 10 TiB:

通过将 10TiB…(10TiB + 物理内存大小) 范围内的虚拟地址专门用于页表映射,我们避免了恒等映射的冲突问题。但要保留虚拟地址空间中这么大的一块区域,只能在虚拟地址空间远大于物理内存大小时才可行。这在x86_64上不是问题,因为其 48 位地址空间大小为 256 TiB。

这种方法仍然有一个缺点,那就是每当我们创建一个新的页表时我们都需要创建一个新的映射。 另外,它不允许访问其他地址空间的页表,这在创建新进程时很有用。

映射整个物理内存

我们可以通过映射完整的物理内存——而不是仅映射页表帧——来解决这些问题:

此方法允许我们的内核访问任意物理内存,包括其他地址空间的页表帧。 保留的虚拟内存范围具有与以前相同的大小,不同之处在于它不再包含未映射的页面。

这种方法的缺点是需要额外的页表来存储物理内存的映射。 这些页表需要存储在某个地方,因此它们会占用一部分物理内存,这在内存量较小的设备上可能会成为问题。

但是在 x86_64 上,我们可以使用大小为 2MiB 的 huge 页 (huge pages) 来进行映射,而不是使用默认的 4KiB 页。 这样一来,由于只需要一个 3 级表和 32 个 2 级表,映射 32 GiB 物理内存仅需要 132 KiB 大小的页表。 大页还可以提高缓存效率,因为它们在转译后备缓冲器(TLB)中使用的条目更少

临时映射

对于物理内存量很小的设备,我们只能在需要访问它们时才临时映射页表帧 。为了能够创建临时映射,我们只需要一个恒等映射的 1 级页表:

此图中的 1 级表控制虚拟地址空间的前2MiB。这是因为它可以通过从CR3寄存器开始,沿着4级,3级和2级页表中的第0个条目来最终访问到。索引为 8 的条目将地址 32 KiB 的虚拟页映射到地址32 KiB的物理帧,从而恒等映射了 1 级页表自身。该图通过 32 KiB 处的水平箭头显示了此恒等映射。

通过写这个恒等映射的 1 级页表,内核最多可以创建511个临时映射(512减去映射自身所需的条目)。在上面的示例中,内核创建了两个临时映射:

  • 通过将 1 级表的第 0 个条目映射到地址为 24 KiB 的帧,它创建了一个临时映射,将 0 KiB 处的虚拟页映射到第 2 级页表的物理帧,如虚线箭头所示。
  • 通过将 1 级表的第 9 个条目映射到地址为 4 KiB 的帧,它创建了一个虚拟映射,将 36 KiB 处的虚拟页映射到 4 级页表的物理帧,如虚线箭头所示。

现在内核可以通过写入 0 KiB 处的页面来访问2级页面表,并通过写入 36 KiB 处的页面来访问 4 级页面表。

使用临时映射访问任意页表帧的过程将是:

  • 在恒等映射的第 1 级页表中搜索空闲条目。
  • 将该条目映射到我们要访问的页表的物理帧。
  • 通过映射到条目的虚拟页面访问目标帧。
  • 将条目设置回未使用状态,从而删除临时映射。

这种方法重复使用相同的512个虚拟页来创建映射,因此仅需要4KiB的物理内存。缺点是它有点麻烦,特别是因为新映射可能需要修改多个表级别,这意味着我们将需要重复上述过程多次。

递归页表

另一个有趣的方法——根本不需要额外页表——是递归地映射页表。 这种方法的思想是将 4 级页面表的某些条目映射到 4 级表本身。 通过这样做,我们有效地保留了虚拟地址空间的一部分,并将所有当前和将来的页表帧映射到该空间。

让我们通过一个例子来理解这一切是如何工作的:

与本文开头示例的唯一区别是,第 4 级表中索引为 511 的条目被映射到了物理帧4 KiB,也就是这个 4 级表它本身。

当 CPU 在翻译地址的过程中跟随这个条目,它不会到达一个 3 级表,而是又到达同一个 4 级表。这类似于一个调用自身的递归函数,因此这个表被称为 递归页表 。需要注意的是,CPU 假定 4 级表中的每个条目都指向一个 3 级表,因此现在 CPU 将这个 4 级表视为一个 3 级表。这是可行的,因为在x86_64上,所有级别的页表的布局都完全相同。

通过在开始实际转换之前进行一次或多次递归,我们可以有效地缩短CPU遍历的级别数。例如,如果我们只跟踪一次递归条目,然后进入 3 级表,则CPU认为 3 级表是 2 级表。更进一步,它将 2 级表视为 1 级表,1 级表视为映射的帧。这意味着我们现在可以读写1级页表,因为CPU认为它是映射的帧。下图说明了5个翻译步骤:

同样,在开始翻译之前,我们可以两次跟踪递归项,以将遍历级别的数量减少到两个:

让我们一步步看:首先,CPU 根据 4 级表上的递归条目进行跳转,并认为它到达了一个 3 级表。然后,它再次进行递归,并认为它到达了一个 2 级表。但实际上,它仍然位于此 4 级表中。现在,CPU跟着另一个不同的条目跳转时,它将实际到达一个 3 级表,但 CPU 认为它已经到了一个 1 级表上。因此,当下一个条目指向2级表时,CPU 认为它指向一个被映射的帧。这使我们可以读写2级表。

访问3级和4级表的工作方式相同。为了访问3级表,我们跟随递归条目进行了 3 次跳转,使CPU认为它已经在1级表中。然后,我们跟随另一个条目并到达第 3 级表,CPU将其视为映射帧。要访问4级表本身,我们只需遵循递归项四次,直到CPU将4级表本身视为映射帧(下图中的蓝色)。

你可能需要一点时间理清楚这些概念,但是它在实际中很有用。

在下面的部分,我们将解释如何构建虚拟地址以方便进行一次或多次的递归。在我们实际的内核实现中我们不会使用递归页表,所以你可以不继续读下面这部分了。但如果你觉得有兴趣,可以点击“地址计算”以展开。
地址计算
递归分页是一种有趣的技术,它向我们展示了一个页表中的映射可以非常有用。 它相对容易实现,只需要很少的设置(只需一个递归项),因此它是第一个分页实验的不错选择。

但是,它也有一些缺点:

  • 它占用大量虚拟内存(512GiB)。 在较大的 48 位地址空间中,这不是一个大问题,但它可能导致非最优的缓存行为。
  • 它仅允许轻松访问当前活动的地址空间。 通过更改递归项仍然可以访问其他地址空间,但是需要临时映射才能切换回去。 我们在(过时的)“重新映射内核”一文中描述了如何执行此操作。
  • 它在很大程度上依赖于 x86 的页表格式,可能无法在其他体系结构上使用。

引导加载器支持

Bootloader 支持
所有这些方法都需要在初始化时对页表进行修改。 例如,需要创建物理内存的映射,或者需要递归映射一个 4 级表的条目。问题在于,如果没有现有的访问页表的方法,我们将无法创建这些必需的映射。

这意味着我们需要引导加载程序的帮助——它创建内核运行的页表。引导加载程序有权访问页表,因此它可以创建我们需要的任何映射。在当前的实现中,bootloader crate支持上述两种方法,并通过 cargo fratures 进行控制:

  • map_physical_memory 功能将整个物理内存映射到虚拟地址空间中的某个位置。因此,内核可以访问所有物理内存,并且可以遵循 “映射完整物理内存” 方法。
  • 借助 recursive_page_table 功能,引导加载程序将递归映射一个 4 级页面表的条目。这允许内核按照“递归页面表”部分中的描述访问页面表。

我们为内核选择第一种方法,因为它简单,平台无关且功能更强大(它还允许访问 非页表帧)。为了启用所需的引导程序支持,我们将map_physical_memory功能添加到了引导程序依赖项中:

要将原来的bootloader注释掉

否则出现如下错误:

启用此功能后,引导加载程序会将完整的物理内存映射到一些未使用的虚拟地址范围。为了将虚拟地址范围传达给我们的内核,引导加载程序会传递一个 引导信息 结构。

引导信息

bootloader crate定义了一个BootInfo 结构体,该结构包含传递给我们内核的所有信息。 这个结构体仍处于早期阶段,因此如果日后升级为 语义版本号不兼容 的版本时,可能会出现不向后兼容的情况。 当启用 map_physical_memory 功能后,当前它具有两个字段 memory_map 和 physical_memory_offset:

  • memory_map 字段包含可用物理内存的概述。这告诉我们内核系统中有多少可用物理内存,以及哪些内存区域为 VGA 硬件等设备保留。内存映射可以从 BIOS 或 UEFI 固件中查询,但是只能在启动过程中的早期进行查询。出于这个原因,它必须由引导加载程序提供,因为内核无法在之后获取它。在本文的后面,我们将需要内存映射。
  • physical_memory_offset 告诉我们物理内存映射的虚拟起始地址。通过将此偏移量添加到物理地址,我们可以获得相应的虚拟地址。这使我们可以从内核访问任意物理内存。

引导加载程序以 _start 函数的 &'static BootInfo 参数的形式将 BootInfo 结构体传递给我们的内核。我们尚未在函数中声明此参数,因此让我们添加一下:

在此之前我们停止此参数不是问题,因为 x86_64 调用约定在 CPU 寄存器中传递第一个参数。 因此,如果不声明参数,这个参数会被忽略。 但是,如果我们不小心使用了错误的参数类型,那就有问题了,因为编译器不知道我们入口点函数的正确类型签名。

entry_point宏

由于_start函数是从引导加载程序外部调用的,因此不会检查函数签名。 这意味着我们可以让它接受任意参数而没有任何编译错误,但是它将失败或在运行时导致未定义的行为。

为了确保入口点函数始终具有引导程序期望的正确签名,bootloader crate提供了entry_point宏,该宏提供了类型检查的方式来将Rust函数定义为入口点。 让我们重写我们的入口点函数以使用此宏:

我们不再需要在我们的入口点上使用extern "C"或no_mangle,因为该宏为我们定义了真正的下层_start入口点。 现在,kernel_main函数是一个完全正常的Rust函数,因此我们可以为其选择任意名称。 重要的是对它进行类型检查,以便在我们使用错误的函数签名时(例如通过添加参数或更改参数类型)发生编译错误。

让我们在lib.rs中执行相同的更改:

由于入口点仅在测试模式下使用,因此我们将#[cfg(test)]属性添加到所有项。 我们为测试入口点指定不同的名称test_kernel_main,以避免与main.rs的kernel_main混淆。 我们暂时不使用BootInfo参数,因此我们在参数名称前添加_以禁用"未使用的变量"警告。

实现

现在,我们可以访问物理内存了,我们终于可以开始实现页表代码了。 首先,我们来看看运行内核的当前活动页表。 在第二步中,我们将创建一个转换函数,该函数返回给定虚拟地址映射到的物理地址。 作为最后一步,我们将尝试修改页表以创建新的映射。

在开始之前,我们为代码创建一个新的内存模块:

访问页表

在上一篇文章的末尾,我们试图查看内核运行的页表,但是由于无法访问CR3寄存器指向的物理帧而失败。 现在,我们可以通过创建一个 active_level_4_table 函数来返回对活动4级页面表的引用,从而继续:

首先,我们从CR3寄存器中读取活动的4级表的物理帧。 然后,我们获取其物理起始地址,将其转换为u64,并将其与 physical_memory_offset 相加,以获取页表帧所映射的虚拟地址。 最后,我们通过 as_mut_ptr 方法将虚拟地址转换为*mut PageTable 原始指针,然后从中安全地创建 &mut PageTable 引用。 我们创建一个 &mut 引用而不是 & 引用,因为我们将在本文后面中对此页表进行可变操作。

我们在这里不需要使用 unsafe 块,因为 Rust 将 unsafe fn 整个函数体都视作一个大的 unsafe 块。 这使我们的代码更加危险,因为我们可能在不注意的情况下意外引入了不安全的操作。 这也使发现不安全操作变得更加困难。 有一个 RFC提案 希望可以更改此行为。

现在,我们可以使用此函数来打印 4 级页表的条目:

首先,我们 将BootInfo 结构的physical_memory_offset 转换为 VirtAddr,并将其传递给active_level_4_table 函数。 然后,我们使用 iter 函数对页表条目进行迭代,并使用 enumerate 组合子为每个元素添加索引 i。 我们仅打印非空条目,因为所有512个条目均无法显示在屏幕上。

运行它时,我们看到以下输出:

我们看到有各种非空条目,它们都映射到不同的3级表。 有这么多区域是因为内核代码,内核堆栈,物理内存映射和引导信息都使用隔开的内存区域。

为了进一步遍历页表并查看 3 级表,我们可以将一个条目的映射到的帧再次转换为虚拟地址:

为了查看2级和1级表,我们对 3 级和 2 级条目重复该过程。 您可以想象,这很快就会变得非常冗长,因此我们在这里不显示完整的代码。

手动遍历页表很有趣,因为它有助于了解CPU如何执行转换。 但是,大多数时候我们只对给定虚拟地址的映射物理地址感兴趣,因此让我们为其创建一个函数。

地址转换

为了将虚拟地址转换为物理地址,我们必须遍历四级页表,直到到达映射的帧。 让我们创建一个执行此转换的函数:

我们将该函数转发给安全的translate_addr_inner函数,以限制 unsafe 的范围。 如上所述,Rust 将 unsafe fn 的整个函数体视为一个大的 unsafe 块。 通过调用私有safe函数,我们使每个 unsafe 操作是显式的。

内部私有函数包含实际的实现:

我们不再重用我们的 active_level_4_table 函数,而是再次从CR3寄存器读取4级帧。我们这样做是因为它简化了此原型的实现。不用担心,我们稍后会创建一个更好的解决方案。

VirtAddr 结构已经提供了将索引计算到四个级别的页表中的方法。我们将这些索引存储在一个小的数组中,因为它允许我们使用 for 循环遍历页表。在循环之外,我们记住最后访问的帧,以便稍后计算物理地址。 frame 变量在迭代时指向页表帧,并在最后一次迭代后(即在跟随1级条目之后)指向映射的帧。

在循环内部,我们再次使用 physical_memory_offset 将帧转换为页表引用。 然后,我们读取当前页表的条目,并使用 PageTableEntry::frame 函数检索映射的帧。 如果条目未映射到帧,则返回 None。 如果条目映射了一个 2MiB 或1GiB 的huge页面,我们现在会 panic。

让我们通过翻译一些地址来测试我们的翻译功能:


跑一下看看,我们可以看到如下输出:

如预期的那样,恒等映射的地址 0xb8000 转换为相同的物理地址。代码页和堆栈页转换为不定的物理地址,这取决于引导加载程序如何为内核创建初始映射。值得注意的是,转换后的最后12位始终保持不变,这也应该是这样的,因为这些位是页面偏移量,不是转换的一部分。

由于可以通过添加 physical_memory_offset 来访问每个物理地址,因此 physical_memory_offset 地址本身的转换应指向物理地址0。但是,转换失败了,因为该映射使用 huge页 来提高效率,这在我们的实现中尚不支持。

使用OffsetPageTable

使用 OffsetPageTable
将虚拟地址转换为物理地址是OS内核中的常见任务,因此x86_64 crate 为其提供了抽象。该实现已经支持 huge 页和其他几个页面表功能(除了 translate_addr),因此我们将在下面使用它,而不是向我们自己的实现添加 huge 页支持。

此抽象的基础是定义各种页表映射功能的两个 trait:

  • Mapper trait 在页面大小上是通用的,并提供可在页面上操作的函数。例如:translate_page (将给定页转换为相同大小的帧)和 map_to(在页面表中创建新的映射)。
  • MapperAllSizes trait 意味着其实现者也同时为所有的页大小都实现 Mapper。此外,它提供了适用于多种页面大小的函数,例如 translate_addr 或常规的 translate。

trait 仅定义接口,它们不提供任何实现。当前,x86_64 crate 提供了三种类型,根据不同的要求实现了这些 trait。 OffsetPageTable 类型假定完整的物理内存以某个偏移量映射到虚拟地址空间。 MappedPageTable 稍微灵活一些:它只需要将每个页表帧映射到可计算地址处的虚拟地址空间即可。 最后,可以使用 RecursivePageTable 类型来通过递归页表访问页表帧。

在我们的例子中,引导加载程序将完整的物理内存映射到由 physical_memory_offset 变量指定的虚拟地址,因此我们可以使用 OffsetPageTable 类型。要初始化它,我们在 memory 模块中创建一个新的 init 函数:

该函数接受 physical_memory_offset 作为参数,并返回一个具有 'static 生命周期的新 OffsetPageTable 实例。这意味着这个实例在内核整个的运行时都有效。 在函数主体中,我们首先调用 active_level_4_table 函数以获取一个对第 4 级页表的可变引用。接着,我们使用这个引用来调用 OffsetPageTable::new 函数。 new 函数的第二个参数需要一个虚拟地址,使得虚拟地址空间从此处开始映射物理地址空间,也就是 physical_memory_offset 变量。

从现在开始,仅应从 init 函数调用 active_level_4_table 函数。 因为如果多次调用它,很容易导致同名可变引用,从而导致未定义行为(UB)。因此,我们通过删除 pub 说明符来使函数私有。

现在,我们可以使用 MapperAllSizes::translate_addr 方法来代替我们自己的 memory::translate_addr 函数。我们只需要在 kernel_main 中更改几行:



我们需要导入 MapperAllSizes trait 以使用它提供的 translate_addr 方法。

现在运行它时,我们会看到与以前相同的翻译结果,不同之处在于 huge页翻译现在也可以工作:

不出所料,0xb8000 的转换以及代码和堆栈地址与我们自己的转换功能相同。 此外,我们现在看到虚拟地址physical_memory_offset 映射到物理地址 0x0。

通过使用 MappedPageTable 类型的转换函数,我们可以节省掉实现 huge 页的工作量。 我们现在还可以调用其他页函数了,例如 map_to,我们将在下一部分中使用。

此时,我们不再需要 memory::translate_addr 函数,因此可以将其删除。

创建一个新映射

到目前为止,我们仅查看页面表,而没有进行任何修改。让我们通过为以前未映射的页面创建一个新的映射来更改它。

我们将使用 Mapper trait 的 map_to 函数进行实现,因此让我们首先看一下该函数。 文档告诉我们,它接受四个参数:我们要映射的页面,该页面应映射到的帧,页表项的一组标记(flags)以及一个 frame_allocator(帧分配器)。我们需要一个帧分配器,因为映射给定页面可能需要创建其他页表,这些页表需要未使用的帧作为后备存储。

create_example_mapping 函数
我们实现的第一步是创建一个新的 create_example_mapping 函数,该函数将给定的虚拟页面映射到 0xb8000(VGA文本缓冲区的物理帧)。我们选择该帧是因为它使我们能够轻松测试映射是否正确创建:我们只需要向新映射的页面写入,然后查看是否看到写入内容出现在屏幕上。

create_example_mapping函数如下所示:


除了应该映射到的 page 之外,该函数还需要一个对 OffsetPageTable 实例的可变引用,和一个 frame_allocator。 frame_allocator 参数使用 impl Trait 语法以定义一个泛型函数来支持所有实现了 FrameAllocator 的参数类型。 FrameAllocator trait 也是一个泛型,接受实现了 PageSize trait 的类型,以同时支持 4KiB 大小的页和 2MiB/1GiB 的页。 这里我们只想创建 4KiB 大小的映射,因此我们设置泛型的参数为 Size4KiB。

map_to 方法被标记为 unsafe 的,因此调用者必须确保帧现在没有被使用。其原因是如果映射了同一个帧两次会导致未定义行为,例如两个不同的 &mut 引用指向了同一个物理地址。在我们这个例子中,我们重新使用 VGA 文字缓冲区帧,它已经被映射过了,我们破坏了调用条件。但是,create_example_mapping 函数只是一个我们用来临时测试的函数,我们马上将会删除它。为了提醒我们这个不安全因素,我们加上一句 FIXME 注释。

除了 page 和 unused_frame 参数,map_to 方法还接受一组 flags 和一个 frame_allcator 的引用,我们马上就会解释。对于这组 flags,我们设置 PRESENT,因为对于所有有效的条目它都是需要的;设置 WRITABLE 以使得被映射的页可写。查看所有可能的 flags,查看上一篇文章的“页面表格式”部分。

map_to函数可能会失败,因此它将返回Result。由于这只是一些示例代码,不需要鲁棒性,因此我们直接在发生错误时使用expect 来引发一个panic。成功后,该函数将返回 MapperFlush 类型,该类型提供了一种使用其 flush 方法从转换后备缓冲区(TLB)中刷新新映射页面的简便方法。像 Result 一样,当我们意外忘记使用它时,由于使用了#[must_use]属性,会发出一个警告。

一个虚拟的 FrameAllocator
为了能够调用create_example_mapping,我们需要创建一个首先实现FrameAllocator Trait的类型。如上所述,如果map_to需要帧,则Trait负责为新页表分配帧。

让我们从简单的案例开始,并假设我们不需要创建新的页表。对于这种情况,始终返回None的帧分配器就足够了。我们创建了一个EmptyFrameAllocator来测试我们的映射功能:

实现 FrameAllocator 是 unsafe 的,因为实现者必须保证分配器仅返回未使用的帧。 否则可能会发生不确定的行为,例如,当两个虚拟页面映射到同一物帧时。 我们的 EmptyFrameAllocator 只返回 None,因此在这种情况下这不是问题。
选择虚拟页面
现在,我们有了一个简单的帧分配器,可以将其传递给 create_example_mapping 函数。 但是,分配器始终返回None,因此只有在不需要其他页表帧来创建映射时,此分配器才起作用。 要了解何时需要其他页表帧以及何时不需要,我们考虑一个示例:

该图在左侧显示虚拟地址空间,在右侧显示物理地址空间,在中间显示页表。页表存储在物理内存帧中,由虚线表示。虚拟地址空间在地址 0x803fe00000 包含一个被映射的页,以蓝色标记。为了将此页转换为其对应的帧,CPU遍历4级页表,直到到达地址36 KiB 的帧。

此外,该图以红色显示VGA文本缓冲区的物理帧。我们的目标是使用我们的 create_example_mapping 函数将一个先前未映射的虚拟页映射到此帧。由于 EmptyFrameAllocator 始终返回 None,因此我们要创建一个不需要分配器中的其他帧的映射。这取决于我们为映射选择的虚拟页。

该图显示了虚拟地址空间中的两个候选页面,均以黄色标记。一页位于地址 0x803fdfd000,即映射页之前的3页(蓝色)。尽管第4级和第3级页表索引与蓝页相同,但第2级和第1级索引却不同(请参阅上一篇文章)。级别2表中的索引不同,意味着此页面使用了不同的级别1表。由于此1级表尚不存在,因此如果我们为示例映射选择该页面,则需要创建该表,这将需要一个额外的未使用的物理帧。相反,位于地址 0x803fe02000 的第二个候选页面不存在此问题,因为它使用与蓝色页面相同的1级页面表。因此,所有必需的页表已经存在。

总而言之,创建新映射的难度取决于我们要映射的虚拟页面。在最简单的情况下,该页面的1级页面表已经存在,我们只需要编写一个条目即可。在最困难的情况下,该页面位于尚不存在第3级的内存区域中,因此我们需要首先创建新的第3级,第2级和第1级页表。

为了使用 EmptyFrameAllocator 调用 create_example_mapping 函数,我们需要选择一个页面,其所有页表均已存在。要找到这样的页面,我们可以利用引导加载程序将自身加载到虚拟地址空间的第一个兆字节中这一事实。这意味着该区域的所有页面都存在一个有效的1级表。因此,我们可以为示例映射选择该存储区域中任何未使用的页面,例如地址 0 的页。 通常,该页面应保持未使用状态,以确保取消引用空指针会引发一个 页错误(page fault)。因此我们知道引导加载程序保留了该页未映射。
创建映射
现在,我们有了用于调用 create_example_mapping 函数的所有必需参数,因此让我们修改 kernel_main 函数,以将页面映射到虚拟地址 0。由于我们将页面映射到VGA文本缓冲区的帧,因此我们应该能够向屏幕写入。实现看起来像这样:


我们首先通过调用 create_example_mapping 函数来调用地址 0 处的页面的映射。 这会将页面映射到 VGA 文本缓冲区帧,因此我们应该在屏幕上看到对其进行的写入。

然后,我们将页面转换为原始指针,并向偏移量 400 写入一个值。我们不写入页面的开头,因为VGA缓冲区的第一行直接由下一个 println 移出屏幕。 我们写入值0x_f021_f077_f065_f04e,它表示字符串“ New!”。 在白色背景上。 正如我们在“ VGA Text Mode”(VGA文本模式)文章中所了解的那样,对VGA缓冲区的写入应该是易失的,因此我们使用write_volatile 方法。

在 QEMU 中运行它时,将看到以下输出:

屏幕上的 “New!” 是通过写入第 0 页来显示的,这意味着我们已在页表中成功创建了新映射。

仅因为负责地址0的页面的1级表已经存在,所以创建该映射才起作用。 当我们尝试为尚不存在1级表的页面进行映射时,map_to函数将失败,因为它试图从 EmptyFrameAllocator 分配帧以创建新的页表。 当我们尝试映射页面 0xdeadbeaf000 而不是 0 时,我们可以看到这种情况:

当我们运行它时,会出现以下错误消息:

要映射没有 1 级页面表的页面,我们需要创建一个适当的FrameAllocator。 但是我们如何知道哪些帧未使用以及有多少物理内存可用?

分配帧

为了创建新的页表,我们需要创建一个适当帧分配器。 为此,我们使用 memory_map,它由引导程序作为 BootInfo 结构的一部分传递:

该结构有两个字段:对引导加载程序传递的内存映射的 'static 引用,以及一个跟踪分配器应返回的下一帧的编号的next字段。

如我们在“引导信息”部分所述,内存映射由BIOS / UEFI固件提供。它只能在引导过程的早期被查询,因此引导加载程序已经为我们调用了相应的函数。内存映射由 MemoryRegion 结构的列表组成,这些结构包含每个存储器区域的起始地址,长度和类型(例如未使用,保留等)。

init 函数使用给定的内存映射初始化一个 BootInfoFrameAllocator。next 字段用 0 初始化,并且将在每次帧分配时递增,以避免两次返回同一帧。由于我们不知道内存映射的可用帧是否已在其他地方使用,因此我们的 init 函数必须为 unsafe 才能要求调用者提供额外的保证。
usable_frames方法
在实现FrameAllocator特性之前,我们添加了一个辅助方法,该方法将内存映射转换为可用帧的迭代器:

此函数使用迭代器组合子方法将初始 MemoryMap 转换为可用物理帧的迭代器:

  • 首先,我们调用 iter 方法将内存映射转换为 MemoryRegions 的迭代器。
  • 然后,我们使用 filter 方法跳过任何保留的区域或其他不可用的区域。引导加载程序会为其创建的所有映射更新内存映射,因此内核使用的帧(代码,数据或堆栈)或用于存储引导信息的帧已被标记为 InUse 或类似的。因此,我们可以确定 Usable 帧没有在其他地方使用。
  • 之后,我们使用 map 组合子和Rust的range语法将内存区域的迭代器转换为地址范围的迭代器。
  • 下一步是最复杂的:我们通过 into_iter 方法将每个范围转换为一个迭代器,然后使用 step_by 隔 4096 选择范围内的每个地址。由于页面大小为4096字节(= 4 KiB),因此我们获得了每个帧的起始地址。 Bootloader 页面会对齐所有可用的内存区域,因此我们在此处不需要任何对齐或舍入代码。通过使用 flat_map 而不是 map,我们得到了 Iterator <Item = u64> 而不是 Iterator <Item = Iterator <Item = u64 >>。
  • 最后,我们将起始地址转换为 PhysFrame 类型,以构造所需的 Iterator <Item = PhysFrame>。然后,我们使用此迭代器创建并返回一个新的 BootInfoFrameAllocator。

该函数的返回类型使用 impl Trait 功能。 这样一来,我们可以指出我们返回了一种类型,其实现了 Iterator trait,item 类型为 PhysFram,而不需要命名具体的返回类型。这一点很重要,因为我们无法命名具体类型,因为它取决于不可命名的闭包类型。
实现 FrameAllocator Trait
现在我们可以实现FrameAllocator trait:

我们首先使用 usable_frames 方法从内存映射中获取可用帧的迭代器。 然后,我们使用 Iterator::nth 函数获取索引为 self.next 的帧(从而跳过 (self.next-1) 帧)。 在返回该帧之前,我们将self.next增加一,以便在下一次调用时返回下一个帧。

这种实现方式并不是十分理想,因为它会在每次分配时都重新创建 usable_frame 分配器。 最好直接将迭代器存储为struct 字段。 然后,我们将不需要 nth 方法,而只需对每个分配调用 next。 这种方法的问题在于,当前无法在struct 字段中存储 impl Trait 类型。等将来完全实现了 具名存在性类型 ,这个方法可能可以使用。
使用BootInfoFrameAllocator
现在,我们可以修改 kernel_main 函数,以传递 BootInfoFrameAllocator 实例而不是 EmptyFrameAllocator:


使用引导信息帧分配器,映射成功了,并且我们看到了黑白“ New!” 再次出现在屏幕上。 在后台,map_to 方法通过以下方式创建缺少的页表:

  • 从传递的 frame_allocator 中分配未使用的帧。
  • 将帧内容全部设置为 0 以创建一个新的空页表。
  • 将更高级别的表的条目映射到该帧。
  • 继续下一个表格级别。

尽管我们的 create_example_mapping 函数只是一些示例代码,但我们现在能够为任意页面创建新的映射。 这对于在以后的帖子中分配内存或实现多线程至关重要。

总结

在这篇文章中,我们了解了访问页表物理帧的各种技术,包括恒等映射,完整物理内存的映射,临时映射和递归页表。 我们选择映射完整的物理内存,因为它简单,可移植且功能强大。

没有页表访问权限,我们无法映射内核中的物理内存,因此我们需要引导加载程序的支持。 bootloader crate 支持通过可选的 cargo features 创建所需的映射。 它将所需信息以 &BootInfo 参数的形式传递给我们的内核入口函数。

对于我们的实现,我们首先手动遍历页表以实现地址翻译功能,然后使用 x86_64 crate 的 MappedPageTable 类型。 我们还学习了如何在页表中创建新的映射,以及如何在引导加载程序传递的内存映射之上创建必要的 FrameAllocator。
操作系统概念1~5次实验报告:https://download.csdn.net/download/weixin_43979304/15321050?spm=1001.2014.3001.5503

操作系统原理实验(五) 内存管理相关推荐

  1. linux内存实验,LINUX编程-实验五 内存管理实验

    实验五内存管理实验 1.目的要求 (1)学习使用内存管理库函数. (2)学习分析.改正内存错误. 2.实验内容 (1)内存库函数实验 ●malloc函数 原型:extern void *malloc( ...

  2. java的内存管理_操作系统实验——java内存管理

    1.Test.java import java.util.Scanner; public class Test { public static void main(String[] args) { T ...

  3. 操作系统(三)内存管理

    操作系统(三)内存管理 一.程序执行过程 装入的三种方式 链接的三种方式 二.内存管理的概念 内存空间的分配与回收 连续分配管理方式 单一连续分配 固定分区分配 动态分区分配 首次适应算法 最佳适应算 ...

  4. 操作系统课设之内存管理

    前言 课程设计开始了,实验很有意思,写博客总结学到的知识 白嫖容易,创作不易,学到东西才是真 本文原创,创作不易,转载请注明!!! 本文链接 个人博客:https://ronglin.fun/arch ...

  5. (王道408考研操作系统)第三章内存管理-第二节1:虚拟内存管理基本概念

    文章目录 一:传统存储管理方式的弊端 二:局部性原理与高速缓冲技术Cache (1)Cache基本原理 (2)局部性原理 三:虚拟内存的定义和特征 (1)定义 (2)特征 四:虚拟内存实现 内存管理需 ...

  6. 操作系统概念学习笔记 15 内存管理(一)

    操作系统概念学习笔记 15 内存管理(一) 背景 内存是现代计算机运行的中心.内存有非常大一组字或字节组成,每一个字或字节都有它们自己的地址.CPU依据程序计数器(PC)的值从内存中提取指令.这些指令 ...

  7. 操作系统概念学习笔记 16 内存管理(二) 段页

    操作系统概念学习笔记 16 内存管理 (二) 分页(paging) 分页(paging)内存管理方案允许进程的物理地址空间可以使非连续的.分页避免了将不同大小的内存块匹配到交换空间上(前面叙述的内存管 ...

  8. (王道408考研操作系统)第三章内存管理-第二节3:页面置换算法2

    上接: (王道408考研操作系统)第三章内存管理-第二节2:页面置换算法1 文章目录 一:时钟置换算法(CLOCK) (1)简单时钟置换算法 (2)改进型时钟置换算法 二:页面置换算法总结 一:时钟置 ...

  9. ZUCC_操作系统原理实验_Lab9进程的通信消息队列

    lab9进程的通信–消息队列 一.两个进程并发执行,通过消息队列,分别进行消息的发送和接收 1.代码: //接受消息 #include<stdio.h> #include<stdli ...

  10. ZUCC_操作系统原理实验_实验九 消息队列

    操作系统原理实验报告 课程名称 操作系统原理实验 实验项目名称 实验九 消息队列 实验目的 了解 Linux 系统的进程间通信机构 (IPC): 理解Linux 关于消息队列的概念: 掌握 Linux ...

最新文章

  1. java软件测试黑盒工具,软件测试中的抽象层次系列之一 – 黑盒与白盒
  2. 浅谈代码的执行效率(4):汇编优化
  3. 在 centos6 上安装 LAMP
  4. Android——SQLite实现面向对象CRUD
  5. Qt Creator使用3D材质
  6. 【机器视觉】 fuzzy_measure_pairing算子
  7. python 多继承 __new___Python3中的__new__方法以及继承不可变类型类的问题
  8. Thrift 异步模式
  9. leetcode 打印_剑指 Offer 总结 - leetcode 剑指offer系列
  10. 卷积神经网络的卷积核的每个通道是否相同?
  11. NFS 服务固定端口
  12. win10php环境配置教程,win10php环境搭建详细教程
  13. 渗透测试流程-全(仅供学习,知识分享)
  14. mediatek无线usb网卡驱动 linux,Ralink雷凌USB无线网卡驱动5.1.7.0版 Ralink雷凌USB无线网卡驱动5.1.7.0版 无线网卡驱动 雷凌...
  15. psd导出jpg太大_解决photoshop 储存PSD或jpg文件异常过大的方法
  16. 携程2021年国庆出游报告出炉
  17. 再不奋斗,我们就老了!
  18. MySQL 5.7 服务端 错误码 (机翻)
  19. Hive的元数据表结构详解(转自lxw1234)
  20. 从遥感卫星(Rapideye)的视角见证“蚂蚁森林”的生长——以“蚂蚁森林1号林”为例

热门文章

  1. 电脑如何让两个文件夹同步更新备份?
  2. Python编程 | 随机生成车牌号
  3. 下一代企业IT架构:云原生架构
  4. CocosCreator中TiledMap简单使用
  5. OS - 计算机组成原理及CPU主频揭秘
  6. day09 CDN绕过漏洞回链接口探针全网扫描反向邮件
  7. creo5.0安装教程(图文详解)
  8. Java菜鸡的学习日常——2021华为软挑(练手)
  9. python水仙花数
  10. 三菱PLC编程软件操作快捷键汇总