文章

[mit6.s081] Lab3: page tables 实验记录

[mit6.s081] Lab3: page tables 实验记录

这是实验过程中参考的一些相关资源:

Lab 3:page tables

本实验基于 xv6-riscv 的 pgtbl 分支,围绕三个小实验展开:

  1. 遍历并打印多级页表;
  2. 建立用户只读共享页,让用户态快速读取 PID;
  3. 读取 RISC-V 页表中的访问位,报告哪些用户页面被访问过。

1. 总框架

用户程序使用虚拟地址,物理内存使用物理地址。页表把两者连接起来:

1
2
3
4
5
6
用户虚拟地址
  → 当前进程页表
  → 找到叶子 PTE
  → 得到物理页起始地址
  → 加上页内偏移
  → 得到最终物理地址

页表同时提供隔离和权限控制:

  • 每个进程可以拥有自己的用户地址空间;
  • 两个进程可以使用相同的虚拟地址,但映射到不同的物理页;
  • PTE_U 决定用户态能否访问;
  • PTE_R、PTE_W、PTE_X 决定读、写、执行权限。

页表不是存放普通数据的数组,而是一棵多级树。页表项的位置隐含虚拟页号,页表项内容保存物理页号和权限。

1.1 两个必须分清的概念

物理页分配

1
kalloc() / kfree()

解决:

1
哪些物理页空闲,如何分配和回收

页表映射

1
walk() / mappages() / uvmunmap()

解决:

1
某个虚拟页应该映射到哪个物理页

例如扩大用户堆时,完整流程是:

1
2
3
kalloc() 分配物理页
  → memset() 清零
  → mappages() 建立用户虚拟地址映射

只有物理页而没有映射,用户程序找不到它;只有映射而没有有效物理页,访问也会出错。


2. Sv39:虚拟地址如何拆分

xv6 使用 RISC-V Sv39 页表。页大小是:

1
PGSIZE = 4096 = 2^12

因此虚拟地址低 12 位是页内偏移。剩余的虚拟页号拆成三级索引:

字段位范围作用
VPN[2]30–38根页表索引
VPN[1]21–29第二级页表索引
VPN[0]12–20第一级页表索引
offset0–11页内字节偏移

每一级页表有 512 个 PTE:

1
2
4096 字节页表页 ÷ 8 字节 PTE = 512 个 PTE
512 = 2^9

所以每一级索引正好占 9 位。

flowchart TB
    accTitle: Sv39 地址转换框架
    accDescr: 虚拟地址被拆成三级页表索引和页内偏移,三级索引定位叶子 PTE。
    address[虚拟地址]
    vpn2[VPN 2 九位]
    vpn1[VPN 1 九位]
    vpn0[VPN 0 九位]
    offset[页内偏移十二位]
    root[根页表]
    second[第二级页表]
    third[第一级页表]
    leaf[叶子 PTE]
    physical[物理页加偏移]

    address --> vpn2 --> root
    address --> vpn1 --> second
    address --> vpn0 --> third
    address --> offset
    root --> second --> third --> leaf --> physical
    offset --> physical

2.1 具体计算例子

假设虚拟地址是:

1
VA = 0x0000000123456789

页内偏移:

1
2
offset = VA & 0xFFF
       = 0x789

三级索引:

1
2
3
VPN[0] = (VA >> 12) & 0x1FF = 86
VPN[1] = (VA >> 21) & 0x1FF = 282
VPN[2] = (VA >> 30) & 0x1FF = 4

假设根页表地址是 root,查找路径是:

1
2
3
4
5
6
7
8
root[4]
  → 第二级页表

第二级页表[282]
  → 第一级页表

第一级页表[86]
  → 叶子 PTE

假设叶子 PTE 保存的物理页起始地址是:

1
0x87654000

则最终物理地址为:

1
2
PA = 0x87654000 + 0x789
   = 0x87654789

这里必须记住:

1
2
3
页表项的位置:隐含虚拟页号
页表项内容:物理页号、有效位和权限
原虚拟地址低 12 位:页内偏移

PTE 不需要再次保存虚拟页号。


3. PTE 标志位

xv6 在 kernel/riscv.h 中定义:

1
2
3
4
5
6
#define PTE_V (1L << 0)
#define PTE_R (1L << 1)
#define PTE_W (1L << 2)
#define PTE_X (1L << 3)
#define PTE_U (1L << 4)
#define PTE_A (1L << 6)
标志含义
PTE_V映射有效
PTE_R允许读取
PTE_W允许写入
PTE_X允许执行
PTE_U用户态允许访问
PTE_A页面被访问过

常见组合:

页面权限
用户代码PTE_R \| PTE_X \| PTE_U
用户数据或堆PTE_R \| PTE_W \| PTE_U
USYSCALLPTE_R \| PTE_U
内核代码PTE_R \| PTE_X
内核数据PTE_R \| PTE_W
trampolinePTE_R \| PTE_X

中间页表 PTE 通常只有 PTE_V;叶子 PTE 至少有一个 PTE_R、PTE_W 或 PTE_X:

1
2
3
4
if((pte & (PTE_R | PTE_W | PTE_X)) == 0)
  // 指向下一级页表
else
  // 指向真正的物理页

这条判断是 vmprint() 和 freewalk() 递归处理的基础。


4. xv6 中到底有几张页表

“每个进程有一张页表”是简化说法。更准确地说:

  • 每个进程有一个用户页表根指针 p->pagetable;
  • 这个根页表通过多级页表页连接到更多页表页;
  • 所有 CPU 共享一个 kernel_pagetable;
  • 用户态运行时使用当前进程的用户页表;
  • 进入内核后切换到内核页表。
1
2
3
4
5
p->pagetable
  → 根页表
  → 中间页表页
  → 叶子 PTE
  → 当前进程的用户物理页

不同进程可以把相同虚拟地址映射到不同物理页:

1
2
进程 A 的 VA 0x4000 → 物理页 A
进程 B 的 VA 0x4000 → 物理页 B

因此它们互相隔离。

内核页表通常是共享的,但每个进程仍有自己的内核栈。内核栈映射到内核页表,用户页表中没有普通内核栈映射。


5. 页表根、satp 和 walk()

pagetable_t 定义为:

1
typedef uint64 *pagetable_t;

因此:

1
p->pagetable

就是内核可以直接访问的根页表地址。

satp 是 CPU 使用的页表寄存器:

1
w_satp(MAKE_SATP(kernel_pagetable));

p->pagetable 和 satp 的区别:

名称含义
p->pagetable内核中的 C 指针,指向页表根
satpCPU 当前使用的地址转换配置

walk() 的原型:

1
pte_t *walk(pagetable_t pagetable, uint64 va, int alloc);

参数:

  • pagetable:要查找的页表根;
  • va:待查询的虚拟地址;
  • alloc:中间页表不存在时是否创建。

walk(pagetable, va, 0) 的含义是:

1
只查找,不创建

walk(pagetable, va, 1) 的含义是:

1
查找;如果中间页表不存在,就分配并创建

walk() 返回的是最后一级 PTE 的地址,不是物理地址。要读取 PTE 中保存的物理页,需要:

1
PTE2PA(*pte)

6. kvmmake()、kvmmap() 和 mappages()

内核启动时,kvmmake() 创建内核页表:

1
2
3
4
5
kvmmake()
  → kvmmap()
    → mappages()
      → walk(..., 1)
      → 填写叶子 PTE

例如:

1
kvmmap(kpgtbl, UART0, UART0, PGSIZE, PTE_R | PTE_W);

表示:

1
2
内核虚拟地址 UART0
    → 物理地址 UART0

xv6 在这里使用直接映射,很多区域满足:

1
虚拟地址 = 物理地址

但这是 xv6 的布局选择,不是页表机制的要求。

kvmmap() 只是对 mappages() 的封装。真正写入叶子 PTE 的代码是:

1
*pte = PA2PTE(pa) | perm | PTE_V;

三个函数职责:

1
2
3
4
5
6
7
8
walk()
    找到最后一级 PTE,必要时创建中间页表

mappages()
    把物理页号和权限写入最终 PTE

kvmmap()
    调用 mappages(),失败时 panic

7. 物理页分配器和页表的关系

kernel/kalloc.c 中的 kmem.freelist 管理空闲物理页。每个空闲页的开头暂时作为链表节点:

1
2
3
struct run {
  struct run *next;
};

kalloc():

1
2
从 freelist 取出一页
  → 返回内核可以访问的地址

kfree():

1
2
3
检查页地址
  → 填充垃圾数据
  → 插入 freelist

mappages() 不分配数据页,只建立映射。因此:

1
2
kalloc():物理页从哪里来
mappages():虚拟地址如何指向它

这是阅读 uvmalloc() 时最重要的拆分。


8. 用户地址空间的生命周期

8.1 创建

uvmcreate() 分配并清零根页表。随后 proc_pagetable(p) 映射:

1
2
3
TRAMPOLINE:跳板代码
TRAPFRAME:当前进程的寄存器保存区
USYSCALL:实验中新增的用户只读共享页

8.2 增长

sbrk() 通过 growproc() 调用 uvmalloc():

1
2
3
4
kalloc() 分配物理页
  → 清零
  → mappages() 建立用户映射
  → 更新 p->sz

8.3 复制

fork() 使用 uvmcopy():

1
2
3
4
读取父进程 PTE
  → 为子进程分配新物理页
  → 复制页面内容
  → 在子进程页表中建立相同虚拟地址映射

xv6 当前实现是复制物理页,不是写时复制。

8.4 替换

exec() 先构造新页表和新用户映像,成功后才替换:

1
2
3
4
新 ELF 页表和栈全部准备完成
  → p->pagetable = newpagetable
  → 设置 epc 和 sp
  → 释放 oldpagetable

这就是为什么 exec() 不会改变 PID 或 struct proc 中的进程属性。

8.5 释放

proc_freepagetable() 必须先解除特殊映射,再调用 uvmfree():

1
2
3
4
5
6
uvmunmap(TRAMPOLINE)
uvmunmap(TRAPFRAME)
uvmunmap(USYSCALL)
uvmfree()
  → uvmunmap 用户叶子映射并释放物理页
  → freewalk() 释放页表页

freewalk() 要求所有叶子映射已经删除;否则会遇到:

1
panic: freewalk: leaf

本实验中曾遇到这个问题,原因就是新增 USYSCALL 后忘记在 proc_freepagetable() 中解除它。


实验一:vmprint

9. 实验目标

vmprint() 要递归打印当前进程的多级页表。每个有效 PTE 输出:

  • 当前页表索引;
  • PTE 原值;
  • PTE2PA(pte) 得到的地址。

实现参考 freewalk(),但行为不同:

  • freewalk() 遇到叶子 PTE 会 panic,因为它要求叶子提前删除;
  • vmprint() 遇到叶子 PTE 要打印并停止递归。

9.1 递归逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static void
vmprintwalk(pagetable_t pagetable, int depth)
{
  for(int i = 0; i < 512; i++){
    pte_t pte = pagetable[i];

    if((pte & PTE_V) == 0)
      continue;

    for(int j = 0; j < depth; j++)
      printf(".. ");

    printf("%d: pte %p pa %p\n",
           i, pte, PTE2PA(pte));

    if((pte & (PTE_R | PTE_W | PTE_X)) == 0)
      vmprintwalk((pagetable_t)PTE2PA(pte), depth + 1);
  }
}

外层接口:

1
2
3
4
5
6
void
vmprint(pagetable_t pagetable)
{
  printf("page table %p\n", pagetable);
  vmprintwalk(pagetable, 1);
}

9.2 如何得到根页表

当前进程的根页表:

1
2
struct proc *p = myproc();
pagetable_t root = p->pagetable;

因此可以:

1
vmprint(myproc()->pagetable);

实验中看到的页表输出通常来自 exec() 成功后、用户程序开始运行前打印的当前进程页表。


实验二:USYSCALL 共享页

10. 实验目标

普通 getpid() 的调用链是:

1
2
3
4
5
6
7
用户函数
  → usys.S
  → ecall
  → usertrap()
  → syscall()
  → sys_getpid()
  → 返回

本实验把 PID 放到用户可读页面中,让用户态直接读取:

1
2
3
用户程序
  → 读取固定虚拟地址 USYSCALL
  → 得到 PID

这减少了一次 trap 和系统调用分派。

10.1 地址布局

memlayout.h 定义:

1
2
3
USYSCALL
TRAPFRAME
TRAMPOLINE

它们位于用户虚拟地址空间顶部:

1
2
3
4
5
6
7
8
9
高地址
┌──────────────────┐
│ TRAMPOLINE       │ 跳板代码
├──────────────────┤
│ TRAPFRAME        │ 当前进程 trapframe
├──────────────────┤
│ USYSCALL         │ 用户只读 PID 页
└──────────────────┘
低地址方向

USYSCALL 是所有进程都使用的固定用户虚拟地址,但每个进程的页表可以把它映射到不同的物理页。

10.2 每进程字段

在 struct proc 中增加:

1
struct usyscall *spid;

这里:

  • USYSCALL 是固定的虚拟地址;
  • p->spid 是当前进程独有的物理页指针。

创建进程时:

1
2
3
p->pid = allocpid();
p->spid = (struct usyscall *)kalloc();
p->spid->pid = p->pid;

映射时:

1
2
3
4
5
mappages(pagetable,
         USYSCALL,
         PGSIZE,
         (uint64)p->spid,
         PTE_R | PTE_U);

用户态实现:

1
2
3
4
5
6
int
ugetpid(void)
{
  struct usyscall *u = (struct usyscall *)USYSCALL;
  return u->pid;
}

10.3 生命周期必须完整

新增物理页后,必须同时考虑:

1
2
3
4
5
分配:allocproc()
填充:p->spid->pid = p->pid
映射:proc_pagetable()
解除映射:proc_freepagetable()
释放物理页:freeproc()

只增加映射而不增加释放逻辑,就会发生:

  • freewalk: leaf;
  • 物理页泄漏;
  • 进程槽位重复使用时状态残留。

10.4 为什么权限是 PTE_R | PTE_U

用户程序只需要读取 PID:

1
2
PTE_R:允许读取
PTE_U:允许用户态访问

不应该设置:

1
2
PTE_W:用户可写
PTE_X:用户可执行

因此不能使用 PTE_R | PTE_X,也不能遗漏 PTE_U。


实验三:pgaccess

11. 实验目标

pgaccess() 查询:

从某个起始虚拟地址开始,连续多少个页面在上次查询之后被访问过?

访问包括:

  • 读取;
  • 写入;
  • 处理器页表遍历成功后产生的访问。

RISC-V 硬件会在叶子 PTE 中设置 PTE_A(Accessed)位。系统调用读取这些位,生成位图,然后清除它们。

11.1 用户接口

1
int pgaccess(void *base, int len, void *mask);

参数:

参数含义
base第一个待检查页面的用户虚拟地址
len检查的页数
mask用户空间接收结果位图的地址

位图规则:

1
2
3
bit 0 → base 所在页面
bit 1 → base + PGSIZE 所在页面
bit i → base + i * PGSIZE 所在页面

实验通常把 len 限制为最多 32 页,因为用一个 uint 保存结果。

11.2 测试如何验证

1
2
3
4
5
6
7
8
9
buf = malloc(32 * PGSIZE);

pgaccess(buf, 32, &abits);

buf[PGSIZE * 1] += 1;
buf[PGSIZE * 2] += 1;
buf[PGSIZE * 30] += 1;

pgaccess(buf, 32, &abits);

期望:

1
2
3
abits == ((1U << 1) |
          (1U << 2) |
          (1U << 30))

也就是只设置第 1、2、30 位。

第一次调用的重要作用是清掉之前可能已经存在的 PTE_A,让第二次查询只反映两次调用之间的访问。

11.3 实现步骤

第一步:解析参数

1
2
3
4
5
6
7
8
uint64 base;
uint64 mask;
int len;

if(argaddr(0, &base) < 0 ||
   argint(1, &len) < 0 ||
   argaddr(2, &mask) < 0)
  return -1;

mask 变量保存的是用户缓冲区地址,不是结果位图本身。

第二步:准备内核位图

1
uint abits = 0;

结果先写入内核变量,最后统一 copyout() 到用户空间。

第三步:逐页查找 PTE

1
2
3
4
5
for(int i = 0; i < len; i++) {
  uint64 va = base + (uint64)i * PGSIZE;
  pte_t *pte = walk(p->pagetable, va, 0);
  ...
}

这里必须使用:

1
walk(p->pagetable, va, 0)

而不是 walkaddr()。原因是:

  • walkaddr() 只返回物理页地址;
  • pgaccess 需要检查并修改 PTE 中的 PTE_A;
  • walk() 返回的是 PTE 指针。

第四步:检查并清除访问位

1
2
3
4
5
6
7
if(pte &&
   (*pte & PTE_V) &&
   (*pte & PTE_U) &&
   (*pte & PTE_A)) {
  abits |= (1U << i);
  *pte &= ~PTE_A;
}

这几行分别完成:

1
2
3
4
5
6
PTE 存在
  → 映射有效
  → 是用户页
  → 页面被访问过
  → 设置结果位
  → 清除 PTE_A

清除不能写成:

1
*pte & PTE_A;

这只是计算表达式,没有修改任何内容。正确写法是:

1
*pte &= ~PTE_A;

设置位不能写成:

1
abits | (1U << i);

正确写法是:

1
abits |= (1U << i);

第五步:复制结果

1
2
3
4
5
6
7
if(copyout(p->pagetable,
           mask,
           (char *)&abits,
           sizeof(abits)) < 0)
  return -1;

return 0;

参数对应关系:

copyout 参数实际含义
p->pagetable当前进程用户页表
mask用户结果缓冲区地址
(char *)&abits内核结果位图地址
sizeof(abits)复制长度

copyout() 的方向是:

1
内核位图 → 用户缓冲区

11.4 我遇到的典型编译问题

walk 隐式声明

报错:

1
implicit declaration of function ‘walk’

原因是 walk() 在 vm.c 中定义,但没有在 defs.h 中声明。系统调用文件通过 defs.h 使用它,因此要增加:

1
pte_t *walk(pagetable_t, uint64, int);

如果把 copyout() 和 return 0 放在页面循环内部,函数只检查第一页就返回,后面的访问位永远不会被扫描。正确顺序是:

1
2
3
4
扫描全部页面
  → 形成完整位图
  → copyout 一次
  → 返回

12. 页表访问和用户指针的边界

argaddr() 只从寄存器中取出用户传来的地址数值,它不会替你完成完整的用户内存访问。

copyin()、copyout() 会使用进程页表:

1
2
3
4
用户虚拟地址
  → walkaddr()
  → 物理页
  → 内核复制数据

因此不能直接写:

1
*(struct sysinfo *)addr = info;

内核必须通过 copyout()。同理,pgaccess 最后也必须通过 copyout() 把位图传回用户。


13. Trap、页表切换与特殊页面

RISC-V 发生 trap 时不会自动切换页表或内核栈。xv6 使用 trampoline.S 完成这件事:

sequenceDiagram
    accTitle: 用户态陷入内核
    accDescr: trampoline 保存用户寄存器并切换页表,内核处理后再切回用户页表。
    participant U as 用户程序
    participant T as trampoline
    participant C as usertrap
    participant K as 内核页表
    U->>T: ecall 或异常
    T->>T: 保存寄存器到 TRAPFRAME
    T->>K: 写 satp 切换内核页表
    T->>C: 跳转到 usertrap
    C->>C: 处理系统调用或异常
    C->>T: usertrapret
    T->>T: 写 satp 切换用户页表
    T->>U: sret 返回用户态

