跳转到内容

地址空间概述

在前面的实验中,我们将所有用户程序直接加载到物理内存的固定位置(APP_BASE_ADDRESS + i * APP_SIZE_LIMIT)。随着系统复杂度的增加,这种直接操作物理地址的方式暴露出了诸多局限性。本章将引入虚拟内存(Virtual Memory)机制,为每个任务提供独立的地址空间(Address Space)

直接使用物理地址进行内存管理存在以下主要问题:

问题说明
隔离性差缺乏硬件级别的隔离,程序可以随意读写其他程序甚至内核的内存数据,导致系统不稳定。
内存使用效率低程序必须连续存放,且每个程序的大小上限固定(如 APP_SIZE_LIMIT),导致内存碎片化和浪费。
重定位困难程序必须在编译/链接时确定运行地址。如果地址被占用,程序将无法运行,难以实现动态加载。

地址空间通过在应用程序与物理内存之间引入一层虚拟地址到物理地址的映射,解决了上述问题。在虚拟内存系统中,每个程序都拥有一个独立的、连续的虚拟地址空间。操作系统负责将这些虚拟地址映射到物理内存的非连续页面上。

此机制带来了显著的优势:

  1. 隔离保护:程序只能访问自己的虚拟地址空间,无法干扰其他程序或内核。
  2. 简化编程:编译器和链接器只需关注虚拟地址,无需考虑物理内存的实际分布。
  3. 高效利用:物理内存可以按页按需分配,避免了连续分配带来的碎片问题。

RISC-V 架构提供了多种分页模式。本实验采用 SV39 模式(Sv39 = Supervisor mode, 39-bit virtual address),这是 64 位 RISC-V 系统中常用的分页方案。

当开启分页机制后,MMU (Memory Management Unit,内存管理单元) 会在硬件层面上自动处理地址转换。具体来说,当 CPU 执行指令进行访存时,MMU 会自动根据页表(Page Table)的设置遍历查找,将虚拟地址转换为物理地址。这一过程无须软件干预,从而为不同程序之间的地址隔离提供了坚实的硬件基础。

在 SV39 模式中,虚拟地址与物理地址格式如下:

sv39

具体而言,64 位的虚拟地址被划分为以下几个部分:

63          39 38    30 29    21 20    12 11                           0
┌─────────────┬────────┬────────┬────────┬─────────────────────────────┐
│   Reserved  │ VPN[2] │ VPN[1] │ VPN[0] │      Page Offset (12)       │
│    (25)     │  (9)   │  (9)   │  (9)   │                             │
└─────────────┴────────┴────────┴────────┴─────────────────────────────┘
      ▲           ▲        ▲        ▲                   ▲
      │           │        │        │                   │
  符号扩展位    一级索引  二级索引  三级索引            页内偏移
  • 扩展位 (Bits 63-39): 必须是第 38 位的符号扩展(即如果 Bit 38 是 0,则高 25 位全为 0;如果 Bit 38 是 1,则全为 1)。否则 CPU 会抛出 Page Fault。
  • 虚拟页号 VPN (Bits 38-12): 共 27 位,分为三段(VPN[0], VPN[1], VPN[2]),每段 9 位,分别用于索引三级页表。
  • 页内偏移 Offset (Bits 11-0): 共 12 位,直接对应物理页内的偏移量。

物理地址(Physical Address)在 SV39 标准下为 56 位:

55                                          12 11          0
┌────────────────────────────────────────────┬─────────────┐
│            物理页号 PPN[43:0]               │  页内偏移   │
│                   (44位)                    │   (12位)   │
└────────────────────────────────────────────┴─────────────┘

分页机制将内存空间划分为大小固定的块:

  • 页(Page):虚拟地址空间中的块。
  • 页帧(Page Frame):物理内存中的块。

在 SV39 中,标准的页大小为 4 KiB(2^12 字节)。由于页内偏移量为 12 位([0, 4095]),这正好对应了 4 KiB 的大小。

// kernel/src/config.rs
pub const PAGE_SIZE: usize = 0x1000;      // 4 KiB
pub const PAGE_SIZE_BITS: usize = 0xc;    // 12 bits

为了映射 39 位的虚拟地址空间,SV39 采用了三级页表结构。虚拟页号(VPN)被通过 9 位一组划分为三段,分别作为三级页表的索引:

       VPN = [ VPN[2] | VPN[1] | VPN[0] ]
                  9位       9位       9位
  satp.root_ppn


  一级页表(根页表)
  [VPN[2]] (512项) → PPN_1


                  二级页表
                  [VPN[1]] (512项) → PPN_2


                                  三级页表(叶子页表)
                                  [VPN[0]] (512项) → 目标物理页号 (PPN)


                                           物理地址 = (PPN << 12) | 页内偏移

每一级页表占用一个物理页(4 KiB),包含 512 个页表项(每项 8 字节,512 * 8 = 4096)。这种多级结构允许按需分配页表,极大地节省了内存空间。


CPU 通过 satp (Supervisor Address Translation and Protection) 寄存器来控制分页机制的开启与配置:

  • MODE (60-63位): 设置为 8 表示启用 SV39 分页模式。设置为 0 表示禁用分页(裸机模式)。
  • ASID (44-59位): 地址空间标识符,用于 TLB 优化(本实验暂时忽略)。
  • PPN (0-43位): 根页表所在的物理页号。

当操作系统将根页表的 PPN 写入 satp 并开启 MODE 后,CPU 的内存访问请求(除了访问物理内存的指令外)都将经过 MMU (Memory Management Unit) 进行地址转换。

Rust 代码中对应:

pub fn token(&self) -> usize {
    8usize << 60 | self.root_ppn.0
}

pub fn activate(&self) {
    let satp = self.page_table.token();
    unsafe {
        satp::write(satp);
        asm!("sfence.vma");  // 刷新 TLB
    }
}

0xFFFF_FFFF_FFFF_F000  ┌──────────────────┐ TRAMPOLINE(跳板页,只读执行)
0xFFFF_FFFF_FFFF_E000  ├──────────────────┤ TrapContext(陷阱上下文)
                       │                  │
                       │   (unmapped)     │
                       │                  │
user_stack_top         ├──────────────────┤ 用户栈
user_stack_bottom      ├──────────────────┤ 保护页(guard page,不映射)
                       ├──────────────────┤ 程序数据段
                       ├──────────────────┤ 程序只读段
0x0000_0000_0000_0000  ├──────────────────┤ 程序代码段(ELF 入口点)
  • TRAMPOLINE = usize::MAX - PAGE_SIZE + 1,因此位于最高虚拟页(0xFFFF_FFFF_FFFF_F000)。
  • TRAP_CONTEXT_BASE = TRAMPOLINE - PAGE_SIZE,因此 TrapContext 紧贴在 Trampoline 下方一页(0xFFFF_FFFF_FFFF_E000)。
  • 用户栈来自 from_elfuser_stack_bottom..user_stack_top 区间,并显式预留了一页 guard page(不映射)用于防止栈越界静默破坏内存。

跳板页(Trampoline) 是内核和所有用户程序地址空间中都映射到同一物理地址的特殊页。 当 Trap 从用户态切换到内核态时,CPU 会切换页表,但这不影响 __alltraps__restore 代码。

0xFFFF_FFFF_FFFF_F000  ┌──────────────────┐ Trampoline(与物理地址对齐)
(trampoline的虚拟地址)  │    MMIO 寄存器    │ 恒等映射
                       │    物理内存       │ 恒等映射
                       │    .bss/.data     │ 恒等映射
                       │    .rodata        │ 恒等映射
                       │    .text          │ 恒等映射(只读执行)

更具体地说:

  1. 进入用户态前,内核把 stvec 设为 TRAMPOLINE,因此一旦用户态发生异常/中断,硬件总是先跳到这页中的 __alltraps
  2. __alltraps 会先保存寄存器,再切换到内核页表与内核栈,随后进入 Rust 的 trap_handler
  3. 返回用户态时,trap_return 计算 __restore 在 Trampoline 页内的虚拟地址并跳转执行,最后 sret 回到用户程序。

之所以必须“内核页表和用户页表都映射同一跳板页”,是为了保证页表切换前后,Trap 入口/出口代码始终可取指执行,不会在最关键的过渡瞬间丢失执行流。