xv6 Lab3 Page tables - MIT 6.1810 Fall 2025 Operating System

本文最后更新于 2026年7月30日 下午

阅读 xv6 book

找到了两个很好的中文翻译的版本:

那么就试着速览一下第三章吧。

  • 我发现我还是读不进去书,不如直接开始做 lab 然后学吧。

Inspect a user-process page table

不需要写代码,主要是 inspect 一下。

运行 pgtbltest

打印出的是此进程的 first 10 and last 10 PTE。

分析一下,首先根据 xv6 book,一个 PTE 的结构如图:

则:

  • va:进程看到的虚拟页地址
  • pte:完整页表项
  • pa:它映射到的物理页地址
  • perm:PTE 低 10 位的标志位

因为一页是 4096 Bytes 即 0x1000 Bytes,故可以见到 va 分别为 0x0 0x1000 0x2000...

逐个解析每个 PTE 的 perm:

虚拟地址 perm 解释
0x0 0x5B V R X U A
0x1000 0x5B V R X U A
0x2000 0x17 V R W U
0x3000 0x07 V R W,但没有 U
0x4000 0xD7 V R W U A D
0x50000x9000 0 不存在有效映射
0x3FFFFF60000x3FFFFFD000 0 不存在有效映射
0x3FFFFFE000 0xC7 V R W A D,没有 U
0x3FFFFFF000 0x4B V R X A,没有 U

再考虑图 3.4:

从低位开始分析 va 0x0va 0x1000 都是 perm 0x5B = R-XU,即:可读、可执行、用户可访问,不可写。那么显然,是 text 部分。

再分析 va 0x2000perm 0x17 = R-WU 对应可读、可写、用户可访问,不可执行,对应 data R-WU

之后分析也同理,对着看就行。而且很显然,最高的两页分别就是 trampoline 和 trapframe。

Speed up system calls

这道题虽然说是 easy,但是我还是有点「面向答案学习」了(指不会做,问 Agent 然后看答案的)。

梳理一下需求:kernel/memlayout.h 里面定义了一个这样的虚拟地址(va):

#define USYSCALL (TRAPFRAME - PGSIZE)

需求是:在这个页面处定义一个这样的 struct:

struct usyscall {
  int pid;  // Process ID
};

将其初始化为当前进程的 PID,以此消除了调用 getpid() 系统调用(陷入内核态)的必要,用户态就可直接从内存中读取 pid。

hint 提示我们:

  • Choose permission bits that allow userspace to only read the page. 选择允许用户空间仅读取该页面的权限位。
  • There are a few things that need to be done over the lifecycle of a new page. For inspiration, understand the trapframe handling in kernel/proc.c. 在新页面的生命周期中,有几件事情需要处理。作为参考,请理解 kernel/proc.c 中的 trapframe 处理。

简单梳理一下的意思就是,参考图 3.4,我们有一个 trapframe 的 va,现在我们在 trapframe 的正下方,又加了一个 usyscall。对于 usyscall 的实现,可以完全参考在 kernel/proc.c 中对于 trapframe 的处理。

那么,首先需要在 kernel/proc.h ,参考 trapframestruct proc 加一个 struct usyscall *usyscall

struct trapframe *trapframe; // data page for trampoline.S
struct usyscall *usyscall;   // USYSCALL page, shared read-only with user

然后,在 kernel/proc.c 中,Ctrl + F 搜索 trapframe,然后依次参考 trapframe 的实现,来仿照着实现 usyscall 的部分。

// static struct proc* allocproc(void)
 // Allocate a trapframe page.
  if((p->trapframe = (struct trapframe *)kalloc()) == 0){
    freeproc(p);
    release(&p->lock);
    return 0;
  }
  if((p->usyscall = (struct usyscall *)kalloc()) == 0) {
    freeproc(p);
    release(&p->lock);
    return 0;
  }
  p->usyscall->pid = p->pid;

因为这里实际上是一个每进程且进程创建之后就不再改变的量,所以 pid 的逻辑在这里就已经实现完毕了。

接下来是:这个是纯粹模仿。

static void
freeproc(struct proc *p)
{
  if(p->trapframe)
    kfree((void*)p->trapframe);
  p->trapframe = 0;
  if(p->usyscall)
    kfree((void *)p->usyscall);
  p->usyscall = 0;	
  ...
}

这里是一个比较关键的地方:创建页表。

代码C · 41 行
// Create a user page table for a given process, with no user memory,
// but with trampoline and trapframe pages.
pagetable_t
proc_pagetable(struct proc *p)
{
  pagetable_t pagetable;

  // An empty page table.
  pagetable = uvmcreate();
  if(pagetable == 0)
    return 0;

  // map the trampoline code (for system call return)
  // at the highest user virtual address.
  // only the supervisor uses it, on the way
  // to/from user space, so not PTE_U.
  if(mappages(pagetable, TRAMPOLINE, PGSIZE,
              (uint64)trampoline, PTE_R | PTE_X) < 0){
    uvmfree(pagetable, 0);
    return 0;
  }

  // map the trapframe page just below the trampoline page, for
  // trampoline.S.
  if(mappages(pagetable, TRAPFRAME, PGSIZE,
              (uint64)(p->trapframe), PTE_R | PTE_W) < 0){
    uvmunmap(pagetable, TRAMPOLINE, 1, 0);
    uvmfree(pagetable, 0);
    return 0;
  }

  if(mappages(pagetable, USYSCALL, PGSIZE,
              (uint64)(p->usyscall), PTE_R | PTE_U) < 0){
    uvmunmap(pagetable, TRAPFRAME, 1, 0);
    uvmunmap(pagetable, TRAMPOLINE, 1, 0); // !!!
    uvmfree(pagetable, 0);
    return 0;
  }

  return pagetable;
}

mappages() 是建立 va 到 实际物理地址的映射的,顺便规定好 perm,这里按照原文的说法,允许用户态读。

重点是这个清理步骤:

uvmunmap(pagetable, TRAPFRAME, 1, 0);
uvmunmap(pagetable, TRAMPOLINE, 1, 0); // !!!
uvmfree(pagetable, 0);

实际上的意思是 (summarized by LLM):

