袂沐 发表于 5 天前

[rCore学习笔记 031] SV39多级页表的硬件机制

看到这个题目就知道上一节提到的RISC-V手册的10.6节又有用武之地了.
这里只需注意,RV32 的分页方案Sv32支持4GiB的虚址空间,RV64 支持多种分页方案,但我们只介绍最受欢迎的一种,Sv39。:
RISC-V 的分页方案以SvX的模式命名,其中X是以位为单位的虚拟地址的长度。
虚拟地址和物理地址

直接访问物理地址

默认情况下是没有开启MMU的,此时无论CPU处于哪个特权级,访问内存的地址都是物理地址.
启用MMU

有一个CSR(Control and Status Register 控制状态寄存器),决定了MMU的控制.名叫satp(Supervisor Address Translation and Protection 监管地址翻译和保护寄存器).
其结构如图所示:


[*]MODE 控制 CPU 使用哪种页表实现;

[*]在MODE为0---b0000的时候,所有的优先级的内存访问都是直接访问物理内存
[*]在MODE设置为8---b1000时,分页机制被开启,而且选用的是SV39分页机制,那么S/U级别的特权级访问的时候就需要通过MMU.
[*]在MODE设置为9---b1001时,分页机制被开启,而且选用的是SV48分页机制,这里不讨论这种分页机制.

[*]ASID 表示地址空间标识符,这里还没有涉及到进程的概念,我们不需要管这个地方.
[*]PPN 存的是根页表所在的物理页号.这样, 给定一个虚拟页号,CPU 就可以从三级页表的根页表开始一步步的将其映射到一个物理页号.
这个PPN也很重要,虽然我们在这里只提到了有关于模式选择的MODE,但是到了后边真正要完成访存的时候PPN决定的根节点决定了我们当前不同APP同样的虚拟内存怎么映射到不同的物理内存的.

地址结构

首先,我们只考虑虚拟地址和物理地址的位数,而不去考虑地址的每个位的作用.
那么39位的虚拟地址,最多可以访问高达512G的内存.

更甚的,拥有56位的物理地址,则能够访问更多大的内存范围(数据来自于GPT).

但是实际上讨论这个最大储存上限是没有意义的,这样的地址形式没办法产生虚拟地址和物理地址之间的映射,没有固定的映射那就更难通过MMU进行地址的转换.
那么我们实际上采用的是分页的储存方式,因此有一套属于分页储存的地址,如下图所示(图是偷的参考手册)的.

这个储存方式是偏移+页数的方式.
这里注意每个页面的大小设置为4KiB.这个是我们自己设置的,对应的是offset是12bit,这样才能通过偏移来访问每个Byte.
这里偏移+页数的方式听起来很奇怪,实际上它只是一个虚拟的概念:
偏移+页数和直接计数地址有区别吗?
答:没有区别,只是把低位的地址看成了偏移量.
可以这样理解,12位(第13位)以上看成页数,每增加1,那么相当于增加了4KiB,相当于翻过了一页.
那么0~11位,看成偏移,每增加1,相当于在当前页的4KiB上移动了一位.
这个概念对于物理地址本身按照内存增加递增是无影响的,把一个物理地址看成两部分是和对虚拟地址的映射是有影响的.
RISC-V要求地址长度为64位,这里我们只用到了39位,但是不代表前25位就完全没有要求.
在启用 SV39 分页模式下,只有低 39 位是真正有意义的。SV39 分页模式规定 64 位虚拟地址的  这 25 位必须和第 38 位相同,否则 MMU 会直接认定它是一个不合法的虚拟地址。通过这个检查之后 MMU 再取出低 39 位尝试将其转化为一个 56 位的物理地址。
那么就会有很神奇的事情发生,就是由于后边的这部分都必须和第38位相同,那么就是都是1或者都是0,那么,实际上这0~38位代表的内存空间不是连续的一个512G,而是分为在地址最高的256G和地址最低的256G.

数据结构抽象

对usize进行封装

所需的数据结构:

[*]物理地址
[*]虚拟地址
[*]物理页数
[*]虚拟页数
这边使用的方式是使用一个元组式结构体.
// os/src/mm/address.rs

#
pub struct PhysAddr(pub usize);

#
pub struct VirtAddr(pub usize);

#
pub struct PhysPageNum(pub usize);

#
pub struct VirtPageNum(pub usize);里边的usize就是STRUCT.0.
这里注意一点,就是使用#自动实现的Trait,尤其是之前没怎么用过的Ord和PartialOrd:

[*]Copy:这个 trait 表示类型的值可以被“复制”。拥有 Copy trait 的类型可以被简单地赋值给另一个变量,而不会改变原始值。例如,基本的数值类型(如 i32、f64)和元组(如果它们包含的所有类型都实现了 Copy)都实现了 Copy。这个 trait 不能手动实现,只能通过派生。
[*]Clone:这个 trait 允许一个值被显式地复制。与 Copy 不同,Clone 需要显式的调用(例如,使用 clone() 方法)。Clone trait 要求类型可以被复制,但不要求复制是无成本的。如果一个类型实现了 Copy,它自动也实现了 Clone,但反之则不一定。
[*]Ord:这个 trait 表示类型支持全序比较,即可以比较任意两个值并确定它们之间的顺序(小于、等于、大于)。这意味着类型必须实现 PartialOrd 和 Eq。
[*]PartialOrd:这个 trait 允许对类型的值进行部分顺序比较。这意味着可以比较两个值,但可能会返回 None,表示它们不可比较。大多数时候,如果类型可以完全排序,那么 PartialOrd 也会被实现。
[*]Eq:这个 trait 表示类型支持等价性比较,即可以比较两个值是否相等。Eq 是 PartialEq 和 Ord 的基础,它要求类型可以比较相等性。
[*]PartialEq:这个 trait 允许对类型的值进行等价性比较,但可能会返回 None,表示它们不可比较。大多数时候,如果类型可以完全比较,那么 PartialEq 也会被实现。
(此处的解释来自于Kimi)
那么这么封装的好处,实际上是加一层抽象,有的时候觉得没用的抽象实际上使用的时候是非常有好处的.
我们刻意将它们各自抽象出不同的类型而不是都使用与RISC-V 64硬件直接对应的 usize 基本类型,就是为了在 Rust 编译器的帮助下,通过多种方便且安全的 类型转换 (Type Conversion) 来构建页表。
通过From进行类型之间和类型与usize之间的转换

封装类型与usize转换

// os/src/mm/address.rs

const PA_WIDTH_SV39: usize = 56;
const PPN_WIDTH_SV39: usize = PA_WIDTH_SV39 - PAGE_SIZE_BITS;

impl From<usize> for PhysAddr {
    fn from(v: usize) -> Self { Self(v & ( (1 << PA_WIDTH_SV39) - 1 )) }
}
impl From<usize> for PhysPageNum {
    fn from(v: usize) -> Self { Self(v & ( (1 << PPN_WIDTH_SV39) - 1 )) }
}

impl From<PhysAddr> for usize {
    fn from(v: PhysAddr) -> Self { v.0 }
}
impl From<PhysPageNum> for usize {
    fn from(v: PhysPageNum) -> Self { v.0 }
}这里注意PAGE_SIZE_BITS是作为一个常数保存在config.rs里边.
//os/src/config.rs

//*  Constants for mm *//
pub const PAGE_SIZE_BITS: usize = 12;封装类型互相之间的转换

刚刚看到这里的时候就发现了一件事,那么地址是比页号要多一个offset信息的,如果直接进行转换,那么怎么才能不进行信息丢失呢?
这里仔细去看如下的代码,我们可以看到,如果只有PhysPageNum,那么把它转换为PhysAddr的时候默认offset是0,那么反过来PhysAddr转化为PhysPageNum的时候,需要去判断offset是不是0,如果offset不是0,那么报错.
// os/src/mm/address.rs

