目录
Task
Task 1: Print a page table
该实验需要增加一个 vmprint 函数,用于打印一个 page table,实现过程可以参考 vm.c 文件中的 freewalk() 函数。
在 defs.h 中增加 vmprint 的定义:
void vmprint(pagetable_t);
在 vm.c 中代码如下:
// 打印一行 PTE
void print_pte(int index, pte_t pte, uint64 pa, int level) {
for (int j = level; j > 0; j--) {
if (j != 1) {
printf(".. ");
} else {
printf("..");
}
}
printf("%d: pte %p pa %p\n", index, pte, pa);
}
// 递归地打印 page table
void display_pt(pagetable_t pagetable, int level) {
static const int PTE_NUMBERS = 512; // 每个 page 的 PTE 数量
for (int i = 0; i < PTE_NUMBERS; i++) {
pte_t pte = pagetable[i];
if ((pte & PTE_V) && (pte & (PTE_R|PTE_W|PTE_X)) == 0) {
uint64 child = PTE2PA(pte);
print_pte(i, pte, child, level);
display_pt((pagetable_t) child, level + 1);
} else if (pte & PTE_V) {
print_pte(i, pte, PTE2PA(pte), level);
}
}
}
/**
* display the page table
*/
void
vmprint(pagetable_t pagetable)
{
printf("page table %p\n", pagetable);
display_pt(pagetable, 1);
}
在 exec.c 的 exec()
函数中,return argc
之前增加对 vmprint 的调用:
int
exec(char *path, char **argv)
{
...
if (p->pid == 1) {
vmprint(p->pagetable);
}
return argc;
...
}
实现结果:输入 make qemu
后
Task 2: A kernel page table per process
这个 task 好难…
目前的情况是全局有一个 kernel page table,每个用户进程有一个 page table,但当 kernel 执行时,如果想要接收用户进程传过来的 system call 的指针参数,必须将其转换成物理地址,因为 kernel page table 中不包含这个指针的虚拟地址到物理地址的映射关系。这个 task 是让每个进程都拥有一个 kernel page table,每当在 kernel 中执行时,可以直接借助于这个进程的 kernel page table 来解引用 user process 传过来的 pointer。
2.1 创建并初始化 process’s kernel page table
首先在 struct proc
中增加一个 process’s kernel page table 的字段 kpt
:
struct proc {
...
pagetable_t kpt; // Kernel page table
...
}
接下来考虑 process’s kernel page table 的创建。之前全局的 kernal page table 是在 vm.c 的 kvminit()
函数中实现的,现在将创建 kernel page table 的代码抽取出来形成一个函数 make_kernel_pagetable()
:
// add a mapping to page table
// 模仿的 kvmmap() 函数实现
void vmmap(pagetable_t pagetable, uint64 va, uint64 pa, uint64 sz, int perm)
{
if (mappages(pagetable, va, sz, pa, perm) != 0)
panic("vmmap");
}
pagetable_t
make_kernel_pagetable()
{
pagetable_t pt = (pagetable_t) kalloc();
memset(pt, 0, PGSIZE);
// uart registers
vmmap(pt, UART0, UART0, PGSIZE, PTE_R | PTE_W);
// virtio mmio disk interface
vmmap(pt, VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);
// CLINT
vmmap(pt, CLINT, CLINT, 0x10000, PTE_R | PTE_W);
// PLIC
vmmap(pt, PLIC, PLIC, 0x400000, PTE_R | PTE_W);
// map kernel text executable and read-only.
vmmap(pt, KERNBASE, KERNBASE, (uint64)etext-KERNBASE, PTE_R | PTE_X);
// map kernel data and the physical RAM we'll make use of.
vmmap(pt, (uint64)etext, (uint64)etext, PHYSTOP-(uint64)etext, PTE_R | PTE_W);
// map the trampoline for trap entry/exit to
// the highest virtual address in the kernel.
vmmap(pt, TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);
return pt;
}
这个函数的实现就是调用 kalloc()
来申请出一个 page 作为 kernal page table,然后初始化为 0,之后在这个 table 中增加各种 mapping。这里参考原来的 kvmmap()
重新实现了一个 vmmap()
来增加从 VA -> PA 的 mapping。
经过这么一次抽取,原来创建 kernel page table 的函数 kvminit()
被修改为:
void
kvminit()
{
kernel_pagetable = make_kernel_pagetable();
}
每个 user process 是通过 proc.c 中的 allocproc()
函数创建出来的,我们需要在这个函数中,为 user process 创建 kernel page table:
static struct proc*
allocproc(void)
{
...(创建 kernal page table)
// An kernel table page
p->kpt = make_kernel_pagetable();
...
}
在 xv6 中,每个 user process 都有一个 kernel stack 来存放 kernel 执行时的上下文数据,参考 xv6 的虚拟内存分配图,可以看到每个 kernel stack(Kstack)位于 VA 的高地址处,Kstack 1 对应 process 1 的 kernel stack,实验要求每个 user process 的 kernel page table 中只需要有这一个 process 的 kstack 映射关系即可,所以在创建出 user process 后,我们需要在 kernel page table 中增加一个 kstack 的 mapping。
原代码的 kstack 映射关系的创建是在 procinit()
函数中,我们需要将创建 kernel stack 以及创建 kstack 映射关系的功能放到 allocproc()
创建出 user process 后:
static struct proc*
allocproc(void)
{
...(创建 kernal page table)
// An kernel table page
p->kpt = make_kernel_pagetable();
// 给 kernel page table 添加一个指向 process's kernel stack 的 mapping
char *kernel_stack_pa = kalloc();
if (kernel_stack_pa == 0)
panic("kalloc");
uint64 kernel_stack_va = KSTACK((int) (p - proc)); // 根据这个进程在 proc table 中的序号获取这个进程对应 kernel stack 的 VA
vmmap(p->kpt, kernel_stack_va, (uint64) kernel_stack_pa, PGSIZE, PTE_R | PTE_W);
p->kstack = kernel_stack_va;
...
}
于是,原来的 procinit()
函数就简化为了:
// initialize the proc table at boot time.
void
procinit(void)
{
struct proc *p;
initlock(&pid_lock, "nextpid");
for(p = proc; p < &proc[NPROC]; p++) {
initlock(&p->lock, "proc");
}
kvminithart();
}
2.2 使用 process’s kernel page table
接着修改 scheduler()
函数,这个函数是,每个 CPU 在启动后运行这个函数,这个函数永远不会 return,会一直运行,寻找可以运行 process 然后运行。在这个函数中有一个无限循环,每轮循环找到一个可以运行的 process,然后调用 swtch 函数来开始运行这个 process,我们需要在 swtch 前,将 process’s kernel page table 加载到 SATP 寄存器中,在 swtch 后,将全局 kernel page table 加载到 SATP 寄存器中:
void
scheduler(void)
{
...
vminithart(p->kpt); // 将 process's kernel page table 加载到 SATP 寄存器中
swtch(&c->context, &p->context);
kvminithart(); // 重新使用全局的 kernel_pagetable
...
}
// 模仿 kvminithart()
// 将一个 page table 加载到 SATP 寄存器中
void
vminithart(pagetable_t pt)
{
w_satp(MAKE_SATP(pt));
sfence_vma(); // 清空 TLB
}
这里自己实现的 vminithart() 模仿原本的 kvminithart() 将一个 page table 加载到 SATP 寄存器中。
另外,vm.c 中的函数 kvmpa() 也会在进程运行期间用来翻译 kernel 虚拟地址,这里也需要使用 process’s kernel page table 而不是全局 kernel page table,需要修改的是调用 walk() 时传递的参数:
uint64
kvmpa(uint64 va)
{
uint64 off = va % PGSIZE;
pte_t *pte;
uint64 pa;
pte = walk(myproc()->kpt, va, 0);
if(pte == 0)
panic("kvmpa");
if((*pte & PTE_V) == 0)
panic("kvmpa");
pa = PTE2PA(*pte);
return pa+off;
}
2.3 释放 process’s kernel page table
在一个 process 运行结束后,需要在释放 process 的时候也把 process’s kernel page table 也给释放掉。
原代码是在 freeproc()
函数中释放一个 process 的相关资源,所以我们的代码应该是写在这里面。这里主要需要释放两种资源(也就是物理内存):kernel stack 所占用的物理内存和 kernel page table 表本身所占用的物理内存。
在释放 kernel stack 时,由于 page table 中存在关于它的 mapping,因此也需要一块删掉:
static void
freeproc(struct proc *p)
{
...
// 释放 kernel stack
if(p->kstack) {
uvmunmap(p->kpt, p->kstack, 1, 1); // 删除 kstack 的 mapping,同时释放 kstack 物理内存
p->kstack = 0;
}
...
}
- 参考原来释放 user process page table 的函数
proc_freepagetable()
得知,可以使用uvmunmap()
函数来删除 page table 中的 mapping,同时函数调用的最后一个参数可以指定是否释放掉对应的物理内存。
然后继续释放 kernel page table,这里借鉴 proc_freepagetable()
实现了一个 proc_free_kernel_pagetable()
来释放 kernel page table,按照要求,不需要 free 掉 leaf physical memory pages,对于内部的 physical memory pages,则需要调用 kree 来释放掉这块物理空间:
// 模仿 proc_freepagetable 函数,释放一个 kernel page table
void
proc_free_kernel_pagetable(pagetable_t kpt)
{
// 512 个 PTE
for (int i = 0; i < 512; i++) {
pte_t pte = kpt[i];
if ((pte & PTE_V) && (pte & (PTE_R|PTE_W|PTE_X)) == 0) { // 非 leaf page
uint64 child = PTE2PA(pte);
proc_free_kernel_pagetable((pagetable_t) child);
kpt[i] = 0;
} else if (pte & PTE_V) { // 如果是 leaf physical memory page,则跳过不 free
continue;
}
}
kfree((void*)kpt);
}
然后在 freeproc() 增加对 proc_free_kernel_pagetable() 的调用,来释放 kernel page table:
static void
freeproc(struct proc *p)
{
....
// 释放 kernel stack
if(p->kstack) {
uvmunmap(p->kpt, p->kstack, 1, 1);
p->kstack = 0;
}
// 释放 kernel page table
if(p->kpt) {
proc_free_kernel_pagetable(p->kpt);
p->kpt = 0;
}
....
}
一个易错点:我们在增加 process’s kernel page table 时,只有 kernel stack 的内存和 kernel page table 表本身的所用的内存是我们自己申请的,所以只有这两部分的物理内存需要我们自己释放。而 process’s kernel page table 其他映射的物理内存,会同时被其他 process 共享使用着,所以不能直接释放这些物理内存。
2.4 运行测试
make qemu
后运行 usertests
程序来检测:
Task 3: Simplify copyin/copyinstr
这个 task 利用上一个 task 实现的 process’s kernel page table,实现在用户进程陷入内核态后,kernel 可以自己利用 kernel page table 来将用户进程传过来的 pointer(一个用户进程的虚拟地址)转为物理地址。
3.1 替换 copyin/copyinstr
按照官网实验的 Hint,需要将 copyin 和 copyinstr 的实现替换为 copyin_new 和 copyinstr_new:
int
copyin(pagetable_t pagetable, char *dst, uint64 srcva, uint64 len)
{
return copyin_new(pagetable, dst, srcva, len);
}
int
copyinstr(pagetable_t pagetable, char *dst, uint64 srcva, uint64 max)
{
return copyinstr_new(pagetable, dst, srcva, max);
}
3.2 在 userinit 中修改 kernel page table
我们需要将 user process page table 中的 mapping 都同步记录到 process’s kernel page table 中。
由于 process’s kernel page table 中同时记录了 user process 的 VA 映射关系和 kernel 的 VA 映射关系,为了不产生冲突,这就要求 user process 所使用的虚拟地址空间不能与 kernel 所使用的虚拟地址空间有重叠。按照实验说明,用户进程使用 0~PLIC 这块虚拟地址,也就是用户进程所使用的虚拟地址不能大于 PLIC,而 kernel 所使用的虚拟地址从更高的地址开始。
userinit()
函数是启动 OS 的第一个用户进程的,我们需要在这个函数中生成 page table 后,将 page table 的内容加入到 process’s kernel page table 中。
查看 userinit()
的实现可以看到,这个时候创建的用户进程只用了一个 page 的内存,因此它的 page table 中只存在一条记录 VA -> PA 的 PTE,我们修改后的实现如下:
为了看懂这段代码,需要了解好 walk()
函数的含义:
pte_t* walk(pagetable_t pagetable, uint64 va, int alloc);
- 它通过查找 pagetable 来寻找 va 的 PTE 记录并返回
- 如果找不到 va 对应的 PTE,也就是如果 pagetable 还没有记录这个 va 的 mapping,当
alloc != 0
时,会创建一个用于记录这个 mapping 的 PTE,但这个 PTE 的内容还没有填写,我们需要利用这个函数返回的pte_t *
指针来填入 PTE 的内容。
所以可以看到,在上面我们的代码中,我们首先将 user process page table 传给 walk,让它寻找虚拟地址 0 的 PTE pte
,然后再利用 walk 函数在 process’s kernel page table 中创建一个用于记录 VA = 0 的 mapping 的 PTE kpte
,然后将 pte
的内容复制给 kpte
,但由于 PTE 中的一个 flag PTE_U
标识了这个 PTE 是否用于 user process,当 PTE_U 这个 flag 为 1 时,kernel 就不能使用,所以在将 pte
复制给 kpte
的时候,需要将 PTE_U 这个 bit 置 0,因此就有了上面这段代码。
3.3 修改 fork(), exec(), and sbrk()
当 user process 的 page table 发生变化时,需要将变化内容同步更改到 process’s kernel page table 中。
在内核函数中,有 fork()、exec() 和 sbrk() 这三个函数改变了 user process 的 page table。这里依次看一下如何修改。
在 kernel/proc.c 的 fork()
函数中,它 fork 了一个新的进程,所以我们需要为新的进程填充 process’s kernel page table:
这里使用变量 j
遍历了一遍 user process 的虚拟地址空间,因为 user process 的虚拟地址从 0 开始,大小为 p->sz
。在迭代过程中,我们使用在 3.2 中使用的方法,将这个虚拟地址对应的 PTE 复制到 process’s kernel page table 中。
在 kernel/exec.c 中,我们修改 exec()
函数,这个函数在进程中加载一个新的程序,所以这里存在两个 page table:进程的原有程序所使用 page table oldpagetable
和新加载的程序所使用的 page table pagetable
,由于 process’s kernel page table 中还存在有 oldpagetable 中的 mapping 记录,所以我们需要将其从 kernel page table 中删除,并填充上新的 pagetable 的 mapping 记录:
同时,实验说明中提到,我们需要检查用户进程所使用的虚拟内存是否大于了 PLIC,所以在 exec() 函数中载入新程序后,我们需要检查一下新程序所使用的内存是否超标:
在 sysproc.c 中,修改 sys_sbrk()
函数,这个函数用来为用户进程申请(释放)内存,因此会涉及到修改 page table:
小心这里处理好边界情况。
3.4 修改 make_kernel_pagetable
在 vm.c 的 make_kernel_pagetable()
函数中,我们创建了 process’s kernel page table,并在其中建立了一些 mapping,但是在这里我们说到,kernel 不再使用 0~PLIC 这块虚拟内存,而之前所建立的对 CLINT 的 mapping 就在这块内存中间(可以参考 xv6 的内存分布图),所以在这个 task 中,我们需要注释掉这个 mapping 的创建:
修改了这里,这个 task 就完成了。
3.5 测试
运行 make grade
进行测试:
至此,这个实验就完成了。