高级分页

简介

在上一篇文章中,我们了解了分页的原理以及x86_64上4级页表的工作原理。我们还发现引导加载程序已经为我们的内核设置了页表层次结构,这意味着我们的内核已经在虚拟地址上运行。这提高了安全性,因为非法内存访问会导致页面错误异常,而不是修改任意物理内存。

但是,当我们尝试从内核访问页表时会出现问题,因为我们无法直接访问存储在页表条目或CR3寄存器中的物理地址。当我们尝试检查活动页表时,我们在上一篇文章的末尾已经遇到了这个问题。

下一节将详细讨论该问题,并提供解决方案的不同方法。之后,我们实现了一个遍历页表层次结构的函数,以便将虚拟地址转换为物理地址。最后,我们将学习如何在页表中创建新的映射以及如何查找未使用的内存帧以创建新的页表。

访问页表

从我们的内核访问页表并不像看起来那么容易。要理解这个问题,让我们再看一下上一篇文章的示例4级页表层次结构:

An example 4-level page hierarchy with each page table shown in physical memory

这里重要的是每个页面条目存储下一个表的物理地址。这样就不需要为这些地址运行转换,这样可以保证性能,并且不会导致无限的转换循环。

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

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

  • 一个简单的解决方案是直接映射所有页表

    A virtual and a physical address space with various virtual pages mapped to the physical frame with the same address

    在此示例中,我们看到页表帧都被直接映射。这样,页表的物理地址就是有效的虚拟地址,这样我们就可以从CR3寄存器开始轻松访问所有级别的页表。

    但是,它使虚拟地址空间变得混乱,并且使得更难以找到更大尺寸的连续存储区域。例如,假设我们想要在上面的图形中创建大小为1000KiB的虚拟存储区域,例如,用于创建文件的内存映射。我们无法在28KiB处启动该区域,因为它将与已经映射的1004MiB页面发生冲突。因此,我们必须一直找,直到找到足够大的未映射区域,例如1008KiB。这与分段一样存在类似的碎片问题。

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

  • 或者,我们只能在需要访问它们时暂时映射页表帧。为了能够创建临时映射,我们只需要一个直接映射的1级页表:

    A virtual and a physical address space with an identity mapped level 1 table, which maps its 0th entry to the level 2 table frame, thereby mapping that frame to page with address 0

    此图中的1级页表控制了虚拟地址空间的前2MiB。这是因为可以通过从CR3寄存器开始并在访问4级,3级和2级页表中的第0个条目之后访问它。索引8的条目将地址32KiB处的虚拟页面映射到地址32KiB处的物理帧,从而直接映射1级页表本身。该图形通过32KiB处的水平箭头显示此身份映射。

    通过写入直接映射的1级页表,我们的内核可以创建多达511个临时映射(512减去直接映射所需的条目)。在上面的示例中,内核将1级表的第0个条目映射到地址为24KiB的帧。这创建了虚拟页面在0KiB到2级页表的物理帧的临时映射,由虚线箭头指示。现在,内核可以通过写入从0KiB开始的页面来访问2级页表。

    使用临时映射访问任意页表框架的过程如下:

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

    这种方法可以保持虚拟地址空间的清洁,因为它重用了相同的512个虚拟页面来创建映射。缺点是它有点麻烦,特别是因为新映射可能需要修改多个级别的表,这意味着我们需要多次重复上述过程。

  • 虽然上述两种方法都有效,但第三种技术称为递归页表,它结合了它们的优点:它始终保持所有页表帧映射,因此不需要临时映射,并且还将映射页保持在一起以避免碎片化虚拟地址空间。这是我们将用于实现的技术,因此将在下一节中详细介绍。

递归页表

这种方法背后的想法是将4级页表的一些条目映射到4级表本身。通过这样做,我们有效地保留了虚拟地址空间的一部分,并将所有当前和未来的页表帧映射到该空间。

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

An example 4-level page hierarchy with each page table shown in physical memory. Entry 511 of the level 4 page is mapped to frame 4KiB, the frame of the level 4 table itself.

与该帖子开头的示例的唯一区别在于4级表中索引511处的附加条目,其被映射到物理帧4KiB,即4级表本身的帧。

CPU在翻译有关此条目的地址时,它不会达到3级表,而是再次访问相同级别4的表。这类似于调用自身的递归函数,因此该表称为递归页表。重要的是CPU假定4级表中的每个条目都指向3级表,因此它现在将4级表视为3级表。这是可以的,因为所有级别的表在x86_64上具有完全相同的布局。

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

The above example 4-level page hierarchy with 5 arrows: "Step 0" from CR4 to level 4 table, "Step 1" from level 4 table to level 4 table, "Step 2" from level 4 table to level 3 table, "Step 3" from level 3 table to level 2 table, and "Step 4" from level 2 table to level 1 table.

类似地,我们可以在开始转换之前递归两次,以将遍历的级别数减少到两个:

The same 4-level page hierarchy with the following 4 arrows: "Step 0" from CR4 to level 4 table, "Steps 1&2" from level 4 table to level 4 table, "Step 3" from level 4 table to level 3 table, and "Step 4" from level 3 table to level 2 table.

让我们一步一步地完成它:首先,CPU遵循4级表上的递归条目,并认为它达到了3级表。然后它再次跟随递归条目并认为它到达2级表。但实际上,它仍然在4级表上。当CPU现在跟随不同的条目时,它将落在3级表上,但认为它已经在1级表上。因此,当下一个入口指向2级表时,CPU认为它指向映射的帧,这允许我们读取和写入2级表。

访问级别3和4的表以相同的方式工作。为了访问3级表,我们遵循递归条目三次,欺骗CPU认为它已经在1级表上。然后我们按照另一个条目进入3级表,CPU将其视为映射帧。为了访问4级表本身,我们只需按照递归条目四次,直到CPU将4级表本身视为映射帧(下图中的蓝色)。

The same 4-level page hierarchy with the following 3 arrows: "Step 0" from CR4 to level 4 table, "Steps 1,2,3" from level 4 table to level 4 table, and "Step 4" from level 4 table to level 3 table. In blue the alternative "Steps 1,2,3,4" arrow from level 4 table to level 4 table.

可能需要花上一些时间来理解这个概念,但实践上这个方法中运作良好。

地址计算

我们看到我们可以通过在实际翻译之前递归一次或多次来访问所有级别的表。 由于四个级别的表中的索引直接来自虚拟地址,因此我们需要为此技术构建特殊的虚拟地址。 请记住,页表索引是通过以下方式从地址中得到的:

Bits 0–12 are the page offset, bits 12–21 the level 1 index, bits 21–30 the level 2 index, bits 30–39 the level 3 index, and bits 39–48 the level 4 index

假设我们想要访问映射特定页面的1级页表。 如上所述,这意味着在继续使用4级,3级和2级索引之前,我们必须递归一次。 为此,我们将地址的每个块向右移动一个块,并将原始的4级索引设置为递归条目的索引:

Bits 0–12 are the offset into the level 1 table frame, bits 12–21 the level 2 index, bits 21–30 the level 3 index, bits 30–39 the level 4 index, and bits 39–48 the index of the recursive entry

为了访问该页面的2级页表,我们将每个索引块向右移动两个块,并将原来的4级索引和原来的3级索引的块都设置为递归条目的索引:

Bits 0–12 are the offset into the level 2 table frame, bits 12–21 the level 3 index, bits 21–30 the level 4 index, and bits 30–39 and bits 39–48 are the index of the recursive entry

访问3级表的方法是将每个块向右移动三个块,并使用原来的4级,3级和2级地址块的递归索引:

Bits 0–12 are the offset into the level 3 table frame, bits 12–21 the level 4 index, and bits 21–30, bits 30–39 and bits 39–48 are the index of the recursive entry

最后,我们可以通过向右移动每个块四个块并使用除偏移之外的所有地址块的递归索引来访问4级表:

Bits 0–12 are the offset into the level l table frame and bits 12–21, bits 21–30, bits 30–39 and bits 39–48 are the index of the recursive entry

我们现在可以计算所有四个级别的页表的虚拟地址。 我们甚至可以通过将其索引乘以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是递归条目的索引。 当索引(三位数)转换为偏移量(四位数)时,通过将其乘以8(页面表条目的大小)来完成。使用此偏移量,结果地址直接指向相应的页表条目。

SSSSSS是符号扩展位,这意味着它们都是第47位的副本。这是x86_64体系结构上对有效地址的特殊要求。 我们在上一篇文章中解释过它。

我们使用八进制数来表示地址,因为每个八进制字符代表三位,这使我们能够清楚地分离不同页表级别的9位索引。 对于每个字符代表四位的十六进制系统,这是不可能的。

实现

在听了这些个理论之后我们终于可以开始实践了。 方便的是,bootloader程序不仅为我们的内核创建了页表,而且还在第4级页表的最后一个条目中创建了一个递归映射。 引导程序执行此操作是因为否则会出现先有鸡还是先有蛋的问题:我们需要访问4级页表来创建递归映射,但是如果没有某种映射我们就无法访问它。

我们已经在上一篇文章的末尾使用了这个递归映射来访问4级表。 我们通过硬编码地址0xffff_ffff_ffff_f000完成了这项工作。 当我们将这个地址转换为八进制并将其与上面的表进行比较时,我们可以看到它完全遵循4级表条目的结构,其中RRR = 0o777AAAA = 0,并且符号扩展位全部设置为1

1
2
structure: 0o_SSSSSS_RRR_RRR_RRR_RRR_AAAA
address: 0o_177777_777_777_777_777_0000

凭借我们对递归页表的了解,我们现在可以创建虚拟地址来访问所有活动页表。 这允许我们在软件中创建翻译功能。

地址翻译

第一步,让我们创建一个函数,通过遍历页表层次结构将虚拟地址转换为物理地址:

1
2
3
// in src/lib.rs

pub mod memory;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// in src/memory.rs

use x86_64::PhysAddr;
use x86_64::structures::paging::PageTable;

/// Returns the physical address for the given virtual address, or `None` if the
/// virtual address is not mapped.
pub fn translate_addr(addr: usize, level_4_table_addr: usize) -> Option<PhysAddr> {
// retrieve the page table indices of the address that we want to translate
let level_4_index = (addr >> 39) & 0o777;
let level_3_index = (addr >> 30) & 0o777;
let level_2_index = (addr >> 21) & 0o777;
let level_1_index = (addr >> 12) & 0o777;
let page_offset = addr & 0o7777;

// check that level 4 entry is mapped
let level_4_table = unsafe {&*(level_4_table_addr as *const PageTable)};
if level_4_table[level_4_index].addr().is_null() {
return None;
}
let level_3_table_addr = (level_4_table_addr << 9) | (level_4_index << 12);

// check that level 3 entry is mapped
let level_3_table = unsafe {&*(level_3_table_addr as *const PageTable)};
if level_3_table[level_3_index].addr().is_null() {
return None;
}
let level_2_table_addr = (level_3_table_addr << 9) | (level_3_index << 12);

// check that level 2 entry is mapped
let level_2_table = unsafe {&*(level_2_table_addr as *const PageTable)};
if level_2_table[level_2_index].addr().is_null() {
return None;
}
let level_1_table_addr = (level_2_table_addr << 9) | (level_2_index << 12);

// check that level 1 entry is mapped and retrieve physical address from it
let level_1_table = unsafe {&*(level_1_table_addr as *const PageTable)};
let phys_addr = level_1_table[level_1_index].addr();
if phys_addr.is_null() {
return None;
}

Some(phys_addr + page_offset)
}

首先,我们通过位操作计算页表索引和页面偏移量,如图中所示:

Bits 0–12 are the page offset, bits 12–21 the level 1 index, bits 21–30 the level 2 index, bits 30–39 the level 3 index, and bits 39–48 the level 4 index

然后我们将level_4_table_addr转换为PageTable引用,这是一个unsafe操作,因为编译器无法知道该地址是有效的。我们使用索引运算符来查看具有level_4_index的条目。如果该条目为null,则此级别4条目没有级别3表,这意味着addr未映射到任何物理内存,因此我们返回None

如果条目不是None,我们知道存在3级表。我们通过将4级地址向左移位9位并将地址位12-21设置为4级索引来计算它的虚拟地址(参见有关地址计算的部分)。我们可以这样做,因为递归索引是0o777,符号扩展也是有效的。然后,我们进行与4级表相同的强制转换和条目检查。

在我们检查了三个级别的页面之后,我们最终可以读取1级表的条目,该表告诉我们地址映射到的物理帧。作为最后一步,我们将页面偏移量添加到该地址并返回它。

如果我们知道地址已映射,我们可以直接访问1级表,而无需先查看更高级别的页面。但由于我们不知道这一点,我们必须先检查1级表是否存在,否则我们的函数会导致未映射地址的页面错误。

试试看

我们可以使用新的翻译功能在我们的_start函数中翻译一些虚拟地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// in src/main.rs

#[cfg(not(test))]
#[no_mangle]
pub extern "C" fn _start() -> ! {
[…] // initialize GDT, IDT, PICS

use blog_os::memory::translate_addr;

const LEVEL_4_TABLE_ADDR: usize = 0o_177777_777_777_777_777_0000;

// the identity-mapped vga buffer page
println!("0xb8000 -> {:?}", translate_addr(0xb8000, LEVEL_4_TABLE_ADDR));
// some code page
println!("0x20010a -> {:?}", translate_addr(0x20010a, LEVEL_4_TABLE_ADDR));
// some stack page
println!("0x57ac001ffe48 -> {:?}", translate_addr(0x57ac001ffe48,
LEVEL_4_TABLE_ADDR));

println!("It did not crash!");
blog_os::hlt_loop();
}

当我们运行这段程序时,我们会看到下面的输出:

0xb8000 -> 0xb8000, 0x20010a -> 0x40010a, 0x57ac001ffe48 -> 0x27be48

正如所料,直接映射地址0xb8000转换为相同的物理地址。 代码页和堆栈页面转换为一些“随机”的物理地址,这取决于引导加载程序如何为我们的内核创建初始映射。

RecursivePageTable类型

x86_64提供了一个RecursivePageTable类型,它为各种页表操作实现了安全的抽象。 通过使用此类型,我们可以以更清晰的方式重新实现translate_addr函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// in src/memory.rs

use x86_64::structures::paging::{Mapper, Page, PageTable, RecursivePageTable};
use x86_64::{VirtAddr, PhysAddr};

/// Creates a RecursivePageTable instance from the level 4 address.
///
/// This function is unsafe because it can break memory safety if an invalid
/// address is passed.
pub unsafe fn init(level_4_table_addr: usize) -> RecursivePageTable<'static> {
let level_4_table_ptr = level_4_table_addr as *mut PageTable;
let level_4_table = &mut *level_4_table_ptr;
RecursivePageTable::new(level_4_table).unwrap()
}

/// Returns the physical address for the given virtual address, or `None` if
/// the virtual address is not mapped.
pub fn translate_addr(addr: u64, recursive_page_table: &RecursivePageTable)
-> Option<PhysAddr>
{
let addr = VirtAddr::new(addr);
let page: Page = Page::containing_address(addr);

// perform the translation
let frame = recursive_page_table.translate_page(page);
frame.map(|frame| frame.start_address() + u64::from(addr.page_offset()))
}

RecursivePageTable类型完全封装了页面表访问的不安全性,因此我们不再需要在translate_addr函数中使用unsafeinit函数需要unsafe,因为调用者必须保证传递的level_4_table_addr有效。

我们的_start函数需要通过以下方式更新为新的函数签名:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// in src/main.rs

#[cfg(not(test))]
#[no_mangle]
pub extern "C" fn _start() -> ! {
[…] // initialize GDT, IDT, PICS

use blog_os::memory::{self, translate_addr};

const LEVEL_4_TABLE_ADDR: usize = 0o_177777_777_777_777_777_0000;
let recursive_page_table = unsafe { memory::init(LEVEL_4_TABLE_ADDR) };

// the identity-mapped vga buffer page
println!("0xb8000 -> {:?}", translate_addr(0xb8000, &recursive_page_table));
// some code page
println!("0x20010a -> {:?}", translate_addr(0x20010a, &recursive_page_table));
// some stack page
println!("0x57ac001ffe48 -> {:?}", translate_addr(0x57ac001ffe48,
&recursive_page_table));

println!("It did not crash!");
blog_os::hlt_loop();
}

我们现在将传递RecursivePageTable类型的引用,而不是LEVEL_4_TABLE_ADDR传递给translate_addr并通过不安全的原始指针访问页表。通过这样做,我们现在拥有安全的抽象和明确的所有权语义。这确保了我们不会意外地同时修改页表,因为需要借用RecursivePageTable来修改它。

当我们运行它时,我们会看到与手工制作的翻译函数相同的结果。

使unsafe的函数safe

我们的memory::init函数是一个unsafe的函数,这意味着调用它需要一个unsafe块,因为调用者必须保证满足某些要求。在我们的例子中,要求是传递的地址被映射到4级页表的物理帧。

unsafe函数的第二个属性是它们的整个的函数体都被视为unsafe块,这意味着它可以执行各种不安全操作而无需额外的unsafe块。这就是我们不需要一个unsafe块来解引用裸指针level_4_table_ptr的原因:

1
2
3
4
5
pub unsafe fn init(level_4_table_addr: usize) -> RecursivePageTable<'static> {
let level_4_table_ptr = level_4_table_addr as *mut PageTable;
let level_4_table = &mut *level_4_table_ptr; // <- this operation is unsafe
RecursivePageTable::new(level_4_table).unwrap()
}

这样做的问题是我们不会立即看到代码的哪些部分是unsafe的。 例如,如果不查看其定义,我们不知道RecursivePageTable::new函数是否不安全。 这使得在不注意的情况下很容易意外地做一些unsafe的事情。

为了避免这个问题,我们可以添加一个安全的内部函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// in src/memory.rs

pub unsafe fn init(level_4_table_addr: usize) -> RecursivePageTable<'static> {
/// Rust currently treats the whole body of unsafe functions as an unsafe
/// block, which makes it difficult to see which operations are unsafe. To
/// limit the scope of unsafe we use a safe inner function.
fn init_inner(level_4_table_addr: usize) -> RecursivePageTable<'static> {
let level_4_table_ptr = level_4_table_addr as *mut PageTable;
let level_4_table = unsafe { &mut *level_4_table_ptr };
RecursivePageTable::new(level_4_table).unwrap()
}

init_inner(level_4_table_addr)
}

现在再次需要一个unsafe块来解引用level_4_table_ptr,我们立即看到这是该函数中唯一unsafe的操作。目前有一个开放的RFC来改变这种不安全功能的不幸属性,这将使我们能够避免上述样板代码。

创建新映射

读取页表并创建转换函数后,下一步是在页表层次结构中创建新映射。

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

让我们从简单的情况开始,假设我们不需要创建新的页表。bootloader程序将自身加载到虚拟地址空间的第一个兆字节中,因此我们知道该区域存在有效的1级表。我们可以在此内存区域中选择任何未使用的页面作为示例映射,例如,地址为0x1000的页面。作为目标帧,我们使用0xb8000,即VGA文本缓冲区的帧。这样我们就可以轻松测试我们的映射是否有效。

我们在内存模块中的一个新的create_mapping函数中实现它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// in src/memory.rs

use x86_64::structures::paging::{FrameAllocator, PhysFrame, Size4KiB};

