这次实验主要实现Lazy allocation的功能,即进程在动态分配内存的时候先不分配,等到要用到发生缺页中断的时候再实际分配,核心是实现缺页中断的处理。xv6的文档介绍了三种缺页中断的应用,第一为Copy on write,即fork的时候先不复制内存,等到要用到发生缺页中断的时候再实际分配;第二为硬盘虚拟内存,就是当内存不够大的时候将一部分硬盘区域当作内存交换区,虚拟地址只映射到一个无效位置,当访问该虚拟地址发生缺页中断时再把一个页的内容保存进磁盘,然后从磁盘中加载当前这个虚拟地址指向的实际内容;第三就是本实验的内容。
这个任务非常简单,没啥好说的:
uint64
sys_sbrk(void)
{
int addr;
int n;
if(argint(0, &n) < 0)
return -1;
struct proc *p = myproc();
addr = p->sz;
if (n < 0) p->sz = uvmdealloc(p->pagetable, p->sz, p->sz + n);
else p->sz += n;
// if(growproc(n) < 0)
// return -1;
return addr;
}
对n小于0情况的处理是第三个任务的内容,这里可以忽略。
这个任务要求实现对缺页中断的处理,因为在sbrk的时候仅仅指扩大了进程的虚拟地址区域,所以在访问这些虚拟地址时会发生缺页中断,这里就需要在发生缺页中断的时候分配物理内存然后映射,中断处理函数usertrap()对缺页中断进行处理:
......
syscall();
} else if((which_dev = devintr()) != 0){
// ok
} else {
if (r_scause() == 13 || r_scause() == 15) {
uint64 va = r_stval(); if (handle_page(va, p) == -1) p->killed = 1;
} else {
printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
printf(" sepc=%p stval=%p\n", r_sepc(), r_stval());
p->killed = 1;
}
}
if(p->killed)
exit(-1);
这里把缺页中断的实际处理过程抽象成了一个函数,实际上仅从任务2考虑是没有必要的,但是任务3中还需要对copyin、copyout这些函数中发生缺页的情况进行处理,所以抽象成一个函数方便各处调用。
handle_page函数我写在proc.c里,因为这里已经包含了所需要的头文件:
int handle_page(uint64 va, struct proc *p) {
uint64 base = PGROUNDDOWN(va);
if (va >= p->sz || va < p->trapframe->sp) return -1;
char *mem = kalloc();
if (mem == 0) return -1;
memset(mem, 0, PGSIZE);
if(mappages(p->pagetable, base, PGSIZE, (uint64)mem, PTE_W|PTE_X|PTE_R|PTE_U) != 0) {
kfree(mem); return -1;
}
return 0;
}
这些return -1的情况也是任务3的内容,任务2可以忽略,主要都是借鉴函数uvmalloc。然后修改一下uvmunmap(),即把一些因为缺页导致的panic跳掉了,因为这些页从来就没分配过,也就不用释放:
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");
......
这个任务主要是把上个任务遗留的一些不合法情况进行处理。
第一是sbrk的参数为负数的问题,根据growproc函数的内容,对参数为负数的情况就是释放参数绝对值大小的内存,仿造growproc()就行了,见上面的代码。uvmdealloc本身不用修改,因为内部就是调用uvmunmap的。
第二是缺页中断中当虚拟地址不合法时应该直接返回并杀掉进程,不合法包含两种情况,一是虚拟地址太大,大出了进程所申请的内存(不管实际有没有分配),因为进程虚拟地址从0开始,所以只要保证虚拟地址小于p->sz
即可;而是虚拟地址太小,比进程的栈顶还低(注意栈是从高往低增长的),这就需要知道栈顶的位置,查看测试程序usertests,发现它获取栈顶的方法就是读sp寄存器,但是缺页中断的处理是在内核态,sp指向的也是内核栈的栈顶,想要获得用户栈的栈顶,可以借助进程的中断帧来实现,即读取p->trapframe->sp
,需要保证虚拟地址大于等于这个值。杀掉进程可以观察usertrap函数的其他位置,发现只要令p->killed=1
即可,见上面的代码。
第三是如果申请物理内存失败时也要杀掉进程,加上映射失败,照着uvmalloc里写就行了。
第四是fork的时候复制到缺页的虚拟地址时的处理,注意到fork的这部分是调用的uvmcopy,所以改uvmcopy,和uvmunmap一样,缺页导致的panic跳掉:
for(i = 0; i < sz; i += PGSIZE){
if((pte = walk(old, i, 0)) == 0) continue;
// panic("uvmcopy: pte should exist");
if((*pte & PTE_V) == 0) continue;
// panic("uvmcopy: page not present");
......
第五是read和write文件的时候如果传入了一个缺页的虚拟地址(在将文件读入内存和将内存写入文件时需要传入地址),追踪这两个函数的过程可以发现最终处理地址调用的是copyin、copyinstr和copyout函数,注意到这几个函数会先walk一下传入的虚拟地址,如果得不到物理地址就直接返回失败,而不会经过缺页中断的过程,所以直接加入代码让其在判断得不到物理地址的情况下调用handle_page函数即可:
va0 = PGROUNDDOWN(srcva);
pa0 = walkaddr(pagetable, va0);
if(pa0 == 0) {
if (handle_page(va0, myproc()) == -1) return -1; else pa0 = walkaddr(pagetable, va0);
// return -1;
}
......
总结一下,缺页中断的发生时刻应该是在MMU访问到一个PTE_V位为0的PTE时,在xv6中这个PTE的其他位是没有意义的,而在riscv-pk(用在spike模拟器上的代理内核)则让PTE的其他位指向一个标记结构体,里面包含了这个缺页的信息,比如该缺页是否是因为内存被置换到硬盘上了,置换到了哪个位置等信息,这样就使得该系统可以处理多种原因导致的缺页中断,而xv6应该是不支持硬盘虚拟内存的。