引用先看你的 bug 你的 proc_pagetable 中 USYSCALL 的错误处理(第 214-219 行): if(mappages(pa...

先看你的 bug

你的 proc_pagetable 中 USYSCALL 的错误处理(第 214-219 行):

if(mappages(pagetable, USYSCALL, PGSIZE,
            (uint64)(p->usyscall), PTE_R | PTE_U) < 0){
    uvmunmap(pagetable, TRAPFRAME, 1, 0);   // 只 unmap 了 TRAPFRAME
    uvmfree(pagetable, 0);
    return 0;
}

漏了 uvmunmap(pagetable, TRAMPOLINE, 1, 0) 对比一下 TRAPFRAME 失败时的错误处理(第 207-211 行)——它 unmap 了 TRAMPOLINE,你这里也应该 unmap TRAMPOLINE + TRAPFRAME 两个。

proc_pagetable 里的错误回滚

想象你在搭积木,从顶部往下搭(因为 TRAMPOLINE 在最顶上):

TRAMPOLINE  ✓ 已搭好
TRAPFRAME   ✓ 已搭好
USYSCALL    ✗ 搭这个的时候失败了!

此时必须把成功搭上去的全拆掉,而且是逆序拆——你不能留一个残废的页表给 freeproc 用。freeproc 会假设页表结构是完好无损的。这就是为什么:

  • TRAPFRAME 失败 → 回滚 TRAMPOLINE(只成功了一个)
  • USYSCALL 失败 → 回滚 TRAMPOLINE TRAPFRAME(成功了两个)

之后,检查到 kfork 中对于 trapframe 的处理:

// copy saved user registers.
*(np->trapframe) = *(p->trapframe);

// Cause fork to return 0 in the child.
np->trapframe->a0 = 0;

我们容易注意到,事实上 trapframe 的逻辑,是在这里实现的(继承父进程的)。但是,对于 usyscall ,因为 PID 进程创建之后即保持不变,所以其实不需要管这个。

至此,本题实现完成。

话说回来,trapframe 的逻辑是在哪里实现的呢?

引用trapframe 和 C 语言函数调用栈帧(call stack frame)是完全不同的两件事。这里的 "frame" 是指 "一份保存下来...

trapframe 和 C 语言函数调用栈帧(call stack frame)是完全不同的两件事。这里的 "frame" 是指 "一份保存下来的 CPU 状态快照"。

它的完整逻辑分布在三个文件中:


  1. 数据结构定义 — kernel/proc.h 第 43-80 行
struct trapframe {
  /*   0 */ uint64 kernel_satp;   // kernel page table
  /*   8 */ uint64 kernel_sp;     // top of process's kernel stack
  /*  16 */ uint64 kernel_trap;   // usertrap()
  /*  24 */ uint64 epc;           // saved user program counter
  /*  32 */ uint64 kernel_hartid; // saved kernel tp
  /*  40 */ uint64 ra;
  /*  48 */ uint64 sp;
  /*  56 */ uint64 gp;
  ...
  /* 280 */ uint64 t6;
};

注意每个字段旁边的注释数字——那是字节偏移量。这就是为什么 trampoline.S 里会出现 sd ra, 40(a0) 这种写法:ra 寄存器在 trapframe 结构体中偏移量就是 40。


  1. 汇编层:寄存器的物理保存/恢复 — kernel/trampoline.S

这是整个机制的核心,分两段:

进入内核时(uservec,第 22 行):

用户态发生 trap
  → 硬件切换到内核态,跳转到 uservec
  → 把所有用户寄存器 sd (store) 到 TRAPFRAME 地址处
     sd ra, 40(a0)    // 保存到 trapframe->ra (偏移 40)
     sd sp, 48(a0)    // 保存到 trapframe->sp (偏移 48)
     ...
  → 从 trapframe 加载内核上下文
     ld sp, 8(a0)     // 加载 kernel stack
     ld t0, 16(a0)    // 加载 usertrap() 地址
     ld t1, 0(a0)     // 加载 kernel page table
  → 切换页表
  → jalr t0           // 跳转到 C 代码 usertrap()

返回用户态时(userret,第 101 行):

C 代码 usertrap() 返回后
  → 切换回用户页表
  → 从 TRAPFRAME 恢复所有用户寄存器
     ld ra, 40(a0)
     ld sp, 48(a0)
     ...
  → sret             // 硬件切回用户态

  1. C 层:填入内核侧信息 — kernel/trap.c

进入时(usertrap,第 38 行):

p->trapframe->epc = r_sepc();   // 保存用户被中断时的 PC
// ... 处理系统调用、中断、缺页异常 ...

返回前(prepare_return,第 100 行):

p->trapframe->kernel_satp = r_satp();         // 内核页表地址
p->trapframe->kernel_sp = p->kstack + PGSIZE; // 内核栈顶
p->trapframe->kernel_trap = (uint64)usertrap; // 下次 trap 跳到哪
p->trapframe->kernel_hartid = r_tp();         // CPU 编号

这样下次进程再 trap 时,trampoline.S 里的 uservec 就能从 trapframe 里读取到正确的内核栈、内核页表、和 C 处理函数的地址。


完整数据流

用户态运行中
    │
    ▼ (trap: 系统调用/中断/异常)
uservec (trampoline.S)
    │  保存用户寄存器 ──→ trapframe.ra, .sp, .a0...
    │  加载内核上下文 ←── trapframe.kernel_sp, .kernel_satp, .kernel_trapusertrap() (trap.c)
    │  处理 trap 逻辑
    │
prepare_return() (trap.c)
    │  写入内核上下文 ──→ trapframe.kernel_sp, .kernel_satp...
    │
userret (trampoline.S)
    │  恢复用户寄存器 ←── trapframe.ra, .sp, .a0...
    ▼
用户态继续运行

所以 trapframe 本质上是一个交换区

  • 进内核时:用户寄存器卸货到这里,内核上下文从这里装货
  • 出内核时:内核上下文卸货到这里,用户寄存器从这里装货

它和 C 语言的函数调用栈(那个是编译器自动生成的 prologue/epilogue,靠 spfp 操作)完全不是一回事。这里的每一个 sd/ld 都是手工写的汇编。

虽然说是 easy,但是我一边拷打 AI 一边自己想,写了差不多有 5 个多小时...

大概归纳一下吧,首先,我发现我对多级页表不熟,然后简单手写整理了一下:

字好丑...

hint 里面说:参考 freewalk 函数:

代码C · 20 行
// Recursively free page-table pages.
// All leaf mappings must already have been removed.
void
freewalk(pagetable_t pagetable)
{
  // there are 2^9 = 512 PTEs in a page table.
  for(int i = 0; i < 512; i++){
    pte_t pte = pagetable[i];
    if((pte & PTE_V) && (pte & (PTE_R|PTE_W|PTE_X)) == 0){
      // this PTE points to a lower-level page table.
      uint64 child = PTE2PA(pte);
      freewalk((pagetable_t)child);
      pagetable[i] = 0;
    } else if(pte & PTE_V){
      // backtrace();
      panic("freewalk: leaf");
    }
  }
  kfree((void*)pagetable);
}

大致模仿其框架就可以实现了。

有一个疑惑点:为什么判定一个 PTE 是一个指向下一层 page table 的 PTE 的条件是:

// 此条件等于该 pte 不是终末层,而是指向下一层
(pte & (PTE_R|PTE_W|PTE_X)) == 0

其实是 RISC-V 的规定,指向下一层页表的 PTE(非叶子 PTE),其 R、W、X 位必须全为 0。只有指向最终数据页的 PTE(叶子 PTE),才允许设置 R/W/X。

分析源码也可得到印证:

# kernel/vm.c mappages()
if(*pte & PTE_V)
  panic("mappages: remap");
*pte = PA2PTE(pa) | perm | PTE_V;

这里传入的 perm,保证了一定包含 R W X 其一,否则 panic。

然后开始实现 vmprint,因为参数锁死了,所以需要设置一个辅助函数来实现递归遍历:

代码C · 26 行
void
vmprint_recur(pagetable_t pagetable, int level, uint64 va)
{
  for (int i = 0; i < 512; ++i) {
    pte_t pte = pagetable[i];
    if (pte & PTE_V) { // is a valid PTE
      // 这里记得强转 i
      uint64 newva = va | ((uint64)i << PXSHIFT(level));
      for (int j = 0; j <= 2 - level; j++) {
        printf(" ..");
      }
      printf("%p: pte %p pa %p\n", (void *)newva, (void *)pte, (void *)PTE2PA(pte));
      if ((pte & (PTE_R|PTE_W|PTE_X)) == 0) {
        vmprint_recur((pagetable_t)PTE2PA(pte), level - 1, newva);
      }
    }
  }
}

void
vmprint(pagetable_t pagetable)
{
  printf("page table %p\n", pagetable);
  // !!从 2 开始 而不是从 1 开始!!
  vmprint_recur(pagetable, 2, 0);
}

别的都还好,主要是这个计算 newva,着重说明一下:

pagetable[i] 里的 i 和 VA 是线性对应的,我开始以为,像 for(int i=0;i<512;i++) pagetable[i] 这样的遍历得到的是毫无逻辑杂乱无章的,但是实际上,考虑多级页表的原理,这个 i 就是对应虚拟地址 va 的第 i 段。更具体地说:

引用真正的原理是:每个页表项代表一段虚拟地址范围。newva 是在计算当前 PTE 所代表范围的起始虚拟地址。 先不要想位运算,先想“切分地址空间”...

真正的原理是:每个页表项代表一段虚拟地址范围。newva 是在计算当前 PTE 所代表范围的起始虚拟地址。

先不要想位运算,先想“切分地址空间”

每张页表有 512 个 PTE,因此它会把当前负责的地址范围平均切成 512 份。

level = 2时:

根页表管理整个 Sv39 地址空间。

它的每个 PTE 负责 1 GiB($\frac{2^{39}}{2^9}=2^{30}bit=1GiB$)

所以根页表:

pagetable[0] → 从 0 GiB 开始
pagetable[1] → 从 1 GiB 开始
pagetable[2] → 从 2 GiB 开始
...

假设当前:

i = 3;

那么第 3 项所代表范围的起点就是:

3 × 1 GiB

即:

3 << 30

因为:

1 GiB = 2^30

level = 1

进入下一张页表后,它把刚才的 1 GiB 再切成 512 份。

每项负责:

1 GiB / 512 = 2 MiB

所以此时第 i 项产生的偏移是:

i × 2 MiB

而:

2 MiB = 2^21

因此:

i << 21

level = 0

最后一级页表又把 2 MiB 切成 512 份。

每项负责:

2 MiB / 512 = 4 KiB

因此第 i 项产生的偏移:

i × 4 KiB

而:

4 KiB = 2^12

所以:

i << 12

PXSHIFT(level) 的根本含义

#define PXSHIFT(level) (12 + 9 * level)

它计算的其实是:当前层一个 PTE 所代表区域的大小是 2^PXSHIFT(level) 字节。

因为:

level 0:一个 PTE 管 4 KiB  = 2^12
level 1:一个 PTE 管 2 MiB  = 2^21
level 2:一个 PTE 管 1 GiB  = 2^30

所以:

(uint64)i << PXSHIFT(level)

等价于:

i * 当前层每个 PTE 负责的字节数

完整例子

假设遍历路径是:

根页表[1]
  → 中间页表[3]
    → 最低页表[5]

初始:

va = 0;

根页表第 1 项

每项管 1 GiB

newva = 0 + 1 * 1 GiB;
      = 0x40000000;

现在已经确定:地址位于第一个 1 GiB 区域。

下一层第 3 项

每项管 2 MiB

newva = 0x40000000 + 3 * 2 MiB;
      = 0x40000000 + 0x600000;
      = 0x40600000;

最后一层第 5 项

每项管 4 KiB

newva = 0x40600000 + 5 * 4 KiB;
      = 0x40600000 + 0x5000;
      = 0x40605000;

所以这条页表路径对应的虚拟页是:

0x40605000

为什么代码写 | 而不是 +

newva = va | ((uint64)i << PXSHIFT(level));

可以先把它理解为:

newva = va + i * 当前层每项负责的大小;

在这里,|+ 结果相同,因为每层使用不同的地址位,不会重叠。

所以这句代码最直白的翻译是:

当前区域的起点
+
当前页表第 i 项在区域内的偏移
=
当前 PTE 所代表区域的起点

level 不是用来计算 i 的。它只决定:

当前页表的每个 PTE 到底代表 4 KiB、2 MiB,还是 1 GiB。

调用递归函数的时候,传入的参数也是个问题;以及最重要的,如何计算虚拟地址 va?这两个问题放在一起说明。

首先,务必要明晰这个模型:

[ VPN2 ][ VPN1 ][ VPN0 ][ offset ]
                              ↑ 12 位

而且通过阅读 walk 函数,我们也容易注意到,level 的取值只有 0 1 2,而且是越开始越高,所以,开始传入的 level 肯定是 2。

举个例子,也可以看宏:

#define PXMASK          0x1FF // 低 9 位是 1
#define PXSHIFT(level)  (PGSHIFT+(9*(level)))
#define PX(level, va) ((((uint64) (va)) >> PXSHIFT(level)) & PXMASK)

如果要提取 VPN[0],那么就是要计算 (va >> 12) & 0x1FF

右移 12 位的含义是:丢掉页内偏移,把第 12~20 位移动到最低位,然后和 0b111111111 进行与运算,自然就得到了那一块是什么。

现在,再考虑 newva 是怎么计算出来的(uint64 newva = va | ((uint64)i << PXSHIFT(*level*));),就很清晰了,就是在上一次的基础上,累计这一层的偏移量。

每次遍历都是每页表的。

这里记得强转 i

总之,感觉我对概念的理解实在是太差了,有点难办呢。花了 5 个小时去解决这么一个 easy 的题目确实有点挫败感了,不过也没办法,继续写下去吧。

Use superpages

看到这个 Hard 的题目,有点慌,所以去网上搜了搜这些知识的解析,结果意外发现一些不错的资料:

简单摘录一些:

  • kvminithart 函数,很重要:

    这个函数首先设置了 SATP 寄存器,kernel_pagetable 变量来自于 kvminit 第一行。所以这里实际上是内核告诉 MMU 来使用刚刚设置好的 page table。当这里这条指令执行之后,下一个指令的地址会发生什么?

    在这条指令之前,还不存在可用的 page table,所以也就不存在地址翻译。执行完这条指令之后,程序计数器(Program Counter)增加了 4。而之后的下一条指令被执行时,程序计数器会被内存中的 page table 翻译

    所以这条指令的执行时刻是一个非常重要的时刻。因为整个地址翻译从这条指令之后开始生效,之后的每一个使用的内存地址都可能对应到与之不同的物理内存地址。因为在这条指令之前,我们使用的都是物理内存地址,这条指令之后 page table 开始生效,所有的内存地址都变成了另一个含义,也就是虚拟内存地址。

  • 为什么通过 3 级 page table 会比一个超大的 page table 更好呢?

    Frans 教授:这是个好问题,这的原因是,3 级 page table 中,大量的 PTE 都可以不存储。比如,对于最高级的 page table 里面,如果一个 PTE 为空,那么你就完全不用创建它对应的中间级和最底层 page table,以及里面的 PTE。所以,这就是像是在整个虚拟地址空间中的一大段地址完全不需要有映射一样。

  • proc_mapstacks()函数比较有趣,参考 MIT6.s081/6.828 lectrue4:page tables 以及 Lab3 心得 - byFMH - 博客园 的部分阅读

  • pageguard 的作用是什么?

    保护页是一页故意禁止访问的虚拟内存,放在栈旁边,用来检测栈溢出。

    例如内核栈:

    kernel stack
    guard page    ← 不映射
    kernel stack
    guard page

    如果内核栈不断向下增长,越过边界访问保护页,就会触发 page fault,最终使内核 panic

    这样比栈悄悄覆盖其他内核数据要好:尽早暴露错误,而不是继续带着损坏的数据运行。

    xv6 中有两种:

    • 内核栈保护页:PTE 无效,内核访问也会 fault。
    • 用户栈保护页:清除 PTE_U,用户态不能访问,栈溢出时进程会 fault。
  • 理解这一节学生问的问题很重要:4.5 Kernel Page Table | MIT6.S081

    内核页表也存在对用户进程的映射,而且对于内核而言,va == pa,是相同的。

总之,把这些内容读完了之后(虽然还是有点混乱),再开始实现这个 Lab:

目的是实现 superpage:

  • RISC-V 默认页面大小为 4KB
  • 这里需要实现大小为 2MB 的页

阅读 hint:

阅读 user/pgtbltest.c 中的 superpg_forksuperpg_free

自己看有点费劲,让 Agent 代劳:

引用关键背景定义 宏/函数 值/作用 SUPERPGSIZE 2MB (2,097,152 字节) — 一个 superpage 的大小 SZ 8...

关键背景定义

宏/函数 值/作用
SUPERPGSIZE 2MB (2,097,152 字节) — 一个 superpage 的大小
SZ 8 * SUPERPGSIZE = 16MB
SUPERPGROUNDUP(a) 将地址 向上 对齐到 2MB 边界
SUPERPGROUNDDOWN(a) 将地址 向下 对齐到 2MB 边界
pgpte(void *va) 系统调用,返回当前进程页表中 va 对应的 PTE
SBRK_ERROR (char *)-1,sbrk 失败的返回值

superpg_fork() — 测试 fork 是否正确复制 superpage

这个函数分 两个阶段

第一阶段(第 138-156 行):验证 fork 能正确复制 superpage

sbrk(SZ=16MB)
    │
    ▼
┌─────────────────────────────┐
│  82MB superpage         │  ← 父进程地址空间
│  (共 16MB)                  │
└─────────────────────────────┘
    │
    │ supercheck(end) — 父进程验证 superpage 存在
    │
    │ fork()
    │
    ├── 父进程:wait()
    │
    └── 子进程:supercheck(end) — 子进程也必须看到相同的 superpage
                  exit(0)

核心逻辑:如果 fork 正确复制了页表(包括 superpage 映射),那么子进程的地址空间中,相同的虚拟地址范围也应该有 superpage 映射,PTE 属性应该完全一致。

第二阶段(第 159-173 行):验证释放后 fork 的子进程不能访问已释放内存

sbrk(-16MB)  — 释放所有内存

fork()
    │
    ├── 父进程:wait(&status)
    │           如果 status == 0(子进程正常退出),说明子进程成功访问了
    │           已释放的内存 → 测试失败!
    │
    └── 子进程:*(end + 1) = '9'  — 尝试写入已被父进程释放的内存
                 如果内核正确工作 → 页错误 → 内核杀死子进程 (status0)

这是一个 安全测试:确保进程释放内存后,fork 出的子进程不能访问那块已经被释放的内存。

supercheck() 辅助函数(第 94-128 行)

这个函数验证一块以 end 结束的内存区域是否使用了 superpage:

  1. 第 98-105 行:检查 end 到下一个 2MB 对齐边界之间的普通页面(非 superpage 部分),确保每个都有有效的 PTE
  2. 第 107-118 行:检查对齐边界之后的 512 个 PGSIZE 页面(即一个 2MB superpage),验证:
    • 所有 PTE 非零(映射存在)
    • 所有 PTE 值完全相同(这是 superpage 的关键特征 — 一个 L2 级别的 PTE 覆盖整个 2MB 区域)
    • PTE 有 PTE_V | PTE_R | PTE_W 标志
  3. 第 120-127 行:对 superpage 区域进行读写测试,验证数据正确性

好的,让我用更直观的方式一步步拆解 superpg_free 的逻辑。核心困惑可能在于:sbrk 操作的对象是什么,以及 SUPERPGROUNDDOWN 对齐到底在做什么


前置知识

  • sbrk(n):将进程的堆(heap)扩大/缩小 n 字节,返回旧的堆顶地址
  • sbrk(0):不改变堆大小,只返回当前堆顶地址(用来查询堆顶在哪)
  • 堆是连续增长的,从低地址向高地址。sbrk(正数) = 堆向上增长,sbrk(负数) = 堆向下收缩

堆的内存布局大概是:

低地址
├── code / data
├── heap(已分配)  ← sbrk 管理的区域
├── ... 未分配 ...
└── stack
高地址

逐行拆解 superpg_free

第 1 步:分配 16MB(第 185-188 行)

char *end = sbrk(SZ);  // SZ = 8 * SUPERPGSIZE = 16MB

此时 end = 分配前的旧堆顶(也是新分配区域的起始地址)。画成图:

                    end(旧堆顶)
                    │
                    ▼
┌───────────────────┼────────────────────────────────┐
│   原来的 heap      │     新分配的 16MB               │
│                   │   = 82MB superpage          │
└───────────────────┼────────────────────────────────┘
                    ▲                                ▲
                    │                                │
                  end                        end + 16MB
                                            = 新堆顶

此时 sbrk(0) 会返回 end + 16MB


第 2 步:释放 "超出一个 superpage 边界" 的部分(第 190-193 行)

这是最关键也最容易迷惑的部分。让我画出来:

char *a = sbrk(0);                      // a = 当前堆顶
uint64 s = SUPERPGROUNDDOWN((uint64)a); // s = 将堆顶向下对齐到 2MB 边界
sbrk(-((uint64)a - s));                 // 释放 a-s 字节
a = sbrk(0);                            // a = 新堆顶(现在对齐到 2MB 了)

为什么需要这一步? 因为 sbrk(16MB) 分配的 16MB 是恰好 8 个 superpage,但如果堆在分配之前不是 2MB 对齐的,那这 16MB 的结束位置也不会是 2MB 对齐的。后面测试需要堆顶恰好对齐到 2MB 边界,所以这一步把"超出去"的碎片裁掉。

画成具体例子(假设堆顶值不对齐):

假设堆顶 a = 0x10_300_000(不对齐到 2MB)

SUPERPGROUNDDOWN(0x10_300_000)
= 向下对齐到 2MB
= 0x10_200_000  ← 这个就是 s

差值 = a - s = 0x10_300_000 - 0x10_200_000 = 0x100_000 = 1MB

sbrk(-1MB) = 释放 1MB,堆顶回退到 0x10_200_000

画成图:

代码ABNF · 19 行
释放前:
┌────────────────────────────────────────┬─────────────┐
│         完整的 superpage 们             │  碎片部分    │
│         每个 2MB                        │  (< 2MB)    │
└────────────────────────────────────────┴─────────────┘
                                         ▲             ▲
                                         │             │
                                    s = 2MB 边界    a = 堆顶
                                    (对齐)         (不对齐)

释放 sbrk(-(a-s)) 后:
┌────────────────────────────────────────┐
│         完整的 superpage 们             │
│         每个 2MB                        │
└────────────────────────────────────────┘
                                         ▲
                                         │
                                    a = 新堆顶 = s
                                    (恰好对齐 2MB)

简化理解:这一步就是"裁边",确保堆顶恰好落在 2MB 的边界上。此时 sbrk(0) 返回的地址是 2MB 对齐的。


第 3 步:验证最后两个 4KB 页属于同一个 superpage(第 195-199 行)

pte_t pte1 = (pte_t) pgpte((void *)(a - PGSIZE));      // 倒数第 1 页的 PTE
pte_t pte2 = (pte_t) pgpte((void *)(a - 2*PGSIZE));     // 倒数第 2 页的 PTE
if (pte1 != pte2) {
    err("not a super page");
}
堆顶 a2MB 对齐)
│
│  a - 1*PGSIZE  ← 最后一个 4KB 页  → pte1
│  a - 2*PGSIZE  ← 倒数第 24KB 页 → pte2
│  ...
│  a - 512*PGSIZE (= a - 2MB) ← superpage 的起始地址
│
▼

如果 pte1 == pte2,说明这两个 4KB 页面共享同一个 2MB 级别的 PTE → 它们属于同一个 superpage ✓

(如果它们是不相关的普通 4KB 页面,PTE 值会不同,因为物理地址不同。)


第 4 步:写入数据(第 201-203 行)

*(a - PGSIZE + 1) = '8';      // 倒数第 1 页
*(a - 2*PGSIZE + 1) = '9';    // 倒数第 2 页
┌──────────────────────────────┬───────────┬───────────┐
│   剩余 superpage 部分         │ 倒数第 2 页  │ 倒数第 1 页  │
│   ...                        │ 写 '9'    │ 写 '8'    │
└──────────────────────────────┴───────────┴───────────┘
                                              ▲
                                              │
                                          a = 堆顶

第 5 步:释放最后 4KB → 触发 superpage 拆分!(第 205-207 行)

sbrk(-PGSIZE);   // 释放最后 4KB
a = sbrk(0);     // 新堆顶
释放前:
┌──────────────────────────────┬───────────┬───────────┐
│   一个 2MB superpage          │   '9''8'     │
│   (5124KB 页,共享 1 个 PTE)     │           │           │
└──────────────────────────────┴───────────┴───────────┘
                                              ▲
                                              │
                                         旧堆顶 a

释放 sbrk(-PGSIZE) 后:
┌──────────────────────────────┬───────────┐
│   被拆分为 510 个独立 4KB 页  │   '9'     │  释放掉的 '8'
│   (不再是 1 个 superpage)     │  保留     │  ← 应该不可访问
└──────────────────────────────┴───────────┘
                               ▲
                               │
                          新堆顶 a

这是核心测试点:当 sbrk(-PGSIZE) 只释放 superpage 的最后一个 4KB 页时,内核必须将原来的 2MB superpage 拆分为 511 个独立的 4KB 普通页 + 释放掉最后 1 页。不能简单地释放整个 2MB。


第 6 步:验证拆分后数据不丢失(第 209-211 行)

if (*(a - PGSIZE + 1) != '9') {
    err("lost content after freeing part of super page");
}

释放后 a 是新堆顶。a - PGSIZE 现在是最后一个仍有效的 4KB 页(之前写的 '9'),数据必须还在。


第 7 步:fork 验证隔离性(第 213-230 行)

if ((pid = fork()) < 0) {
    ...
} else if (pid == 0) {
    // 子进程尝试访问 a 处的内存(已被父进程释放)
    if (*(a + 1) == '9') {
        exit(0);  // 如果能读到 → 安全漏洞!
    }
}
  • 父进程释放了 a 处开始的 4KB 页
  • 子进程 fork 时,这页不应该出现在子进程的地址空间中
  • 如果子进程访问 *(a + 1) 成功且正常退出 → 测试失败(隔离性被破坏)
  • 正确行为:子进程页错误 → 被内核杀死 → 父进程看到 status ≠ 0测试通过

第 8-9 步:验证 PTE 清理 + 逐页释放(第 232-244 行)

pte1 = (pte_t)pgpte((void *)a);
if (pte1 != 0) {
    err("pte for freed memory is valid");  // 已释放页的 PTE 必须为 0
}

s = SUPERPGROUNDDOWN((uint64)a);  // 找到这个(已拆分)superpage 的起始地址
for (; (uint64)a > s; a -= PGSIZE) {
    a = sbrk(-PGSIZE);           // 逐页释放
    pte1 = (pte_t)pgpte(sbrk(0));
    if (pte1 != 0) {
        err("page hasn't been freed");  // 每释放一页,其 PTE 必须变 0
    }
}

循环的意思是:从当前堆顶开始,一页一页往下释放,每释放一页就检查该页的 PTE 是否已经变为 0。一直释放到这个 2MB 区域的起始边界 s


整体逻辑总结

superpg_free 的测试链路:

分配 16MB
  → 裁边让堆顶对齐 2MB
    → 确认最后两页属于同一个 superpage
      → 写入测试数据
        → 只释放最后 4KB(迫使 superpage 拆分)
          → 验证:剩余数据完好 ✓
          → 验证:子进程不能访问释放页 ✓
          → 验证:释放页 PTE = 0 ✓
          → 逐页释放剩余页,验证每页 PTE 归零 ✓

一句话概括:验证当只释放 superpage 的一部分时,内核能否正确地将 superpage "打散"为普通页面,同时保持未释放部分的数据完整和进程间隔离。


两个测试的关系

测试 验证内容
superpg_fork superpage 在 fork 时能否完整复制,以及释放后子进程不能访问已释放内存
superpg_free superpage 部分释放时能否正确拆分为普通页,数据完整性,以及释放后的隔离性

两者共同覆盖了 xv6 中 superpage(2MB 大页)支持的核心正确性:创建、复制、拆分、释放、隔离

再看下一个 hint:

一个好的起点是 kernel/sysproc.c 中的 sys_sbrk ,它由 sbrk 系统调用调用。跟踪代码路径到 growproc 函数,该函数为 sbrk 立即分配内存。

代码C · 26 行
// kernel/sysproc.c
uint64
sys_sbrk(void)
{
  uint64 addr;
  int t;
  int n;

  argint(0, &n); // 第 0 个参数:要增长/缩减的字节数
  argint(1, &t); // 第 1 个参数:分配策略 (SBRK_EAGER=1 或 SBRK_LAZY=2)
  addr = myproc()->sz; // 保存旧堆顶地址,作为返回值

  if(t == SBRK_EAGER || n < 0) {
    if(growproc(n) < 0) {
      return -1;
    }
  } else {
    // Lazily allocate memory for this process: increase its memory
    // size but don't allocate memory. If the processes uses the
    // memory, vmfault() will allocate it.
    if(addr + n < addr)
      return -1;
    myproc()->sz += n;
  }
  return addr;
}

这里可能会误以为 sbrk() 有两个参数,但实际上是用户态有两个调用入口:

// user/ulib.c
char* sbrk(int n)     { return sys_sbrk(n, SBRK_EAGER); }  // 立即分配
char* sbrklazy(int n)  { return sys_sbrk(n, SBRK_LAZY);  }  // 惰性分配

立即分配:调用 growproc(n) 立即调配物理内存:

代码C · 20 行
// kernel/proc.c
// Shrink user memory by n bytes.
// Return 0 on success, -1 on failure.
int
growproc(int n)
{
  uint64 sz;
  struct proc *p = myproc();

  sz = p->sz;
  if(n > 0){
    if((sz = uvmalloc(p->pagetable, sz, sz + n, PTE_W)) == 0) {
      return -1;
    }
  } else if(n < 0){
    sz = uvmdealloc(p->pagetable, sz, sz + n);
  }
  p->sz = sz;
  return 0;
}
  • n > 0 则分配物理页 + 建立页表的映射(在 uvmalloc 函数里面建立)
  • n < 0 则立即释放物理页

这里注意,n < 0 时则无视懒分配策略

懒分配

只改 p->sz,不分配物理内存,实际分配推迟到 page fault 发生时:"vmfault() will allocate it.":

代码C · 27 行
// kernel/vm.c
// allocate and map user memory if process is referencing a page
// that was lazily allocated in sys_sbrk().
// returns 0 if va is invalid or already mapped, or if
// out of physical memory, and physical address if successful.
uint64
vmfault(pagetable_t pagetable, uint64 va, int read)
{
  uint64 mem;
  struct proc *p = myproc();
  
  if (va >= p->sz)
    return 0;
  va = PGROUNDDOWN(va);
  if(ismapped(pagetable, va)) {
    return 0;
  }
  mem = (uint64) kalloc();
  if(mem == 0)
    return 0;
  memset((void *) mem, 0, PGSIZE);
  if (mappages(p->pagetable, va, PGSIZE, mem, PTE_W|PTE_U|PTE_R) != 0) {
    kfree((void *)mem);
    return 0;
  }
  return mem;
}

核心就是:在 va < p->sz (合法但未分配)时,分配内存。

为什么 myproc()->sz 是旧堆顶地址?

本质还是 xv6 的用户进程的内存布局:

[!tip]

这里肯定是用户进程,因为 sbrk 是用户态的时候调用的 syscall

0x0
├── text (代码)
├── data (数据)
├── guard page (保护页)
├── heap (堆) ← sbrk 管理,向上增长
│   ...
│   └── 堆顶 = p->sz
│
│   (未分配)
│
└── MAXVA
    └── trampoline / trapframe

解析一下 ismapped 函数:

int
ismapped(pagetable_t pagetable, uint64 va) {
  pte_t *pte = walk(pagetable, va, 0);
  if (pte == 0) {
    return 0;
  }
  if (*pte & PTE_V){
    return 1;
  }
  return 0;
}

首先理解 walk(pagetable, va, 0) 的返回值:非零指针则为存在,0(NULL)则为不存在。

那么,如果是空指针肯定要首先避免掉,不然后面会出异常。

然后,只有 *pte & PTE_V 才符合。

再看下一个 hint:

你的内核需要能够分配和释放 2MB 的区域。修改 kalloc.c 以预留一些两兆字节的物理内存区域,并创建 superalloc()superfree() 函数。你只需要少量两兆字节的内存块。

什么叫「预留一些内存区域」?我们可以阅读源码得到答案。

首先,看到 main.c

代码C · 37 行
// start() jumps here in supervisor mode on all CPUs.
void
main()
{
  if(cpuid() == 0){
    consoleinit();
    printfinit();
    printf("\n");
    printf("xv6 kernel is booting\n");
    printf("\n");
    kinit();         // physical page allocator
    kvminit();       // create kernel page table
    kvminithart();   // turn on paging
    procinit();      // process table
    trapinit();      // trap vectors
    trapinithart();  // install kernel trap vector
    plicinit();      // set up interrupt controller
    plicinithart();  // ask PLIC for device interrupts
    binit();         // buffer cache
    iinit();         // inode table
    fileinit();      // file table
    virtio_disk_init(); // emulated hard disk
    userinit();      // first user process
    __sync_synchronize();
    started = 1;
  } else {
    while(started == 0)
      ;
    __sync_synchronize();
    printf("hart %d starting\n", cpuid());
    kvminithart();    // turn on paging
    trapinithart();   // install kernel trap vector
    plicinithart();   // ask PLIC for device interrupts
  }

  scheduler();        
}

注意到,这里 kinit(); // physical page allocator ,一个定义在 kalloc.c 里面的函数,起到了分配物理页的作用,其实也就是分配了空闲列表。

这个在启动 xv6 的部分也有提到。

再去看到 kalloc.c

代码C · 18 行
extern char end[]; // first address after kernel.
                   // defined by kernel.ld.

struct run {
  struct run *next;
};

struct {
  struct spinlock lock;
  struct run *freelist;
} kmem;

void
kinit()
{
  initlock(&kmem.lock, "kmem");
  freerange(end, (void*)PHYSTOP);
}

kmem 就是一个带锁的空闲列表,看到 kinit,暂时忽略锁的部分(后续章节再涉及),发现调用了 freerange 函数,那么再看:

void
freerange(void *pa_start, void *pa_end)
{
  char *p;
  p = (char*)PGROUNDUP((uint64)pa_start);
  for(; p + PGSIZE <= (char*)pa_end; p += PGSIZE)
    kfree(p);
}

这里 PGROUNDUP 宏的作用是向上匀,保证开始的地址是 PGSIZE 的整数倍。

可以发现,是从新的开始处开始,直到 pa_end,一页一页地调用 kfree(p) 来释放。

为什么要定义成 char*?这和字符串没有任何关系。用 char * 纯粹是因为 char 在 C 语言里就是 1 字节的别名

核心原因:指针算术

当你写 p + 1 时,实际加的字节数取决于指针的类型:

char*   p + 1  →  地址 + 1 字节   ✓ 精细到每个字节
int*    p + 1  →  地址 + 4 字节   ✗ 太粗糙了
uint64* p + 1  →  地址 + 8 字节   ✗ 太粗糙了

操作物理内存地址时,需要逐字节级别的控制(比如对齐到某个地址、跳过某个字节数)。charsizeof 保证为 1,所以 char * 是唯一的、自然的、"步长为 1" 的指针类型。

void * 不行吗?不行——标准 C 不允许对 void * 做算术运算(p + PGSIZE 会编译报错)。


看具体代码:这里的 p += PGSIZE 就是在做字节级的地址偏移

  • PGSIZE = 4096
  • pchar *,所以 p += 4096 就是让地址前进 4096 字节(正好一页)

如果 puint64 *,那 p += 4096 会让地址前进 4096 × 8 = 32768 字节,直接废了。

那么我们再看 kfree()

代码C · 22 行
// Free the page of physical memory pointed at by pa,
// which normally should have been returned by a
// call to kalloc().  (The exception is when
// initializing the allocator; see kinit above.)
void
kfree(void *pa)
{
  struct run *r;

  if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
    panic("kfree");

  // Fill with junk to catch dangling refs.
  memset(pa, 1, PGSIZE);

  r = (struct run*)pa;

  acquire(&kmem.lock);
  r->next = kmem.freelist;
  kmem.freelist = r;
  release(&kmem.lock);
}

首先是一个合理性校验,虽然读 OSTEP 的时候说,这一块主要是硬件 MMU 实现的,不知道为什么这里在 OS 代码层面实现了。

然后是把这一块内存填满垃圾。

最后,这一块就是保证原本的 r->next 不丢失,从而释放掉原本的 pa 处的页。

...太久没手写代码,这个地方都思考了一会儿。得写点 Leetcode 的链表题了。

干脆读完,看 kalloc 函数:

代码C · 18 行
// Allocate one 4096-byte page of physical memory.
// Returns a pointer that the kernel can use.
// Returns 0 if the memory cannot be allocated.
void *
kalloc(void)
{
  struct run *r;

  acquire(&kmem.lock);
  r = kmem.freelist;
  if(r)
    kmem.freelist = r->next;
  release(&kmem.lock);

  if(r)
    memset((char*)r, 5, PGSIZE); // fill with junk
  return (void*)r;
}

别的没什么,这里我开始有个疑惑:这里明明是分配的栈上的指针 r,函数结束之后不应该就消失了吗?

但是实际上,r 存的是地址,不是内存本身。这个指针变量确实在栈上,函数返回后它作为局部变量就销毁了。

总之,读完这部分代码之后,再去理解「预留」:实际上就是 freerange 这一块:

for(; p + PGSIZE <= (char*)pa_end; p += PGSIZE)

每次只按照 PGSIZE 为单位进行分配,那当然就没有 superpage 的份了。

那么我们动手开始实现:

仿照 4KB 页的 freelist 照抄一个:

struct {
  struct spinlock lock;
  struct run *freelist;
} ksupermem;

顺便,在 kernel/riscv.h 里面定义一下 hint 中所说的 a handful of 具体是多少,考虑到 xv6 的内存没多大,姑且设置成 8 个:

#define SUPERPGAMOUNT 16

然后,逻辑基本仿照即可。

这里留一个疑问,设置 cnt_superpage 是在 superfree 还是 superalloc?之后解决。

再看下两个 hint(一块实现):

支持大页的进程在 fork 时需分配大页,在退出时释放大页;你需要修改 uvmcopy()uvmunmap() 的相关逻辑。

sbrk 部分释放一个超级页(例如,释放一个超级页的最后 4096 字节)时,你需要将该超级页“降级”为普通页。

读一下代码:

代码C · 27 行
void
uvmunmap(pagetable_t pagetable, uint64 va, uint64 npages, int do_free)
{
  uint64 a;
  pte_t *pte;
  int sz = PGSIZE;                    // 每次跳 4KB(后面 superpage 需要改这里)

  if((va % PGSIZE) != 0)
    panic("uvmunmap: not aligned");

  for(a = va; a < va + npages*PGSIZE; a += sz){
    if((pte = walk(pagetable, a, 0)) == 0)   // ① 找 PTE
      continue;                              //    没找到 → 跳过(允许不存在)
    if((*pte & PTE_V) == 0)                  // ② V 位没置
      continue;                              //    已失效 → 跳过
    sz = PGSIZE;                             // ③ 重置 sz(后面 superpage 需要改这里)

    if(PTE_FLAGS(*pte) == PTE_V)
      panic("uvmunmap: not a leaf");         // ④ 必须是叶节点

    if(do_free){
      uint64 pa = PTE2PA(*pte);
      kfree((void*)pa);                      // ⑤ 释放物理页
    }
    *pte = 0;                                // ⑥ 清空 PTE
  }
}

注意这里的 do_free 参数

  • 如果是普通用户内存页:
    • 物理页由 uvmalloc / kalloc 分配
    • 解除映射时必须同时释放物理内存
    • do_free = 1
  • 如果是特殊页 (trampoline / trapframe / usyscall)
    • 物理页被多个进程共享
    • 或者物理页的指针存在 proc 结构体里,单独管理
    • 物理内存由别处负责释放
    • do_free = 0

看例子:kernel/proc.c 里面:

// 场景:创建页表,先 map 了 trampoline,然后 map trapframe 失败了
if(mappages(pagetable, TRAPFRAME, ...) < 0){
    uvmunmap(pagetable, TRAMPOLINE, 1, 0);   // ← do_free=0
    uvmfree(pagetable, 0);
    return 0;
}

这里就是 do_free = 0,因为 trampoline 的物理页不能释放。

代码C · 34 行
int
uvmcopy(pagetable_t old, pagetable_t new, uint64 sz)
{
  pte_t *pte;
  uint64 pa, i;
  uint flags;
  char *mem;
  int szinc = PGSIZE;              // 步长(后面 superpage 需要改这里)

  for(i = 0; i < sz; i += szinc){
    if((pte = walk(old, i, 0)) == 0)    // ① 在父进程页表中找 i 对应的 PTE
      continue;                          //    没有 → 跳过
    if((*pte & PTE_V) == 0)             // ② PTE 无效
      continue;                          //    跳过

    szinc = PGSIZE;                      // ③ 重置步长
    pa = PTE2PA(*pte);                   // ④ 提取物理地址
    flags = PTE_FLAGS(*pte);             // ⑤ 提取权限位

    if((mem = kalloc()) == 0)            // ⑥ 分配新物理页
      goto err;
    memmove(mem, (char*)pa, PGSIZE);     // ⑦ 把父进程物理页内容拷贝过来

    if(mappages(new, i, PGSIZE, (uint64)mem, flags) != 0){  // ⑧ 映射到子进程页表
      kfree(mem);
      goto err;
    }
  }
  return 0;

 err:
  uvmunmap(new, 0, i / PGSIZE, 1);      // 失败回滚:释放已分配的所有页
  return -1;
}

现在两个函数都硬编码了 4KB 粒度szinc = PGSIZEsz = PGSIZE)。支持 superpage 后,walk 可能返回的是一个 L2 级别的 leaf PTE(代表 2MB 大页),所以才需要修改。

但是,这里有个问题:如何判断一个 PTE 是不是 superpage?LLM 的给的说法是,如果在 L1 层就发现这个 PTE 是 leaf page(L2 -> L1 -> L0),那么就是 superpage。

// return the page size of a specific pte
int
pagesize(pagetable_t pagetable, uint64 va)
{
  pte_t *pte;
  pte = &pagetable[PX(2, va)];
  if ((*pte & PTE_V) == 0) {
    return PGSIZE;
  }
  pagetable_t l1 = (pagetable_t)PTE2PA(*pte);
  pte = &l1[PX(1, va)];
  if ((*pte & PTE_V) && PTE_LEAF(*pte)) {
    return SUPERPGSIZE;
  }
  return PGSIZE;
}

然后,接下来这一部分写了我快两天...

先分析一下 uvmunmap,作用是解除 [va, va + npages*PGSIZE) 的映射。这个 PTE 可能是 4KB 页也可能是 2MB superpage。

对于 4KB 页正常处理,对于 superpage:

  • 首先判断其是否是 superpage,方法就是 if(pagesize(*pagetable*, a) == SUPERPGSIZE)

  • 找到一些关键的参数:

    uint64 sp_start = ((a % SUPERPGSIZE) == 0) ? a : SUPERPGROUNDDOWN(a); // 这里注意一下,这个宏有问题
    uint64 sp_end = sp_start + SUPERPGSIZE;
    uint64 unmap_end = va + npages * PGSIZE;
  • 然后,分三种情况:

    • 整块释放:a == sp_start && sp_end <= unmap_end,则就直接调用 superfree 即可

    • 部分释放:这个地方就需要考虑把当前 superpage 给降级成一个或多个 4KB 页,这里实现一个函数 split_superpage

      代码C · 40 行
      // Split a 2MB superpage into 512 individual 4KB pages.
      // Pages within [unmap_start, unmap_end) are left unmapped (L0[j]=0).
      // All other pages within the superpage are allocated new 4KB pages
      // with data copied from the original 2MB block.
      // The original 2MB physical block is freed.
      static void
      split_superpage(pagetable_t pagetable, uint64 sp_start,
                      uint64 unmap_start, uint64 unmap_end)
      {
        if (pagesize(pagetable, sp_start) != SUPERPGSIZE) {
          panic("split_sp: not superpage");
        }
        uint64 sp_end = sp_start + SUPERPGSIZE;
        unmap_end = unmap_end < sp_end ? unmap_end : sp_end;
        pte_t *l2_pte = &pagetable[PX(2, sp_start)];
        pagetable_t l1 = (pagetable_t)PTE2PA(*l2_pte);
        pte_t *sp_pte = &l1[PX(1, sp_start)];
        uint64 sp_pa = PTE2PA(*sp_pte);
        int flags = PTE_FLAGS(*sp_pte);
      
        pagetable_t l0 = (pagetable_t)kalloc();
        if (l0 == 0) {
          panic("split_sp: kalloc l0");
        }
        memset((void*)l0, 0, PGSIZE);
        for (int i = 0; i < 512; ++i) {
          uint64 va = sp_start + i * PGSIZE;
          if (va >= unmap_start && va < unmap_end) {
            continue;
          }
          uint64 new_page = (uint64) kalloc();
          if (new_page == 0) {
            panic("split_sp: kalloc page");
          }
          memmove((void*)new_page, (void*)(sp_pa + i * PGSIZE), PGSIZE);
          l0[i] = PA2PTE(new_page) | flags | PTE_V;
        }
        *sp_pte = PA2PTE(l0) | PTE_V;
        superfree((void*)sp_pa);
      }

      大致的思路就是原本是 L2 指向的 L1 后面直接指向一个 superpage,现在需要把这个 superpage 拆散成若干普通页,这些普通页记录在一个 L0 页表里面,最后把原本 L1 指向的 PTE(就是 superpage 的物理地址)直接修改为重新分配到的 L0 的位置

