在学习了 page fault 这一节课后,了解了操作系统是如何结合 page table 和 trap 利用 page fault 来实现一系列的神奇的功能。这个 lab 就是在 XV6 中实现 lazy allocation 机制。
xv6 默认是 eager allocation,也就是用户程序一旦调用 sbrk,内核会立刻分配应用程序所需要的物理内存。这个实验就是将其修改为 lazy allocation,用户在调用 sbrk 时不会立刻分配物理内存,只是做一个记录,等到程序真正读写这个内存 page 时,才会因触发 page fault 而让内核分配一个实际的物理内存并修改到 page table 中。
这个 task 让我们删除 sbrk 的系统调用实现函数 sys_sbrk()
中分配物理内存的代码,内核只需要增大 myproc()->sz
这个值即可。
代码(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;
myproc()->sz += n;
return addr;
}
make qemu 后执行 echo hi
:
这里会发生 page fault,在下面的 task 中,我们将处理发生的 page fault,并为其分配物理内存 page 且修改对应的 page table。
这个 task 需要修改代码来处理 page fault 并为用户程序分配物理内存。
当 page fault 发生时,程序会通过 trap 机制进入 usertrap()
函数,我们可以在这里实现相关的而逻辑。SCAUSE 寄存器记录了本次 trap 发生的原因,当寄存器的值为 13 或 15 时,表示因 load 或 store 指令访问一个地址但没找到相应 PTE 而发生 page fault,所以我们需要在 usertrap() 函数中判断 SCAUSE 寄存器的值,并实现相应的 page fault 处理逻辑。
首先在 usertrap()
函数(kernel/trap.c)中添加对 page fault 的识别并调用 page fault handler 来处理:
添加并实现 page_fault_handler()
函数(代码紧跟在 usertrap 函数后面就可以):
//
// handle page fault
//
void
page_fault_handler(struct proc * const p)
{
uint64 va = r_stval(); // 触发 page fault 的虚拟地址
if (p->sz <= va || va < p->trapframe->sp) { // 如果 va 高于 sbrk 申请的地址或者低于栈顶地址
p->killed = 1;
} else {
uint64 ka = (uint64) kalloc();
if (ka == 0) { // 如果物理内存不足
p->killed = 1;
} else {
memset((void*) ka, 0, PGSIZE); // 为这块地址填充 0
va = PGROUNDDOWN(va); // round the faulting virtual address down to a page boundary.
// 将 va -> ka 的 mapping 添加到 user page table 中
if (mappages(p->pagetable, va, PGSIZE, ka, PTE_W | PTE_X | PTE_U | PTE_R) != 0) {
kfree((void*) ka);
p->killed = 1;
}
}
}
}
还存在一个问题,因为用户申请的内存并没有一定分配实际的物理内存,所以在对申请但未分配的内存做 unmap 时会产生错误,因此需要对 unmap 的代码进行修改,当想要释放一个未分配的 page 时,代码中只需要直接忽视就可以了(vm.c):
完成以上修改后,make qemu 之后就可以正常运行 echo hi
了:
前一个 task 实现了一个简单的 lazy allocation,执行 echo hi
是没问题了,但在更复杂的场景下,仍然有许多需要考虑的事情,本 task 要求完善 lazy allocation 并能够通过 lazytests
和 usertests
两个测试。
首先为了实现的方便,这里将实际分配物理内存的代码逻辑封装到 alloc_memory_page()
函数(kernel/vm.c)中:
// 分配一个实际物理内存,并映射到 va 中,将这个 mapping 添加到 page table 中
uint64
alloc_memory_page(uint64 va, pagetable_t pagetable)
{
uint64 ka = (uint64) kalloc();
if (ka == 0) { // 如果物理内存不足
return 0;
}
memset((void*) ka, 0, PGSIZE); // 为这块地址填充 0
va = PGROUNDDOWN(va); // round the faulting virtual address down to a page boundary.
if (mappages(pagetable, va, PGSIZE, ka, PTE_W | PTE_X | PTE_R | PTE_U) != 0) {
kfree((void*) ka);
return 0;
}
return ka;
}
这个函数通过 kalloc()
来分配一个物理内存 page,并将其映射到 va 中,然后将这个 mapping 添加到 page table 中。当成功时,函数返回分配的物理内存地址,当失败时(内存不足或添加 mapping 失败),函数返回 0。
我们将这个 alloc_memory_page()
函数的声明放到 defs.h 头文件中。
有了这个 alloc_memory_page 函数,我们在上一个实验写的 page fault handler 就可以简化一下了,分配物理内存的逻辑改为调用 alloc_memory_page
即可:
//
// handle page fault
//
void
page_fault_handler(struct proc * const p)
{
uint64 va = r_stval();
if (p->sz <= va || va < p->trapframe->sp) { // 如果 va 高于 sbrk 申请的地址或者低于栈顶地址
p->killed = 1;
} else {
if (alloc_memory_page(va, p->pagetable) == 0) { // 当分配内存或添加 mapping 失败,就直接杀死该进程
p->killed = 1;
}
}
}
修改 uvmummap()
函数,当无法从 page table 中找到 va 的 PTE 时或者 PTE 未映射时,直接跳过:
我们还需要正确处理 fork 时父进程向子进程 copy 内存的逻辑,这里需要修改 uvmcopy()
函数,也是在页表不存在或 PTE 未映射时直接跳过:
还有一种情况是,当用户程序把通过 sbrk() 申请的内存(但还未实际分配)的内存地址传递给系统调用时,kernel 可能会在 copyin
和 copyout
这两个函数中访问这个内存地址,而 kernel 内是无法像用户程序那样走 page fault handler 来 lazy allocation 的,所以我们必须在 copyin
和 copyout
函数内也实现“访问用户程序传来的内存地址时做 lazy allocation”的逻辑:
这里 copyout 通过 walkaddr
来将 va 借助 user page table 来翻译得到 pa,但由于我们采用了 lazy allocation 机制,所以这里可能无法找到 PTE 映射,所以,当没有找到 PTE 映射时,我们需要立刻为其分配物理内存并修改 user page table,这也是上图红方框内代码的逻辑。对于 copyin 函数,所做的修改类似: