我们在Rust上编写一个操作系统。 页面的内存组织

在本文中,我们介绍pages ,这是一种非常常见的内存管理方案,我们也将其应用于操作系统。 本文介绍了为什么需要内存隔离, 分段如何工作,什么是虚拟内存以及页面如何解决分段问题。 我们还探讨了x86_64体系结构中的多级页表的方案。

该博客发布在GitHub上 。 如果您有任何疑问或问题,请在此处打开相应的请求。

记忆保护


操作系统的主要任务之一是将程序彼此隔离。 例如,浏览器不应干扰文本编辑器。 有多种方法,具体取决于硬件和操作系统的实现。

例如,某些ARM Cortex-M处理器(在嵌入式系统中)具有一个内存保护单元 (MPU),该保护单元定义了少量(例如8个)具有不同访问权限(例如,无访问权限,只读,只读和只读)的内存区域。记录)。 每次访问内存时,MPU都会确保该地址位于具有正确权限的区域中,否则会引发异常。 通过更改范围和访问权限,操作系统确保每个进程只能访问其内存,以使进程彼此隔离。

在x86上,支持两种不同的保护内存的方法: 分段分页

细分


分段于1978年实施,最初是为了增加可寻址内存的数量。 当时,CPU仅支持16位地址,这将可寻址内存量限制为64 KB。 为了增加容量,引入了附加的段寄存器,每个段寄存器都包含一个偏移地址。 CPU会在每次访问内存时自动添加此偏移量,从而寻址多达1 MB的内存。

CPU根据存储器访问的类型自动选择一个段寄存器: CS代码段寄存器用于接收指令,而SS堆栈段寄存器用于堆栈操作(推入/弹出)。 其他指令使用DS数据段寄存器或可选的ES段寄存器。 后来,添加了两个额外的段寄存器FSGS供免费使用。

在分段的第一个版本中,寄存器直接包含偏移量,并且未执行访问控制。 随着保护模式的出现机制已经发生了变化。 当CPU在这种模式下运行时,段描述符将索引存储在本地或全局描述符表中,该表除偏移地址外还包含段大小和访问权限。 通过为每个进程加载单独的全局/本地描述符表,操作系统可以将进程彼此隔离。

通过在实际访问之前更改内存地址,分段实现了一种现在几乎在所有地方都使用的方法:它是虚拟内存

虚拟记忆体


虚拟内存的想法是从物理设备中提取内存地址。 代替直接访问存储设备,首先执行转换步骤。 在分段的情况下,活动段的偏移地址在转换阶段添加。 想象一个程序在一个偏移量为0x1111000的段中访问内存地址0x1111000 :实际上,该地址为0x2345000

为了区分两种类型的地址,转换前的地址称为虚拟 ,转换后的地址称为物理 。 它们之间有一个重要的区别:物理地址是唯一的,并且始终引用内存中相同的唯一位置。 另一方面,虚拟地址取决于翻译功能。 两个不同的虚拟地址可能会引用相同的物理地址。 另外,相同的虚拟地址可以在转换后引用不同的物理地址。

有效使用此属性的一个示例是两次并行启动同一程序:



在这里,同一程序运行两次,但转换功能不同。 第一个实例的段偏移量为100,因此其虚拟地址0-150转换为物理地址100-250。 第二个实例的偏移量为300,它将虚拟地址0-150转换为物理地址300-450。 这允许两个程序执行相同的代码并使用相同的虚拟地址,而不会互相干扰。

另一个优点是现在程序可以放在物理内存上的任意位置。 因此,OS使用了全部可用内存,而无需重新编译程序。

碎片化


虚拟地址和物理地址之间的差异是分段的真正成果。 但是有一个问题。 想象一下,我们想运行上面看到的程序的第三个副本:



尽管物理内存中的空间已绰绰有余,但第三个副本无法容纳在任何地方。 问题在于他需要连续的内存片段,而我们不能使用单独的空闲部分。

解决碎片的一种方法是暂停程序执行,将内存的已使用部分彼此靠近,更新转换,然后恢复执行:



现在有足够的空间启动第三个实例。

这种碎片整理的缺点是需要复制大量内存,从而降低了性能。 必须定期执行此过程,直到内存碎片过多。 性能变得不可预测,程序会随时停止并且可能会停止响应。

分段是大多数系统中未使用分段的原因之一。 实际上,即使在x86上的64位模式下也不再支持它。 代替分段,使用页面来完全消除分段问题。

页面的内存组织


这个想法是将虚拟和物理内存的空间分成固定大小的小块。 虚拟内存块称为页面,物理地址空间块称为帧。 每个页面分别映射到一个框架,这使您可以在不相邻的物理框架之间划分较大的内存区域。

如果您使用片段化的内存空间重复该示例,则优点变得显而易见,但是这次使用页面而不是分段:



在此示例中,页面大小为50个字节,即,每个存储区域都分为三个页面。 每个页面都映射到单独的帧,因此虚拟内存的连续区域可以映射到隔离的物理帧。 这使您可以在不进行碎片整理的情况下运行程序的第三个实例。

隐藏的碎片


与分段相比,分页组织使用许多小的固定大小的内存区域,而不是几个大的可变大小的区域。 每个帧的大小相同,因此不可能由于帧太小而造成碎片。

但这只是一种现象 。 实际上,存在一种隐藏的碎片形式,即所谓的内部碎片 ,这是因为并非每个内存区域都恰好是页面大小的倍数。 想象一下在上面的示例中,一个大小为101的程序:它仍将需要三个大小为50的页面,因此它将比您需要的多49个字节。 为了清楚起见,由于分段而导致的分段称为外部分段

内部碎片并没有什么好处,但是通常它比外部碎片要少。 仍然会消耗额外的内存,但是现在您不需要对其进行碎片整理,并且碎片量是可以预测的(平均每个内存区域半页)。

页表


我们看到,数百万个可能的页面中的每一个都分别映射到一个框架。 该地址转换信息需要存储在某处。 分段时,每个活动存储区都使用单独的分段寄存器,这在分页的情况下是不可能的,因为它们比寄存器多得多。 相反,它使用称为页表的结构。

对于上面的示例,表将如下所示:



如您所见,程序的每个实例都有其自己的页表。 指向当前活动表的指针存储在CPU的特殊寄存器中。 在x86它称为CR3 。 在启动程序的每个实例之前,操作系统必须在此处加载指向正确页表的指针。

每次访问存储器时,CPU都会从寄存器中读取表指针,并在表中搜索相应的帧。 这是一个完全硬件功能,对于正在运行的程序完全透明地运行。 为了加快处理速度,许多处理器体系结构都有一个特殊的缓存,该缓存可以记住最新转换的结果。

根据体系结构,权限等属性也可以存储在页表的标志字段中。 在上面的示例中, r/w标志使页面可读可写。

分层页表


简单页表存在较大的地址空间问题:浪费内存。 例如,程序使用四个虚拟页面1_000_0501_000_100 (我们使用_作为数字分隔符):



仅需要四个物理框架,但是页表中有超过一百万条记录。 我们不能跳过空的条目,因为在转换过程中,CPU将无法直接转到正确的条目(例如,不再保证第四页使用第四条条目)。

为了减少内存丢失,您可以使用两级组织 。 这个想法是我们在不同的区域使用不同的表。 另一个表称为第二级页表,它在地址区域和第一级页表之间进行转换。

最好通过示例来解释。 我们定义每个1级页表负责一个大小为10_000的区域。 然后在上面的示例中,将存在以下表格:



页0属于10_000字节的第一个区域,因此它将使用第二级页表中的第一条记录。 该条目指向第一级T1页面表,该表确定页面0引用帧0。

1_000_0001_000_100属于1_000_100的第100个字节区域,因此它们使用第2级页表的第100条记录,该记录指向另一个第一级表T2,该表将三页转换为帧10_000和200。第一级的表中的页面地址不包含区域偏移,因此,例如,页面1_000_050的记录仅为50

我们在第二级表中仍然有100个空条目,但这比前一百万个少得多。 节省的原因是,您不需要为10_0001_000_000之间10_000内存区域创建第一级页表。

两级表的原理可以扩展到三级,四级或更多级。 通常,这样的系统称为多级分层页表。

了解了页面组织和多级表之后,您可以看到如何在x86_64体系结构中实现页面组织(我们假定处理器以64位模式工作)。

x86_64上的页面组织


x86_64体系结构使用页面大小为4 KB的四级表。 无论级别如何,每个页表都有512个元素。 每个记录的大小为8个字节,因此表的大小为512×8字节= 4 KB。



如您所见,每个表索引包含9位,这很有意义,因为表具有2 ^ 9 = 512个条目。 最低的12位是4 KB页面偏移量(2 ^ 12字节= 4 KB)。 第48到64位被丢弃,因此x86_64实际上不是64位系统,而仅支持48位地址。 已经计划通过5级页表将地址大小扩展到57位,但是尚未创建这种处理器。

尽管丢弃了位48至64,但不能将其设置为任意值。 该范围内的所有位都必须是位47的副本,以保留唯一的地址并允许将来扩展到例如5级页表。 这被称为符号扩展,因为它与附加代码中的符号扩展非常相似。 如果地址扩展不正确,则CPU会引发异常。

转换范例


让我们看一个地址转换如何工作的示例:



级别4页面的当前活动页面表的物理地址(即该级别页面的页面根表)存储在CR3 。 然后,每个页表条目都指向下一级表的物理框架。 一级表条目指示显示的帧。 请注意,页表中的所有地址都是物理地址而不是虚拟地址,因为否则CPU将需要转换这些地址(这可能导致无限递归)。

上面的层次结构转换了两个页面(蓝色)。 从索引中,我们可以得出结论,这些页面的虚拟地址为0x803fe7f0000x803FE00000 。 让我们看看当程序尝试读取地址0x803FE7F5CE内存时会发生什么。 首先,将地址转换为二进制并确定页表索引和地址的偏移量:



使用这些索引,我们现在可以遍历页表的层次结构并找到相应的框架:

  • CR3读取第四级表的地址。
  • 第四级的索引为1,因此我们在此表中查看索引为1的记录。 她说,第3级表存储为16 KB。
  • 我们从该地址加载第三级表,并查看索引为0的记录,该索引指向24 KB的第二级表。
  • 第二级的索引是511,因此我们正在寻找该页面上的最后一条记录,以找出第一级表的地址。
  • 从第一级表中索引为127的记录中,我们最终发现该页面对应于一个12 KB帧或十六进制格式的0xc000。
  • 最后一步是向帧地址添加偏移量以获得物理地址:0xc000 + 0x5ce = 0xc5ce。



对于第一级表中的页面,指定了r标志,即,仅允许读取。 如果我们尝试在硬件级别记录,则会在硬件级别引发异常。 较高级表的权限扩展到较低级,因此,如果我们在第三级上设置了只读标志,那么即使在该处指示了允许写入的标志,也不会写入任何后续的较低级页。

尽管此示例仅使用每个表的一个实例,但是通常在每个地址空间中每个级别都有多个实例。 最大值:

  • 第四层一张桌子
  • 第三级的512个表(由于第四级的表中有512条记录),
  • 512 * 512个第二级表(因为每个第三级表都有512个条目),并且
  • 第一级的512 * 512 * 512个表(第二级的每个表有512条记录)。

页表格式


在x86_64体系结构中,页表本质上是512个条目的数组。 在Rust语法中:

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

repr属性所示,表格应在页面上对齐,即4 KB边框。 此要求确保表格始终以最佳方式填充整个页面,从而使条目非常紧凑。

每条记录的大小为8字节(64位),格式如下:

职称价值
0现在内存中的页面
1个可写的允许记录
2用户可访问如果未设置该位,则只有内核有权访问该页面
3通过缓存写直接写入内存
4禁用缓存禁用此页面的缓存
5已访问使用页面时,CPU将该位置位。
6脏的写入页面时,CPU将该位置位
7大页面/ nullP1和P4中的零位在P3中创建1 KB页面,在P2中创建2 MB页面
8全球性切换地址空间时,页面未从高速缓存中填充(必须将CR4寄存器的PGE位置1)
9-11可用的操作系统可以自由使用它们
12-51实际地址页或下一页页的页面对齐的52位物理地址
52-62可用的操作系统可以自由使用它们
63不执行禁止在此页面上执行代码(必须在EFER寄存器中将NXE位置1)

我们看到只有位12-51用于存储帧的物理地址,其余位用作标志或可由操作系统自由使用。 这是可能的,因为我们总是指向一个4096字节对齐的地址,或者指向表格的对齐页,或者指向相应帧的开头。 这意味着位0-11始终为零,因此无法存储它们,只需在使用地址之前将它们复位为硬件级别即可。 由于x86_64体系结构仅支持52位物理地址(并且仅支持48位虚拟地址),因此对52-63位也是如此。

让我们仔细看看可用的标志:

  • present标志将显示的页面与未显示的页面区分开。 当主内存已满时,它可用于将页面临时保存到磁盘。 下次访问该页面时,将发生特殊的PageFault异常,操作系统通过从磁盘交换页面来响应该异常,该程序继续工作。
  • writableno execute标志分别确定页面内容是可写还是包含可执行指令。
  • 读取或写入页面时,处理器会自动设置accessed标志和dirty标志。 操作系统可以使用此信息,例如,它是否交换页面,或者在检查自上次泵入磁盘以来页面的内容是否已更改时。
  • write through cachingdisable cache标志使您可以分别管理每个页面的缓存。
  • user accessible标志使页面可从用户空间访问代码,否则它仅对内核可用。 此功能可用于加快系统调用的速度,同时在用户程序运行时维护内核的地址映射。 但是, Spectre漏洞允许程序从用户空间读取这些页面。
  • global , (. TLB ) (address space switch). user accessible .
  • huge page , 2 3 . 512 : 2 = 512 × 4 , 1 = 512 × 2 . .

x86_64体系结构定义了页表及其记录的格式,因此我们不必自己创建这些结构。

关联转换缓冲区(TLB)


由于有四个级别,每个地址转换都需要四个内存访问。出于性能原因,x86_64将最后的几个转换缓存在所谓的关联转换缓冲区(TLB)中。如果转换仍在高速缓存中,则可以跳过该转换。

与其他处理器缓存不同,TLB并非完全透明,在更改页表的内容时不会更新或删除转换。这意味着内核在修改页表时必须更新TLB本身。为此,有一条特殊的CPU指令invlpg(无效页面),该指令从TLB中删除指定页面的转换,以便下次从页面表中再次加载该页面时使用。通过重新加载寄存器可以完全清除TLBCR3模拟地址空间切换。这两个选项都可以通过Rust中tlb模块获得。

重要的是不要忘记在每次更改页表之后都清理TLB,否则CPU将继续使用旧的转换,这将导致难以预测的错误,这些错误很难调试。

实作


我们没有提到一件事:我们的核心已经支持页面组织。文章“ Rust上的最小内核”中的引导程序已经建立了一个四级层次结构,该层次结构将内核的每个页面映射到一个物理框架,因为在x86_64上64位模式下需要页面组织。

这意味着在我们的核心中,所有内存地址都是虚拟的。在访问VGA缓冲器0xb8000能工作是因为加载器标识符广播当前页在存储器中,即对比虚拟页0xb8000到物理帧0xb8000

多亏了页面组织,内核已经相对安全:超出允许内存的每次访问都会导致页面错误,并且不允许写入物理内存。加载程序甚至为每个页面设置正确的访问权限:只有带有代码的页面才是可执行的,只有带有数据的页面才可以写入

页面错误(PageFault)


让我们尝试通过访问内核外部的内存来调用PageFault。首先,创建一个错误处理程序并将其注册到我们的IDT中,以查看特定的异常而不是常规类型双重错误

 // 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将自动设置case CR2。它包含导致失败的页面的虚拟地址。要读取并显示此地址,请使用功能Cr2::read。通常,该类型PageFaultErrorCode会提供有关导致错误的内存访问类型的更多信息,但是由于LLVM错误,将传输无效的错误代码,因此我们暂时将忽略此信息。在解决页面错误之前,无法继续执行程序,因此请在末尾插入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,因此我们知道该地址指向代码页。代码页由只读加载器显示,因此从该地址读取有效,并且写入将导致错误。尝试将指针更改0xdeadbeaf0x20430a

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

如果我们注释掉最后一行,则可以确保阅读有效,并且书写会导致PageFault错误。

访问页表


现在看一下内核的页表:

 // in src/main.rs #[cfg(not(test))] #[no_mangle] pub extern "C" fn _start() -> ! { 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()); […] } 

Cr3::readfrom 函数x86_64寄存器返回CR3第四级页面当前活动表。回到一对夫妇PhysFrameCr3Flags。我们只对第一个感兴趣。

启动后,我们看到以下结果:

Level 4 page table at: PhysAddr(0x1000)

因此,目前,第四级页面的活动表存储在物理存储器中0x1000的type所指示的地址处PhysAddr。现在的问题是:如何从内核访问该表?

通过页面组织,不可能直接访问物理内存,否则程序将能够轻松绕过保护并获得对其他程序内存的访问权限。因此,获得访问权的唯一方法是通过某个虚拟页面,该页面在以下位置转换为物理帧:0x1000。这是一个典型的问题,因为内核应定期访问页表,例如,在为新线程分配堆栈时。

下一篇文章将详细描述该问题的解决方案。现在,让我们只说加载器使用一种称为递归页表的方法。虚拟地址空间的最后一页是0xffff_ffff_ffff_f000,我们用它来读取此表中的一些条目:

 // in src/main.rs #[cfg(not(test))] #[no_mangle] pub extern "C" fn _start() -> ! { 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); } […] } 

我们已将最后一个虚拟页面的地址减小为的指针u64。如上一节所述,每个页表条目的大小为8个字节(64位),因此u64恰好表示一个条目。使用循环,for我们显示表的前10条记录。在循环内部,我们使用一个不安全的块直接从指针读取并 offset计算指针。

开始后,我们看到以下结果:



根据该格式,如上所述,值0x2023具有记录装置标志0 presentwritableaccessed并翻译成一帧0x2000。记录1在该帧中广播,0x6e2000并且具有相同的标志,再加上dirty。条目2–9丢失,因此这些虚拟地址范围未映射到任何物理地址。

您可以使用以下类型PageTable代替直接使用不安全的指针x86_64

 // in src/main.rs #[cfg(not(test))] #[no_mangle] pub extern "C" fn _start() -> ! { 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]); } […] } 

0xffff_ffff_ffff_f000 , Rust. - , , . &PageTable , , .

x86_64 , :



— 0 1 3. , 0x2000 0x6e5000 , . .

总结


本文介绍了两种保护内存的方法:分段和页面组织。第一种方法使用可变大小的内存区域并遭受外部碎片的困扰,第二种方法使用固定大小的页面并允许对访问权限进行更精细的控制。

页面组织将页面翻译信息存储在一个或多个级别的表中。 x86_64体系结构使用页面大小为4 KB的四级表。设备自动绕过页表,并将转换结果缓存在关联的转换缓冲区(TLB)中。更改页表时,应强制将其清除。

我们了解到我们的核心已经支持页面组织,并且对内存的未授权访问会丢弃PageFault。我们试图访问当前活动的页表,但由于页地址存储了物理地址,因此仅访问了第四级表,因此我们无法直接从内核访问它们。

接下来是什么?


下一篇文章基于我们现在已经了解的基本基础。为了从内核访问页表,一种称为递归页表的高级技术用于遍历表层次结构并实现程序化地址转换。本文还介绍了如何在页表中创建新的翻译。

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


All Articles