然后是修改 uvmcopy

原本的函数只是用 mappages 来建立虚拟地址到物理地址的映射,且只使用 4KB 页。为了支持 superpage,在 uvmcopy 中,如果原本的进程处的页是一个 superpage,子进程这里也应该是一个 superpage,所以我们必须要先创建一个 map_superpage

关于 map_superpage,无非就是 split_superpage逆操作:在子进程页表里新建一个 L1 叶子 PTE。

  • 首先取到 L1 页表,如果不存在自行创建(为什么 mappages 不需要「不存在则自行创建」这一步?因为在 walk 中解决了)
  • 然后,在 L1 页表的 PTE,原本该指向 L0 页表的 pte 的位置处,改为 PA2PTE(*pa*) | *perm* | PTE_V
  • 为什么这是一个 superpage?因为通过设置 flags,在 L1 层的 leaf page 当然就是一个 superpage。
  • 实现完这个之后,修改 uvmcopy 其实就照抄 4KB 的逻辑差不多了。

可以看到,和 mappages 相比,其实就是去掉了循环,然后提前一层实现了映射,顺便处理了原本该由 walk 函数处理的部分逻辑。

代码C · 23 行
// Create a 2MB superpage mapping at L1.
// va and pa must be 2MB-aligned. Returns 0 on success, -1 on failure.
static int
map_superpage(pagetable_t pagetable, uint64 va, uint64 pa, int perm)
{
  // Ensure the L1 page table exists (L2 already points to it)
  pte_t *l2 = &pagetable[PX(2, va)];
  pagetable_t l1;
  if(*l2 & PTE_V){
    l1 = (pagetable_t)PTE2PA(*l2);
  } else {
    if((l1 = (pagetable_t)kalloc()) == 0)
      return -1;
    memset(l1, 0, PGSIZE);
    *l2 = PA2PTE(l1) | PTE_V;
  }

  pte_t *l1_pte = &l1[PX(1, va)];
  if(*l1_pte & PTE_V)
    return -1;  // already mapped
  *l1_pte = PA2PTE(pa) | perm | PTE_V;  // R/W/X bits → hardware sees a leaf
  return 0;
}

最后,还需要改 uvmalloc 函数,因为目前还没有处理关于超级页的分配的逻辑。

这个其实自己没想到因为 hint 里面没有,还是对 vm.c 的呼叫依赖关系太不敏感了。

这里也不麻烦,如果剩余的范围有 2MB 并且地址是对齐的,就尝试调用 superalloc 分配一个,这里的逻辑和下面调用 kalloc 以分配正常页的逻辑没什么区别。

至此就完成了,测试的时候,先 make qemu,再输入 pgtbltest

后记一下的话,首先我觉得应该实现一个 superwalk 函数,然后在这里面判断一个 PTE 是否对应一个 superpage,顺便还能简化 map_superpage 的逻辑。

通过所有测试