对于页表硬件,O/S可以使用许多巧妙的技巧之一是用户空间堆内存的懒分配。xv6应用程序使用sbrk()
系统调用向内核请求堆内存。 在给出的内核中,sbrk()
分配物理内存并将其映射到进程的虚拟地址空间。内核为大型请求分配和映射内存可能需要很长时间。比如,一个GB包含262144个4096字节的页面,这是一个巨大的配置数量。 此外,一些程序分配的内存比实际使用的要多(比如,实现稀疏数组),或者为了更好的使用,而分配较多的内存。 为了允许sbrk()
在这些情况下更快地完成任务,复杂的内核会惰性地分配用户内存。 也就是说,sbrk()
不分配物理内存,而只是记住分配了哪些用户地址,并在用户页表中将这些地址标记为无效。 当进程第一次尝试使用任何给定的惰性分配的内存页时,CPU会产生一个页面错误,内核通过分配物理内存、清零和映射来处理这个错误。在这个实验中,你需要把这个懒分配特性添加到xv6中。
在进行Lab之前,需要阅读Chap4.6,和相关文件:
kernel/trap.c
kernel/vm.c
kernel/sysproc.c
切换到Lab5分支:
$ git fetch
$ git checkout lazy
$ make clean
Lab5:xv6 lazy page allocation
Eliminate allocation from sbrk() (easy)
要求:
第一个任务是从sbrk(n)系统调用实现中删除页面分配,该实现是sysproc.c中的函数sys_sbrk()
旧的sbrk(n)系统调用将进程内存大小增加n个字节,然后返回新分配的区域的开始(即旧的大小)(返回的是myproc()->sz)
新的sbrk(n)应该只增加进程大小(myproc()->sz) n,并返回旧的大小。它不应该分配内存——所以你应该删除对 growproc() 的调用(但是需要增加进程的大小myproc()->sz)
进行此修改后,启动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
中的用户trap
处理程序;它捕获了一个不知道如何处理的异常。 确保你理解了威为什么会出现这个页面错误。 stval = 0x0..04008
表示导致页面错误的虚拟地址是0x4008
代码:
// kernel/sysproc.c
uint64
sys_sbrk(void)
{
int addr;
int n;
// if(argint(0, &n) < 0)
// return -1;
// addr = myproc()->sz;
// if(growproc(n) < 0) // * 急分配
// return -1;
// return addr;
if (argint(0, &n) < 0)
return -1;
addr = myproc()->sz;
myproc()->sz = myproc()->sz + n; // * 懒分配,只增加进程大小
return addr;
}
启动xv6后,并输入echo hi
,获得如下输出:
$ echo hi
usertrap(): unexpected scause 0x000000000000000f pid=3
sepc=0x00000000000012ac stval=0x0000000000004008
panic: uvmunmap: not mapped
Lazy allocation(moderate)
要求:
修改 trap.c 中的代码,分配新的物理内存页并映射到之前出错的虚拟地址,从而响应来自用户空间的页面错误,然后返回到用户空间,让进程继续执行
你应该将代码添加到产生"usertrap(): ..." 消息的printf调用之前。修改你需要的任何xv6代码,以便让 echo hi 工作
提示:
- 你可以通过查看
usertrap()
中的r_scause()
是13还是15来检查错误是否为页面错误(见RISC-V 特权指令 pdf) r_stval()
返回RISC-Vstval
寄存器,其中包含导致页面错误的虚拟地址。- 参考
vm.c
中的uvmalloc()
代码,这是sbrk()
通过growproc()
调用的函数,你需要调用kalloc()
和mappages()
- 使用
PGROUNDDOWN(va)
将出错的虚拟地址四舍五入到页面边界 uvmunmap()
会panic
;修改它,使某些页面没有映射时不会出现问题(进程请求分配的内存,如果进程没有使用,那么就没有真正地分配物理内存,也就没有映射在页表里,当进程退出时,调用uvmunmap
来回收分配的页面时会发生错误)- 如果内核崩溃,在
kernel/kernel.asm
中查找sepc
- 使用 pgtbl Lab 中的
vmprint()
函数打印页表的内容 - 如果你看到错误 "incomplete type proc",导入(#include)
spinlock.h
,然后是proc.h
如果一切顺利,你的懒分配代码应该会让 echo hi
工作。你应该至少会遇到一个页面错误(从而导致懒分配),或者两个。
代码:
// kernel/trap.c
void usertrap(void) {
...
if(r_scause() == 8) {
...
} else if((which_dev = devintr()) != 0) {
...
} else if (r_scause() == 13 || r_scause() == 15) {
// * 判断是否是页面错误
// * 页面错误处理时,需要分配一个新物理页,并将其映射到之前出错的虚拟地址上
uint64 va = r_stval(); // * 获得出错的虚拟地址
char* mem = kalloc();
if (mem == 0) {
printf("page fault: not enough memory");
p->killed = 1;
}
else {
memset(mem, 0, PGSIZE);
va = PGROUNDDOWN(va);
if (mappages(p->pagetable, va, PGSIZE, (uint64)mem, PTE_W | PTE_X | PTE_R | PTE_U) != 0) {
kfree((void*) mem);
p->killed = 1;
}
}
}
else {
...
}
...
}
同时由于懒分配,可能部分分配的地址空间并没有被使用,那么在页表中也就不会有映射,之后调用uvmunmap
的时候会panic
,需要对uvmunmap
进行修改:
// 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)
// panic("uvmunmap: walk");
// if((*pte & PTE_V) == 0)
// panic("uvmunmap: not mapped");
// * 由于懒分配,进程所请求的内存如果没有被使用,那么也不会有映射,这种情况不应该报错
if ((pte = walk(pagetable, a, 0)) == 0)
continue;
if ((*pte & PTE_V) == 0)
continue;
if(PTE_FLAGS(*pte) == PTE_V)
panic("uvmunmap: not a leaf");
if(do_free){
uint64 pa = PTE2PA(*pte);
kfree((void*)pa);
}
*pte = 0;
}
}
测试:
输入echo hi
,可以得到:
$ echo hi
hi
Lazytests and Usertests(moderate)
在xv6用户程序 lazytests
中会测试一些可能对惰性内存分配器造成压力的特定情况。修改内核代码,使所有的惰性测试和用户测试都能通过。
提示:
- 处理负的
sbrk()
参数 - 如果一个进程的虚拟内存地址高于使用
sbrk()
分配的任何虚拟内存地址,则终止该进程(注意进程申请的地址范围是) - 正确处理
fork()
中的父进程到子进程的内存拷贝 - 处理这样的情况:进程从
sbrk()
传递了一个有效的地址给系统调用,比如读或写,但是这个地址的内存还没有分配 - 正确处理内存不足:如果
kalloc()
在页面错误处理程序中失败,则杀死当前进程 - 处理用户堆栈下面的无效页上的错误(如果是保护页的错误,应该终止进程)
如果产生如下输出,说明通过了lazytests
和usertests
:
$ lazytests
lazytests starting
running test lazy alloc
test lazy alloc: OK
running test lazy unmap...
usertrap(): ...
test lazy unmap: OK
running test out of memory
usertrap(): ...
test out of memory: OK
ALL TESTS PASSED
$ usertests
...
ALL TESTS PASSED
$
代码:
首先修改kernel/sysproc.c
中的sys_sbrk()
函数,当sbrk()
的参数为负数时,立即释放空间:
// kernel/sysproc.c
uint64
sys_sbrk(void)
{
int addr;
int n;
// if(argint(0, &n) < 0)
// return -1;
// addr = myproc()->sz;
// if(growproc(n) < 0) // * 急分配
// return -1;
// return addr;
if (argint(0, &n) < 0)
return -1;
addr = myproc()->sz;
if (n < 0) { // * 处理负的sbrk参数,此时立即释放空间
if (myproc()->sz + n < 0) // * 缩小的空间不能大于当前的空间
return -1;
uvmdealloc(myproc()->pagetable, myproc()->sz, myproc()->sz + n);
}
myproc()->sz = myproc()->sz + n; // * 懒分配,只增加进程大小
return addr;
}
然后,修改kernel/trap.c
中的usertrap()
函数,添加判断条件:1. 如果虚拟内存地址高于使用sbrk分配的任何虚拟地址,则终止该进程 2. 如果是保护页上的错误,那么应该终止进程:
void usertrap(void)
{
...
if(r_scause() == 8){
...
} else if((which_dev = devintr()) != 0){
// ok
} else if (r_scause() == 13 || r_scause() == 15) { // * 判断是否是页面错误
// * 页面错误处理时,需要分配一个新物理页,并将其映射到之前出错的虚拟地址上
uint64 va = r_stval(); // * 获得出错的虚拟地址
if (va >= p->sz) { // * 如果虚拟内存地址高于使用sbrk分配的任何虚拟地址,则终止该进程,注意进程申请的地址范围是[0, p->sz)
p->killed = 1;
}
else if (va < PGROUNDDOWN(p->trapframe->sp)) { // * PGROUNDDOWN(p->trapframe->sp)表示栈底,如果是保护页上的错误,那么应该终止进程
p->killed = 1;
}
else {
char* mem = kalloc();
if (mem == 0) { // * 处理内存不足:如果kalloc失败,则杀死当前进程
printf("page fault: not enough memory");
p->killed = 1;
}
else {
memset(mem, 0, PGSIZE);
va = PGROUNDDOWN(va);
if (mappages(p->pagetable, va, PGSIZE, (uint64)mem, PTE_W | PTE_X | PTE_R | PTE_U) != 0) {
kfree((void*) mem);
p->killed = 1;
}
}
}
}
else {
...
}
...
}
修改fork()
中调用的uvmcopy()
函数,当从父进程拷贝用户内存到子进程时,如果父进程中某些内存没有被分配,那么在拷贝过程中,不应该报错:
// kernel/vm.c
int
uvmcopy(pagetable_t old, pagetable_t new, uint64 sz)
{
...
for(i = 0; i < sz; i += PGSIZE){
// if((pte = walk(old, i, 0)) == 0)
// panic("uvmcopy: pte should exist");
// if((*pte & PTE_V) == 0)
// panic("uvmcopy: page not present");
// * 由于父进程中的某些内存可能并没有被分配,此时在拷贝过程中,不应该报错
if ((pte = walk(old, i, 0)) == 0)
continue;
if ((*pte & PTE_V) == 0)
continue;
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;
}
修改kernel/vm.c
中的walkaddr
函数,对于copyin
和copyout
函数(它们会调用walkaddr
函数),它们被分别用来从用户空间虚拟地址读数据和向用户空间虚拟地址写数据,如果该虚拟地址没有被分配内存,则会出现错误;对于copyinstr
函数,也会调用walkaddr
函数;对于kernel/exec.c
中的loadseg
函数,同样会调用walkaddr
函数,所以应该对walkaddr
进行修改(注意,要注意判断walkaddr
中哪种情况可能是懒分配(pte == 0
或者 (*pte & PTE_V) == 0
),并且分配新的物理页,此时应该直接返回新物理页的物理地址)(注意进程申请的地址范围是):
// kernel/vm.c
// * 将由虚拟地址找到的PTE转换为物理地址,得到虚拟地址对应的物理地址
uint64
walkaddr(pagetable_t pagetable, uint64 va)
{
pte_t *pte;
uint64 pa;
if(va >= MAXVA)
return 0;
struct proc* p = myproc();
pte = walk(pagetable, va, 0);
// if(pte == 0)
// return 0;
// if((*pte & PTE_V) == 0)
// return 0;
// * 如果该虚拟地址没有映射到物理地址,需要进行映射
if (pte == 0 || (*pte & PTE_V) == 0) { // * 这两种情况可能是懒分配
if (va >= p->sz) { // * 由于进程申请的范围是[0, p->sz),所以判断条件是 va >= p->sz
return 0;
}
else if (va < PGROUNDDOWN(p->trapframe->sp)) {
return 0;
}
else {
char *mem = kalloc();
if (mem == 0) {
return 0;
}
else {
memset(mem, 0, PGSIZE);
va = PGROUNDDOWN(va);
if (mappages(p->pagetable, va, PGSIZE, (uint64)mem, PTE_W | PTE_X | PTE_R | PTE_U) != 0) {
kfree((void*) mem);
return 0;
}
return (uint64)mem; // * 如果分配新页面,应该返回新页面的物理地址
}
}
}
if((*pte & PTE_U) == 0) // * 注意条件的判断顺序
return 0;
pa = PTE2PA(*pte);
return pa;
}
测试:
总体测试: