通过Rust编写操作系统之内存的分页与管理介绍(下)
2019-08-03 10:27:53 Author: www.4hou.com(查看原文) 阅读量:123 收藏

上一篇文章,我们已对分段和分页的优缺点进行了介绍,并最终决定用分页技术对编写操作系统。本文,我会接着介绍分页的具体使用过程和其中所遇到的问题。

在x86_64上进行分页

x86_64体系结构使用4级页表,页面大小为4KiB。每个页表独立于级别,具有512个条目的固定大小。每个条目的大小为8个字节,因此每个表格为4KiB(512 * 8B = 4KiB)大,因此正好适合放在一个页面中。

不同级的页表索引会直接从虚拟地址中派生出来:

8.png

我们看到每个表索引由9位组成,这是有意义的,因为每个表有2^9 = 512个条目。最低的12位是4KiB页面中的偏移量(2^12字节= 4KiB)。位48到64被删除,这意味着x86_64实际上不是64位的,因为它只支持48位地址。目前开发者计划通过5级页表将地址大小扩展到57位,但却还没有支持此功能的处理器。

即使删除了位48到64,也不能将它们设置为任意值。相反,这个范围内的所有位都必须是位47的副本,以便保持地址的惟一性,并允许将来扩展,如5级页表。这叫做符号扩展,因为它和补码中的符号扩展很相似。当地址没有正确地进行签名扩展时,CPU会抛出异常相应信息。

详细示例说明

让我们通过一个示例来详细了解转换过程是如何进行的:

9.png

当前活动的4级页表的物理地址(第4级页表的根)存储在CR3寄存器中,然后,每个页表条目都指向下一级表的物理帧。最后,1级表的条目指向映射的帧。请注意,页表中的所有地址都是物理地址,而不是虚拟地址,否则CPU也需要转换这些地址,这可能导致无休止的递归。

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

10.png

有了这些索引,我们现在可以遍历页表层次结构来确定地址的映射帧:

1. 我们首先从CR3寄存器中读取4级表的地址。

2. 4级索引是1,所以我们查看表中索引为1的条目,它告诉我们3级表存储在地址16KiB。

3. 我们从该地址加载level 3表,并查看索引为0的条目,它指向位于24KiB的2级表。

4. 2级索引是511,因此我们查看该页的最后一项来找出1级表的地址。

5. 通过1级表中索引为127的条目,我们最终发现该页被映射到帧12KiB,或者十六进制中的0xc000。

6. 最后一步是将页面偏移量添加到帧地址,以获得物理地址0xc000 + 0x5ce = 0xc5ce。

11.png

1级表中的页面权限为r,表示只读。如果我们试图写入该页面,硬件会强制执行这些权限,从而引发异常。由于更高级别页面中的权限限制了较低级别的可能权限,因此如果我们将3级条目设置为只读,即使低级页指定了读/写权限,那使用此条目的页面还是不具有写入权限。

需要注意的是,尽管本示例仅使用每个表的一个实例,但在每个地址空间中通常有多个级别的实例。比如:

1. 一个4级表;

2. 512个3级表(因为4级表有512个条目);

3. 512 * 512 个2级表(因为512个3级表中的每个表都有512个条目);

4. 512 * 512 * 512 个1级表(每个2级表有512个条目)。

页表的格式

x86_64架构上的页表基本上是由512个条目组成的数组,在Rust语法中,数组形式如下所示:

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

如repr属性所示,页表需要页面对齐,即在4KiB边界上对齐。这一要求可确保页表总是填满完整的页面,并允许优化,使条目非常紧凑。

每个条目为8字节(64位)大,格式如下:

13.jpg

我们看到只有位12-51被用来存储物理帧地址,其余的位被用作标志或者可以被操作系统自由使用。这是可能的,因为我们总是指向一个4096字节对齐的地址,或者指向一个页面对齐的页表,或者指向一个映射帧的开头。这意味着位0-11始终为0,所以没有理由存储这些位,因为硬件可以在使用地址之前将它们设置为0。对于位52-63也是如此,因为x86_64体系结构只支持52位物理地址(类似于它只支持48位虚拟地址)。

让我们仔细介绍一下其中可用的标志:

1. present标志将映射的页面与未映射的页面区分开来,当主内存被耗尽时,可以使用它,临时将页面存储到磁盘。当页面随后被访问时,会发生一个称为页面错误的特殊异常。此时,操作系统可以通过从磁盘重新加载缺失的页面,然后继续执行程序来对此作出反应。

2. writable 和 no execute标志分别控制页面内容是可写的还是包含可执行指令。

3. 当对页面进行读取或写入操作时,CPU会自动设置accessed标志和dirty标志,操作系统可以利用这些信息,例如,决定换出 (swap out)哪些页面,或者自上次保存到磁盘以来是否修改了页面内容。

4. write through caching 和 disable cache标志允许分别控制每个页面的高速缓存情况。

5. user accessible标志使页面可用于用户空间代码,否则只有在CPU处于内核模式时才可访问该页面。这个特性可以在用户空间程序运行时保持内核映射,从而加快系统调用的速度,然而Spectre漏洞可以允许用户空间程序读取这些页面。

6. global标志会向硬件发出信号,表明某个页面在所有地址空间中都可用,因此不需要从地址空间切换的转换缓存中删除。此标志通常与已清除的user accessible标志一起使用,以将内核代码映射到所有地址空间。

7. 通过让2级或第3级页表的条目直接指向映射的帧,huge page标志允许创建更大的页面,设置此位后,对于2级条目,页面大小增加到了2MiB (512 * 4KiB=2MiB),对于3级条目,页面大小甚至增加乐1GiB (512 * 2MiB =1GiB),使用huge page标志的优点是需要更少的转换缓存行和更少的页表。

由于x86_64Crate已经提供了页表及其条目的类型,因此我们不需要自己创建这些结构。

TLB (Translation Lookaside Buffer)

本文,我们将ranslation Lookaside Buffer译为页表缓冲。它里面存放的是一些页表文件(虚拟地址到物理地址的转换表)。当处理器要在主内存寻址时,不是直接在内存的物理地址里查找的,而是通过一组虚拟地址转换到主内存的物理地址,TLB就是负责将虚拟内存地址翻译成实际的物理内存地址,而CPU寻址时会优先在TLB中进行寻址。

一个4级的页表就会使得虚拟地址的转换非常慢,因为每个转换需要4个内存访问。为了提高性能,x86_64体系结构会在TLB中缓存最后几个转换,这就允许缓存转换进行时直接将其跳过即可。

与其他CPU缓存不同,TLB不是完全透明的,当页表的内容发生更改时,TLB不会更新或删除转换,这意味着内核必须在修改页表时手动更新TLB。为此,有一条名为invlpg(“invalidate page”)的特殊CPU指令,它可以从TLB中删除指定页面的转换,以便在下一次访问时从页表中再次加载它。还可以通过重新加载CR3寄存器(它模拟地址空间切换)来完全刷新TLB,x86_64Crate为tlb模块中的两个变体提供了Rust函数。

切记,在每次修改页表时刷新TLB是很重要的,否则CPU可能会继续使用旧的转换,这可能导致非常难以调试的非确定性错误。

错误的出现

截至目前,有一件事我们还没有提到:我们的内核已经在分页时运行。我们在"A minimal Rust Kernel" 文章中添加的引导加载程序已经建立了一个4级分页层次结构,它将内核的每个页面映射到一个对应的物理帧中,引导加载程序这样做是因为在64位模式下x86_64必须分页。

这意味着我们在内核中使用的每个内存地址都是一个虚拟地址,访问地址0xb8000处的VGA缓冲区之所以有效,是因为引导加载程序标识映射了内存页面,这意味着它将虚拟页面0xb8000映射到了物理帧0xb8000。

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

页面错误

现在,就让我们尝试通过访问内核外部的一些内存来触发页面错误。首先,我们创建一个页面错误处理程序,并将其注册到我们的IDT中,这样我们就可以看到一个页面错误异常,而不是一般的“double fault”

简单地说,double fault是当CPU无法调用异常处理程序时发生的特殊异常。例如,当一个页面错误被触发,但是中断描述符表(IDT)中没有注册页面错误处理程序时,就会发生这种情况。所以它有点类似于编程语言中的catch-all块,但有例外,例如c++中的catch(…)或Java或c#中的catch(Exception e)。

double fault的行为类似于正常异常(normal exception),它的向量为8,我们可以在IDT中为它定义一个普通的处理函数。提供双重错误处理程序非常重要,因为如果没有处理double fault,就会发生致命的三重错误。三重故障无法捕获,大多数硬件会对系统重置做出反应。

// in src/interrupts.rs
lazy_static! {
    static ref IDT: InterruptDescriptorTable = {
        let mut idt = InterruptDescriptorTable::new();
        […]
        idt.page_fault.set_handler_fn(page_fault_handler); // new
        idt
    };
}
use x86_64::structures::idt::PageFaultErrorCode;
extern "x86-interrupt" fn page_fault_handler(
    stack_frame: &mut InterruptStackFrame,
    _error_code: PageFaultErrorCode,
) {
    use x86_64::registers::control::Cr2;
    println!("EXCEPTION: PAGE FAULT");
    println!("Accessed Address: {:?}", Cr2::read());
    println!("{:#?}", stack_frame);
    hlt_loop();
}

CR2寄存器由CPU在页面错误上自动设置,并包含导致页面错误的已访问虚拟地址。我们使用x86_64Crate的Cr2::read函数来读取和打印它。通常,PageFaultErrorCode类型会提供关于导致页面错误的内存访问类型的更多信息,但目前存在一个传递无效错误代码的LLVM漏洞,因此我们暂时忽略它。在没有解决页面错误的情况下,我们无法继续执行,因此只能输入hlt_loop当作结尾。

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

// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
    println!("Hello World{}", "!");
    blog_os::init();
    // new
    let ptr = 0xdeadbeaf as *mut u32;
    unsafe { *ptr = 42; }
    // as before
    #[cfg(test)]
    test_main();
    println!("It did not crash!");
    blog_os::hlt_loop();
}

在运行时,我们可以看到页面错误处理程序被调用:

16.png

可以看出,CR2寄存器确实包含0xdeadbeaf,这是我们试图访问的地址。

从上图可以看到,当前的指令指针是0x20430a,所以我们知道这个地址指向一个代码页。代码页会被引导加载程序以只读方式映射,因此从该地址读取有效但写入会导致页面错误。你可以通过将0xdeadbeaf指针更改为0x20430a,来尝试此操作。

// Note: The actual address might be different for you. Use the address that
// your page fault handler reports.
let ptr = 0x20430a as *mut u32;
// read from a code page -> works
unsafe { let x = *ptr; }
// write to a code page -> page fault
unsafe { *ptr = 42; }

看最后一行,我们可以发现读取访问是有效的,但是写入访问会导致页面错误。

访问页表

让我们来看看我们的内核运行的页表:

// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
    println!("Hello World{}", "!");
    blog_os::init();
    use x86_64::registers::control::Cr3;
    let (level_4_page_table, _) = Cr3::read();
    println!("Level 4 page table at: {:?}", level_4_page_table.start_address());
    […] // test_main(), println(…), and hlt_loop()
}

x86_64的Cr3::read 函数从Cr3寄存器返回当前活动的4级页表,它返回一个包含 PhysFrame 和Cr3Flags元组。不过,我们只对帧感兴趣,因此忽略了元组的第二个元素。

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

Level 4 page table at: PhysAddr(0x1000)

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

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

下一篇文章,我们将详细解释这个问题的解决方案,敬请关注。

总结

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

分页技术会将页面的映射信息存储在具有一个或多个级别的页表中,x86_64体系结构使用4级页表,页面大小为4KiB。硬件自动遍历页表并将结果转换缓存到TLB中。此缓冲区不是透明的,需要在页表更改时手动刷新。

此次我们的内核已经在分页之上运行,并且非法内存访问会导致页面错误异常。我们尝试访问当前活动的页表,但我们无法执行此操作,因为CR3寄存器存储了我们无法直接从内核访问的物理地址。

下一篇文章我们将解释如何在内核中实现分页支持,该方法提供了从内核访问物理内存的不同方法,这使得访问内核运行的页表成为可能,我们最后的目的是能够实现将虚拟地址转换为物理地址以及在页表中创建新映射的函数。


文章来源: https://www.4hou.com/web/19188.html
如有侵权请联系:admin#unsafe.sh