为何指定 TRAMPOLINE 和 TRAPFRAME 在 va 的最高位? TRAMPOLINE 和 TRAPFRAME 被定义在最高的虚拟内存地址上,是因为它们在操作系统的内存布局中起着重要作用。 TRAMPOLINE 被用作从用户模式切换到内核模式的跳转目标。当发生异常或中断时,处理器将从用户模式切换到内核模式,并将控制权转移到内核中预定义的位置,也就是陷阱处理程序。TRAMPOLINE 页面被映射到最高虚拟地址,以便处理器能够在这个转换过程中方便地引用它。通过将其放置在最高地址,确保了无论系统的具体内存布局如何,它始终是可访问的。 另一方面,TRAPFRAME 用于在发生异常或中断时存储机器状态。它包含寄存器、标志和其他操作系统处理异常所需的信息。TRAPFRAME 也被放置在最高的虚拟地址上,以确保它易于访问,并且陷阱处理程序可以高效地访问它。 通过将 TRAMPOLINE 和 TRAPFRAME 定义在最高的虚拟内存地址上,内核可以方便而可靠地处理异常和中断,而无需关心它们在内存中的特定位置。
如何确定分页方案 - satp
在 MMU 没有使能的情况下,虚拟地址和物理地址是相同的。在 MMU 使能的情况下,虚拟地址会被转换成物理地址。这个转换过程是由操作系统来管理的,操作系统需要维护一个数据结构来记录虚拟地址和物理地址的映射关系。这个数据结构就是页表。
转换的过程需要分页机制,分页机制有多种。RISC-V 的分页方案以 SvX 的模式命名,其中 X 是以位为单位的虚拟地址的长度。在 RV64 架构下,RISC-V 支持多种分页方案,包括 Sv39,Sv48,Sv57 以及 Sv64。Sv39 最大支持 39 位的虚拟地址,这意味着它可以支持 512 GB 的虚拟地址空间。Sv48 最大支持 48 位的虚拟地址,这意味着它可以支持 256 TB 的虚拟地址空间。我们将在本章中实现 Sv39 分页方案。
如何开启分页机制呢?RISC-V 的分页机制是通过 satp(Supervisor address translation and protection)寄存器来开启的。satp 寄存器字段分布如下:
- Mode 字段可以决定是否开启分页以及分页级数。Mode=0 时,不开启分页;Mode=8 时,开启 Sv39 分页机制。
- ASID(Address Space Identifier,地址空间标识符)域是可选的,它可以用来降低上下文切换的开销。目前我们暂不考虑这个字段的作用。
- PPN(Physical Page Number,物理页号),保存了根页表的物理地址。
SV39 多级页表机制
页表项描述
Sv39 页表项(page-table entry,PTE)的布局,从左到右分别包含如下所述的域:
- V 位决定了该页表项的其余部分是否有效 (V=1 时有效)。若 V=0,则任何遍历到此页表项的虚址转换操作都会导致页错误。
- R、W 和 X 位分别表示此页是否可以读取、写入和执行。如果这三个位都是 0,那么这个页表项是指向下一级页表的指针,否则它是页表树的一个叶节点。
- U 位表示该页是否是用户页面。若 U=0,则 U 模式不能访问此页面,但 S 模式可以。若 U=1,则 U 模式下能访问这个页面,而 S 模式不能。
- G 位表示这个映射是否对所有虚址空间有效,硬件可以用这个信息来提高地址转换的性能。这一位通常只用于属于操作系统的页面。
- A 位表示自从上次 A 位被清除以来,该页面是否被访问过。
- D 位表示自从上次清除 D 位以来页面是否被弄脏(例如被写入)。
- RSW 域留给操作系统使用,它会被硬件忽略。
- PPN 域包含物理页号,这是物理地址的一部分。若这个页表项是一个叶节点,那么 PPN 是转换后物理地址的一部分。否则 PPN 给出下一节页表的地址。
虚拟地址转换物理地址过程
当 satp 寄存器中开启分页时,S 模式和 U 模式中访存的地址都会被视为虚拟地址,需要将其转换为物理地址。虚拟地址转换物理地址的过程如下:
- 从 satp 寄存器中读取 PPN,得到根页表的物理地址,为了表述方便,我们将其记做三级页表基地址 satp.PPN;
- 从虚拟地址中取出三级虚拟页号 L2
- 处理器会读取地址位于 satp.PPN * 4096 + L2 * 4 的页表项,得到下一级页表的基地址 L1.PPN;
- 从虚拟地址中取出二级虚拟页号 L1
- 处理器会读取地址位于 L1.PPN * 4096 + L1 * 4 的页表项,得到下一级页表的基地址 L0.PPN;
- 从虚拟地址中取出一级虚拟页号 L0
- 处理器会读取地址位于 L0.PPN * 4096 + L0 * 4 的页表项,得到物理页号 PPN;
- 将 PPN 和虚拟地址的低 12 位也就是 Offset 拼接起来,得到物理地址。
我们看代码中是如何实现的:
#define PTE2PA(pte) (((pte) >> 10) << 12)
// 从虚拟地址中提取三个 9 位的页表索引
#define PXMASK 0x1FF // 9
// PGSHIFT = 12,这段宏定义用于定位 VPNx 的位置
#define PXSHIFT(level) (PGSHIFT + (9 * (level)))
// 从虚拟地址 VA 中提取出第 level 级页表的索引
#define PX(level, va) ((((uint64)(va)) >> PXSHIFT(level)) & PXMASK)
上面这三个工具宏可以用来提取虚拟页号 VPN。
// 返回页表 pagetable 中与虚拟地址 va 对应的 PTE 的地址。
// 如果 alloc != 0,则创建所需的页表页。
//
// RISC-V Sv39 方案有三级页表页。一个页表页包含 512 个 64 位的 PTEs。
// 一个 64 位的虚拟地址被分为五个字段:
// 39..63 -- 必须为零。
// 30..38 -- 2 级索引的 9 位。
// 21..29 -- 1 级索引的 9 位。
// 12..20 -- 0 级索引的 9 位。
// 0..11 -- 页面内的 12 位字节偏移量。
// pagetable 页表
// va 虚拟地址
// alloc 页表项不存在时是否分配
pte_t *walk(pagetable_t pagetable, uint64 va, int alloc)
{
if (va >= MAXVA)
panic("walk");
for (int level = 2; level > 0; level--) {
pte_t *pte = &pagetable[PX(level, va)];
// 通过 PTE 的标志位判断每一级的 pte 是否是有效的(V 位)
if (*pte & PTE_V) {
pagetable = (pagetable_t)PTE2PA(*pte);
} else {
// 如果该项无效且 alloc 标志被设置,则分配一个新的页表
// 如果 alloc 参数=0 或者已经没有空闲的内存了,那么遇到中途 V=0 的 pte 整个 walk 过程就会直接退出
if (!alloc || (pagetable = (pde_t *)kalloc()) == 0) {
return 0;
}
// 清空分配的页表
memset(pagetable, 0, PGSIZE);
// 更新页表项,将其指向新分配的页表,并设置有效位 PTE_V
*pte = PA2PTE(pagetable) | PTE_V;
}
}
// 返回最低级和虚拟地址的页表项,不是返回物理地址
return &pagetable[PX(0, va)];
}
每次从虚拟地址 va 中提取出一个虚拟页号,然后根据这个虚拟页号从页表中取出下一级页表的基地址。如果这个页表项无效,那么根据 alloc 参数决定是否分配一个新的页表。如果 alloc 参数为 0 或者已经没有空闲的内存了,那么遇到中途 V=0 的 pte 整个 walk 过程就会直接退出。如果 alloc 参数为 1,那么就会分配一个新的页表,然后将这个页表项指向新分配的页表,并设置有效位 PTE_V。
我们可以发现 walk 返回的结果不是物理地址,而是页表项的地址。这是因为 walk 函数的作用是将虚拟地址转换为物理地址,而页表项中的 PPN 只是物理地址的一部分,还需要加上虚拟地址的低 12 位偏移量才能得到物理地址。
如何建立页表
前面的过程实际上是以用户的角度来考虑的,也就是给你一个虚拟地址按照分页的规则将其转化成物理地址就能访问了。但是作为一个操作系统,我们还需要多考虑一下,页表是哪来的?我们知道从虚拟地址中去获取页表地址,但是页表的内容是哪来的呢?页表是如何建立起来的呢?这些是需要操作系统来完成的。
建立页表也就是建立虚拟地址到物理地址的映射关系。也就是给你一个虚拟地址,你需要告诉我如何查到物理地址,实际上这个过程就是建立页表的过程。这个过程也是通过 walk 函数来完成的,从上文我们知道如果页表都建好的情况下 walk 就是不断查页表的过程,那么在没有页表的情况下,walk 还可以建立一个个页表。稍有不同的是,walk 返回的是最后一级页表项的地址,我们需要将物理地址写入这个页表项中。
在 uCore 中使用 mappages 函数封装了 walk 函数,具体如下:
#define PA2PTE(pa) ((((uint64)pa) >> 12) << 10)
/**
* 为从虚拟地址 va 开始的页面创建指向物理地址 pa 开始的页表项(PTE)
* 注意:va 和 size 可能不是页面对齐的
* 如果无法分配所需的页表,则返回 0,否则返回 -1
*
* @param pagetable 根页表地址
* @param va 虚拟地址
* @param size 映射的字节数
* @param pa 物理地址
* @param perm 权限位
* @return 成功返回 0,否则返回 -1
*/
int mappages(pagetable_t pagetable, uint64 va, uint64 size, uint64 pa, int perm)
{
uint64 virtualAddress, lastVirtualAddress;
pte_t *pte;
// 地址必须是页面对齐的
virtualAddress = PGROUNDDOWN(va);
lastVirtualAddress = PGROUNDDOWN(va + size - 1);
for (;;) {
// 返回最低级的虚拟地址的页表项,如果不存在会创建一个新的页表项
// 页表项可能会因为内存不足创建失败,如果创建失败,则返回 -1
if ((pte = walk(pagetable, virtualAddress, 1)) == 0) {
return -1;
}
// 如果 PTE 已经有效,则输出错误信息并返回 -1
if (*pte & PTE_V) {
errorf("remap");
return -1;
}
// 将物理地址 pa 转换为页表项,并设置权限位 perm 和 有效位 PTE_V
*pte = PA2PTE(pa) | perm | PTE_V;
// 如果当前是最后一个地址,则结束循环
if (virtualAddress == lastVirtualAddress) {
break;
}
virtualAddress += PGSIZE;
pa += PGSIZE;
}
return 0;
}
问答作业
请列举 SV39 页表页表项的组成,结合课堂内容,描述其中的标志位有何作用/潜在作用?
Sv39 页表页表项的组成如下:
- 有效位 (V):这是页表项的最高位,用于指示页表项是否有效。如果有效位设置为 1,表示页表项有效,可以使用;如果设置为 0,表示页表项无效,禁止使用。这是虚拟内存中页表项的基本有效性标志。
- 写入位 (W):这个标志位用于指示是否可以对此页进行写入操作。如果设置为 1,表示允许写入;如果设置为 0,表示禁止写入。它是页表项的访问权限控制标志之一。
- 用户位 (U):用户位用于指示是否允许用户态程序访问此页。如果设置为 1,表示允许用户态访问;如果设置为 0,表示只允许内核态访问。它是页表项的访问权限控制标志之一。
- 执行位 (X):执行位用于指示是否允许执行此页上的指令。如果设置为 1,表示允许执行;如果设置为 0,表示禁止执行。它也是页表项的访问权限控制标志之一。
- 全局位 (G):全局位用于指示此页是否是全局的,即无需 TLB 缓存,通常用于内核页。如果设置为 1,表示是全局的;如果设置为 0,表示不是全局的。
- 已访问位 (A):已访问位表示是否已经访问过此页,通常由硬件设置。操作系统可以用它来实现页面置换算法,如 LRU。
- 已修改位 (D):已修改位表示是否已经对此页进行了写入操作。与已访问位类似,操作系统可以用它来实现页面置换算法。
- 物理页框地址 (PPN):这是页表项中存储的物理页框的地址。它指示了虚拟页到物理页的映射关系。
Sv39 页表的页表项标志位允许操作系统和硬件实现对虚拟内存的细粒度控制和保护。不同的标志位组合可以实现不同级别的内存保护和权限控制,从而提高系统的安全性和可用性。例如,有效位、写入位、用户位和执行位的不同组合可以实现不同级别的内存保护,使操作系统可以将不同的内存区域分配给用户态和内核态,并设置不同的权限。已访问位和已修改位则用于实现页面置换算法,帮助操作系统决定哪些页面应该被置换出去,以优化内存利用率。全局位可以用于标识全局共享的页,从而节省 TLB 缓存空间。物理页框地址是页表项的核心,它建立了虚拟地址到物理地址的映射关系,使虚拟内存管理成为可能。
缺页相关问题
请问哪些异常可能是缺页导致的?
缺页异常是由于进程访问的页面不在页表中或者在页表中无效而引发的异常。以下这些异常可能是因为缺页导致的:
Load Page Fault(Load 异常):当进程试图读取一个不在页表中或者无效的页面时,会引发 Load Page Fault 异常。在 RISC-V 中,这个异常对应的异常代码是 5。
Store Page Fault(Store 异常):当进程试图写入一个不在页表中或者无效的页面时,会引发 Store Page Fault 异常。在 RISC-V 中,这个异常对应的异常代码是 7。
Instruction Page Fault(指令页异常):当进程试图执行一个不在页表中或者无效的页面上的指令时,会引发 Instruction Page Fault 异常。在 RISC-V 中,这个异常对应的异常代码是 12。
发生缺页时,描述相关的重要寄存器的值(lab2 中描述过的可以简单点)。
- sepc(Exception Program Counter):trap 发生时会将当前指令的下一条指令地址写入其中,用于 trap 处理完成后返回。
- stval(Machine Trap Value):mtval 寄存器包含导致异常的原因,即导致异常的指令的具体信息。例如,如果是缺页异常,那么 mtval 寄存器包含导致缺页异常的虚拟地址。
- scause: 中断/异常发生时, CSR 寄存器 scause 中会记录其信息, Interrupt 位记录是中断还是异常, Exception Code 记录中断/异常的种类。
- sstatus: 记录处理器当前状态,其中 SPP 段记录当前特权等级。
- stvec: 记录处理 trap 的入口地址,现有两种模式 Direct 和 Vectored 。
- sscratch: 其中的值是指向hart相关的S态上下文的指针,比如内核栈的指针。
以下行为的好处?
缺页有两个常见的原因,其一是 Lazy 策略,也就是直到内存页面被访问才实际进行页表操作。比如,一个程序被执行时,进程的代码段理论上需要从磁盘加载到内存。但是 os 并不会马上这样做,而是会保存 .text 段在磁盘的位置信息,在这些代码第一次被执行时才完成从磁盘的加载操作。
Lazy Loading 策略有以下好处:
- 减少初始化开销:Lazy Loading 允许操作系统在程序启动时只加载必需的页面,而不是一次性加载整个程序。这可以减少启动时间和初始化开销,因为不需要将整个程序加载到内存中。
- 节省内存:Lazy Loading 策略避免了不必要的内存占用。如果程序的某些部分从不被访问,那么它们就不会被加载到内存中,从而节省了内存资源。
- 提高响应速度:通过仅在需要时加载页面,Lazy Loading 可以提高系统的响应速度。只有当程序访问某个页面时,操作系统才会执行磁盘加载操作,而不会在程序启动时浪费时间加载可能永远不会被访问的内容。
- 更好的磁盘利用率:Lazy Loading 允许操作系统将程序的不同部分分散在磁盘上,根据需要加载。这可以提高磁盘利用率,因为不需要在磁盘上为整个程序分配连续的空间。
请问处理 10G 连续的内存页面,需要操作的页表实际大致占用多少内存 (给出数量级即可)?
此外 COW(Copy On Write) 也是常见的容易导致缺页的 Lazy 策略,这个之后再说。其实,我们的 mmap 也可以采取 Lazy 策略,比如:一个用户进程先后申请了 10G 的内存空间,然后用了其中 1M 就直接退出了。按照现在的做法,我们显然亏大了,进行了很多没有意义的页表操作。
处理 10GB 连续的内存页面所需的页表实际上占用的内存量取决于操作系统的页表结构和管理策略。在 RISC-V 的页表结构中,一个页表项(Page Table Entry,PTE)通常占据 8 字节(64 位系统),其中包括物理页框号和一些标志位。让我们假设一个 PTE 占用 8 字节。
为了估算 10GB 连续内存页面所需的页表实际占用内存量,我们可以按照以下步骤进行计算:
首先,将 10GB 转换为字节数。1GB 等于 1,073,741,824 字节,所以 10GB 等于 10 * 1,073,741,824 = 10,737,418,240 字节。
然后,计算每个页面表项覆盖的内存范围。假设每个页面表项管理 4KB(4 * 1024 字节)的内存页面。
计算需要多少个页面表项来管理 10GB 的内存。这可以通过将 10GB 除以每个页面表项管理的内存范围来实现。
最后,将所需的页面表项数量乘以每个 PTE 的大小来估算所需的总内存量。
让我们进行具体计算:
- 内存大小:10,737,418,240 字节
- 每个页面表项管理的内存范围:4KB = 4 * 1024 字节
- 需要的页面表项数量:10,737,418,240 字节 / 4KB = 2,621,440 个页表项
假设每个页表项占用 8 字节,则需要的总内存量为:
2,621,440 个页表项 * 8 字节/页表项 = 20,971,520 字节
所以,处理 10GB 连续的内存页面所需的页表实际占用内存量约为 20,971,520 字节,或者大约 20MB。这只是一个估算,实际内存占用可能会因操作系统的管理策略和对齐等因素而有所不同。
请简单思考如何才能在现有框架基础上实现 Lazy 策略,缺页时又如何处理?描述合理即可,不需要考虑实现。
要在现有框架基础上实现 Lazy 策略,可以采取以下简单思路:
延迟加载(Lazy Loading):在用户进程请求内存映射时,不立即将整个内存区域加载到物理内存中。而是仅创建虚拟内存映射和页表项,记录对应的磁盘位置等信息。
缺页处理(Page Fault Handling):当用户进程访问虚拟内存中的某个尚未加载的内存页面时,会触发缺页异常。在缺页异常处理程序中,操作系统会根据页表中的磁盘位置信息,将相应的磁盘数据加载到物理内存中,并更新页表项,使其指向新加载的物理页面。
惰性加载(Demand Paging):为了提高性能,可以采用惰性加载策略,即只加载实际被访问的内存页面,而不是一次性加载整个区域。这可以通过在缺页处理程序中进行懒加载操作来实现。
内存回收(Memory Reclamation):当系统内存不足时,操作系统可以选择回收一些不常访问的内存页面,将其写回磁盘,并更新页表项为无效。这需要根据页面访问模式和策略来确定哪些页面可以被回收。
性能优化:为了提高性能,可以采用预读取(Prefetching)策略,即在缺页处理时,不仅加载当前访问的页面,还预先加载相邻的页面,以减少未来可能的缺页次数。
此时页面失效如何表现在页表项 (PTE) 上?
缺页的另一个常见原因是 swap 策略,也就是内存页面可能被换到磁盘上了,导致对应页面失效。
Dirty bit (D 位):当页面被修改并且尚未写回到主存时,该位会被设置为 1。如果页面已经被换出到磁盘上,D 位将保持为 1,以指示页面数据已过期。
Valid bit (V 位):当页面在主存中有效时,V 位被设置为 1。如果页面被换出到磁盘上,V 位将被清除为 0,表示该页无效。
通过检查页表项的 D 位和 V 位,操作系统可以确定页面是否需要从磁盘重新加载到内存中。如果 D 位为 1,说明页面需要写回到主存,在将其置为有效之前,必须将页数据从磁盘读取到内存中。如果 V 位为 0,说明页面当前无效,需要将其从磁盘加载到内存中,并将 V 位设置为 1,表示页面有效。
双页表与单页表
为了防范侧信道攻击,我们的 os 使用了双页表。但是传统的设计一直是单页表的,也就是说,用户线程和对应的内核线程共用同一张页表,只不过内核对应的地址只允许在内核态访问。请结合课堂知识回答如下问题:(备注:这里的单/双的说法仅为自创的通俗说法,并无这个名词概念,详情见 KPTI )
单页表情况下,如何更换页表?
在单页表情况下,页表的更换通常是由操作系统的上下文切换来触发的。当从用户态切换到内核态或从一个进程切换到另一个进程时,操作系统会根据相应的上下文信息加载不同的页表,实现页表的更换。
单页表情况下,如何控制用户态无法访问内核页面?(tips:看看第一题最后一问)
- 设置页面权限:内核页面通常会被设置为只能在内核态下访问(例如,设置 PTE_U 位为 0),这样用户态无法访问内核页面。
- 操作系统权限:操作系统内核态拥有较高的权限,可以通过特权级别或访问控制机制来确保用户态无法直接访问内核页面。用户程序只能通过系统调用进入内核态,并在内核态下由操作系统执行,从而实现对内核页面的访问控制。
单页表有何优势?(回答合理即可)
单页表的主要优势在于简化了地址转换过程,减少了内存访问的开销。由于用户线程和内核线程共享同一张页表,不需要在上下文切换时频繁切换页表,这可以提高地址转换的效率。此外,单页表还可以节省内存,因为不需要为每个用户线程分配独立的页表。
双页表实现下,何时需要更换页表?假设你写一个单页表操作系统,你会选择何时更换页表(回答合理即可)?
在双页表实现下,页表的更换通常在发生上下文切换时需要。当从用户态切换到内核态或从一个进程切换到另一个进程时,需要加载相应的页表,以确保正确的地址转换。如果操作系统采用了每个进程独立的页表,那么在进程切换时需要更换页表。
如果我写一个单页表操作系统,我会选择在发生进程切换时更换页表,因为这是最频繁的上下文切换情况之一。在其他情况下,如从用户态切换到内核态,可能不需要更换整张页表,而只需修改页表项的权限位来实现访问控制。这样可以减少页表更换的开销,提高性能。
附录
修改user项目中的makefile,删除ch4_