pub fn create_example_mapping(
recursive_page_table: &mut RecursivePageTable,
frame_allocator: &mut impl FrameAllocator<Size4KiB>,
) {
use x86_64::structures::paging::PageTableFlags as Flags;

let page: Page = Page::containing_address(VirtAddr::new(0x1000));
let frame = PhysFrame::containing_address(PhysAddr::new(0xb8000));
let flags = Flags::PRESENT | Flags::WRITABLE;

let map_to_result = unsafe {
recursive_page_table.map_to(page, frame, flags, frame_allocator)
};
map_to_result.expect("map_to failed").flush();
}

该函数接受RecursivePageTable的可变引用,因为它需要修改它,它还接受一个下面会解释的FrameAllocator。然后,它使用Mapper trait的map_to函数将地址0x1000处的页面映射到地址0xb8000处的物理帧。该函数是unsafe的,因为可以使用无效参数破坏内存安全性。

除了pageframe参数之外,map_to函数还需要两个参数。第三个参数是页表条目的一组标志。我们设置PRESENT标志,因为它是所有有效条目所必需的,而WRITABLE标志使映射页面可写。

第四个参数需要是一些实现FrameAllocator trait的结构。map_to方法需要此参数,因为它可能需要未使用的帧来创建新的页表。需要trait实现中的Size4KiB参数,因为PagePhysFrame类型在PageSizetrait上是通用的,可以使用标准的4KiB页面和2MiB/1GiB页面。

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

由于我们知道地址0x1000不需要新的页表,因此总是返回None的帧分配器就足够了。我们创建了一个EmptyFrameAllocator来测试我们的映射函数:

1
2
3
4
5
6
7
8
9
10
// in src/memory.rs

/// A FrameAllocator that always returns `None`.
pub struct EmptyFrameAllocator;

impl FrameAllocator<Size4KiB> for EmptyFrameAllocator {
fn allocate_frame(&mut self) -> Option<PhysFrame> {
None
}
}

(如果看见报错:’method allocate_frame is not a member of trait FrameAllocator’,则需要将x86_64更新为0.4.0版。)

我们现在可以在main.rs中测试新的映射函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// in src/main.rs

#[cfg(not(test))]
#[no_mangle]
pub extern "C" fn _start() -> ! {
[…] // initialize GDT, IDT, PICS

use blog_os::memory::{create_example_mapping, EmptyFrameAllocator};

const LEVEL_4_TABLE_ADDR: usize = 0o_177777_777_777_777_777_0000;
let mut recursive_page_table = unsafe { memory::init(LEVEL_4_TABLE_ADDR) };

create_example_mapping(&mut recursive_page_table, &mut EmptyFrameAllocator);
unsafe { (0x1900 as *mut u64).write_volatile(0xf021f077f065f04e)};

println!("It did not crash!");
blog_os::hlt_loop();
}

我们首先通过调用接受RecursivePageTable实例的可变引用的create_example_mapping函数为0x1000创建页面映射。 这将页面0x1000映射到VGA文本缓冲区,因此我们应该在屏幕上看到对它的任何写入。

然后我们将值0xf021f077f065f04e写入此页面,该页面代表白色背景上的字符串”New!”。 我们不直接将数据写入0x1000页面的开头,因为那一行会被下一个println移出屏幕。 相反,我们写入偏移量0x900,它位于屏幕中间。 正如我们在“VGA文本模式”帖子中所了解到的,对VGA缓冲区的写入应该是volatile的,因此我们使用write_volatile方法。

当我们在QEMU中运行它时,我们会看到以下输出:

QEMU printing "It did not crash!" with four completely white cells in the middle of the screen

屏幕上的”New!”是我们写入0x1900的结果,这意味着我们在页表中成功创建了一个新的映射。

这能起作用,因为已经有一个用于映射页面0x1000的1级表。 当我们尝试映射一个尚未存在1级表的页面时,map_to函数会失败,因为它尝试从EmptyFrameAllocator分配帧以创建新的页表。 我们可以看到,当我们尝试映射页面0xdeadbeaf000而不是0x1000时会发生这种情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// in src/memory.rs

pub fn create_example_mapping(…) {
[…]
let page: Page = Page::containing_address(VirtAddr::new(0xdeadbeaf000));
[…]
}

