译注:这篇文章节选自《用Rust编写一个操做系统》系列。它由浅入深的介绍了分页技术(Paging)的历史由来,以及在现代操做系统中的实现。这是我目前读过的把Paging讲的最清楚的一篇文章,所以将它翻译出来,但愿更多的读者可以受益。
翻译:喵叔
译文:blog.betacat.io/post/introd…
原文:os.phil-opp.com/paging-intr…
做者:Philipp Oppermannhtml
分页技术是现代操做系统中经常使用的一种内存管理方案。这篇文章介绍了咱们为何须要内存隔离(memory isolation),内存分段(segmentation)是怎么实现的,虚拟内存(virtual memory)是什么,以及分页技术是怎样解决内存碎片化(fragmentation)的问题。同时,这里还探讨了在x86_64架构上多级页表的层次结构。git
该系列博客使用GitHub进行管理和开发,若是你有任何问题或疑问,请给我开一个issue,你也能够在底部留言。这篇文章的完整代码能够在这里找到。github
操做系统的一个主要职责就是隔离应用程序,好比说,web浏览器不该该可以干扰到文本编辑器。为了实现此目的,操做系统会利用硬件的某些功能来确保一个进程的内存空间不会被另外一个进程访问到。固然,不一样的硬件和操做系统也会有不一样的实现。web
举个栗子,某些ARM Cortex-M处理器(多用于嵌入式系统)具备内存保护单元(MPU),它容许你定义少许具备不一样访问权限(例如无访问、只读、读写)的内存区域。在每次发生内存访问的时候,MPU都会确保该地址所在的区域具备合法的访问权限,不然将触发异常。同时在发生进程切换的时候,操做系统也会及时的更新相应区域及其访问权限,这样就确保了每一个进程只能访问本身的地址空间,从而达到隔离进程的做用。数组
另外一方面,在x86平台上,硬件支持两种不一样的内存保护方式:分段(segmentation)和分页(paging)。浏览器
分段技术早在1978年就已经出现,起初是为了增长CPU的寻址空间。当时的状况是CPU只使用16位地址,这将可寻址空间限制为64KiB(216)。为了访问到更高的内存,一些段寄存器被引入进来,每一个段寄存器都包含一个偏移地址。CPU将这些偏移地址做为基地址,加到应用程序要访问的内存地址上,这样它就可使用到高达1MiB的内存空间了。缓存
段寄存器有好几种,CPU会根据不一样的内存访问请求而选择不一样的段寄存器:对于取指令请求,代码段寄存器CS
会被使用;堆栈操做(push/pop)会用到堆栈段寄存器SS
;其余操做则使用数据段DS
或额外的段ES
,后来又添加了两个能够自由使用的段寄存器FS
和GS
。安全
在分段技术的初版实现中,段寄存器直接存放了段偏移量,而且没有访问控制的检查。后来,随着保护模式的引入,这种状况发生了改变。当CPU以这种模式运行时,段寄存器中包含了一个局部或全局描述符表中的索引,该表除了包含偏移地址外,还包含段大小和访问权限。经过为每一个进程加载单独的描述符表,操做系统就能将进程的内存访问限制到它本身的地址空间内,从而达到隔离进程的做用。bash
经过在实际访问发生以前修改要访问的目标地址,分段技术实际上已经在使用一种如今广为使用的技术:虚拟内存。架构
虚拟内存背后的思想是将内存地址从底层存储设备中抽象出来。即在访问存储设备前,增长一个地址转换的过程。对于上面的分段来讲,这里的转换过程就是为内存地址加上一个段偏移量。例如,当一个进程在一个偏移量为0x1111000
的段中访问内存地址0x1234000
时,它实际访问到的地址是0x2345000
。
为了区分这两种地址类型,咱们将转换前的地址称为虚拟地址(virtual),转换后的地址称为物理地址(physical)。这两种地址之间的一个重要区别是物理地址是惟一的,而且老是指向同一个内存位置。而虚拟地址则依赖于转换函数,因此两个不一样的虚拟地址彻底有可能指向相同的物理地址。一样,相同的虚拟地址在不一样的转换函数做用下也有可能指向不一样的物理地址。
下面咱们将同一个程序并行的运行两次,来看看它的内存映射状况:
在这里,相同的程序运行了两次,可是使用了不一样的转换函数。第一个进程实例的段偏移量为100,所以它的虚拟地址0-150被转换为物理地址100-250。第二个进程实例的偏移量为300,因此它的虚拟地址0-150被转换为物理地址300-450。这样就容许了两个进程互不干扰地运行相同的代码而且使用相同的虚拟地址。
这个技术的另外一个优势是,无论程序内部使用什么样的虚拟地址,它均可以被加载到任意的物理内存点。这样,操做系统就能够在不从新编译程序的前提下,充分利用全部的内存。
在将内存地址区分为虚拟地址和物理地址以后,分段技术做为链接这二者的桥梁就显得尤其重要。但分段技术的一个问题在于它可能致使内存碎片化。好比,若是咱们想在上面两个进程的基础上再运行第三个程序:
能够看到,即使有足够多的空闲内存,咱们也没法将第三个进程映射到物理内存中。这里的主要问题是,咱们须要连续的大块内存,而不是大量不连续的小块内存。
解决这种碎片化问题的一个办法就是,先暂停程序的执行,移动已使用的内存使他们更紧凑一些,而后更新转换函数,最后再恢复执行:
如今咱们有了足够的连续空间来运行第三个进程了。
但在碎片整理的过程当中,须要移动大量的内存,这会下降性能。并且这种整理须要按期完成,以避免内存变得过于分散。这使得程序会被随机的暂停而且失去响应,因此这种方法会使得程序的性能变得不可预测。
综上,内存碎片化是使得大多数系统再也不使用分段技术的缘由之一。事实上,64位的x86甚至再也不支持分段,而是使用另外一种分页技术,由于它能够彻底避免这些碎片问题。
分页技术的核心思想是将虚拟内存空间和物理内存空间划分红固定大小的小块,虚拟内存空间的块称为页(pages),物理地址空间的块称为帧(frames)。每个页均可以单独映射到一个帧上,这使得咱们能够将一个大块的虚拟内存分散的映射到一些不连续的物理帧上。
若是回顾刚才内存碎片化的示例,咱们能够看到使用分页显然更有优点:
在本例中,咱们的页大小为50字节,这意味着咱们的每一个进程空间都被划分红了三页,每页都单独映射到一个帧,所以连续的虚拟内存空间能够被映射到非连续的物理帧上。这就容许咱们在没有执行碎片整理的状况下直接运行第三个程序。
与分段相比,分页使用大量容量较小、但大小固定的内存区域,而不是少许容量较大、但大小可变的区域。由于每一个帧都有相同的大小,因此不会出现因帧过小而没法使用的状况,于是也“不会”产生内存碎片。
固然这仅仅是“看起来 ”没有碎片产生,实际上仍是会有一些隐藏的碎片,咱们称之为“内部碎片”。发生内部碎片是由于不是每一个内存区域都正巧是页面大小的整数倍。仍拿上面的程序举例,假如该程序的大小为101字节,咱们仍然须要3个大小为50字节的页来装载它,这比实际须要的多占了49个字节。为了区分这两种状况,咱们把使用分段而引起的碎片称为“外部碎片”。
虽然内部碎片也不是咱们想要的,但比起外部碎片来讲要好不少。它仍然会浪费一些内存,但好在不须要碎片整理,而且碎片的总量是可预测的(平均每一个内存区域有半页碎片)。
能够看到,可能有数以百万的的内存页被映射到了帧,这里的映射信息是须要额外存放的。在分段技术的实现中,每一个活跃的内存单元都有一个单独的段寄存器来存放段信息,但这对于分页来讲是不可能的,由于分页的数量远远多于寄存器。分页使用一个名为页表(page table) 的结构来存储它的映射信息。
对于上面的示例,它的页表大概长这个样子:
每一个进程都有它本身的页表,咱们用一个特殊的寄存器来存放指向当前活动页表的指针。在x86
上,该寄存器为CR3
。在每次运行一个进程以前,操做系统将负责把正确的指针放到该寄存器中。
在每次发生内存访问时,CPU会从寄存器中读取页表指针,并从页表中查找该页面被映射哪一个帧上。这个过程由硬件完成,对程序彻底透明。为了加快这里的转换过程,许多CPU都有一个特殊的缓存,用来记住上次转换的结果。
根据架构的不一样,页表中的每一项还能够在flags字段中存储访问权限等属性。在上面的例子中,r/w
表示该页既可读又可写。
咱们刚才看到的那个简单的页表存在一个问题:在较大的地址空间中它会浪费内存。例如,假设一个进程使用4个虚拟页面0
、1_000_000
、1_000_050
和1_000_100
(这里的_
表示千位分隔符):
它只须要4个物理帧,可是页表中有超过100万个项目。并且咱们还不能省略空项目,由于这样的话在地址转换的过程当中,CPU就不能直接跳转到正确的页表项(例如,不能保证第四页就在页表的第四位)。
为了减小内存的浪费,咱们可使用一个二级页表。这里的第二级页表包含的是内存地址区间跟第一级页表的映射信息。
或许用一个例子能解释的更清楚一点。咱们假设每一个1级页表负责一个大小为10_000
的内存区域,那上面的映射关系能够扩展为:
这里,第0页属于第一个10_000
字节区域,所以它的映射关系落入到第二级页表的头一项。这一项指向了一个一级页表T1,而T1又代表,第0页被映射到第0个帧。
另外,第1_000_000
、1_000_050
和1_000_100
页都属于第100个10_000
字节区域,因此它们使用第二级页表的第100项。这一项指向了另外一个一级页表T2,而T2又将这三个页面分别映射到帧100、150和200。值得注意的是,这里一级页表的页地址不包括区域的偏移量,因此这里映射1_000_050
页的那一项写的仅仅是50
。
咱们在第2级表中仍然有100个空位置,但比以前的百万个空位置要少得多。这是由于咱们不须要为10_000
和1_000_000
之间未映射的内存区域建立1级页表。
两级页表的原理能够扩展到3、四或更多级别。而后,页表寄存器CR3
就指向最高级别的页表,该表则指向下一个较低级别的表,这个低级别的表再指向另外一个更低一级的表,以此类推。最后,1级页面表指向映射的帧。该原理一般被称为多级 或分层 页表。
如今咱们已经了解了分页和多级页表的工做方式,接下来咱们来看看x86_64架构是如何实现分页的(下面假设CPU工做在64位模式下)。
x86_64架构使用4级页表,页大小为4KiB。这里每级页表都有固定的512项,每项都为8个字节,所以每一个页表的大小为512 * 8B = 4KiB —— 正巧放满一页。
一个虚拟地址的结构以下,它包含每级页表的索引:
能够看到,每级页表的索引都是9比特,这是由于每级页表都有2^9 = 512项。这里最低的12位表示的是该地址在一个4KiB大小的页中的偏移量(2^12 bytes = 4KiB)。第48到64位被丢弃,这意味着x86_64实际上只支持48位地址,因此它并非真正的64位。虽然有计划经过一个5级页表来将地址扩展到第57位,可是目前尚未生产出支持此功能的处理器。
虽然说第48到64位被丢弃,但也不能将它们设为任意值。相反,此范围内的全部bit都必须跟第47位的值同样,这一方面是为了保证地址的惟一性,另外一方面也是为了之后的扩展,好比5级页表。这被称为符号扩展 ,由于它与二进制补码中的符号扩展很是类似。 若是地址未进行正确的符号扩展,则CPU会抛出异常。
让咱们经过一个例子来了解这整个转换的过程:
CR3
寄存器存放的是当前活动的第4级页表的物理地址,即整个4级页表的根表地址。表中的每一项都指向下一个级别表的物理帧,最终,第1级表中的内容则指向被映射的页帧。值得注意的是,页表中的全部地址都是物理的而不是虚拟的,不然CPU也须要转换这些地址(这会致使无休止的递归)。
上面页表的层次结构映射了两个页面(蓝色)。从页表索引咱们能够推断出这两个页面的虚拟地址是0x803FE7F000
和0x803FE00000
。咱们来看看当进程读取地址0x803FE7F5CE
时会发生什么。首先,咱们将该地址转换为二进制,看看它的页表索引和页面偏移量:
使用这些索引,咱们就能够遍历页表的层次结构以肯定该地址对应的物理帧:
CR3
寄存器中读取第4级页表的物理地址。1
,因此咱们查看第4级页表的第一项,它告诉咱们第3级表存储在地址16KiB。511
,所以咱们查看该页表的最后一个项以找出第1级页表的地址。127
项咱们终于找到该页被映射到了物理帧12KiB,即十六进制的0xc000。第1级页表的权限标志位为r
,表示只读。硬件会强制检查这些权限,若是咱们尝试对该页面进行写操做,那将会触发异常。高级别页表中的权限会限制低级别页表的权限,所以若是咱们将第3级页表中的第511
项设为只读
,则由它所指向的页面都不可写,即便在第4级页表中有的页面权限标志位为读写
。
须要注意的是,尽管本示例中,每级页表仅有一个实例,但实际上,在一个地址空间中,每级页表一般会有多个实例,包含:
x86_64中的页表其实是一个长度为512的数组。 用Rust的话说:
#[repr(align(4096))]
pub struct PageTable {
entries: [PageTableEntry; 512],
}
复制代码
如repr
属性所示,页表须要跟页面对齐,即在4KiB的边界上对齐。这能够确保一个页表始终填充满整个页面,并容许内部结构的空间优化。
数组中的每一项大小都为8 bytes(64bits),格式为:
Bit(s) | Name | Meaning |
---|---|---|
0 | present | 该页已经被加载到内存中 |
1 | writable | 该页是可写的 |
2 | user accessible | 若是未被设置,只有运行在内核模式下的代码能够访问这个页面 |
3 | write through caching | 写操做直接反应到内存中 |
4 | disable cache | 该页不使用缓存 |
5 | accessed | 在使用该页后,CPU会设置此位 |
6 | dirty | 在写操做发生后,CPU会设置此位 |
7 | huge page/null | 在P1和P4中必须是0,在P3中则建立1GiB页面,在P2中则建立2MiB页面 |
8 | global | 在地址空间切换的时候,该页未从缓存中刷新(必须设置CR4寄存器的PGE位) |
9-11 | available | 能够由操做系统自由使用 |
12-51 | physical address | 该页跟页帧的52位对齐,或者是下一个页表 |
52-62 | available | 能够由操做系统自由使用 |
63 | no execute | 禁止执行此页上的代码(必须设置EFER寄存器中的NXE位) |
能够看到只有位12-51被用来存储物理帧的地址,其他的位被用做标志或者能够被操做系统自由使用。这是创建在咱们老是指向4096 byes整数倍地址的基础上,这个地址多是另外一个页表,也有多是一个物理帧。这也意味着第0-11位始终为零,并且硬件会在使用该地址以前将他们置为0,所以咱们没有必要存储这些位。第52-63位也是如此,由于x86_64架构仅支持52位物理地址(道理跟它仅支持48位虚拟地址相似)。
下面来详细的解释下这些标志位:
present
用来区分已映射页面和未映射页面。当内存用满以后,操做系统会临时的将一些页面交换到磁盘上。随后,若是该页面被访问到了,一个叫缺页中断 的异常会被抛出,那么操做系统就知道须要从磁盘中从新加载这个丢失的页面,而后再继续执行应用程序。writable
和no execute
分别表示,该页的内容是可写的仍是包含可执行的指令。accessed
和dirty
标记。操做系统能够利用这些信息,来决定好比自上次存盘以后,哪些页能够被换出或者哪些页的内容已经被修改过。write through caching
和disable cache
用来控制每一页的缓存。user accessible
用来标志该页是否能够被用户空间的代码访问,不然只有内核空间的代码才能够访问。这个特性使得在用户空间的程序运行时,内核代码仍然保持住它的映射,从而加快系统调用。然而,Spectre漏洞仍然容许处于用户空间的代码读取这些页面。global
标志用来告诉硬件,该页在全部的地址空间中均可用,所以在发生地址空间切换的时候不须要从缓存中删除(请参阅下面有关TLB的部分)。该标志一般与一个未被设置的user accessible
一块儿使用,用来将内核代码映射到全部地址空间。huge page
用在第2级或者第3级页表中表示该页中的每一项都直接指向一个物理帧。有了这个标志后,页面大小将增长512倍,对于第2级的页表项,页面大小变为2MiB = 512 * 4KiB,对于第3级的页表项,页面大小变为1GiB = 512 * 2MiB。使用较大页面的优势是,咱们只须要更少的转换缓存行和更少的页表。Rust里的x86_64
包已经有了页表及页表项这两种类型,所以咱们不须要本身建立它们。
上面所说的4级页表使得虚拟地址的转换变得很昂贵,由于每次转换都须要4次内存访问。为了提升性能,x86_64架构将最近的几回转换缓存在TLB(translation lookaside buffer)中,这样地址转换就有可能直接从缓存中读取结果。
与其余CPU缓存不一样,TLB不是彻底透明的,当页表的内容发生变化时,它不会更新或删除缓存,这意味着内核必须在修改页表时手动更新TLB。为此,有一个名为invlpg(invalidate page)的特殊CPU指令,用于从TLB中删除指定页面的转换缓存,以便在下一次访问时从页表中再次加载该页面。另外,还能够经过从新加载CR3
寄存器(即模拟一次地址空间切换)来刷新TLB。x86_64
包在tlb
模块中将这两种方法封装成了不一样的Rust函数。
切记,在每次页表修改时都要刷新TLB,不然CPU可能会继续使用旧的转换缓存,这会致使很是难以调试且又不肯定的错误。
有一件事尚未提到:咱们的内核已经运行在分页上了。咱们在“A minimal Rust Kernel”那篇文章中添加的引导程序已经设置了一个4级分页的层次结构,它将内核的每一个页面都映射到一个物理帧。这样作主要是由于在x86_64的64位模式下分页是必需的。
这意味着咱们在内核中使用的每一个内存地址都是一个虚拟地址。访问地址为0xb8000
的VGA缓冲区会起做用,是由于引导程序对该页进行了恒等映射 ,即虚拟页0xb8000
被映射到物理帧0xb8000
。
分页使咱们的内核已经相对安全,由于每一个超出边界的内存访问都会致使页错误,所以不会有随机的物理内存写入发生。引导程序甚至为每一个页都设置了正确的访问权限,这意味着只有包含代码的页是可执行的,以及只有数据页是可写入的。
让咱们试着经过访问内核以外的一段内存来引发缺页中断。首先,咱们建立一个缺页中断处理程序并将其注册到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 ExceptionStackFrame,
_error_code: PageFaultErrorCode,
) {
use crate::hlt_loop;
use x86_64::registers::control::Cr2;
println!("EXCEPTION: PAGE FAULT");
println!("Accessed Address: {:?}", Cr2::read());
println!("{:#?}", stack_frame);
hlt_loop();
}
复制代码
在发生缺页中断时,CPU会将引发该错误的虚拟地址放置到CR2寄存器中。咱们使用x86_64包的Cr2::read函数来读取并打印它。一般,PageFaultErrorCode会提供该错误的更多信息,但目前LLVM存在一个传递无效错误代码的bug,所以咱们暂时忽略它。若是缺页中断不被解决,咱们的代码就没法继续执行,所以最后咱们进入了一个hlt_loop的循环。
如今,让咱们来访问内核以外的一段内存:
// in src/main.rs
#[cfg(not(test))]
#[no_mangle]
pub extern "C" fn _start() -> ! {
use blog_os::interrupts::PICS;
println!("Hello World{}", "!");
// set up the IDT first, otherwise we would enter a boot loop instead of
// invoking our page fault handler
blog_os::gdt::init();
blog_os::interrupts::init_idt();
unsafe { PICS.lock().initialize() };
x86_64::instructions::interrupts::enable();
// new
let ptr = 0xdeadbeaf as *mut u32;
unsafe { *ptr = 42; }
println!("It did not crash!");
blog_os::hlt_loop();
}
复制代码
运行以后,能够看到咱们的缺页中断处理程序被调用了:
并且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
#[cfg(not(test))]
#[no_mangle]
pub extern "C" fn _start() -> ! {
[…] // initialize GDT, IDT, PICS
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());
println!("It did not crash!");
blog_os::hlt_loop();
}
复制代码
咱们使用x86_64
包的Cr3::read函数从CR3
寄存器中读取当前活动的第4级页表,这个函数的返回值是一个二元组:PhysFrame和Cr3Flags,咱们只关心页帧(frame),因此暂时忽略第二个返回值。
运行以后,能够看到这样的输出:
Level 4 page table at: PhysAddr(0x1000)
复制代码
能够看到,输出的是一个PhysAddr类型,它包装的是当前活动的第4级页表的物理地址0x1000
,如今的问题就变成:咱们如何从内核中访问该页表?
当分页处于激活状态时,咱们不可能直接访问物理内存,不然一个程序就能够很轻易地绕过内存保护来访问另外一个程序的内存。所以,访问该表的惟一方法就是经过一个被映射到物理地址0x1000
的虚拟页。而为页表的物理帧建立映射是一个常见的问题,好比当内核在为新线程分配堆栈时,它就须要访问页表。
下一篇文章将详细介绍此问题的解决方案。如今,咱们只须要知道引导程序使用一种叫递归页表 的技术将虚拟地址空间的最后一页映射到第4级页表的物理帧。虚拟地址空间的最后一页是0xffff_ffff_ffff_f000
,因此咱们能够经过它来读取该表的内容:
// in src/main.rs
#[cfg(not(test))]
#[no_mangle]
pub extern "C" fn _start() -> ! {
[…] // initialize GDT, IDT, PICS
let level_4_table_pointer = 0xffff_ffff_ffff_f000 as *const u64;
for i in 0..10 {
let entry = unsafe { *level_4_table_pointer.offset(i) };
println!("Entry {}: {:#x}", i, entry);
}
println!("It did not crash!");
blog_os::hlt_loop();
}
复制代码
首先,咱们将这个地址强制转换为一个u64
类型的指针,由于正如上一节所提到的,每一个页表项都是8个字节(64位),所以u64
正巧能够装下一项。而后再使用for
循环打印页表的前10项,在循环内部,咱们使用unsafe块来读取原始指针,使用offset
方法来执行指针的偏移运算。
运行以后,是这样的结果:
一样,由页表结构那一节可知,第0项的0x2023
表示该项存在、可写、已被CPU访问过,而且映射到帧0x2000
。第1项也有相同的标志位,而且被映射到0x6e2000
,除此以外,它还有一个表示该页已被写入的dirty
标志。第2-9项全为0,表示它们还未被加载到内存中,所以这些范围内的虚拟地址尚未被映射到任何物理地址上。
固然,若是不想使用不安全的原始指针,咱们也可使用x86_64
包所提供的PageTable类型:
// in src/main.rs
#[cfg(not(test))]
#[no_mangle]
pub extern "C" fn _start() -> ! {
[…] // initialize GDT, IDT, PICS
use x86_64::structures::paging::PageTable;
let level_4_table_ptr = 0xffff_ffff_ffff_f000 as *const PageTable;
let level_4_table = unsafe {&*level_4_table_ptr};
for i in 0..10 {
println!("Entry {}: {:?}", i, level_4_table[i]);
}
println!("It did not crash!");
blog_os::hlt_loop();
}
复制代码
咱们先将0xffff_ffff_ffff_f000
转换为一个原始指针,而后再把它变为一个Rust的引用,这个操做仍然须要在unsafe
块中进行,由于编译器并不知道能不能访问该地址。在转换完成以后,咱们就有了一个安全的&PageTable
类型,它能让咱们使用数组索引的方式来访问各个页表项。
该类型还为每一个页表项的属性提供了描述信息,所以在打印的时候咱们就能够直观的看到,哪些标志位被设置了:
下一步就是顺着第0或者1个页表项的指针追踪到第3级页表。但一样的问题,这里的0x2000
和0x6e5000
都是物理地址,咱们不能直接访问它们。这个问题将在下一篇文章中解决。
本文介绍了两种内存保护技术:分段和分页。前者使用的是可变大小的内存区域,但会引起外部碎片;后者使用的是固定大小的页面,并支持更细粒度的访问权限控制。
分页技术将页的映射信息存在页表当中,页表之间能够组织出支持多个级别的层次结构。x86_64架构使用的是4级页表和4KiB页大小。硬件会自动遍历页表,并在TLB中缓存转换结果。该缓存区不会自动更新,所以须要在每次页表有变化的时候手动刷新。
咱们了解到内核已经运行在分页技术上,而且非法的内存访问会致使页错误。咱们还尝试访问当前活动的页表,但只能访问到第4级页表,由于页表中存储的是物理地址,而咱们不能从内核中直接访问物理地址。
下一篇文章将在这篇文章的基础上更深刻一步,介绍一种叫递归页表 的技术,用来解决咱们刚才遇到的内核代码不能直接访问页表的问题。这个技术容许咱们遍历整个页表的层次结构,而且用软件实现地址转换功能。同时,咱们还将介绍怎样在已有的页表中添加一个新的映射关系。