ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从分段到分页:为 Rust OS 内核搭建 x86_64 内存保护基础(Paging Introduction)

从分段到分页:为 Rust OS 内核搭建 x86_64 内存保护基础(Paging Introduction) 文档教程技术博客操作系统【免费下载链接】blog_osWriting an OS in Rust项目地址https://gitcode.com/GitHub_Trending/bl/blog_os点击查看免费下载这篇技术指南是《Writing an OS in Rust》系列「内存管理」章节的第八篇围绕x86_64 分页Paging展开它先解释操作系统为什么需要内存隔离再对比分段Segmentation与分页两种硬件级方案随后深入拆解 x86_64 4 级页表的地址分解、条目格式与 TLB 缓存最后回到我们的 Rust 内核通过真实触发的页错误Page Fault实验验证分页下的内存保护行为。读完本文你将理解虚拟地址/物理地址的翻译机制掌握 x86_64 页表条目的完整位布局并能编写页错误处理函数定位非法内存访问。本文对应仓库源码分支为post-08全文代码与运行示例均以当前仓库blog_os为准。为什么操作系统需要内存保护操作系统的一项核心职责是隔离进程。比如浏览器不应干扰文本编辑器的内存反之亦然。为了做到这一点操作系统借助硬件能力确保某个进程的内存区域不能被其他进程访问。不同硬件与 OS 实现采用的方案各不相同。以嵌入式领域常见的 ARM Cortex-M 处理器为例它提供一种Memory Protection UnitMPU内存保护单元开发者可以定义少量例如 8 个内存区域并为每个区域指定访问权限无访问、只读、读写。CPU 在每次内存访问时都会校验目标地址是否落在权限正确的区域内否则抛出异常。操作系统在每次进程切换时改写这些区域与权限就能保证每个进程只能访问自己的内存。而在 x86 平台上硬件提供了两种不同的内存保护手段分段segmentation与分页paging。本系列博客的内核最终选用分页但理解分段的历史与缺陷有助于我们理解分页的设计动机。分段诞生于 1978 年的虚拟内存雏形分段最早出现于 1978 年其初衷是扩大可寻址内存范围。当时的 CPU 只使用 16 位地址最多只能寻址 64 KiB 内存。为了突破这一限制硬件引入了额外的段寄存器segment registers每个寄存器保存一个偏移地址CPU 在每次内存访问时自动把该偏移加到目标地址上从而把可寻址空间扩展到 1 MiB。段寄存器的选择由 CPU 根据访问类型自动决定取指令时使用代码段CS栈操作push/pop使用栈段SS其余指令使用数据段DS或附加段ES后来新增的FS与GS两个段寄存器可以自由使用。第一代分段中段寄存器直接存放偏移没有任何访问控制。随着protected mode保护模式的引入这一情况发生了变化保护模式下段描述符不再直接存放偏移而是存放一个索引指向本地或全局的descriptor table描述符表中的某个表项表项除了偏移地址外还包含段大小与访问权限。操作系统为每个进程加载独立的全局/本地描述符表把内存访问限制在进程自身区域内从而实现进程隔离。值得注意的是分段在真正访问内存之前先修改了地址——这一手法正是如今几乎无处不在的**虚拟内存virtual memory**技术的雏形。虚拟内存虚拟地址与物理地址虚拟内存的核心思想是把程序所见的地址与底层的物理存储设备解耦。程序不再直接访问存储设备而是先经过一层翻译。对分段而言翻译步骤就是把活动段的偏移地址加到目标地址上。举个例子假设一个程序访问地址0x1234000而它所在段的偏移为0x1111000那么真正被访问的物理地址是0x1234000 0x1111000 0x2345000。为了区分这两类地址翻译前的地址称为虚拟地址virtual address翻译后的地址称为物理地址physical address。两者有一个关键差异物理地址是唯一的始终指向同一个确定的内存位置虚拟地址则取决于翻译函数两个不同的虚拟地址完全可能指向同一个物理地址反过来相同的虚拟地址在不同翻译函数下也可能指向不同的物理地址。这一特性最典型的应用就是并行运行同一个程序。参考 segmentation-same-program-twice.svg同一份程序运行两次但使用不同的翻译函数——第一个实例段偏移为 100其虚拟地址 0–150 被翻译到物理地址 100–250第二个实例偏移为 300其虚拟地址 0–150 被翻译到物理地址 300–450。两个实例可以运行完全相同的代码、使用完全相同的虚拟地址却互不干扰。虚拟内存带来的另一个好处是程序可以被放置在任意的物理内存位置哪怕它使用的虚拟地址完全不同。这样操作系统就能利用全部可用内存而无需重新编译程序。碎片化分段的致命伤虚拟地址与物理地址的区分让分段变得强大但它有一个严重问题碎片化fragmentation。仍以前面的程序为例如果我们要再运行第三份副本可参考 segmentation-fragmentation.svg此时空闲内存总量足够但第三份程序找不到一段连续空间来放置——问题在于分段需要连续的内存块无法利用那些零散的小块空闲区域。对抗这种碎片化的一种办法是压缩defragmentation暂停程序执行、把内存中已使用的部分向一端靠拢、更新翻译函数、再恢复执行如 segmentation-fragmentation-compacted.svg 所示。压缩后腾出了足够连续空间可以启动第三份程序。但压缩的代价也很明显需要复制大量内存降低性能必须在内存过度碎片化之前定期执行程序会在随机时刻被暂停可能导致无响应性能变得不可预测。正是碎片化问题让分段逐渐被大多数系统弃用。事实上x86 的 64 位模式long mode已经不再支持分段取而代之的是能从根本上避免外部碎片化的分页。分页固定大小块的内存管理分页的思路是把虚拟内存空间与物理内存空间都划分为大小固定的块。虚拟内存空间的块称为页page物理地址空间的块称为帧frame。每一页都可以独立映射到任意一个帧因此可以把较大的内存区域拆分到物理上不连续的多个帧中。重新审视前面碎片化的例子但这次改用分页页大小为 50 字节每个内存区域被拆成 3 页见 paging-fragmentation.svg每页独立映射到某个帧一段连续的虚拟内存区域可以被映射到不连续的物理帧上因此第三份程序无需任何压缩就能启动。隐藏的碎片化内部碎片与分段使用少量大块变长区域不同分页使用大量等大小的固定小块。由于每个帧大小相同不存在太小无法使用的帧所以看起来没有碎片化。但碎片化并未消失只是换了一种形式被称为内部碎片化internal fragmentation并非每个内存区域都恰好是页大小的整数倍。仍以上面页大小为 50 的例子一个大小为 101 的程序仍需要 3 个大小为 50 的页因此多占用了 49 字节。为区分二者分段产生的碎片化被称为外部碎片化external fragmentation。内部碎片化固然浪费内存但通常优于分段的外部碎片化——它不需要压缩且浪费量可预测平均每个内存区域浪费半页。页表映射信息的存储结构潜在的数百万个页各自独立映射到帧这份映射信息必须存放在某处。分段为每个活动内存区域维护一个段选择子寄存器这对分页行不通——页的数量远超寄存器数量。因此分页采用一种表结构来存储映射信息即页表page table。仍用前面的例子三个程序实例的页表如 paging-page-tables.svg 所示每个程序实例拥有自己的页表当前活动页表的指针保存在专用 CPU 寄存器中在 x86 上这个寄存器叫CR3。操作系统负责在运行每个程序实例前把正确的页表指针装入CR3。每次内存访问时CPU 从CR3读出表指针再在页表中查找被访问页所映射的帧。整个过程完全由硬件完成对运行中的程序完全透明。为了加速翻译过程许多 CPU 架构都内置了特殊缓存用于记住最近几次翻译的结果下文会详细讨论 TLB。此外页表条目还能通过标志位字段存储访问权限等属性。例如上例中的r/w标志表示该页既可读又可写。多级页表根治内存浪费前面这种一张表对应整个地址空间的简单页表在较大地址空间中会浪费内存。设想一个程序只使用四个虚拟页0、1_000_000、1_000_050、1_000_100_为千位分隔符如 single-level-page-table.svg 所示。它只需要 4 个物理帧但页表却有超过一百万个条目。我们不能省略空条目否则 CPU 就无法在翻译过程中直接跳到正确的条目例如不再保证第 4 个页使用第 4 个条目。解决之道是使用两级页表two-level page table为不同的地址区域使用不同的一级页表再由一张额外的二级页表存放地址区域 → 一级页表的映射。用例子说明。规定每个一级页表负责大小为10_000的区域则上面的映射会生成如下结构见 multilevel-page-table.svg页0落在第一个10_000字节区域内使用二级页表的第 0 个条目该条目指向一级页表T1T1规定页0映射到帧0页1_000_000、1_000_050、1_000_100都落在第 100 个10_000字节区域内使用二级页表的第 100 个条目该条目指向另一张一级页表T2T2把这三个页映射到帧100、150、200。注意一级页表中的页地址不包含区域偏移例如页1_000_050在T2中的条目只是50。这样二级页表中仍有 100 个空条目但远比之前一百万个空条目少得多。节省的根源在于我们不必为10_000到1_000_000之间那些未映射的内存区域创建一级页表。两级页表的原理可以扩展到三级、四级甚至更多级页表寄存器指向最高级表最高级表指向下一级表依此类推最后一级页表才指向被映射的帧。这种通用结构被称为多级multilevel或层级hierarchical页表。理解分页与多级页表的基本原理后下面进入正题x86_64 架构如何实现分页以下假设 CPU 运行在 64 位模式。x86_64 架构下的分页x86_64 使用4 级页表页大小为4 KiB。每张页表无论处于哪一级都固定包含512 个条目每个条目占 8 字节因此每张表大小为512 × 8 B 4 KiB恰好填满一页。每级页表的索引直接从虚拟地址中解析得出见 x86_64-table-indices-from-address.svg位 0–1112 位页内偏移2^12 字节 4 KiB位 12–21一级页表索引位 21–30二级页表索引位 30–39三级页表索引位 39–48四级页表索引位 48–64被丢弃。每级索引恰好 9 位因为每张表有 2^9 512 个条目。位 48 到 64 被丢弃这意味着x86_64 并非真正的 64 位——它只支持 48 位地址。尽管位 48–64 被丢弃它们也不能随意取值该范围内的所有位必须是位 47 的拷贝以保持地址唯一并给未来的扩展如 5 级页表留出空间。这被称为符号扩展sign-extension与二进制补码的符号扩展非常相似。若地址未正确符号扩展CPU 会抛出异常。顺带一提较新的 Intel Ice Lake CPU 可选支持5 级页表把虚拟地址从 48 位扩展到 57 位。不过在这个阶段为特定 CPU 优化内核意义不大本系列统一使用标准的 4 级页表。示例翻译0x803FE7F5CE 的页表漫游来看一个完整的翻译例子页表层级见 x86_64-page-table-translation.svg。当前活动的四级页表4 级页表树的根的物理地址存放在CR3寄存器中。每个页表条目指向下一级表的物理帧一级页表的条目则指向被映射的帧。注意页表中所有地址都是物理地址而非虚拟地址——否则 CPU 还得再翻译这些地址本身可能造成无限递归。图中页表层级映射了两个页图中以蓝色标出。由页表索引可推断这两个页的虚拟地址是0x803FE7F000与0x803FE00000。现在假设程序尝试读取地址0x803FE7F5CE先把地址转换为二进制解析出页表索引与页内偏移见 x86_64-page-table-translation-addresses.png符号扩展位全为 0四级索引为 1三级索引为 0二级索引为 511一级索引为 127页内偏移为0x5ce从CR3寄存器读出四级表的地址四级索引为 1查看该表第 1 个条目得知三级表存放在地址16 KiB从该地址加载三级表查看索引 0 的条目它指向位于24 KiB的二级表二级索引为 511查看该页最后一个条目得到一级表的地址通过一级表索引 127 的条目最终得知该页映射到帧12 KiB即十六进制的0x3000最后把页内偏移加到帧地址上0x3000 0x5ce 0x35ce这就是物理地址。完整漫游路径可参考 x86_64-page-table-translation-steps.svg它标注了从CR3到四级表Step 0、四级表到三级表Step 1、三级表到二级表Step 2、二级表到一级表Step 3、一级表到被映射帧Step 4的全过程。例子中一级表里该页的权限标志是r只读。硬件会强制执行这些权限若程序尝试写入该页就会抛出异常。同时高层级页表的权限会限制低层级页表——如果把三级表条目设为只读那么所有经由该条目映射的页都不可写即使低层级表指定了读写权限也不行。值得注意的是尽管例子中每级只有一张表实际每个地址空间中每级通常有多个实例。理论上限为1 张四级表512 张三级表四级表有 512 个条目512 × 512 张二级表每张三级表有 512 个条目512 × 512 × 512 张一级表每张二级表有 512 个条目。页表格式8 字节条目的 64 位布局x86_64 的页表本质上就是 512 个条目的数组。用 Rust 语法表示#[repr(align(4096))] pub struct PageTable { entries: [PageTableEntry; 512], }repr属性表明页表必须按页对齐即 4 KiB 边界对齐。这一要求保证页表恰好填满一页并为下面提到的紧凑条目格式提供了优化空间。每个条目占 8 字节64 位完整格式如下位名称含义0present该页当前在内存中1writable允许写入该页2user accessible若未设置只有内核态代码可以访问该页3write-through caching写入直接到达内存4disable cache该页不使用缓存5accessedCPU 在该页被使用时自动置位6dirtyCPU 在发生写入时自动置位7huge page/null在 P1 和 P4 中必须为 0在 P3 中创建 1 GiB 页在 P2 中创建 2 MiB 页8global地址空间切换时不从缓存清除该页需设置 CR4 的 PGE 位9–11available操作系统可自由使用12–51physical address帧或下一级页表的页对齐 52 位物理地址52–62available操作系统可自由使用63no execute禁止在该页上执行代码需设置 EFER 的 NXE 位可以看到只有位 12–51 用来存储物理帧地址其余位要么是标志位要么留给操作系统自由使用。这之所以可行是因为我们总是指向4096 字节对齐的地址页对齐的页表或映射帧的起始位置因此位 0–11 恒为零硬件使用地址前直接补零即可无需存储。位 52–63 同理x86_64 只支持 52 位物理地址类似于只支持 48 位虚拟地址。逐一看一下这些标志的实际用途present区分已映射页与未映射页。当主存耗尽时操作系统可借它把页临时换出到磁盘之后访问该页会触发一个特殊的异常——页错误page fault操作系统据此从磁盘重新加载缺失页并继续运行程序。writable与no execute分别控制页内容是否可写、是否包含可执行指令。accessed与dirty由 CPU 在发生读取/写入时自动置位。操作系统可据此决定换出哪些页或判断页内容自上次保存到磁盘后是否被修改。write-through caching与disable cache允许对每一页单独控制缓存策略。user accessible使页对用户态代码可用否则只有 CPU 处于内核态时才能访问。利用它可以在运行用户程序时保持内核映射从而加速系统调用。不过 Spectre 漏洞可能让用户态程序借机读到这些页。global向硬件表明该页在所有地址空间中可用因此地址空间切换时无需从翻译缓存见下文 TLB中清除。它通常与清空的user accessible标志配合把所有地址空间都映射上内核代码。huge page让二级或三级页表的条目直接指向被映射的帧从而创建更大尺寸的页。置位后页大小按 512 倍增长二级条目为 2 MiB 512 × 4 KiB三级条目为 1 GiB 512 × 2 MiB。大页的优势在于更少的翻译缓存条目与更少的页表。好消息是x86_64crate 已经提供了页表与页表条目的类型我们不必自己手写这些结构。翻译后备缓冲TLB需要手动维护的翻译缓存4 级页表使虚拟地址翻译变得昂贵每次翻译都需要 4 次内存访问。为提升性能x86_64 架构把最近的若干次翻译结果缓存在所谓的**翻译后备缓冲translation lookaside bufferTLB**中缓存命中时可直接跳过翻译过程。与其他 CPU 缓存不同TLB并非完全透明当页表内容发生变化时它不会自动更新或清除翻译结果。这意味着内核每次修改页表后都必须手动更新 TLB。为此CPU 提供了专用指令invlpginvalidate page它会从 TLB 中移除指定页的翻译使下一次访问时重新从页表加载。也可以通过重新加载CR3寄存器模拟一次地址空间切换来整体刷新 TLB。x86_64crate 在tlb模块中为这两种方式都提供了 Rust 函数。务必牢记每次修改页表后都要刷新 TLB否则 CPU 可能继续使用旧的翻译结果导致难以调试的非确定性 bug。我们的内核其实已经在分页上运行前面铺垫了这么多理论回到我们的 Rust 内核它从启动起就运行在分页之上。在 《A Minimal Rust Kernel》 中引入的 bootloader 已经建立了一套 4 级分页层级把内核的每一页都映射到了对应的物理帧。bootloader 之所以必须这么做是因为x86_64 的 64 位模式下分页是强制的。这意味着我们内核中使用过的每个内存地址都是虚拟地址。例如访问0xb8000地址处的 VGA 缓冲区之所以能正常工作只是因为 bootloader 把虚拟页0xb8000**恒等映射identity mapped**到了物理帧0xb8000。分页已经让我们的内核相对安全任何越界的内存访问都会触发页错误异常而不是悄悄写入随机的物理内存。bootloader 还为每个页设置了正确的访问权限——只有包含代码的页可执行只有数据页可写。实验一触发页错误并解读错误码让我们故意访问内核之外的某块内存来触发页错误。首先编写页错误处理函数并注册到 IDT中断描述符表中这样我们看到的将是具体的页错误异常而不是笼统的双重错误double fault。IDT 的搭建方式与x86_64crate 提供的InterruptDescriptorTable类型在《CPU Exceptions》 中已有详细介绍这里直接复用// 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; use crate::hlt_loop; extern x86-interrupt fn page_fault_handler( stack_frame: InterruptStackFrame, error_code: PageFaultErrorCode, ) { use x86_64::registers::control::Cr2; println!(EXCEPTION: PAGE FAULT); println!(Accessed Address: {:?}, Cr2::read()); println!(Error Code: {:?}, error_code); println!({:#?}, stack_frame); hlt_loop(); }这里用到几个关键部件CR2寄存器发生页错误时由 CPU 自动写入存放导致错误的被访问虚拟地址。我们通过x86_64crate 的Cr2::read读取并打印。PageFaultErrorCode提供更多关于导致错误的访问类型的信息例如由读还是写触发。hlt_loop由于不解决页错误就无法继续执行我们在处理函数末尾进入hlt_loop在《Hardware Interrupts》 中定义内部循环执行x86_64::instructions::hlt()。然后在_start中访问一块内核之外的地址// in src/main.rs #[no_mangle] pub extern C fn _start() - ! { println!(Hello World{}, !); blog_os::init(); // new let ptr 0xdeadbeaf as *mut u8; unsafe { *ptr 42; } // as before #[cfg(test)] test_main(); println!(It did not crash!); blog_os::hlt_loop(); }运行后页错误处理函数被调用QEMU 输出如下图所示CR2寄存器确实包含了0xdeadbeaf——正是我们尝试访问的地址。错误码通过CAUSED_BY_WRITE告诉我们错误发生在一次写操作中而它还能通过未设置的位传达更多信息——例如PROTECTION_VIOLATION标志未被置位说明页错误的原因是目标页不存在而非权限违规。实验二读代码页可以写代码页不行从输出中还能看到当前指令指针为0x2031b2说明这个地址指向一个代码页。bootloader 把代码页映射为只读因此从该地址读取没问题写入则会触发页错误。把指针换成这个地址验证一下// Note: The actual address might be different for you. Use the address that // your page fault handler reports. let ptr 0x2031b2 as *mut u8; // read from a code page unsafe { let x *ptr; } println!(read worked); // write to a code page unsafe { *ptr 42; } println!(write worked);注释掉最后一行后运行可以看到read worked被打印出来读操作未出错但write worked没有出现取而代之的是一个页错误这一次错误码中PROTECTION_VIOLATION与CAUSED_BY_WRITE同时被置位表明页是存在的但该操作不被允许——代码页被映射为只读写入自然被拒。这个实验直观证明了分页下硬件强制执行的内存保护。实验三尝试访问页表——CR3 里的物理地址最后试着看看定义内核映射方式的页表长什么样// 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() }Cr3::read返回当前活动的四级页表返回值是一个由PhysFrame与Cr3Flags组成的元组这里只关心帧所以忽略元组第二个元素。运行后得到类似输出Level 4 page table at: PhysAddr(0x1000)当前活动的四级页表存放在物理地址0x1000PhysAddr包装类型表明这是物理地址。问题来了我们怎样才能从内核访问这张表分页激活后无法直接访问物理内存——否则程序就能轻易绕过内存保护访问其他程序的内存。唯一途径是经由某个映射到物理帧0x1000的虚拟页。为页表帧创建映射是一个普遍问题因为内核需要经常访问页表例如为线程分配新栈时。这个问题的解决方案将在下一篇博客中详细展开。总结本文介绍了两种内存保护技术分段使用可变大小的内存区域受外部碎片化困扰且在 x86 64 位模式下已不再受支持分页使用固定大小的页允许对访问权限进行更细粒度的控制。分页把页的映射信息存放在单级或多级页表中。x86_64 采用4 级页表、4 KiB 页大小硬件自动漫游页表并把翻译结果缓存在TLB中该缓存不会透明更新页表修改后必须手动刷新。我们还验证了内核从启动起就运行在分页之上非法内存访问会引发页错误异常。我们也尝试访问当前活动的页表但没有成功——因为CR3寄存器保存的是物理地址无法从内核直接访问。下一步Paging Implementation下一篇博客将介绍如何在内核中实现分页支持它会给出从内核访问物理内存的不同方法使我们可以访问内核运行其上的页表在此基础上我们就能实现虚拟地址到物理地址的翻译函数以及在页表中创建新映射的能力。这将为后续的堆分配Heap Allocation与分配器设计Allocator Designs铺平道路对应内容可见 edition-2 的内存管理章节。赞分享文档教程技术博客操作系统【免费下载链接】blog_osWriting an OS in Rust项目地址https://gitcode.com/GitHub_Trending/bl/blog_os点击查看免费下载相关推荐Linux x86_64 内核基础四级分页Paging与中断描述符表IDT从理论到源码Linux x86_64 内核基础四级分页Paging与中断描述符表IDT从理论到源码 本篇文章是开源书籍 linux insides 中 X86 章文档教程操作系统Linux 内核揭秘深入解析 x86_64 分页机制Paging TheoryLinux 内核揭秘深入解析 x86_64 分页机制Paging Theory 在深入学习 Linux 内核初始化流程之前必须先理解一个最核心的底层机制在 AArch64 上为 Rust 内核搭建异常处理框架以 rust-raspberrypi-OS-tutorials Tutorial 11 为例在 AArch64 上为 Rust 内核搭建异常处理框架以 rust raspberrypi OS tutorials Tutorial 11 为例 导读 本示例工程上一篇25章带你从加减乘除到机器学习《矩阵力量》快速入门指南下一篇5分钟跑通B站视频批量上传BilibiliUploader 完整指南创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表