// in src/main.rs

#[no_mangle]
pub extern "C" fn _start() -> ! {
[…]
unsafe { (0xdeadbeaf900 as *mut u64).write_volatile(0xf021f077f065f04e)};
[…]
}

当我们运行这一段的时候,程序panic了,输出了下面的错误信息:

1
panicked at 'map_to failed: FrameAllocationFailed', /…/result.rs:999:5

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

Boot 信息

由VGA硬件等设备保留的物理内存量和内存区域在不同的机器之间有所不同。 只有BIOS或UEFI固件确切地知道操作系统可以使用哪些存储区域以及哪些区域被保留。 两种固件标准都提供了检索内存映射的功能,但它们只能在引导过程中被调用。 因此,bootloader程序已经从固件中查询此信息和其他信息。

要将此信息传递给我们的内核,引导加载程序在调用_start函数时将引导信息结构的引用作为参数传给。 现在我们没有在我们的函数中声明这个参数,所以它被忽略了。 我们来添加它:

1
2
3
4
5
6
7
8
9
// in src/main.rs

use bootloader::bootinfo::BootInfo;

#[cfg(not(test))]
#[no_mangle]
pub extern "C" fn _start(boot_info: &'static BootInfo) -> ! { // new argument
[…]
}

BootInfo结构体仍处于开发的早期阶段,因此在更新到未来的bootloader程序时会可能会出现一些破坏。它目前有三个字段p4_table_addrmemory_mappackage

  • p4_table_addr字段包含4级页表的递归虚拟地址。通过使用此字段,我们可以避免硬编码地址0o_177777_777_777_777_777_0000
  • memory_map字段对我们来说是最有趣的,因为它包含所有内存区域及其类型的列表(即未使用,保留或其他)。
  • package字段是一个正在开发中的的功能,用于将其他数据与引导加载程序捆绑在一起。实现尚未完成,因此我们暂时可以忽略此字段。

在我们使用memory_map字段创建正确的FrameAllocator之前,我们要确保不能使用错误类型的boot_info参数。

entry_point

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

为了确保入口点函数始终具有引导加载程序所需的正确签名,bootloader crate提供了一个entry_point宏,该宏提供了一种类型检查方式,用于将Rust函数定义为入口点。让我们重写我们的入口点函数来使用这个宏:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// in src/main.rs

use bootloader::{bootinfo::BootInfo, entry_point};

entry_point!(kernel_main);

#[cfg(not(test))]
fn kernel_main(boot_info: &'static BootInfo) -> ! {
[…] // initialize GDT, IDT, PICS

let mut recursive_page_table = unsafe {
memory::init(boot_info.p4_table_addr as usize)
};

[…] // create and test example mapping

println!("It did not crash!");
blog_os::hlt_loop();
}

我们不再需要为我们的入口点使用extern "C"no_mangle,因为宏为我们定义了真正的底层_start入口点。kernel_main函数现在是一个完全正常的Rust函数,因此我们可以为它选择一个任意名称。 重要的是它是有类型检查的,以便当我们现在尝试以任何方式修改函数签名时发生编译错误,例如添加参数或更改参数类型。

请注意,我们现在将boot_info.p4_table_addr而不是硬编码地址传递给memory::init。 因此,即使未来版本的引导加载程序选择4级页表的不同条目进行递归映射,我们的代码仍会继续工作。

分配帧

现在我们可以通过引导信息访问内存映射,我们可以在顶部创建一个合适的帧分配器。 我们从通用骨架开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
// in src/memory.rs

pub struct BootInfoFrameAllocator<I> where I: Iterator<Item = PhysFrame> {
frames: I,
}

impl<I> FrameAllocator<Size4KiB> for BootInfoFrameAllocator<I>
where I: Iterator<Item = PhysFrame>
{
fn allocate_frame(&mut self) -> Option<PhysFrame> {
self.frames.next()
}
}

可以使用任何帧的Iterator来初始化frames字段。 这允许我们将alloc调用委托给Iterator::next方法。

BootInfoFrameAllocator的初始化发生在新的init_frame_allocator函数中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// in src/memory.rs

use bootloader::bootinfo::{MemoryMap, MemoryRegionType};

/// Create a FrameAllocator from the passed memory map
pub fn init_frame_allocator(
memory_map: &'static MemoryMap,
) -> BootInfoFrameAllocator<impl Iterator<Item = PhysFrame>> {
// get usable regions from memory map
let regions = memory_map
.iter()
.filter(|r| r.region_type == MemoryRegionType::Usable);
// map each region to its address range
let addr_ranges = regions.map(|r| r.range.start_addr()..r.range.end_addr());
// transform to an iterator of frame start addresses
let frame_addresses = addr_ranges.flat_map(|r| r.into_iter().step_by(4096));
// create `PhysFrame` types from the start addresses
let frames = frame_addresses.map(|addr| {
PhysFrame::containing_address(PhysAddr::new(addr))
});

BootInfoFrameAllocator { frames }
}

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

  • 首先,我们调用iter方法将内存映射转换为MemoryRegions的迭代器。然后我们使用filter方法跳过任何保留或不可用的区域。引导加载程序更新它创建的所有映射的内存映射,因此我们的内核(代码,数据或堆栈)使用的帧或存储引导信息的帧已标记为InUse或类似。因此,我们可以确定可用框架不会在其他地方使用。

  • 第二步,我们使用map combinator和Rust的range语法将内存区域的迭代器转换为地址范围的迭代器。

  • 第三步是最复杂的:我们通过into_iter方法将每个范围转换为迭代器,然后使用step_by选择每个第4096个地址。由于4096字节(= 4 KiB)是页面大小,我们得到每帧的起始地址。引导加载程序页面对齐所有可用的内存区域,这样我们就不需要任何对齐或舍入代码。通过使用flat_map而不是map,我们得到Iterator<Item = u64>而不是Iterator<Item = Iterator <Item = u64>>

  • 最后一步,我们将起始地址转换为PhysFrame类型,以构造所需的Iterator <Item = PhysFrame>。然后我们使用这个迭代器来创建并返回一个新的BootInfoFrameAllocator

我们现在可以修改kernel_main函数来传递BootInfoFrameAllocator实例而不是EmptyFrameAllocator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// in src/main.rs

#[cfg(not(test))]
fn kernel_main(boot_info: &'static BootInfo) -> ! {
[…] // initialize GDT, IDT, PICS

use x86_64::structures::paging::{PageTable, RecursivePageTable};

let mut recursive_page_table = unsafe {
memory::init(boot_info.p4_table_addr as usize)
};
// new
let mut frame_allocator = memory::init_frame_allocator(&boot_info.memory_map);

blog_os::memory::create_mapping(&mut recursive_page_table, &mut frame_allocator);
unsafe { (0xdeadbeaf900 as *mut u64).write_volatile(0xf021f077f065f04e)};

println!("It did not crash!");
blog_os::hlt_loop();
}

现在映射成功了,我们看到黑白”New!” 再次在屏幕上出现。在幕后,map_to方法以下列方式创建缺少的页表:

  • 从传递的frame_allocator中分配一个未使用的帧。
  • 将更高级别表的条目映射到该帧。现在可以通过递归页表访问该帧。
  • 将帧归零以创建新的空页面表。
  • 继续处理下一个级别的表。

虽然我们的create_mapping函数只是一些示例代码,但我们现在能够为任意页面创建新的映射。这对于在将来的帖子中分配内存或实现多线程非常重要。

总结

在这篇文章中,我们学习了如何使用递归4级表条目将所有页表帧映射到可计算的虚拟地址。我们使用这种技术来实现地址转换功能并在页表中创建新的映射。

我们看到创建新映射需要使用未使用的帧来创建新的页表。这样的帧分配器可以在bootloader程序传递给我们的内核的引导信息结构之上实现。

接下来?

下一篇文章将为我们的内核创建一个堆内存区域,这将允许我们分配内存并使用各种集合类型。