xv6 page fault —— MIT6.S081操作系统工程
当硬件对用户使用的虚拟地址进行翻译时,若该虚拟地址不正确,比如尚未映射、权限不足等,硬件会产生一个page fault陷阱给操作系统,就是这样一个看似简单平常的机制,却给了操作系统很大的能力,它可以做很多有趣的事。
- lazy allocation:操作系统在给用户进程分配内存时,可以先不分配实际的物理内存页,也不在用户页表中建立映射,而是等用户真正的访问这部分内存时再分配,操作系统可以在page fault处理程序中进行分配工作
- copy on write fork:当父进程调用
fork()
创建子进程时,可以不将自己的内存和页表完全复制给子进程,而是将所有PTE改为只读,然后和子进程共享页表。当任意一方对共享页表进行修改时,会因为权限不足得到一个page fault,处理程序可以在这里完成对该页的实际拷贝 - demand paging:若程序的某些段(比如存储指令的文本段)特别大时,可以不在
exec()
创建进程时就为该段分配实际的物理内存,page fault处理程序可以在发现某些段的页在页表中并不存在时,再从该程序的文件中加载该页。同时,在内存无法装下更多页时,可以进行页面换出。 - mmap:操作系统可以将某个文件(一切皆文件)的某一部分映射到虚拟内存中,当发生page fault时,操作系统将使用
read
来读取文件,加载到页中,之后,用户可以通过store
和load
来在内存中操作这些页,当一切都完成了,unmap
操作会将脏页刷回文件。 - .....
page fault时需要的信息
page fault可以干的事很多,但是硬件只通过一个trap
来通知操作系统发生了page fault,我们需要从某些地方来获取该page fault的上下文信息,不然我们根本没法判断现在我们要做的是帮助lazy allocation实际分配一个物理页还是要去磁盘的程序静态文件中读一个页并加载进内存。
虚拟地址
stval
寄存器中保存了使page fault发生的那条虚拟地址,这样,page fault就知道是哪个虚拟内存地址的访问出错了,并修复该虚拟内存地址所在的页面。
同时,我们也可以通过虚拟地址的范围来判断,若它在当前进程可用堆内存的大小范围内(对于xv6来说就是TRAPFRAME下面STACK上面),那我们要做的可能就是去帮lazy allocation机制完成实际的页分配,而如果虚拟地址的范围在文本段中,我们可能需要去当前进程的静态程序文件中去读取并建立实际的内存页。
scause
scause
寄存器中保存了进入trap的原因,下图是risc-v中该寄存器中值所代表的原因的表格,可以看到12、13、15都是和page fault相关的:
从scause
,也可以区分此次page fault是来自于指令页面的还是其它页面的。
导致page fault的指令
通过硬件寄存器sepc和进程trapframe中的epc都可以知道是哪条指令导致了此次page fault,我们可能需要在处理完page fault后重新执行该指令。
xv6 lazy page allocation实验
一个操作系统可以使用页表硬件来实现的灵活的技巧就是用户空间堆内存的懒分配(lazy allocation)。xv6应用通过sbrk()
系统调用向内核申请堆内存。在我们给你的内核中,sbrk()
分配物理内存并且将它映射到进程的虚拟地址空间中。对于一个足够大的请求,内核可能会花费很长时间在分配和映射内存上。举个例子,1GB包含262144个4K页,尽管每一个单独的页面分配都很廉价,但总的来说这也是一个巨大的分配。此外,一些程序分配比它们实际使用到的更加多的内存(比如为了实现稀疏数组),或者提前分配内存。在这个场景中,复杂精妙的内核对用户内存进行懒分配,以让sbrk()
调用结束的更快。也就是说,sbrk()
实际上并不分配物理内存,只是记住哪些用户地址被分配了,并且在用户页表中标记这些地址为无效的。当进程第一次尝试使用任意给定的被懒分配的内存,CPU会生成一个page fault,内核通过分配物理内存、将其置零,并映射它来处理。在本次实验中,你将为xv6添加懒分配特性。
在你开始编码之前,阅读xv6 book的第4章(特别是4.6),以及你可能会修改到的相关文件:
kernel/trap.c
kernel/vm.c
kernel/sysproc.c
消除sbrk()的实际分配(easy)
你的第一个任务是从sbrk(n)
系统调用实现中删除页分配,它在sysproc.c
的sys_sbrk()
函数中。sbrk(n)
系统调用会将进程的内存大小增加n字节,然后返回新分配的区域的起始位置(也就是老的大小)。你的心sbrk(n)
应该只是将进程大小(myproc()->sz)增加n字节,并返回老的大小。这将不会分配内存——所以你应该删除growproc()
调用(但你仍然需要增加进程大小)
猜一猜这次修改后会发生什么!?什么会坏掉?
修改后,启动xv6,在shell中输入echo hi
,你会看到如下内容:
init: starting sh
$ echo hi
usertrap(): unexpected scause 0x000000000000000f pid=3
sepc=0x0000000000001258 stval=0x0000000000004008
va=0x0000000000004000 pte=0x0000000000000000
panic: uvmunmap: not mapped
信息——"usertrap(): ..."——是从trap.c
中的陷阱处理器传出的,它捕获了一个不知道该如何处理的异常,请确保你已经理解为什么page fault会出现。"stval=0x0..04008"说明是0x4008
这个虚拟地址导致了page fault。
// kernel/sysproc.c
uint64
sys_sbrk(void)
{
uint64 addr;
int n;
if(argint(0, &n) < 0)
return -1;
addr = myproc()->sz;
myproc()->sz += n; // 将进程大小增加
// if(growproc(n) < 0) // 不进行实际的growproc
// return -1;
return addr;
}
懒分配(moderate)
修改trap.c
中的代码以响应用户空间的page fault,在faulting地址上映射一个新分配的物理内存页,然后返回到用户空间让进程继续执行。你应该只在产生"usertrap(): ..."
输出信息的printf
调用之前添加你的代码。修改任何你需要的其它xv6内核代码以让echo hi
正常工作。
这里有一些提示:
- 在
usertrap()
中,你可以通过查看r_scause()
是13或15来检查一个fault是否是page fault r_stval()
返回了RISC-V的stval
寄存器,它包含了导致page fault的虚拟地址- 从
vm.c
中的uvmalloc()
窃取代码,这是sbrk()
通过growproc()
调用的函数。你将需要调用kalloc()
和mappages()
- 使用
PGROUNDDOWN(va)
来对失败的虚拟地址进行向下取整,取整到一个页边界 uvmunmap()
将会panic,修改它,让它在页面没有被映射时不panic- 如果内核崩溃了,根据
sepc
去kernel/kernel.asm
中查看 - 使用你pgtbl实验中的的
vmprint
函数来打印页表内容 - 如果你看到了"incomplete type proc"这个错误,include "spinlock.h"以及"proc.h"
如果一切正常,你的懒分配代码将会使得echo hi
正常工作,你最少应该获得一个page fault(引起懒分配),并且或许是两个。
// kernel/trap.c -> usertrap()
// ...添加代码分支...
} else if (r_scause() == 13 || r_scause() == 15) {
// PS:该代码为github repo https://github.com/duguosheng/xv6-labs-2020
// 我之前的代码有一点点逻辑漏洞,导致usertests死循环,实际的内容差不多,我懒得再改一遍了
uint64 fault_va = r_stval(); // 产生页面错误的虚拟地址
char* pa; // 分配的物理地址
if(PGROUNDUP(p->trapframe->sp) - 1 < fault_va && fault_va < p->sz &&
(pa = kalloc()) != 0) {
memset(pa, 0, PGSIZE);
if(mappages(p->pagetable, PGROUNDDOWN(fault_va), PGSIZE, (uint64)pa, PTE_R | PTE_W | PTE_X | PTE_U) != 0) {
kfree(pa);
p->killed = 1;
}
} else {
p->killed = 1;
}
}
// kernel/vm.c
void
uvmunmap(pagetable_t pagetable, uint64 va, uint64 npages, int do_free)
{
uint64 a;
pte_t *pte;
if((va % PGSIZE) != 0)
panic("uvmunmap: not aligned");
for(a = va; a < va + npages*PGSIZE; a += PGSIZE){
if((pte = walk(pagetable, a, 0)) == 0)
continue; // 由于懒分配,有一些已经许诺给用户的虚拟地址还尚未在页表中分配,也没有实际的物理页映射,所以,遇到这种页直接跳过
// panic("uvmunmap: walk");
if((*pte & PTE_V) == 0)
continue; // 和上面相同的原因
// panic("uvmunmap: not mapped");
if(PTE_FLAGS(*pte) == PTE_V)
panic("uvmunmap: not a leaf");
if(do_free){
uint64 pa = PTE2PA(*pte);
kfree((void*)pa);
}
*pte = 0;
}
}
Lazytests和Usertests(moderate)
我们为你提供了lazytests
,它是一个测试在一些可能会给你的懒内存分配器带来压力的特定情况下的用户程序,修改你的内核代码,让lazytests
和usertests
都可以通过。
- 处理负
sbrk()
参数 - 如果page faults的虚拟地址高于任何
sbrk()
分配的地址,杀掉进程 - 正确处理
fork()
中父到子的内存拷贝 - 处理一个进程将有效地址从
sbrk()
传递到一个系统调用(如read
或write
),但这个地址的内存尚未被实际分配的情况 - 正确处理out-of-memory:如果在page fault处理器中
kalloc()
失败,杀掉当前进程 - 处理在用户栈之下的非法页fault
要在内核中修改的地方太多了,懒得往上贴了......