我们在Rust上编写一个操作系统。 实现页面内存(新)

在本文中,我们将探讨如何在我们的核心中实现页面内存支持。 首先,我们将研究各种方法,以便物理页表的框架可用于内核,并讨论它们的优缺点。 然后,我们实现地址转换功能和创建新映射的功能。

该系列文章在GitHub发布 。 如果您有任何疑问或问题,请在此处打开相应的票证。 本文的所有来源都在此线程中

关于分页的另一篇文章?
如果遵循此周期,则在一月底您会看到文章“页面内存:高级” 但是我因递归页表而受到批评 因此,我决定使用另一种访问框架的方法来重写文章。

这是一个新选项。 本文仍然说明了递归页表的工作方式,但是我们使用了一个更简单,更强大的实现。 我们不会删除前一篇文章,但会将其标记为过时的并且不会更新。

希望您喜欢新的选择!

目录内容



引言


上一篇文章中,我们了解了分页内存的原理以及x86_64上的四级页表如何x86_64 。 我们还发现加载器已经为内核设置了页表层次结构,因此内核在虚拟地址上运行。 这会提高安全性,因为对内存的未授权访问会导致页面错误,而不是随机更改物理内存。

最终文章无法从我们的内核访问页表,因为它们存储在物理内存中,并且内核已经在虚拟地址上运行。 在这里,我们继续该主题,并探索用于从内核访问页表框架的不同选项。 我们将讨论它们各自的优缺点,然后为我们的核心选择适当的选项。

需要引导加载程序支持,因此我们将首先对其进行配置。 然后,我们实现了一个功能,该功能贯穿页表的整个层次结构,以便将虚拟地址转换为物理地址。 最后,我们将学习如何在页表中创建新的映射,以及如何找到未使用的内存帧来创建新表。

依赖关系更新


本文要求您在依赖项中注册0.4.0或更高版本的bootloader以及0.5.2或更高版本的x86_64 。 您可以在Cargo.toml更新依赖Cargo.toml

 [dependencies] bootloader = "0.4.0" x86_64 = "0.5.2" 

有关这些版本的更改,请参见bootloader日志x86_64日志

访问页表


从内核访问页表并不像看起来那样容易。 要了解此问题,请再看看上一篇文章中的四级表层次结构:



重要的是每个页面条目都存储下一张表的物理地址。 这避免了这些地址的转换,从而降低了性能并容易导致无限循环。

问题是我们不能直接从内核访问物理地址,因为它也可以在虚拟地址上工作。 例如,当我们访问地址4 KiB ,我们将访问虚拟地址4 KiB ,而不是存储第4级页面表的物理地址。 如果我们要访问4 KiB的物理地址,则需要使用一些虚拟地址,将其转换为虚拟地址。

因此,要访问页表的框架,您需要将一些虚拟页面映射到这些框架。 有多种创建此类映射的方法。

身份映射


一个简单的解决方案是所有页表的显示相同



在此示例中,我们看到相同的帧显示。 页表的物理地址同时是有效的虚拟地址,因此我们可以轻松地从寄存器CR3开始访问所有级别的页表。

但是,这种方法会使虚拟地址空间变得混乱,并使得很难找到较大的连续区域的空闲内存。 假设我们要在上图中创建一个1000 KiB虚拟内存区域,例如, 在memory中显示一个文件 。 我们不能从28 KiB区域开始,因为它位于已经占用的页面1004 KiB 。 因此,您将不得不进一步寻找,直到找到合适的大片段,例如1008 KiB 。 存在与分段存储器相同的碎片问题。

另外,新页表的创建要复杂得多,因为我们需要找到尚未使用相应页的物理框架。 例如,对于我们的文件,我们保留了1000 KiB的虚拟内存区域,从地址1008 KiB 。 现在,我们不能再使用物理地址在1000 KiB2008 KiB之间的任何帧,因为它不能显示相同。

固定偏移图


为避免虚拟地址空间混乱,可以将页表显示在单独的存储区中 。 因此,我们无需标识映射,而是在虚拟地址空间中映射具有固定偏移量的帧。 例如,偏移量可以是10 TiB:



通过完全分配此虚拟内存范围来显示页表,我们避免了显示相同的问题。 仅当虚拟地址空间远大于物理内存的大小时,才可以保留如此大的虚拟地址空间区域。 在x86_64这不是问题,因为48位地址空间为256 TiB。

但是这种方法的缺点是,在创建每个页表时,您需要创建一个新的映射。 另外,它不允许访问其他地址空间中的表,这在创建新进程时很有用。

完整的物理内存映射


我们可以通过显示所有物理内存而不仅仅是页面表框架来解决这些问题:



这种方法允许内核访问任意物理内存,包括其他地址空间的页表框架。 保留一定范围的虚拟内存,大小与以前相同,但是其中没有剩余的不匹配页面。

这种方法的缺点是需要额外的页表来显示物理内存。 这些页表应存储在某个位置,以便它们使用一些物理内存,这在具有少量RAM的设备上可能是个问题。

但是,在x86_64上,我们可以使用巨大的 2个MiB 页面来显示,而不是使用默认大小4 KiB。 因此,要显示32 GiB的物理内存,每页表仅需要132 KiB:仅一个第三级表和32个第二级表。 由于它们在动态转换缓冲区(TLB)中使用的条目较少,因此也可以更有效地缓存大量页面。

临时展示


对于物理内存很少的设备,仅在需要访问它们时才可以临时显示页表 。 对于临时比较,只需要第一级表的相同显示即可:



在此图中,级别1表管理虚拟地址空间的前2个MiB。 这是可能的,因为从CR3寄存器通过级别4、3和2的表中的空条目进行访问。索引为8的记录将32 KiB的虚拟页转换为32 KiB的物理帧,从而识别1级表本身。在图中用水平箭头表示。

通过写入相同映射的1级表,我们的内核最多可以创建511次时间比较(512减去身份映射所需的记录)。 在上面的示例中,内核创建了两个时间比较:

  • 将1级表中的空条目映射到24 KiB处的帧。 这将在0 KiB处创建虚拟页面到由虚线箭头指示的页面级别2表的物理帧的临时映射。
  • 将第1级表的第9条记录与4 KiB的帧相匹配。 这将在36 KiB处创建虚拟页面到由虚线箭头指示的页面级别4表的物理框架的临时映射。

现在,内核可以通过写入从0 KiB开始的页面访问2级表,并通过写入从33 KiB开始的页面访问4级表。

因此,使用临时映射访问页表的任意框架包括以下操作:

  • 在相同显示的1级表中找到一个免费条目。
  • 将此条目映射到我们要访问的页表的物理框架。
  • 通过与条目关联的虚拟页面访问此框架。
  • 将记录重新设置为未使用,从而删除临时映射。

使用这种方法,虚拟地址空间保持干净,因为经常使用相同的512个虚拟页面。 缺点是麻烦,特别是因为新的比较可能需要更改表的多个级别,也就是说,我们需要重复上述过程几次。

递归页表


另一个根本不需要附加页表的有趣方法是递归匹配

这个想法是将一些记录从第四级表转换成它本身。 因此,我们实际上保留了虚拟地址空间的一部分,并将所有当前和将来的表框架映射到该空间。

让我们看一个例子,以了解这一切如何工作:



与本文开头的示例唯一的不同是,在第4级表中具有索引511的附加记录被映射到位于该表本身中的物理帧4 KiB

当CPU继续执行该记录时,它不会引用3级表,而是再次引用4级表,这类似于调用自身的递归函数。 重要的是,处理器必须假定4级表中的​​每个条目都指向3级表,所以现在将4级表视为3级表,因为x86_64中所有级别的表都具有相同的结构,所以这种方法有效。

通过在开始实际转换之前跟踪一次或多次递归记录,我们可以有效地减少处理器经历的级别数。 例如,如果我们跟踪一次递归记录,然后转到3级表,则处理器认为3级表是2级表,接着,他将2级表视为1级表,并将1级表视为已映射物理内存中的帧。 这意味着我们现在可以读写页面1级表,因为处理器认为这是一个映射的帧。 下图显示了这种转换的五个步骤:



同样,在开始转换之前,我们可以跟踪两次递归项以减少传递给两个级别的数量:



让我们逐步完成此过程。 首先,CPU跟踪4级表中的​​递归条目,并认为它已到达3级表,然后再次跟踪递归记录,并认为它已达到2级。但实际上,它仍处于4级,然后CPU转到了新地址。进入第3层表,但认为它已经在第1层表中。最后,在第2层表的下一个入口点,处理器认为它已经访问了物理内存帧。 这使我们可以读写2级表。

还访问了3级和4级表。要访问3级表,我们遵循三遍递归记录:处理器认为它已经在1级表中,并且在下一步中我们达到3级,CPU将其视为映射帧。 要访问4级表本身,我们只需遵循递归记录四次,直到处理器将4级表本身作为映射帧处理(下图中的蓝色)。



起初很难理解这个概念,但实际上它运作良好。

地址计算


因此,我们可以通过遵循一次或多次递归记录来访问所有级别的表。 由于四级表中的索引是直接从虚拟地址派生的,因此必须为此方法创建特殊的虚拟地址。 我们记得,页表索引是从地址中提取的,如下所示:



假设我们要访问显示特定页面的1级表。 如上所述,您需要遍历一次递归记录,然后遍历第四,第三和第二级的索引。 为此,我们将所有地址块向右移动一个块,并将递归记录的索引设置为4级初始索引的位置:



要访问此页面的2级表,我们将所有索引块向右移动两个块,并将递归索引设置为两个源块的位置:4级和3级:



要访问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

在这里, 是4级索引, 是3级, 是2级, DDD是所显示帧的1级索引, EEEE是其偏移量。 RRR是递归记录的索引。 索引(三位数)通过乘以8(页表项的大小)而转换为偏移量(四位数)。 使用此偏移量,结果地址直接指向相应的页表条目。

SSSS是带符号数字的扩展位,也就是说,它们都是位47的副本。这是对x86_64体系结构中有效地址的特殊要求,我们在上一篇文章已经讨论

地址是八进制的 ,因为每个八进制字符代表三位,这使您可以清楚地区分不同级别的表的9位索引。 在每个字符代表四个位的十六进制系统中,这是不可能的。

防锈码


您可以使用位运算在Rust代码中构造这样的地址:

 // the virtual address whose corresponding page tables you want to access let addr: usize = […]; let r = 0o777; // recursive index let sign = 0o177777 << 48; // sign extension // retrieve the page table indices of the address that we want to translate let l4_idx = (addr >> 39) & 0o777; // level 4 index let l3_idx = (addr >> 30) & 0o777; // level 3 index let l2_idx = (addr >> 21) & 0o777; // level 2 index let l1_idx = (addr >> 12) & 0o777; // level 1 index let page_offset = addr & 0o7777; // calculate the table addresses let level_4_table_addr = sign | (r << 39) | (r << 30) | (r << 21) | (r << 12); let level_3_table_addr = sign | (r << 39) | (r << 30) | (r << 21) | (l4_idx << 12); let level_2_table_addr = sign | (r << 39) | (r << 30) | (l4_idx << 21) | (l3_idx << 12); let level_1_table_addr = sign | (r << 39) | (l4_idx << 30) | (l3_idx << 21) | (l2_idx << 12); 

该代码假定递归匹配索引为0o777 (511)的最后4级记录的递归映射。 当前情况并非如此,因此代码尚无法使用。 请参见下文,了解如何告知加载程序设置递归映射。

作为手动执行按位操作的替代方法,可以使用x86_64板条箱的RecursivePageTable类型,该类型为各种表操作提供安全的抽象。 例如,以下代码显示了如何将虚拟地址转换为其相应的物理地址:

 // 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. let level_4_table_addr = […]; let level_4_table_ptr = level_4_table_addr as *mut PageTable; let recursive_page_table = unsafe { let level_4_table = &mut *level_4_table_ptr; RecursivePageTable::new(level_4_table).unwrap(); } /// Retrieve the physical address for the given virtual address let addr: u64 = […] 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())) 

同样,此代码需要正确的递归映射。 使用此映射,将像第一个代码示例一样计算丢失的level_4_table_addr



递归映射是一种有趣的方法,它显示了通过单个表进行匹配的强大程度。 它相对容易实现,只需要最少的设置(只需一个递归项),因此这对于第一个实验是一个不错的选择。

但这有一些缺点:

  • 大量的虚拟内存(512 GiB)。 在较大的48位地址空间中,这不是问题,但可能导致次优缓存行为。
  • 它可以轻松地仅访问当前活动的地址空间。 通过更改递归项,仍然可以访问其他地址空间,但是切换需要临时匹配。 我们在上一篇(过时的)文章中介绍了如何执行此操作。
  • 它在很大程度上取决于x86页表格式,并且可能无法在其他体系结构上使用。

引导加载程序支持


上述所有方法都需要更改页表和相应的设置。 例如,要映射物理内存相同或递归地映射第四级表的记录。 问题在于,如果不访问页表,就无法进行这些设置。

因此,我需要引导加载程序的帮助。 他可以访问页表,因此可以创建我们需要的任何显示。 在当前的实现中, bootloader板条箱使用货物功能支持上述两种方法:

  • map_physical_memory函数将完整的物理内存映射到虚拟地址空间中的某个位置。 因此,内核可以获得对所有物理内存的访问权,并且可以采用显示完整物理内存的方法
  • 使用recursive_page_table函数,加载程序以递归方式显示第四级页表条目。 这允许内核根据“递归页表”一节中描述的方法工作。

对于我们的内核,我们选择第一个选项,因为它是一种简单,独立于平台且功能更强大的方法(它还可以访问其他框架,而不仅仅是页面表)。为了获得引导加载程序的支持,请将函数添加到其依赖项中map_physical_memory

 [dependencies] bootloader = { version = "0.4.0", features = ["map_physical_memory"]} 

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

开机资讯


箱子bootloader定义了BootInfo的结构,并将所有信息传递给内核。该结构仍在最终确定中,因此当升级到与semver不兼容的将来版本时,可能会出现一些故障当前,该结构具有两个字段:memory_mapphysical_memory_offset

  • 该字段memory_map提供了可用物理内存的概述。它告诉内核系统上有多少物理内存可用,以及哪些内存区域为VGA等设备保留。可以从BIOS或UEFI固件中请求存储卡,但只能在启动过程的开始阶段。由于这个原因,加载程序必须提供它,因为这样内核将不再能够接收此信息。本文稍后将提供存储卡。
  • physical_memory_offset报告物理内存映射的虚拟起始地址。将此偏移量添加到物理地址,我们得到相应的虚拟地址。这样就可以从内核访问任意物理内存。

加载程序将结构BootInfo作为&'static BootInfo函数的参数传递内核_start添加:

 // in src/main.rs use bootloader::BootInfo; #[cfg(not(test))] #[no_mangle] pub extern "C" fn _start(boot_info: &'static BootInfo) -> ! { // new argument […] } 

指定正确的参数类型很重要,因为编译器不知道我们的入口点函数的正确签名类型。

入口点宏


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

为了确保入口点功能始终具有正确的签名,包装箱bootloader提供了一个macro entry_point。我们使用以下宏重写函数:

 // in src/main.rs use bootloader::{BootInfo, entry_point}; entry_point!(kernel_main); #[cfg(not(test))] fn kernel_main(boot_info: &'static BootInfo) -> ! { […] } 

您不再需要使用入口点,extern "C"或者no_mangle,因为宏为我们定义了较低级别的实际入口点_start该函数kernel_main现在已成为完全正常的Rust函数,因此我们可以为其选择任意名称。重要的是它是按类型检查的,因此,如果您使用了错误的签名,例如,通过添加参数或更改其类型,则会发生编译错误

实作


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

首先,在代码中创建一个新模块memory

 // in src/lib.rs pub mod memory; 

对于模块,创建一个空文件src/memory.rs

访问页表


上一篇文章的末尾我们试图查看内核可以运行的页面表,但是无法访问register指向的物理帧CR3。现在我们可以从这里继续工作:该函数active_level_4_table将返回一个链接到第四级活动页面的表:

 // in src/memory.rs use x86_64::structures::paging::PageTable; /// Returns a mutable reference to the active level 4 table. /// /// This function is unsafe because the caller must guarantee that the /// complete physical memory is mapped to virtual memory at the passed /// `physical_memory_offset`. Also, this function must be only called once /// to avoid aliasing `&mut` references (which is undefined behavior). pub unsafe fn active_level_4_table(physical_memory_offset: u64) -> &'static mut PageTable { use x86_64::{registers::control::Cr3, VirtAddr}; let (level_4_table_frame, _) = Cr3::read(); let phys = level_4_table_frame.start_address(); let virt = VirtAddr::new(phys.as_u64() + physical_memory_offset); let page_table_ptr: *mut PageTable = virt.as_mut_ptr(); &mut *page_table_ptr // unsafe } 

首先,我们从寄存器中读取第4级活动表的物理帧CR3。然后我们获取其物理起始地址,并通过添加将其转换为虚拟地址physical_memory_offset。最后,我们在原始指针变换地址*mut PageTable的方法as_mut_ptr,然后从它的不安全链接创建&mut PageTable。我们&mut而是创建链接&,因为在本文后面,我们将修改这些页表。

此处无需插入不安全的块,因为Rust将整个身体unsafe fn视为一个大的不安全的块。这增加了风险,因为有可能在前面的行中意外引入不安全的操作。这也使检测不安全操作变得困难。已经创建RFC来修改Rust的这种行为。

现在我们可以使用此函数输出第四级表的记录:

 // in src/main.rs #[cfg(not(test))] fn kernel_main(boot_info: &'static BootInfo) -> ! { […] // initialize GDT, IDT, PICS use blog_os::memory::active_level_4_table; let l4_table = unsafe { active_level_4_table(boot_info.physical_memory_offset) }; for (i, entry) in l4_table.iter().enumerate() { if !entry.is_unused() { println!("L4 Entry {}: {:?}", i, entry); } } println!("It did not crash!"); blog_os::hlt_loop(); } 

我们physical_memory_offset传入结构的相应字段BootInfo。然后,我们使用一个函数iter来迭代页表条目,并使用组合器为每个元素enumerate添加索引i。仅显示非空条目,因为所有512个条目均无法显示在屏幕上。

