本文细致的剖析了 2021 FALL MIT 6.S081 课程的一项实验, Lab 链接 Lab: page tables (mit.edu) 。
新人博主,大家的每一次阅读都会激励我继续创作,大家的点赞将会是我继续更新的巨大动力,对文中内容或实验过程中有任何疑问欢迎留言!
Lab 3 将探索页表并对其进行修改,以简化将数据从用户空间复制到内核空间的函数。本实验涉及到一些虚拟地址和物理地址结构,以及两者之间的转换,需要对虚拟内存的知识有基本认识。
xv6 内核中与内存管理相关的一些文件包括:
- kernel/memlayout.h ,它捕获了内存的布局。
- kernel/vm.c ,其中包含大多数虚拟内存(VM)代码。
- kernel/kalloc.c ,它包含分配和释放物理内存的代码。
Speed up system calls (easy)
题目要求
有些操作系统(例如 Linux)通过在用户空间和内核之间的只读区域共享数据,加速特定的系统调用。这消除了在执行这些系统调用时进行内核切换的需要。为了帮助您学习如何建立页表映射,您的第一个任务是在 xv6 中为 getpid()
系统调用实现这种优化。
在创建每个进程时,在虚拟地址
USYSCALL
处映射一个只读页面 usyscall(USYSCALL
是在 memlayout.h 中定义的虚拟地址)。在此页面的开始处,存储一个struct usyscall
(同样在 memlayout.h 中定义),并初始化它以存储当前进程的 PID 。在这个实验中,用户空间提供了ugetpid()
,它将自动使用USYSCALL
映射。如果在运行pgtbltest
时ugetpid
测试用例通过,您将获得此实验的全部分数。
提示
- 您可以在 kernel/proc.c 中的
proc_pagetable()
函数中执行映射。 - 选择允许用户空间仅读取页面的权限位。
- 您可能会发现
mappages()
是一个有用的实用工具。 - 别忘了在
allocproc()
中分配和初始化页面。 - 确保在
freeproc()
中释放页面。
实验过程
首先,按照实验要求,我们先看一下测试代码是怎么实现的, user/pgtbltest.c 调用了 ugetpid()
函数:
C
// user/pgtbltest.c
// ...
void ugetpid_test();
void pgaccess_test();
int
main(int argc, char *argv[])
{
ugetpid_test();
pgaccess_test();
printf("pgtbltest: all tests succeeded\n");
exit(0);
}
// ...
void
ugetpid_test()
{
int i;
printf("ugetpid_test starting\n");
testname = "ugetpid_test";
for (i = 0; i < 64; i++) {
int ret = fork();
if (ret != 0) {
wait(&ret);
if (ret != 0)
exit(1);
continue;
}
if (getpid() != ugetpid())
err("missmatched PID");
exit(0);
}
printf("ugetpid_test: OK\n");
}
// ...
ugetpid()
函数定义在 user/ulib.c 中:
C
//user/ulib.c
//...
int
ugetpid(void)
{
struct usyscall *u = (struct usyscall *)USYSCALL;
return u->pid;
}
该函数将一个 struct usyscall
结构体的起始地址设为 USYSCALL
,然后返回这个结构体中保存的 pid
。
要进一步理解这个函数,首先我们来看 struct usyscall
结构体的定义:
C
// kernel/memlayout.h
// ...
// User memory layout.
// Address zero first:
// text
// original data and bss
// fixed-size stack
// expandable heap
// ...
// USYSCALL (shared with kernel)
// TRAPFRAME (p->trapframe, used by the trampoline)
// TRAMPOLINE (the same page as in the kernel)
#define TRAPFRAME (TRAMPOLINE - PGSIZE)
#ifdef LAB_PGTBL
#define USYSCALL (TRAPFRAME - PGSIZE)
struct usyscall {
int pid; // Process ID
};
#endif
可以看到 struct usyscall
结构体中仅保存了一个 pid
,而 USYSCALL
是一个与用户内存空间布局相关的常量,我们来看这个常量具体是怎么定义的:
C
// kernel/memlayout.h
// ...
// map the trampoline page to the highest address,
// in both user and kernel space.
#define TRAMPOLINE (MAXVA - PGSIZE)
// ...
// User memory layout.
// Address zero first:
// text
// original data and bss
// fixed-size stack
// expandable heap
// ...
// USYSCALL (shared with kernel)
// TRAPFRAME (p->trapframe, used by the trampoline)
// TRAMPOLINE (the same page as in the kernel)
#define TRAPFRAME (TRAMPOLINE - PGSIZE)
#define USYSCALL (TRAPFRAME - PGSIZE)
C
// kernel/riscv.h
// ...
#define PGSIZE 4096 // bytes per page
#define PGSHIFT 12 // bits of offset within a page
// ...
// one beyond the highest possible virtual address.
// MAXVA is actually one bit less than the max allowed by
// Sv39, to avoid having to sign-extend virtual addresses
// that have the high bit set.
#define MAXVA (1L << (9 + 9 + 9 + 12 - 1))
// ...
#define MAXVA (1L << (9 + 9 + 9 + 12 - 1))
用于表示最大虚拟地址( MAXVA
),在这里,1L 是一个长整数常量,表示数值 1 的长整数类型。那么后面的 (9 + 9 + 9 + 12 - 1)
是怎么来的呢?
RISC-V 的寄存器是 64 位的,因此其虚拟内存地址都是 64 位,但是实际上,在我们使用(模拟)的 RISC-V 处理器上,并不是所有的 64 位都被使用了,高 25 位并没有被使用。在剩下的 39 位中,有 27 位被用来当做 index (标记), 12 位被用来当做 offset (页内偏移)。 offset 必须是 12 位,因为一个 page (页面)有 4096 个字节,对于按字节编址的机器,应该用 12 个比特位来表示。
RISC-V 采用三级页表的结构,虚拟地址前 27 位的 index 被拆分为 3 段,每一段的 9 个比特位应相应级别 page directory (页目录表)的索引,page directory 的一个条目称为 PTE(page table entry),一个 page directory 的大小为 512 * 8 = 4096 B ,与一个 page 的大小相同,如下图所示:
这个宏定义最终生成一个二进制数,其中最高位为 1,其余为 0,即 0b100...0
,共有 39 位,即为虚拟地址的最大值。按照 kernel/memlayout.h 中的注释,用户地址空间从高位到低位依次是 TRAMPOLINE 、 TRAPFRAME 、 USYSCALL 等等,而这些区域均占用一个页面大小。
每个进程有着自己独立的虚拟地址空间,因此在创建每个进程时,在虚拟地址USYSCALL
处映射的页面的地址应该是属于进程的属性,参考 kernel/proc.c 中的 allocproc
函数:
C
// Look in the process table for an UNUSED proc.
// If found, initialize state required to run in the kernel,
// and return with p->lock held.
// If there are no free procs, or a memory allocation fails, return 0.
static struct proc*
allocproc(void)
{
struct proc *p;
for(p = proc; p < &proc[NPROC]; p++) {
acquire(&p->lock);
if(p->state == UNUSED) {
goto found;
} else {
release(&p->lock);
}
}
return 0;
found:
p->pid = allocpid();
p->state = USED;
// Allocate a trapframe page.
if((p->trapframe = (struct trapframe *)kalloc()) == 0){
freeproc(p);
release(&p->lock);
return 0;
}
// An empty user page table.
p->pagetable = proc_pagetable(p);
if(p->pagetable == 0){
freeproc(p);
release(&p->lock);
return 0;
}
// Set up new context to start executing at forkret,
// which returns to user space.
memset(&p->context, 0, sizeof(p->context));
p->context.ra = (uint64)forkret;
p->context.sp = p->kstack + PGSIZE;
return p;
}
allocproc
函数用于分配一个空闲的进程表( struct proc
)的实例,以表示一个新的进程。allocproc
函数还会为进程分配必要的物理内存、创建用户页表、设置上下文用于执行 forkret
。可以看到在分配 trapframe 的部分, trapframe
是一个属于进程的属性,照猫画虎,我们为进程也添加一个 usyscall
属性用于保存 usyscall 页面的地址,属于进程的属性应该定义在 kernel/proc.h 中:
c
// kernel/proc.h
// ...
// Per-process state
struct proc {
struct spinlock lock;
// p->lock must be held when using these:
enum procstate state; // Process state
void *chan; // If non-zero, sleeping on chan
int killed; // If non-zero, have been killed
int xstate; // Exit status to be returned to parent's wait
int pid; // Process ID
// wait_lock must be held when using this:
struct proc *parent; // Parent process
// these are private to the process, so p->lock need not be held.
uint64 kstack; // Virtual address of kernel stack
uint64 sz; // Size of process memory (bytes)
pagetable_t pagetable; // User page table
struct trapframe *trapframe; // data page for trampoline.S
struct context context; // swtch() here to run process
struct file *ofile[NOFILE]; // Open files
struct inode *cwd; // Current directory
char name[16]; // Process name (debugging)
struct usyscall *usyscall; // data page for sharing data between userspace and the kernel
};
依照提示,我们应该在进程创建时为其分配和初始化 usyscall 页面,因此我们需要修改 kernel/proc.c 中的 allocproc()
函数,按照用户内存空间结构,应该先分配 trapframe ,再分配 usyscall ,因此我们将分配 usyscall 页面的部分添加到分配 trapframe 的后面。分配成功后,将当前进程的 pid 存入 usyscall 页面的开始处:
C
// kernel/proc.c
// ...
static struct proc*
allocproc(void)
{
struct proc *p;
for(p = proc; p < &proc[NPROC]; p++) {
acquire(&p->lock);
if(p->state == UNUSED) {
goto found;
} else {
release(&p->lock);
}
}
return 0;
found:
p->pid = allocpid();
p->state = USED;
// Allocate a trapframe page.
if((p->trapframe = (struct trapframe *)kalloc()) == 0){
freeproc(p);
release(&p->lock);
return 0;
}
// Allocate a usyscall page.
if((p->usyscall = (struct usyscall *)kalloc()) == 0){
freeproc(p);
release(&p->lock);
return 0;
}
p->usyscall->pid = p->pid ;
// An empty user page table.
p->pagetable = proc_pagetable(p);
if(p->pagetable == 0){
freeproc(p);
release(&p->lock);
return 0;
}
// Set up new context to start executing at forkret,
// which returns to user space.
memset(&p->context, 0, sizeof(p->context));
p->context.ra = (uint64)forkret;
p->context.sp = p->kstack + PGSIZE;
return p;
}
// ...
注意, kalloc
函数分配的是物理内存,我们还需要完成从虚拟地址到物理地址的映射,这一过程需要在 kernel/proc.c 的 proc_pagetable()
函数中,利用 mappages()
函数实现。
proc_pagetable()
函数用于为进程创建一个用户页表。用户页表用于映射用户进程的虚拟地址到物理地址,但在此过程中,还会映射一些特殊页,如 trampoline
和 trapframe
,我们需要为其增加一个到 usyscall
的映射:
C
// kernel/proc.c
// ...
// Create a user page table for a given process,
// with no user memory, but with trampoline 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.
// int mappages(pagetable_t pagetable, uint64 va, uint64 size, uint64 pa, int perm)
if(mappages(pagetable, TRAMPOLINE, PGSIZE,
(uint64)trampoline, PTE_R | PTE_X) < 0){
uvmfree(pagetable, 0);
return 0;
}
// map the trapframe just below TRAMPOLINE, 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;
}
// map the usyscall page.
if(mappages(pagetable, USYSCALL, PGSIZE,
(uint64)(p->usyscall), PTE_U | PTE_R) < 0){
uvmfree(pagetable, 0);
return 0;
}
return pagetable;
}
// ...
这个函数中的代码逻辑较为复杂,但功能非常重要,有必要深入分析一下。首先我们来看函数定义:
C
pagetable_t proc_pagetable(struct proc *p)
函数的参数为一个进程,返回值为 pagetable_t
,在 pagetable_t
定义为指向 uint64
类型的指针,这个指针指向一个页表的起始地址。
C
// An empty page table.
pagetable = uvmcreate();
if(pagetable == 0)
return 0;
这段代码创建了一个空白页表,我们跳转查看 uvmcreate()
函数的定义:
c
// kernel/vm.c
// create an empty user page table.
// returns 0 if out of memory.
pagetable_t
uvmcreate()
{
pagetable_t pagetable;
pagetable = (pagetable_t) kalloc();
if(pagetable == 0)
return 0;
memset(pagetable, 0, PGSIZE);
return pagetable;
}
这段函数其实就是用 kalloc()
分配了一个页面大小的内存,然后将这块内存的起始地址赋值给 pagetable
,再用 memset()
函数将这块内存区域初始化。
继续分析 proc_pagetable()
的代码:
C
// 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.
// int mappages(pagetable_t pagetable, uint64 va, uint64 size, uint64 pa, int perm)
if(mappages(pagetable, TRAMPOLINE, PGSIZE,
(uint64)trampoline, PTE_R | PTE_X) < 0){
uvmfree(pagetable, 0);
return 0;
}
这段代码将 trampoline code 映射到用户虚拟内存区的高地址处(与前文用户内存空间结构对应)。这里出现了一个 mappages()
函数,以提示所述,此函数极为重要,我们来看这个函数的定义:
C
// kernel/vm.c
// ...
// Create PTEs for virtual addresses starting at va that refer to
// physical addresses starting at pa. va and size might not
// be page-aligned. Returns 0 on success, -1 if walk() couldn't
// allocate a needed page-table page.
int
mappages(pagetable_t pagetable, uint64 va, uint64 size, uint64 pa, int perm)
{
uint64 a, last;
pte_t *pte;
if(size == 0)
panic("mappages: size");
a = PGROUNDDOWN(va);
last = PGROUNDDOWN(va + size - 1);
for(;;){
if((pte = walk(pagetable, a, 1)) == 0)
return -1;
if(*pte & PTE_V)
panic("mappages: remap");
*pte = PA2PTE(pa) | perm | PTE_V;
if(a == last)
break;
a += PGSIZE;
pa += PGSIZE;
}
return 0;
}
// ...
mappages()
函数可以将虚拟地址范围 [va, va + size)
映射到物理地址范围 [pa, pa + size)
,并设置相应的访问权限。
mappages()
函数有五个参数,分别为
pagetable_t pagetable
:表示页表的指针,用于指定将要修改的页表。uint64 va
:虚拟地址的起始地址。uint64 size
:需要映射的虚拟地址范围的大小。uint64 pa
:物理地址的起始地址。int perm
:权限标志,用于设置 PTE 的权限。
函数首先对起始虚拟地址 va
向下取整,得到页面对齐的地址 a
。然后计算虚拟地址范围的结束地址 last
,同样进行向下取整。
进入循环,对每一页进行映射,直到 a
等于 last
。下面具体解释循环中的代码:
C
if((pte = walk(pagetable, a, 1)) == 0)
return -1;
PTE
代表 Page Table Entry,即页表项。该 if 语句使用 walk
函数获取虚拟地址 a
对应的页表项指针 pte
,并在需要时创建页表页。
c
if(*pte & PTE_V)
panic("mappages: remap");
如果页表项 *pte
已经被映射,说明发生了重复映射,产生了错误。注意, PTE_V
是定义在 kernel/riscv.h 中的宏,是一个权限参数,用于表示该页表项合法,与之类似的权限参数还有:
C
// kernel/riscv.h
// ...
#define PTE_V (1L << 0) // valid
#define PTE_R (1L << 1) // readable
#define PTE_W (1L << 2) // writable
#define PTE_X (1L << 3) // Executable
#define PTE_U (1L << 4) // 1 -> user can access
// ...
C
*pte = PA2PTE(pa) | perm | PTE_V;
将物理地址 pa
转换成页表项并设置权限,更新页表项。
要将这行代码理解透彻我们又需要参考这张图片了,
如上图所示,要将物理地址转化为页表项,需要将 pa
的高 44 位,即图中的 PPN ,放在页表项的 10 到 53 位中,并将低位的标记位按需求置位。
PA2PTE(pa)
这个宏定义在 kernel/riscv.h 中,用于将物理地址 pa
转换成页表项(Page Table Entry,PTE)。:
C
// kernel/riscv.h
// ...
// shift a physical address to the right place for a PTE.
#define PA2PTE(pa) ((((uint64)pa) >> 12) << 10)
// ...
(uint64)pa
将物理地址 pa
强制类型转换为 64 位无符号整数。 >> 12
将物理地址右移 12 位。在 RISC-V 中,物理页框的大小通常是 4KB 。右移 12 位相当于除以 <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 12 2^{12} </math>212 ,从而得到页的索引部分,即图中的 PPN 。 << 10
将上一步的结果又左移 10 位,因为低 10 位保存了一些其他的标志和信息。 perm
是传递给函数的权限参数,表示希望为这个虚拟地址设置的权限,通常使用位掩码来表示不同的权限,在上文中已作介绍,比如,PTE_U
表示用户权限,PTE_W
表示可写权限,PTE_X
表示可执行权限。PTE_V
即将页表项中的有效位置位,表示这个虚拟地址是合法的。
c
if(a == last)
break;
a += PGSIZE;
pa += PGSIZE;
如果 a 已经是当前进程虚拟地址空间的最后一页,则退出循环,否则根据页大小增加虚拟地址 a
和物理地址 pa
。
当前已完成了分配和映射工作,按照提示,我们还需要在必要的时候释放页面,比如终止进程时,我们需要在 kernel/proc.c 的 freeproc()
函数中,依葫芦画瓢,将我们分配的 usyscall 和 trapframe 页面做相同处理,添加相关代码:
C
// kernel/proc.c
// ...
// free a proc structure and the data hanging from it,
// including user pages.
// p->lock must be held.
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;
if(p->pagetable)
proc_freepagetable(p->pagetable, p->sz);
p->pagetable = 0;
p->sz = 0;
p->pid = 0;
p->parent = 0;
p->name[0] = 0;
p->chan = 0;
p->killed = 0;
p->xstate = 0;
p->state = UNUSED;
}
到这里我们完成了实验要求和提示中给出的全部内容,但当我们运行 make qemu
时,发现报错,报错结果如下:
shell
xv6 kernel is booting
hart 2 starting
hart 1 starting
panic: freewalk: leaf
我们来查看是什么触发了这个 panic
,通过调试(如何进行调试请移步文章(待更新)),发现 panic
是由 kernel/vm.c 的 freewalk()
函数发出的,这以函数的代码如下所示:
C
// ***kernel/vm.c***
// ...
// 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){
panic("freewalk: leaf");
}
}
kfree((void*)pagetable);
}
// ...
freewalk()
函数的作用是递归地释放页表(而不是页面)及其所有子表,其参数是页表的物理地址,而不是虚拟地址。在这个过程中,有效的页表项会被继续追踪,而对于叶子页表项的出现则会导致 panic。我们来看看具体是怎么实现的,该函数诸葛遍历页表项,将页表项复制到 pte
变量中,先来看第一个 if 判断:
C
if((pte & PTE_V) && (pte & (PTE_R|PTE_W|PTE_X)) == 0)
这个条件判断包含两个部分:
(pte & PTE_V)
:检查页表项是否有效(被映射到物理内存)。(pte & (PTE_R|PTE_W|PTE_X)) == 0
:检查页表项是否没有设置任何权限标志(Read、Write、Execute)。
如果这两个条件都满足,表示当前页表项有效,且没有设置权限标志,即不包含具体的映射关系,这说明指向了一个更低级别的页表(内存的递归结构)。这种情况下, uint64 child = PTE2PA(pte);
负责将页表项转换为物理地址,以获取更低级别页表的物理地址。然后递归调用 freewalk();
,释放更低级别的页表及其所有子表,并将当前页表项设置为 0,表示释放当前页表项。
再来看第二个 if 判断:
C
if(pte & PTE_V)
这个 if 语句仅检查页表项是否有效。在第一个 if 条件不满足的情况下,如果这个条件满足,说明当前页表项是一个有效的叶子页表项(Leaf Entry),它实际映射到物理内存中的一个页。这在 freewalk
函数中是不应该出现的,因为 freewalk
专门用于释放页表结构,而不是释放实际的页,因此触发 panic
。
分析函数调用结构(这里的分析过程较为复杂,后续会补上),我们可以发现,还需要在 kernel/proc.c 的 proc_freepagetable()
函数中释放我们之前建立的虚拟地址到物理地址的映射,将这段代码修改为:
c
// kernel/proc.c
// ...
// Free a process's page table, and free the
// physical memory it refers to.
void
proc_freepagetable(pagetable_t pagetable, uint64 sz)
{
uvmunmap(pagetable, TRAMPOLINE, 1, 0);
uvmunmap(pagetable, TRAPFRAME, 1, 0);
uvmunmap(pagetable, USYSCALL, 1, 0);
uvmfree(pagetable, sz);
}
// ...
至此,本 Lab 完成,可以按照实验要求运行测试:
shell
$ make qemu
xv6 kernel is booting
hart 1 starting
hart 2 starting
init: starting sh
$ pgtbltest
ugetpid_test starting
ugetpid_test: OK
pgaccess_test starting
pgtbltest: pgaccess_test failed: incorrect access bits set, pid=3
评注
本实验码量很小,大多可以依葫芦画瓢完成,提示也给出了应该添加代码的位置,但是要想完全理解代码的内容,仍需要做大量的工作。同时,提示中给出的需要添加的内容并不完整,面对 panic: freewalk: leaf
这一报错信息,一开始我完全摸不到头脑,这就需要通过调试代码,逐层研读函数之间的调用关系,同时还需要对虚拟内存结构有较深的理解。