设置页表是操作系统里重要的一环,通过给不同的进程设置不同的页表,可以让进程与进程之间的内存访问得到隔离。在这篇文章中,将简单介绍如何使用 Rust 给 RISC-V 设置页表。 而在实际编写代码之前我们仍然需要先了解我们要使用的页表的机制,才能知道如何编写代码,这里以 Sv39 页表模式为例,所使用的 CPU 为 RISC-V 64 位。

Sv39 页表模式

页表模式是指 RISC-V 如何将虚拟地址映射为物理地址,在 RISC-V 中支持 Sv32Sv39S48Sv57,这四种模式,关于这四种模式的规范文档可以在这里看到: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 为的物理地址。我们现在看虚拟地址部分。

file

在上面的图例 60 中,可以看到 39 位的虚拟地址被分成了四个部分,从右到左分别是:12 位的偏移量(offset)、9 位的虚拟页号 0(VPN[0])、9 位的虚拟页号 1(VPN[1])、9 位的虚拟页号 2(VPN[2])。

file

例图 62 是页表项,从右往左第 0 位是有效位,1 表示该页表项有效,0 表示无效;第 1 到 4 位是权限位。 第 10 到 35 是 44 位是物理地址(PPN),在第一级和第二级页表中这个物理地址指向的是下一级页表的地址,在第三级页表中指向物理页帧。

在 RISC-V 得到虚拟地址后,会先找到在 satp 寄存器指向的根页表,将虚拟地址中的 VPN[2] 作为偏移量,找到页表项,页表项中会有存放二级页表的物理地址;找到二级页表,使用 VPN1 作为二级页表的偏移量,找到三级页表;使用 VPN0 作为偏移量,找到页表项,该页表项的 PPN 就是物理页帧,和虚拟地址的偏移量组成一个物理地址。

file

上面的介绍太过简单了,让我们用一个实际的例子来感受一下。

小工具

我会有一些拆解地址和页表项的过程,这个过程不是很直观和方便,所以我做了一个小工具,用于方便地拆解这些东西: PTE Cal

解析虚拟地址

假设我要访问地址 0x8020000f,而我设置了页表模式为 Sv39,那么 RISC-V 在收到这个地址后认为是虚拟地址,它将虚拟地址分隔为四部分,左边三部分是三个虚拟页号,右边是偏移量。利用我的小工具能够方便看到各个部分是什么。

file

可以看到虚拟地址被分成的各个部分的值:

  • 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 的结构如下:

file

上图里是 64 位的 satp 寄存器,32 位的略有不同,关于 satp 寄存器更详细的说明在规范的 10.1.11 节。

第 0 到第 43 位是物理地址,指向根页表;第 44 位到 59 位是 ASID,你可以把它当作是页表的唯一 ID,在切换页表的时候,RISC-V 如果发现页表的 ASID 相同,就认为两个页表相同以提高效率;第 60 到 63 位是 MODE,表示页表模式,比如 Sv32,、Sv39 等。

在 64 位下 MODE可选的值如下表:

file

可以看到 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,那么后面物理页帧分配器就不能再给出 0x808000000x80800FFF 的这段区间了。

我们将分配器抽象为 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 代表一个页帧。字段 currentend 代表可用的物理内存,它们必须是 4k 对齐的。假设 current0x80500000end0x80600000,那么我要分配一个页帧,就将可用的 0x80500000 给出,这表示从 0x805000000x80500FFF 的 4k 物理内存不可以再分配,current 需要加上 4k 为 0x80501000,表示从 0x80501000 开始是未分配的物理内存。 返回一个页帧 Frame(0x80500000)这个页帧 Frame 代表就是从 0x805000000x80501000 的物理内存

当使用完一个页帧,就要该页帧回收,回收的页帧直接放在字段 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,这相当于清除快表,就当作是页表的缓存吧。

至此,页表设置完毕。