在运行代码时,我们看到以下结果:



我们看到了一些非空记录,它们映射到各种第三级表。使用这么多内存区域是因为内核代码,内核堆栈,物理内存转换和引导信息需要单独的区域。

要浏览页面表并查看第三级表,我们可以再次将显示的框架转换为虚拟地址:

 // in the for loop in src/main.rs use x86_64::{structures::paging::PageTable, VirtAddr}; if !entry.is_unused() { println!("L4 Entry {}: {:?}", i, entry); // get the physical address from the entry and convert it let phys = entry.frame().unwrap().start_address(); let virt = phys.as_u64() + boot_info.physical_memory_offset; let ptr = VirtAddr::new(virt).as_mut_ptr(); let l3_table: &PageTable = unsafe { &*ptr }; // print non-empty entries of the level 3 table for (i, entry) in l3_table.iter().enumerate() { if !entry.is_unused() { println!(" L3 Entry {}: {:?}", i, entry); } } } 

若要查看第二级和第一级的表,请分别重复此过程以获取第三级和第二级的记录。您可以想象,代码量增长非常快,因此我们将不会发布完整清单。

手动遍历表很有趣,因为它有助于了解处理器如何转换地址。但是通常我们只希望为特定的虚拟地址显示一个物理地址,因此让我们为此创建一个功能。

地址翻译


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

 // in src/memory.rs use x86_64::{PhysAddr, VirtAddr}; /// Translates the given virtual address to the mapped physical address, or /// `None` if the address is not mapped. /// /// This function is unsafe because the caller must guarantee that the /// complete physical memory is mapped to virtual memory at the passed /// `physical_memory_offset`. pub unsafe fn translate_addr(addr: VirtAddr, physical_memory_offset: u64) -> Option<PhysAddr> { translate_addr_inner(addr, physical_memory_offset) } 

我们使用安全功能translate_addr_inner来限制不安全代码的数量。如上所述,Rust将整个身体unsafe fn视为不安全的大障碍。通过调用一个安全功能,我们再次使每个操作都明确unsafe

特殊的内部功能具有实际功能:

 // in src/memory.rs /// Private function that is called by `translate_addr`. /// /// This function is safe to limit the scope of `unsafe` because Rust treats /// the whole body of unsafe functions as an unsafe block. This function must /// only be reachable through `unsafe fn` from outside of this module. fn translate_addr_inner(addr: VirtAddr, physical_memory_offset: u64) -> Option<PhysAddr> { use x86_64::structures::paging::page_table::FrameError; use x86_64::registers::control::Cr3; // read the active level 4 frame from the CR3 register let (level_4_table_frame, _) = Cr3::read(); let table_indexes = [ addr.p4_index(), addr.p3_index(), addr.p2_index(), addr.p1_index() ]; let mut frame = level_4_table_frame; // traverse the multi-level page table for &index in &table_indexes { // convert the frame into a page table reference let virt = frame.start_address().as_u64() + physical_memory_offset; let table_ptr: *const PageTable = VirtAddr::new(virt).as_ptr(); let table = unsafe {&*table_ptr}; // read the page table entry and update `frame` let entry = &table[index]; frame = match entry.frame() { Ok(frame) => frame, Err(FrameError::FrameNotPresent) => return None, Err(FrameError::HugeFrame) => panic!("huge pages not supported"), }; } // calculate the physical address by adding the page offset Some(frame.start_address() + u64::from(addr.page_offset())) } 

active_level_4_table我们没有重用函数,而是从寄存器重新读取了第四级框架CR3,因为这简化了原型的实现。不用担心,我们会尽快改善解决方案。

该结构VirtAddr已经提供了用于计算四级页面表中的索引的方法。我们将这些索引存储在一个小的数组中,因为它允许您循环访问所有表for。在循环之外,我们记得访问的最后一帧以稍后计算物理地址。frame在迭代过程中指向页表的框架,并在最后一次迭代后(即,通过1级记录后)指向关联的框架。

在循环内部,我们再次应用physical_memory_offset将框架转换为页表链接。然后,我们读取当前页表的记录,并使用该函数PageTableEntry::frame检索匹配的框架。如果记录未映射到框架,则返回None。如果记录显示2 MiB或1 GiB的巨大页面,那么到目前为止,我们将感到恐慌。

因此,让我们在某些地址检查翻译功能:

 // in src/main.rs #[cfg(not(test))] fn kernel_main(boot_info: &'static BootInfo) -> ! { […] // initialize GDT, IDT, PICS use blog_os::memory::translate_addr; use x86_64::VirtAddr; let addresses = [ // the identity-mapped vga buffer page 0xb8000, // some code page 0x20010a, // some stack page 0x57ac_001f_fe48, // virtual address mapped to physical address 0 boot_info.physical_memory_offset, ]; for &address in &addresses { let virt = VirtAddr::new(address); let phys = unsafe { translate_addr(virt, boot_info.physical_memory_offset) }; println!("{:?} -> {:?}", virt, phys); } println!("It did not crash!"); blog_os::hlt_loop(); } 