因此:

  • TRAMPOLINE 要在用户页表和内核页表中都映射;
  • TRAPFRAME 保存每个进程的用户寄存器;
  • USYSCALL 只在用户页表中映射为用户只读页面;
  • KSTACK(p) 是内核页表中的进程内核栈。

用户地址空间顶部大致是:

1
2
3
4
5
6
7
8
9
高地址
TRAMPOLINE
TRAPFRAME
USYSCALL
...
用户堆
用户栈
代码和数据
低地址

内核栈不在这个用户布局中,而是在内核地址空间中,每个进程一页,旁边有 guard page。


14. 如何根据题目找到应该调用的函数

面对新的页表题目,先把需求分成四个问题:

问题对应抽象
需要物理页吗?kalloc()
需要建立虚拟到物理映射吗?mappages()
需要查某个地址的 PTE 吗?walk()
需要访问用户内存吗?copyin() / copyout()
需要创建或释放整个地址空间吗?uvmcreate() / uvmfree()
需要遍历页表吗?参考 freewalk() 实现递归
需要调整进程内存大小吗?uvmalloc() / uvmdealloc()

再区分函数的职责:

1
2
3
4
5
walk:找 PTE
mappages:填 PTE
walkaddr:从用户 VA 找物理页
copyin/copyout:跨用户和内核边界复制
kalloc/kfree:分配和释放物理页

这种“先确定数据在哪里,再寻找已有抽象”的方法,比从空白函数开始凭感觉写更可靠。


16. 复习

问题一:虚拟页号存在哪里?

不直接存于 PTE。虚拟页号由页表路径隐含:

1
根页表索引 + 第二级索引 + 第一级索引

PTE 只保存:

1
物理页号 + 权限位 + 有效位

问题二:每个进程是不是只有一个页表页?

每个进程有一个用户页表根指针,但根页表会连接多个中间页表页和叶子 PTE。通常所说的“一张页表”指的是一棵多级页表树。

问题三:为什么 pgaccess 用 walk 而不是 walkaddr?

因为 pgaccess 需要读取和清除 PTE_A。walkaddr 只返回物理页地址,无法直接修改 PTE 标志位。

问题四:为什么 vmprint 不能对所有有效 PTE 递归?

有效叶子 PTE 指向用户数据页或代码页;只有没有 R/W/X 的有效 PTE 才指向下一级页表。递归叶子会把普通数据当作页表解析。

问题五:为什么新增页面时要同时修改创建和释放路径?

物理页有完整生命周期:

1
2
3
4
5
6
kalloc 分配
  → 填充数据
  → 建立映射
  → 使用
  → 解除映射
  → kfree 释放

遗漏释放会泄漏;遗漏解除映射会让 freewalk() 发现叶子;重复释放会破坏空闲页链表。

问题六:为什么 exec 要先创建新页表,成功后再替换?

为了保证失败安全:

1
2
加载失败 → 释放新页表,旧程序仍可返回
加载成功 → 提交新页表,再释放旧页表

问题七:USYSCALL 为什么每个进程都使用同一个虚拟地址?

虚拟地址可以相同,但每个进程的页表把它映射到不同的物理页。这样用户库只需要把固定地址转换为结构体指针,内核仍能让每个进程看到自己的 PID。


17. 本章最终框架

flowchart TB
    accTitle: Lab 3 知识框架
    accDescr: Lab 3 从硬件地址转换开始,连接页表管理、物理页分配、进程地址空间和三个实验任务。
    hardware[硬件规则 Sv39、satp、PTE]
    lookup[地址查找 PX、walk]
    mapping[映射管理 mappages、uvmunmap]
    physical[物理页管理 kalloc、kfree]
    process[进程地址空间 fork、exec、sbrk]
    experiments[实验功能 vmprint、USYSCALL、pgaccess]

    hardware --> lookup
    lookup --> mapping
    physical --> mapping
    mapping --> process
    process --> experiments
本文由作者按照 CC BY 4.0 进行授权