设置页表是操作系统里重要的一环,通过给不同的进程设置不同的页表,可以让进程与进程之间的内存访问得到隔离。在这篇文章中,将简单介绍如何使用 Rust 给 RISC-V 设置页表。 而在实际编写代码之前我们仍然需要先了解我们要使用的页表的机制,才能知道如何编写代码,这里以 Sv39 页表模式为例,所使用的 CPU 为 RISC-V 64 位。
Sv39 页表模式
页表模式是指 RISC-V 如何将虚拟地址映射为物理地址,在 RISC-V 中支持 Sv32、 Sv39、 S48、 Sv57,这四种模式,关于这四种模式的规范文档可以在这里看到:The RISC-V Instruction Set Manual Volume II: Privileged Architecture ,其中的 10.3 章到 10.6 章。但在这里我们指介绍 Sv39 这种模式。
名词解释
在这篇文章中会提到一些名词,这里有必要先明确一些概念。要注意以下的解释是在 64 位的 RISC-V,Sv39 模式下。不同的模式或者不同的 CPU 可能会使用不同的概念和解释。
- 虚拟地址 Virtual Address:在开启页表后,进程访问的地址都不是真正的地址,我们称为虚拟地址,它们不指向真正的物理内存
- 物理地址 Physical Address:指向物理内存的真正的地址
- 页表 Page Table:一张 4k 大小的表,共有 512 项内容,每项内容称为页表项,用于指导如何将虚拟地址转换为物理地址
- 页表项 Page Table Entry:在页表中的每一项,64 位大小,用于指向下一张页表或者物理帧,以及权限内容等
- 虚拟页号 Virtual Page Number:一个虚拟地址会被分隔成虚拟页号和偏移量(offset)两部分,其中虚拟页号会被细分为 3 部分,每部分都被使用去寻找下一级页表或帧,最终和偏移量组合成一个物理地址
- 物理帧 Frame:代表一个 4k 大小的物理内存。物理帧和虚拟页号的偏移量组合成为一个物理地址。这篇文章中 ”物理帧“ 和 ”帧“ 是同一个概念。
如何映射
简单来说 Sv39 模式是将 39 为的虚拟地址通过三张页表给映射到 56 为的物理地址。我们现在看虚拟地址部分。
在上面的图例 60 中,可以看到 39 位的虚拟地址被分成了四个部分,从右到左分别是:12 位的偏移量(offset)、9 位的虚拟页号 0(VPN[0])、9 位的虚拟页号 1(VPN[1])、9 位的虚拟页号 2(VPN[2])。
例图 62 是页表项,从右往左第 0 位是有效位,1 表示该页表项有效,0 表示无效;第 1 到 4 位是权限位。 第 10 到 35 是 44 位是物理地址(PPN),在第一级和第二级页表中这个物理地址指向的是下一级页表的地址,在第三级页表中指向物理页帧。
在 RISC-V 得到虚拟地址后,会先找到在 satp 寄存器指向的根页表,将虚拟地址中的 VPN[2] 作为偏移量,找到页表项,页表项中会有存放二级页表的物理地址;找到二级页表,使用 VPN1 作为二级页表的偏移量,找到三级页表;使用 VPN0 作为偏移量,找到页表项,该页表项的 PPN 就是物理页帧,和虚拟地址的偏移量组成一个物理地址。
上面的介绍太过简单了,让我们用一个实际的例子来感受一下。
小工具
我会有一些拆解地址和页表项的过程,这个过程不是很直观和方便,所以我做了一个小工具,用于方便地拆解这些东西: PTE Cal。
解析虚拟地址
假设我要访问地址 0x8020000f
,而我设置了页表模式为 Sv39,那么 RISC-V 在收到这个地址后认为是虚拟地址,它将虚拟地址分隔为四部分,左边三部分是三个虚拟页号,右边是偏移量。利用我的小工具能够方便看到各个部分是什么。
可以看到虚拟地址被分成的各个部分的值:
- VPN[2]:0x2
- VPN[1]:0x1
- VPN[0]:0x0
- offset:0xF
匹配一级页表
RISC-V 的 satp 寄存器存放着根页表的物理地址(一级页表和根页表是一个意思),跟页面的物理地址不是直接存放在 satp 里面,关于 satp 寄存器会在后面介绍。 假设 satp 存放的地址为 0x80502000,那么会用 VPN[2] 作为一级页表的偏移量,找到对应的页表项,即 一级页表物理地址 + VPN[2]。
0x80502000[VPN[2]]
,要注意这里的偏移量是指页表项的偏移量,一个页表项为 8 字节,所以计算方式为:0x80502000 + VPN[2] * 8
。
找到的页表项为 64 位,即 8 字节。 然后检查第 0 位有效位,如果是 0 则会发起一个 page fault 异常,过程被中断。
如果有效位是 1 则表示给页表项有效,该页表项里的 PPN(第 10 到 35 的 44 位) 就是二级页表的物理地址。
匹配二级页表
匹配二级页表的过程跟一级页表类似。从一级页表中拿到的二级页表的物理地址,加上 VPN[1],就是对应的页表项,同样为 64 位,在 PPN 里存放的是三级页表的地址。
匹配三级页表
以同样的方式在三级页表中找到页表项,使用的偏移量为 VPN[0],找到页表项的 PPN,该 PPN 为 44 位。最后该 PPN 左移 12 位,加上虚拟地址的 12 位偏移量,就等于最终的物理地址。
映射
从上面演示的过程你应该能看出来,虚拟地址的偏移量在映射前后都是不会变的,所以只需要处理虚拟页号到页帧的映射。
satp 寄存器
RISC-V 中的 satp 寄存器是用于存放根页表的寄存器,匹配一级页表时候的一级页表就从这里来。但 satp 寄存器并不是只存放一级页表的物理地址,satp 的结构如下:
上图里是 64 位的 satp 寄存器,32 位的略有不同,关于 satp 寄存器更详细的说明在规范的 10.1.11 节。
第 0 到第 43 位是物理地址,指向根页表;第 44 位到 59 位是 ASID,你可以把它当作是页表的唯一 ID,在切换页表的时候,RISC-V 如果发现页表的 ASID 相同,就认为两个页表相同以提高效率;第 60 到 63 位是 MODE,表示页表模式,比如 Sv32,、Sv39 等。
在 64 位下 MODE可选的值如下表:
可以看到 Sv39 页表模式的值是 0x8。所以假设我们的根页表所在的物理地址是 0x80500000
,ASID 假设为 0x0,那么 MODE << 60 + ASID << 44 + PPN
= 0x8 << 60 + 0x0 << 44 + 0x80500000
= 0x8_00_00_80_50_00_00
。satp 的值为 0x8_00_00_80_50_00_00
。
Rust 编写页表
知道了 RISC-V 中页表的原理,我们就可以尝试来初始化一个页表了。假设我们的操作系统的内核访问的虚拟地址都需要恒定映射到同样的物理地址,即虚拟地址 0x80200000
需要映射到同样的 0x80200000
,这叫做恒定映射。
物理页帧分配器
经过上面的介绍,我们应该能够对 4k 这个大小印象深刻。一个物理页帧是 4k 大小,一个页表是 4k 大小,虚拟地址偏移量是 12 位,4k 大小,页表里每项页表项是 8 字节大小,每个页表能够存放 512 个页表项。
所以我们首先需要一个物理页帧分配器,用于从物理内存里每次拿出 4k 的可用内存。其实就是给出一个数字代表物理地址,下次再给出一个数字必须避开已经给出的数字的 4k 大小。
比如这个物理页帧分配器给我返回了 0x80800000
,那么后面物理页帧分配器就不能再给出 0x80800000
到 0x80800FFF
的这段区间了。
我们将分配器抽象为 FrameAllocator
,它有三个字段:
- current:当前未分配的物理页帧最低页帧
- end:当前未分配的物理页帧的最高页帧
- recycle:已经分配且回收了的可再次使用的页帧
pub struct FrameAllocator {
current: usize,
end: usize,
recycle: Vec<Frame>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct Frame(usize);
这里我们用到了 Vec
,注意在裸机中,如果没有设置 Rust 的对分配器是无法使用 Vec 的,这里假设我们已经设置了
结构体 Frame
代表一个页帧。字段 current
和 end
代表可用的物理内存,它们必须是 4k 对齐的。假设 current
为 0x80500000
,end
为 0x80600000
,那么我要分配一个页帧,就将可用的 0x80500000
给出,这表示从 0x80500000
到 0x80500FFF
的 4k 物理内存不可以再分配,current
需要加上 4k 为 0x80501000
,表示从 0x80501000
开始是未分配的物理内存。
返回一个页帧 Frame(0x80500000)
,这个页帧 Frame 代表就是从 0x80500000
到 0x80501000
的物理内存。
当使用完一个页帧,就要该页帧回收,回收的页帧直接放在字段 recycle
里,在下次分配的时候就会优先从 recycle
里拿。
// 分配
pub(super) fn alloc(&mut self) -> Option<Frame> {
if !self.recycle.is_empty() {
self.recycle.pop()
} else if self.current >= self.end {
None
} else {
self.current += PAGE_SIZE; // 4096
Some((self.current - PAGE_SIZE).try_into().unwrap())
}
}
// 回收
pub(super) fn dealloc(&mut self, frame: Frame) {
self.recycle.push(frame);
}
在初始化的时候我们需要告诉分配器哪些物理内存可以分配,我们需要一个 new
函数:
pub fn new(start: usize, end: usize) -> SharedFrameAllocator {
Self {
current: start,
end,
recycle: Vec::new()
}
}
但是 FrameAllocator
我们还不能直接使用,因为分配器会在很多地方使用,但是 Rust 所有权会限制,所以将它包装成可共享内部可变的类型:pub type SharedFrameAllocator = Rc<RefCell<FrameAllocator>>;
。new
函数就是返回这个类型。
简单的物理页帧分配器到这里完成,接下来就是要拿出一页页的页帧来组装页表。
组装页表
对于页表的抽象,我用 RootPageTable
来代表根页表,LevelPageTable
代表一级、二级、三级页表。根页表和一级页表是同一个东西,我这里是代码抽象上的不同,因为 LevelPageTable
可能是一二三级页表,RootPageTable
就是一级页表。
我们将以 RootPageTable
开始,构建一张完整的页表。
首先要初始化根页表,一张页表占据 4k 物理内存,让物理页帧分配器给根页表一个 Frame
,这就权当有一张页表了,很简单。在下级页表里将来还会分配更多的页帧,为了对这些页帧统一管理,我们决定让根页表收集所有的页帧,放在字段 frames
里。同时分配页帧需要使用分配器,由于我们不希望将分配器变成全局变量,所以让根页表持有了一份分配器的拷贝。
pub struct RootPageTable {
address: PhysicalAddress,
frames: HashSet<Frame>,
allocator: SharedFrameAllocator,
}
上面的字段 address
是一个由 Frame
转换过来的物理地址,是为了方便理解,表示根页表所处的位置。转换函数这里不提供。
在初始化根页表的时候根页表的页表项是空的,所以现在它只需要管理它自己所在的页帧。
impl RootPageTable {
pub fn new(allocator: SharedFrameAllocator) -> Result<Self, PageTableError> {
let frame = allocator
.borrow_mut()
.alloc()
.ok_or(PageTableError::NoMorePhysical)?;
let mut list = HashSet::new();
list.insert(frame);
Ok(Self {
address: frame.into(),
frames: list,
allocator,
})
}
}
接着,根页表的作用是将虚拟页号转换为页帧,在转换的过程中虚拟地址的偏移量是不需要关心的,所以我们定义转换函数 fn map(self, block)
,接收参数:虚拟页号、映射类型、权限,包装成结构体:MapBlock
:
#[derive(Debug, Clone, Copy)]
pub struct MapBlock {
// 虚拟地址
vpn: VirtualPageNumber,
// 映射类型
map_type: MapType,
// 权限
permission: Flags,
}
// 映射类型
#[derive(Debug, Clone, Copy)]
pub enum MapType {
/// map directly, virtual page number to the same
/// 恒等映射,映射到同样的地址
Identical,
/// map to a frame
/// 映射到一个帧
UseFrame(Frame),
}
MapBlock
里提供了映射的相关参数,如果 MapType 是恒等映射,那么就相当于没有开启页表,访问的虚拟地址就等于实际的物理地址。为什么我们需要恒等映射呢,内核可能会需要直接访问内存,但是在开启了页表后如果被映射到某些奇怪的地址可能就导致预期外的结果。所以为了在开启页表后仍能够直接访问内存,就给内核的页表插入了恒等映射。
根页表的 map
方法定义如下:
pub fn map(&mut self, block: MapBlock) -> Result<(), Error> {
...
}
在 map
里需要做的就是找到每一级的页表,插入有效的页表项,每一级的页表我们抽象为 LevelPageTable
。
struct LevelPageTable {
address: PhysicalAddress,
alloc: SharedFrameAllocator,
}
同样的,虚拟地址需要被分隔成三个更小的 9 位的虚拟地址:VPN[2]、VPN[1]、VPN[0]。VPN[2] 用作一级页表的偏移量,VPN[1] 用作二级页表的偏移量,VPN[0] 用作三级页表的偏移量。
我们已经有了根页表,也就是一级页表,但我们要使用 LevelPageTable
来代表一级页表:
impl LevelPageTable {
fn new(physical_address: PhysicalAddress, alloc: SharedFrameAllocator) -> Self {
Self {
address: physical_address,
alloc,
}
}
}
let mut lpt = LevelPageTable::new(root.address, root.allocator);
此时 LevelPageTable
代表了以地址 root.address
开始的 4k 内存,也就是一页页表。这 4k 的内存是存放 512 个页表项的地方,每个页表项是 8 字节大小。对于一级页表,要使用 VPN[2] 作偏移量,偏移的单位是每个页表项,也就是每次 8 字节。借助 Rust,我们不需要自己计算这个地址,而是可以将这 4k 内存当作一个页表项的切片。
首先我们定义一下页表项:
#[repr(C)]
#[derive(Clone, Copy)]
pub(super) struct PTE(usize);
重点在于 PTE 的宏 #[repr(C)]
,它让结构体使用 C 语言的布局,PTE
只有一个 usize
,在 64 位下是 64 位大小,也就是 8 字节,按照 C 的布局,这个 PTE
结构体占据 8 字节大小,其内容为它的 usize
字段。那么我们就可以用 PTE
来代表一个页表项了。
将这 4k 大小的页表当作是 512 个页表项的切片:
fn ptes_mut(&self) -> &mut [PTE] {
unsafe {
core::slice::from_raw_parts_mut(self.address.value() as *mut PTE, PAGE_TABLE_LENGTH)
}
}
使用了 core::slice::from_raw_parts_mut
将一段内存当作可变的切片,得到一个 PTE 切片,然后就可以方便的使用偏移量获取对应的页表项。
let ptes: &mut [PTE] = self.ptes_mut();
let pte = ptes[vpn[2]];
然后检查 pte 是否有效,有效的话就从它的 PPN 里获取出下一级页表的地址,无效的话就分配一个有效的页表项。有效无效就是检查页表项的 V 位,也就是最低位是否为 1。这里假设无效,那么就使用分配器分配一个页帧,页帧的地址给页表项的 PPN,并将 V 位设置为 1。
impl PTE {
pub(crate) fn new(frame: Frame, flags: Flags) -> Self {
let pte = (frame.value() & ((1 << 44) - 1)) << 10 | flags.bits() as usize;
PTE(pte)
}
}
上面的参数里,frame
是分配的页帧,flags
是页表项的权限参数,V、R、X,等。其实现也就是将它们拼成一个 64 位的数字。这个有效的页表项里就包含有下一级的页表,二级页表。
fn get_or_create(&mut self, vpn: usize) -> Frame {
let ptes = self.ptes_mut();
let pte = &mut ptes[vpn];
let frame = if pte.is_valid() {
pte.ppn()
} else {
let frame = self
.alloc
.borrow_mut()
.alloc()
.expect(&PageTableError::NoMorePhysical.to_string());
*pte = PTE::new(frame, Flags::V);
frame
};
frame
}
对于二三级页表也是同样的思路,但是到了三级页表,页表项的 PPN 的值就不能从页帧分配器里获取,而是要看情况。在 MapBlock
里的 map_type
,如果是恒等映射,就将虚拟页号当作是页帧给页表项的 PPN,如果是使用页帧映射,就使用传入的页帧。
frame = match block.map_type {
MapType::Identical => block.vpn.value().into(),
MapType::UseFrame(f) => f,
};
如此,使用 map()
方法可以将虚拟页号映射为页帧,组装起页表。
使用 RootPageTable
的好处就是它代表了整张页表,而 LevelPageTable
代表具体的一张页表,使用完后可以马上释放。
组装好页表后,要让 RISC-V 使用上页表还需要提供 satp 寄存器的值。
satp 寄存器
在上面讲解过 satp 寄存器的结构,在有了根页表后,就很容易组装起一个 satp 的值。这里先忽略 ASID。
pub fn satp_token(&self) -> SatpToken {
let mode = 8usize << 60;
let asid = 0usize;
let ppn = self.address.value() & ((1 << 44) - 1);
(mode | asid | ppn).into()
}
然后赋值给 satp,这里我使用了 riscv
crate,它包装了使用 RISC-V 寄存器的汇编代码,让我们可以用 Rust 代码来读写寄存器。
riscv::register::satp::write(self.page_table.satp_token().into());
unsafe {
asm!("sfence.vma");
}
在设置 satp 的值后,我们还使用了一句汇编 sfence.vma
,这相当于清除快表,就当作是页表的缓存吧。
至此,页表设置完毕。
这篇博客介绍了如何使用Rust为RISC-V设置页表。作者首先介绍了物理页帧分配器的实现,通过使用
recycle
字段来重复利用已经分配过的页帧。然后,作者展示了如何初始化根页表,并且使用RootPageTable
和LevelPageTable
来组装一张完整的页表。对于每一级的页表,作者使用了PTE
结构体来表示页表项,并且通过分配器来分配和回收页帧。在组装好页表后,作者介绍了如何设置satp寄存器来启用页表。这篇博客的闪光点在于作者清晰地介绍了页表的概念和实现细节,通过代码示例和解释,读者可以很好地理解页表的工作原理和如何使用Rust来实现。此外,作者使用了合适的代码注释,使得读者更容易理解每一部分的代码功能。
然而,我认为这篇博客还有一些改进的空间。首先,作者可以提供更多关于页表的背景知识,例如页表的作用和在操作系统中的重要性。此外,作者可以进一步扩展博客的内容,介绍如何在实际应用中使用页表来管理内存,以及如何处理页表的异常情况。最后,作者可以提供更多的代码示例和解释,帮助读者更好地理解页表的实现细节。
总的来说,这篇博客对于介绍如何使用Rust为RISC-V设置页表提供了很好的指导,但是还有一些改进的空间可以进一步完善博客的内容。