运行代码时,将得到以下结果:



按预期,通过相同的映射,地址将0xb8000转换为相同的物理地址。代码页和堆栈页被转换为任意物理地址,这取决于加载程序如何为我们的内核创建初始映射。映射physical_memory_offset应指向物理地址0,但会失败,因为转换会使用大量页面来提高效率。引导加载程序的未来版本可能会对内核和堆栈页面应用相同的优化。

使用MappedPageTable


虚拟地址到物理地址的转换是OS内核的典型任务,因此,包装箱x86_64为其提供了抽象。它已经支持大页面和除之外的其他几个功能,translate_addr因此,我们使用它而不是在自己的实现中添加对大页面的支持。

抽象的基础是两个特征,它们定义了页表的各种翻译功能:

  • 该特征Mapper提供了可在页面上使用的功能。例如,translate_page将该页面转换为相同大小的框架,并map_to在表中创建新的映射。
  • 该特征MapperAllSizes意味着适用Mapper于所有页面大小。此外,它还提供了适用于不同大小的页面(包括translate_addr或常规)的功能translate

特性仅定义接口,但不提供任何实现。现在,板条箱x86_64提供了两种实现特征的类型:MappedPageTableRecursivePageTable第一个要求页表的每一帧都显示在某个位置(例如,带有偏移量)。如果第四级的表递归显示,则可以使用第二种类型。

我们已将所有物理内存映射到physical_memory_offset,因此您可以使用MappedPageTable类型。要初始化它,请init在模块中创建一个新函数memory

 use x86_64::structures::paging::{PhysFrame, MapperAllSizes, MappedPageTable}; use x86_64::PhysAddr; /// Initialize a new MappedPageTable. /// /// This function is unsafe because the caller must guarantee that the /// complete physical memory is mapped to virtual memory at the passed /// `physical_memory_offset`. Also, this function must be only called once /// to avoid aliasing `&mut` references (which is undefined behavior). pub unsafe fn init(physical_memory_offset: u64) -> impl MapperAllSizes { let level_4_table = active_level_4_table(physical_memory_offset); let phys_to_virt = move |frame: PhysFrame| -> *mut PageTable { let phys = frame.start_address().as_u64(); let virt = VirtAddr::new(phys + physical_memory_offset); virt.as_mut_ptr() }; MappedPageTable::new(level_4_table, phys_to_virt) } // make private unsafe fn active_level_4_table(physical_memory_offset: u64) -> &'static mut PageTable {…} 

我们不能直接MappedPageTable从函数返回,因为它是闭包类型所共有的。我们将通过语法构造解决这个问题impl Trait。另一个优点是,您可以随后将内核切换到RecursivePageTable而不更改函数的签名。

该函数MappedPageTable::new需要两个参数:到第4级页表的可变链接,以及phys_to_virt将物理帧转换为页表指针的闭包*mut PageTable。对于第一个参数,我们可以重用该函数active_level_4_table。对于第二个,我们创建一个physical_memory_offset用于执行转换的闭包

我们也将其active_level_4_table设为私有函数,因为从现在开始只能调用它init

使用方法MapperAllSizes::translate_addrmemory::translate_addr除了我们自己的功能,我们只需要更改以下几行kernel_main

 // in src/main.rs #[cfg(not(test))] fn kernel_main(boot_info: &'static BootInfo) -> ! { […] // initialize GDT, IDT, PICS // new: different imports use blog_os::memory; use x86_64::{structures::paging::MapperAllSizes, VirtAddr}; // new: initialize a mapper let mapper = unsafe { memory::init(boot_info.physical_memory_offset) }; let addresses = […]; // same as before for &address in &addresses { let virt = VirtAddr::new(address); // new: use the `mapper.translate_addr` method let phys = mapper.translate_addr(virt); println!("{:?} -> {:?}", virt, phys); } println!("It did not crash!"); blog_os::hlt_loop(); } 

开始之后,我们看到的翻译结果与以前相同,但是现在只有大的页面也可以使用:



如预期的那样,虚拟地址已physical_memory_offset转换为物理地址0x0使用type的转换功能MappedPageTable,我们无需实现对大页面的支持。我们还可以访问其他页面功能,例如map_to下一部分中将使用的功能在此阶段,我们不再需要该功能memory::translate_addr,可以根据需要将其删除。