impl PhysAddr {
    pub fn page_offset(&self) -> usize { self.0 & (PAGE_SIZE - 1) }
}

impl From<PhysAddr> for PhysPageNum {
    fn from(v: PhysAddr) -> Self {
      assert_eq!(v.page_offset(), 0);
      v.floor()
    }
}

impl From<PhysPageNum> for PhysAddr {
    fn from(v: PhysPageNum) -> Self { Self(v.0 << PAGE_SIZE_BITS) }
}最后我们产生了一些其它的疑问,那么如果offset不是0,难道就不能进行转换了吗?答案是可以进行转换,但是不能经过隐式的转换,要通过显式的转换,即你要知道自己进行了带有四舍五入的取整.
//os/src/config.rs

//*  Constants for mm *//
pub const PAGE_SIZE: usize = 4096;其中floor是向下取整,ceil是向上取整.
这里实际上还是很难理解向上取整的意义,因为这个offset太过靠近下一页,所以干脆取下一页吗?
向上取整是取一页新的没有占用过的,向下取整是取当前页.
From和Into

这里是参考书中为我们提到的有关于From和Into的表述:
当我们为 U 实现了 From 之后,Rust 会自动为 T 实现 Into<U> Trait,因为它们两个本来就是在做相同的事情。因此我们只需相互实现 From 就可以相互 From/Into 了。
其余的部分我不细说,之说对于T,你直接调用它的into它是不知道的,有点类似于CPP里的自动类型auto,在编译器可以隐式推倒到你需要的到底是什么类型的时候,就可以自动帮你决定这一点.
页表项

页表项就是一个同时含有页号信息和标志位的数据结构.

页号信息相当于是一个字典,映射一个虚拟地址和一个物理地址.
标志位则各不相同:

[*]V(Valid):仅当位 V 为 1 时,页表项才是合法的;
[*]R(Read)/W(Write)/X(eXecute):分别控制索引到这个页表项的对应虚拟页面是否允许读/写/执行;
[*]U(User):控制索引到这个页表项的对应虚拟页面是否在 CPU 处于 U 特权级的情况下是否被允许访问;
[*]G:暂且不理会;
[*]A(Accessed):处理器记录自从页表项上的这一位被清零之后,页表项的对应虚拟页面是否被访问过;
[*]D(Dirty):处理器记录自从页表项上的这一位被清零之后,页表项的对应虚拟页面是否被修改过。
除了 G 外的上述位可以被操作系统设置,只有 A 位和 D 位会被处理器动态地直接设置为 1 ,表示对应的页被访问过或修过( 注:A 位和 D 位能否被处理器硬件直接修改,取决于处理器的具体实现)。
这时候要在rust中实现它,这时候使用的是用一个位代表一个FLAG的形式,rust提供了这样的一个包,名字叫做bitflags.
因此要:

[*]在Cargo.toml中声明依赖这个包.
[*]在main.rs中声明使用这个包.
在我写下这篇博客的时候bitflag的版本截止到2.6.0,可以通过查看相关网站看到最新版本和细节.
impl PhysAddr {
    pub fn page_offset(&self) -> usize { self.0 & (PAGE_SIZE - 1) }
}在main.rs中加入:
// os/src/mm/address.rs

impl PhysAddr {
    pub fn floor(&self) -> PhysPageNum { PhysPageNum(self.0 / PAGE_SIZE) }
    pub fn ceil(&self) -> PhysPageNum { PhysPageNum((self.0 + PAGE_SIZE - 1) / PAGE_SIZE) }
}创建os/src/mm/page_table.rs,实现这部分FLAG:
use bitflags::bitflags;bitflags!{    pub struct PTEFlags: u8{      const V = 1
页: [1]
查看完整版本: [rCore学习笔记 031] SV39多级页表的硬件机制