本文介绍了操作系统的内核如何访问物理内存帧。 我们将研究将虚拟地址转换为物理地址的功能。 我们还将弄清楚如何在页表中创建新的映射。
该博客发布在
GitHub上 。 如果您有任何疑问或问题,请在此处打开相应的票证。 本文的所有资料都
在这里 。
引言
从
上一篇文章中,我们了解了分页内存的原理以及x86_64上的四级页表如何工作。 我们还发现加载器已经为内核设置了页表层次结构,因此内核在虚拟地址上运行。 这样可以提高安全性,但是会出现问题:如何访问存储在页表项或
CR3
实际物理地址?
在本文的第一部分中,我们将讨论该问题以及解决该问题的不同方法。 然后,我们实现了一个功能,它可以潜入页表的层次结构,将虚拟地址转换为物理地址。 最后,学习如何在页表中创建新的映射并查找未使用的内存框架以创建新表。
依赖关系更新
要工作,您需要
x86_64
0.4.0或更高版本。 更新我们的
Cargo.toml
的依赖
Cargo.toml
:
[dependencies] x86_64 = "0.4.0" # or later
访问页表
从内核访问页表并不像看起来那样容易。 要了解此问题,请再看看上一篇文章中的四级表层次结构:
重要的是每个页面条目都存储下一张表的
物理地址。 这避免了这些地址的转换,从而降低了性能并容易导致无限循环。
问题是我们不能直接从内核访问物理地址,因为它也可以在虚拟地址上工作。 例如,当我们访问地址
4 KiB
,我们可以访问
虚拟地址
4 KiB
,而不能访问存储第四级页面表的
物理地址。 如果我们要访问
4 KiB
的物理地址,则需要使用一些虚拟地址,将其转换为虚拟地址。
因此,要访问页表的框架,您需要将一些虚拟页面映射到这些框架。 有多种创建此类映射的方法。
一种简单的解决方案是
所有页表的显示相同 。
在此示例中,我们看到相同的帧显示。 页表的物理地址同时是有效的虚拟地址,因此我们可以轻松地从寄存器CR3开始访问所有级别的页表。
但是,这种方法会使虚拟地址空间变得混乱,并使得很难找到较大的连续区域的空闲内存。 假设我们要在上图中创建一个1000 KiB虚拟内存区域,例如,
在memory中显示一个文件 。 我们不能从
28 KiB
区域开始,因为它位于已经占用的页面
1004 KiB
。 因此,您将不得不进一步寻找,直到找到合适的大片段,例如
1008 KiB
。 存在与分段存储器相同的碎片问题。
另外,新页表的创建要复杂得多,因为我们需要找到尚未使用相应页的物理框架。 例如,对于我们的文件,我们保留了1000 KiB的
虚拟内存区域,从地址
1008 KiB
。 现在,我们不能再使用物理地址在
1000 KiB
和
2008 KiB
之间的任何帧,因为它不能显示相同。
2.另一个选择是
仅在需要访问
页表时临时广播它们。 对于临时比较,只需要第一级表的相同显示即可:
在此图中,级别1表管理虚拟地址空间的前2个MiB。 这是可能的,因为来自CR3寄存器的访问是通过级别4、3和2的表中的零条目进行的。具有索引
8的记录将
32 KiB
处的虚拟页转换为
32 KiB
的物理帧,从而标识了1级表本身。在图中用水平箭头表示。
通过写入相同映射的1级表,我们的内核最多可以创建511次时间比较(512减去身份映射所需的记录)。 在上面的示例中,内核将1级表的空记录与
24 KiB
处的帧进行了匹配。 这将虚拟页面从
0 KiB
临时映射到页面2级别表的物理帧,由虚线箭头指示。 现在,内核可以通过写入从
0 KiB
开始的页面来访问2级表。
因此,使用临时映射访问页表的任意框架包括以下操作:
- 在相同显示的1级表中找到一个免费条目。
- 将此条目映射到我们要访问的页表的物理框架。
- 通过与条目关联的虚拟页面访问此框架。
- 将记录重新设置为未使用,从而删除临时映射。
使用这种方法,虚拟地址空间保持干净,因为经常使用相同的512个虚拟页面。 缺点是麻烦,特别是因为新的比较可能需要更改表的多个级别,也就是说,我们需要重复上述过程几次。
3.尽管以上两种方法都有效,但是还有第三种方法:
递归页表 。 它结合了这两种方法的优点:它不断地比较页表的所有帧,而无需临时比较,并且并排保持相邻页,避免虚拟地址空间的碎片化。 这就是我们将要使用的方法。
递归页表
这个想法是将一些记录从第四级表转换成它本身。 因此,我们实际上保留了虚拟地址空间的一部分,并将所有当前和将来的表框架映射到该空间。
让我们看一个例子,以了解这一切如何工作:
与本文开头的示例唯一的不同是,在第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位索引。 在每个字符代表四个位的十六进制系统中,这是不可能的。
实作
经过所有这些理论,我们终于可以继续执行了。 方便的是,加载器不仅生成页面表,还生成了4级表的最后一条记录中的递归显示,之所以这样做,是因为否则会出现鸡或蛋的问题:我们需要访问4级表以创建递归映射但是我们无法在没有任何显示的情况下访问它。
在上一篇文章的结尾,我们已经使用了此递归映射来通过硬编码地址
0xffff_ffff_ffff_f000
访问4级表。 如果将这个地址转换为八进制并与上表进行比较,我们将看到它与4级表中的记录结构完全对应,其中
RRR
=
0o777
,
AAAA
=
0
和符号
1
的扩展位:
结构:0o_SSSSSS_RRR_RRR_RRR_RRR_AAAA
地址:0o_177777_777_777_777_777_0000
多亏了递归表的知识,我们现在可以创建虚拟地址来访问所有活动表。 并具有播放功能。
地址翻译
第一步,创建一个函数,该函数将虚拟地址转换为物理地址,并通过页表的层次结构进行传递:
首先,我们为递归索引(511 =
0o777
)和符号扩展位(每个为1)引入变量。 然后,我们通过按位运算来计算页表的索引和偏移量,如图所示:
下一步是计算四个页表的虚拟地址,如上一节所述。 接下来,在函数中,我们将每个地址转换为
PageTable
链接。 这些是不安全的操作,因为编译器无法知道这些地址是否有效。
计算完地址后,我们使用索引运算符查看4级表中的记录,如果该记录为零,则该4级记录没有3级表,这意味着
addr
没有映射到任何物理内存。 因此,我们返回
None
。 否则,我们知道存在3级表。 然后,像上一级一样,重复该过程。
在检查了更高级别的三页之后,我们最终可以读取级别1表的记录,该记录告诉我们地址所映射的物理帧。 最后一步,添加页面偏移量-并返回地址。
如果我们确定知道地址已映射,则可以直接访问1级表,而无需查看更高级别的页面。 但是由于我们不知道这一点,因此我们首先需要检查是否存在1级表,否则我们的函数将为不匹配的地址返回缺少页面的错误。
试一下
让我们尝试在
_start
函数中为虚拟地址使用转换函数:
启动后,我们看到以下结果:
如预期的那样,与标识符关联的地址0xb8000转换为相同的物理地址。 代码页和堆栈页被转换为任意的物理地址,这取决于加载程序如何为内核创建初始映射。
RecursivePageTable
x86_64提供了
RecursivePageTable
类型,该类型为各种页面表操作实现安全抽象。 使用这种类型,您可以更简洁地实现
translate_addr
函数:
RecursivePageTable
类型完全封装了不安全的页表爬网,因此不再需要
translate_addr
函数中的
unsafe
代码。 由于需要保证传递的
level_4_table_addr
的正确性,因此
init
函数仍然不安全。
必须更新我们的
_start
函数以重新签名该函数,如下所示:
现在,我们将引用传递给
RecursivePageTable
类型,而不是将
LEVEL_4_TABLE_ADDR
传递给
translate_addr
并通过不安全的原始指针访问页表。 因此,我们现在有了一个安全的抽象和明确的所有权语义。 这确保了我们不会在共享访问中意外更改页表,因为要更改它,我们需要排他地拥有
RecursivePageTable
。
此功能提供的结果与手动编写的原始翻译功能相同。
使不安全的功能更安全
memory::init
是一个不安全的函数:它需要一个块来调用它unsafe
,因为调用者必须保证满足某些要求。在我们的情况下,要求将发送的地址精确地映射到第4级页表的物理帧,将不安全功能的unsafe
整个主体放置在该块中,以便执行各种操作而无需创建其他块unsafe
。因此,我们不需要不安全的块来取消引用level_4_table_ptr
: 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
就无法说出它是否安全。因此,很容易意外跳过一些不安全的代码。为避免此问题,可以添加安全的内置函数:
现在,unsafe
再次需要使用该块进行取消引用level_4_table_ptr
,并且我们立即看到这些是唯一不安全的操作。Rust目前开放了一个RFC,以更改这种不安全功能的不成功属性。创建一个新的映射
当我们读取页表并创建转换函数时,下一步是在页表层次结构中创建新的映射。此操作的复杂性取决于我们要显示的虚拟页面。在最简单的情况下,此页面已经存在一个1级页面的表格,我们只需要输入一个即可。在最困难的情况下,该页面位于尚不存在3级的内存区域中,因此首先您需要创建3级,2级和1级的新表。让我们从一个不需要创建新表的简单案例开始。加载程序被加载到虚拟地址空间的第一个兆字节中,因此我们知道该区域有一个有效的1级表,例如,我们可以选择该存储区中任何未使用的页面,例如address处的页面0x1000
。我们0xb8000
将VGA文本缓冲区的帧用作所需的帧。检查我们的地址翻译的工作原理非常简单。我们create_maping
在模块的新功能中实现它memory
:
该函数接受对RecursivePageTable
(和将对其进行更改)和的可变引用,FrameAllocator
下面对此进行了说明。然后,它应用map_to
托盘中的功能Mapper
将页面映射到该地址0x1000
,并将物理框架映射到该地址0xb8000
。该函数不安全,因为使用无效参数可能会破坏内存安全性。除了参数page
和frame
功能map_to
有两个更多的参数。第三个参数是页表的标志集。我们PRESENT
为所有有效条目设置了必要的标志WRITABLE
,并为可写性设置了标志。第四个参数应该是实现该特征的某种结构FrameAllocator
。该方法需要此参数。map_to
因为创建新的页表可能需要未使用的框架。实现需要的参数特征Size4KiB
,如类型Page
和PhysFrame
是普遍的特点PageSize
,标准的4个KiB页面和具有巨大页2 MIB / 1吉布工作。该函数map_to
可能会失败,因此返回Result
。由于这只是一个不可靠的代码示例,因此expect
在发生错误时,我们只需使用panic 即可。如果成功,函数将返回一种类型MapperFlush
,该类型提供了一种从关联翻译缓冲区(TLB)方法中清除最近匹配的页面的简便方法flush
。喜欢Result
,该类型会使用该属性#[must_use]
,如果我们意外忘记应用该属性,则会发出警告。由于我们知道该地址0x1000
不需要新的页表,因此它FrameAllocator
可以始终返回None
。要测试功能,请创建以下代码EmptyFrameAllocator
:
(如果出现错误“方法allocate_frame
不是特征的成员FrameAllocator
”,则需要升级x86_64
到版本0.4.0。)现在我们可以测试新的翻译功能:
首先,我们在地址处为页面创建一个映射0x1000
,并create_example_mapping
使用指向实例的可变链接来调用该函数RecursivePageTable
。这0x1000
会将页面转换为VGA文本缓冲区,因此我们将在屏幕上看到一些结果。然后,我们在此页面中写入一个值,该值0xf021f077f065f04e
对应于“ New!”行。在白色背景上。只是不需要将此值立即写到页面顶部0x1000
,因为顶行将从屏幕上移出println
,并以0x900
大约位于屏幕中间的偏移量写入它。从“ VGA文本模式”一文中我们知道,写入VGA缓冲区应该是易失的,因此我们使用该方法write_volatile
。当我们在QEMU中运行它时,我们看到以下内容:屏幕上的题字。该代码有效,因为已经有一个1级表来显示该页面0x1000
。如果我们尝试转换尚不存在此类表的页面,该函数map_to
将返回错误,因为它将尝试从中选择框架以创建新的页面表EmptyFrameAllocator
。如果我们尝试翻译页面0xdeadbeaf000
而不是0x1000
:
启动时,会从以下错误消息开始恐慌: 惊慌于'map_to失败:FrameAllocationFailed',/.../result.rs:999haps
要显示尚无页面级别1表的页面,您需要创建正确的表FrameAllocator
。但是,您如何知道哪些帧是空闲的以及多少物理内存可用?开机资讯
不同的计算机具有不同的物理内存量,并且VGA等设备保留的不同区域也不同。只有BIOS或UEFI固件才能确切知道可以使用哪些存储区以及保留哪些存储区。两种固件标准都提供了获取内存分配卡的功能,但只能在下载开始时调用它们。因此,我们的引导加载程序已经从BIOS请求了此(和其他)信息。为了将信息传递给OS的内核,加载器在调用函数时作为参数_start
提供了指向启动信息结构的链接。将此参数添加到我们的函数中:
该结构BootInfo
仍在最终确定中,因此当升级到与semver不兼容的将来版本的bootloader时崩溃时,不要感到惊讶。他目前拥有三个领域p4_table_addr
,memory_map
并且package
:- 该字段
p4_table_addr
包含第4级页面的表的递归虚拟地址,因此,不必硬注册该地址0o_177777_777_777_777_777_0000
。
- 该字段
memory_map
最受关注,因为它包含所有内存区域及其类型(未使用,保留或其他)的列表。
- 该字段
package
是用于将其他数据与加载程序相关联的当前函数。实现尚未完成,因此我们暂时可以忽略它。
在使用该字段memory_map
创建正确的字段之前FrameAllocator
,我们要保证参数的类型正确boot_info
。巨集 entry_point
由于_start
是从外部调用的,因此不检查功能的签名。这意味着任意参数都不会导致编译错误,但可能导致崩溃或未定义的运行时行为。为了验证签名,bootloader
用于将Rust函数定义为入口点的板条箱使用entry_point
具有经过验证的类型的宏。我们为此宏重写函数:
对于入口点,您不再需要使用extern "C"
或no_mangle
,因为宏会设置真正的低层入口点_start
。该函数kernel_main
现在已成为完全正常的Rust函数,因此我们可以为其选择任意名称。重要的是它已经被键入,因此,如果您更改函数的签名(例如,通过添加参数或更改其类型),则会发生编译错误。请注意,现在我们正在发送到一个memory::init
硬编码的地址,但是boot_info.p4_table_addr
。因此,即使将来的引导加载程序版本在页面级别4的表表中选择另一个条目进行递归显示,代码也将起作用。选框
现在,由于有了BIOS中的信息,我们才可以访问内存分配卡,以便您可以制作普通的帧分配器。让我们从通用骨架开始:
该字段frames
由任意帧迭代器初始化。这使您可以简单地将调用委派alloc
给Iterator :: next方法。初始化BootInfoFrameAllocator
发生在一个新函数中init_frame_allocator
:
此函数使用组合器将原始内存分配图转换为使用的物理帧的迭代器:iter
MemoryRegion
. filter
, . , , (, ) , InUse
. , , - .
map
range Rust .
- 第三步是最困难的:使用方法将每个范围转换为一个迭代器
into_iter
,然后使用选择每个第4096个地址step_by
。由于页面大小为4096字节(4 KiB),因此我们获得了每个帧开始的地址。加载程序页面会对齐所有已用的内存区域,因此我们不需要对齐或舍入代码。更换map
上flat_map
,我们得到Iterator<Item = u64>
代替Iterator<Item = Iterator<Item = u64>>
。
- 在最后阶段,我们将起始地址转换为类型
PhysFrame
,以构建所需的地址Iterator<Item = PhysFrame>
。然后使用此迭代器创建并返回一个新的迭代器BootInfoFrameAllocator
。
现在,我们可以改变我们的功能kernel_main
广播它的实例BootInfoFrameAllocator
,而不是EmptyFrameAllocator
:
现在地址翻译成功了,我们再次在屏幕上看到黑白消息“ New!”。 。
在幕后,该方法map_to
创建丢失的页表,如下所示:- 从中提取未使用的帧
frame_allocator
。
- 将顶级表条目与此框架匹配。现在可以通过递归页表访问该框架。
- 将框架归零以创建一个新的空页表。
- 转到下一级表。
尽管我们的功能create_maping
只是一个示例,但是我们现在可以为任意页面创建新的映射。在以后的文章中分配内存和实现多线程时,这非常有用。总结
在本文中,您学习了如何使用4级递归表将所有帧转换为可计算的虚拟地址。我们使用此方法来实现地址转换功能并在页表中创建新的映射。我们看到,创建新映射需要新表的未使用框架。可以根据引导加载程序传递给我们内核的BIOS中的信息来实现这种帧分配器。接下来是什么
在下一篇文章中,我们将为内核创建一个堆内存区域,这将使我们能够分配内存并使用不同类型的集合。