创建一个新的映射


到目前为止,我们仅查看页表,但未进行任何更改。让我们为以前未显示的页面创建一个新的映射。

我们将使用map_totrait中的函数Mapper,因此首先我们将考虑该函数。该文档说它需要四个参数:我们要显示的页面;页面应映射到的框架。用于写页表和框架分配器的标志集frame_allocator帧分配器是必需的,因为映射此页面可能需要创建其他表,这些表需要将未使用的帧作为备份存储。

功能介绍 create_example_mapping


我们实现的第一步是创建一个新功能create_example_mapping,将该页面映射到0xb8000VGA文本缓冲区物理帧。我们选择此框架是因为它可以轻松检查显示是否正确创建:我们只需要写到最近显示的页面,然后查看它是否出现在屏幕上。

该函数create_example_mapping如下所示:

 // in src/memory.rs use x86_64::structures::paging::{Page, Size4KiB, Mapper, FrameAllocator}; /// Creates an example mapping for the given page to frame `0xb8000`. pub fn create_example_mapping( page: Page, mapper: &mut impl Mapper<Size4KiB>, frame_allocator: &mut impl FrameAllocator<Size4KiB>, ) { use x86_64::structures::paging::PageTableFlags as Flags; let frame = PhysFrame::containing_address(PhysAddr::new(0xb8000)); let flags = Flags::PRESENT | Flags::WRITABLE; let map_to_result = unsafe { mapper.map_to(page, frame, flags, frame_allocator) }; map_to_result.expect("map_to failed").flush(); } 

除了页面page要关联的函数需要一个实例mapperframe_allocator。类型mapper实现Mapper<Size4KiB>方法提供的特征map_to。通用参数是Size4KiB必需的,因为特征对于trait Mapper通用的,因此PageSize可以同时使用标准4 KiB页面和2 MiB和1 GiB的大页面。我们只想创建4个KiB页面,所以我们可以使用它Mapper<Size4KiB>代替require MapperAllSizes

为了进行比较,请设置标志PRESENT,因为所有有效条目都是必需的,并设置标志WRITABLE以使显示的页面可写。挑战赛map_to不安全:您可以使用无效的参数违反内存安全性,因此必须使用block unsafe有关所有可能的标志的列表,请参见上一篇文章的“页面表格式”部分

该函数map_to可能会失败,因此返回Result由于这只是不可靠代码的示例,因此我们仅expect在发生错误时使用它来恐慌。如果成功,函数将返回一个类型MapperFlush该类型提供了一种使用方法从动态转换缓冲区(TLB)清除最近显示的页面的简便方法flushResult,这种类型将[ #[must_use]] 属性应用于如果我们意外忘记使用它,则发出警告

虚构的 FrameAllocator


要呼叫create_example_mapping,您必须先创建FrameAllocator如上所述,创建新显示的复杂性取决于我们要显示的虚拟页面。在最简单的情况下,该页面的1级表已经存在,我们只需要创建一条记录。在最困难的情况下,页面位于尚未创建第3级的内存区域中,因此首先您必须创建第3、2和1级的页表。

让我们从一个简单的案例开始,并假定您不需要创建新的页表。始终返回的帧分配器足以满足此要求None我们创建了这样的EmptyFrameAllocator显示功能以进行测试:

 // 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 } } 

现在,您需要找到一个无需创建新页面表即可显示的页面。加载程序被加载到虚拟地址空间的第一个兆字节中,因此我们知道该区域有一个有效的1级表,例如,我们可以选择该存储区中任何未使用的页面,例如address处的页面0x1000

为了测试该功能,我们首先显示page 0x1000,然后显示内存的内容:

 // in src/main.rs #[cfg(not(test))] fn kernel_main(boot_info: &'static BootInfo) -> ! { […] // initialize GDT, IDT, PICS use blog_os::memory; use x86_64::{structures::paging::Page, VirtAddr}; let mut mapper = unsafe { memory::init(boot_info.physical_memory_offset) }; let mut frame_allocator = memory::EmptyFrameAllocator; // map a previously unmapped page let page = Page::containing_address(VirtAddr::new(0x1000)); memory::create_example_mapping(page, &mut mapper, &mut frame_allocator); // write the string `New!` to the screen through the new mapping let page_ptr: *mut u64 = page.start_address().as_mut_ptr(); unsafe { page_ptr.offset(400).write_volatile(0x_f021_f077_f065_f04e)}; println!("It did not crash!"); blog_os::hlt_loop(); } 

首先,我们为中的页面创建一个映射0x1000,调用一个create_example_mapping具有到实例mapper的可变链接的函数frame_allocator。这会将页面映射0x1000到VGA文本缓冲区框架,因此我们应该在屏幕上看到那里写的内容。

然后将页面转换为原始指针,并将值写入offset 400。我们不会写到页面顶部,因为VGA缓冲区的第一行直接从屏幕上移出,如下所示println。输入0x_f021_f077_f065_f04e与字符串“ New!”相对应的值在白色背景上。正如我们在“ VGA文本模式”一文中所了解的那样,写入VGA缓冲区必须是易失的,因此我们使用了该方法write_volatile

当我们在QEMU中运行代码时,我们看到以下结果:



写入页面后0x1000,题为“ New!”。因此,我们已经在页表中成功创建了新的映射。

该排序规则很有效,因为已经有1级的排序表0x1000当我们尝试映射尚不存在1级表的页面时,该函数map_to失败,因为它试图从中分配帧EmptyFrameAllocator以创建新表。我们看到这种情况是在尝试显示页面0xdeadbeaf000而不是显示页面时发生0x1000

 // in src/main.rs #[cfg(not(test))] fn kernel_main(boot_info: &'static BootInfo) -> ! { […] let page = Page::containing_address(VirtAddr::new(0xdeadbeaf000)); […] } 

如果启动此操作,则会出现以下错误消息,导致出现紧急情况:

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

要显示尚无页面级别1表的页面,您需要创建正确的表FrameAllocator但是,您如何知道哪些帧是空闲的以及多少物理内存可用?

选框


对于新的页表,您需要创建正确的框架分配器。让我们从通用骨架开始:

 // 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() } } 

frames可以使用任意帧迭代器初始化该字段。这使您可以简单地将调用委派给alloc方法Iterator::next

对于初始化,我们BootInfoFrameAllocator使用memory_map引导加载程序传输的存储卡作为该结构的一部分BootInfo。如引导信息部分所述,存储卡由BIOS / UEFI固件提供。仅在引导过程的开始就可以请求它,因此引导加载程序已经调用了必要的功能。

存储卡由一系列结构组成,这些结构MemoryRegion包含每个存储区的起始地址,长度和类型(例如,未使用,保留等)。通过创建一个从未使用的区域生成帧的迭代器,我们可以创建一个有效的迭代器BootInfoFrameAllocator

初始化BootInfoFrameAllocator发生在一个新函数中init_frame_allocator

 // 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.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将存储卡转换为迭代器的方法MemoryRegion然后,我们使用该方法filter跳过保留或不可访问的区域。加载程序会为其创建的所有映射更新存储卡,因此内核使用的帧(代码,数据或堆栈)或用于存储有关引导的信息已标记为InUse或类似。因此,我们可以确定Usable没有在其他地方使用框架
  • map range Rust .
  • : into_iter , 4096- step_by . 4096 (= 4 ) — , . , . flat_map map , Iterator<Item = u64> Iterator<Item = Iterator<Item = u64>> .
  • PhysFrame , Iterator<Item = PhysFrame> . BootInfoFrameAllocator .

现在,您可以改变我们的功能kernel_main来发送一个副本BootInfoFrameAllocator,而不是EmptyFrameAllocator

 // in src/main.rs #[cfg(not(test))] fn kernel_main(boot_info: &'static BootInfo) -> ! { […] let mut frame_allocator = memory::init_frame_allocator(&boot_info.memory_map); […] } 

这次地址映射成功了,我们再次看到黑色和白色的“ New!”。在幕后,该方法map_to创建丢失的页表,如下所示:

  • 从发送的帧中选择一个未使用的帧frame_allocator
  • 零帧创建一个新的空页表。
  • 将更高级别的表条目映射到此框架。
  • 转到表的下一级。

尽管我们的功能create_example_mapping只是示例代码,但是我们现在可以为任意页面创建新的映射。在以后的文章中,这对于分配内存和实现多线程很有必要。

总结


在本文中,我们了解了访问页表物理框架的各种方法,包括身份映射,映射完整的物理内存,临时映射和递归页表。我们选择显示完整的物理内存作为一种简单而强大的方法。

我们无法访问页面表而无法从内核映射物理内存,因此需要引导加载程序支持。货架bootloader通过附加的货物功能创建必要的映射。它将必要的信息作为&BootInfo入口点函数的参数传递给内核

对于我们的实现,我们首先手动浏览页面表,创建翻译功能,然后使用MappedPageTable包装箱类型x86_64我们还学习了如何在页表中创建新映射,以及如何FrameAllocator在引导加载程序传输的存储卡上创建新映射

接下来是什么?


在下一篇文章中,我们将为内核创建一个堆内存区域,这将使我们能够分配内存并使用不同类型的集合

Source: https://habr.com/ru/post/zh-CN445618/


All Articles