#Lab5: lazy page allocation
I. Source
- MIT-6.S081 2020 课程官网
- Lab5: lazy page allocation 实验主页
- MIT-6.S081 2020 xv6 book
- B站 - MIT-6.S081 Lec8 Page Faults
II. My Code
III. Motivation
Lab5: lazy page allocation 主要是想让我们了解 xv6 在内存管理方面还可以有另一种玩法:内存空间延迟分配机制(与空间立即分配机制相反)
在开始实验之前,一定要阅读 xv6-6.S081 的第四章节 Traps and device drivers 的第六小节及
kernel/trap.c
、kernel/vm.c
和kernel/sysproc.c
IV. Eliminate allocation from sbrk() (easy)
i. Motivation
Lab: Eliminate allocation from sbrk() 是 Lab5: lazy page allocation 的第一步,主要是想让我们简化 sbrk()
的具体业务流程,将原先的立即分配改成只做标注,这么一件简单的事情
ii. Solution
首先需要了解 xv6 的内存结构,如下图,
![](https://i-blog.csdnimg.cn/blog_migrate/aa5818690a86d4f931733a18219d2980.png#pic_center)
sbrk()
的扩容操作其实就是从 stack 的顶端出发,最大可以扩展到 trapframe 的底端,如此之大的空间(整个 heap )
原先的 sbrk()
会调用 growproc()
(见 kernel/proc.c
),实现内存空间立即分配;现在我们需要将立即分配改成延迟分配(好处和缺点需要自己细品),延迟分配的第一步,就是在 sbrk()
被调用时仅仅做个标记,如此简单。kernel/sysproc.c:sys_sbrk()
代码如下,
sys_sbrk(void)
{
int addr;
int n;
struct proc* p;
if(argint(0, &n) < 0)
return -1;
p = myproc();
addr = p->sz;
/** sbrk越界处理 */
if(addr+n>=TRAPFRAME || addr+n<=0)
return addr;
p->sz += n;
/** 堆空间负增长 */
if(n < 0)
uvmdealloc(p->pagetable, addr, p->sz);
return addr;
}
其中,需要说明的是 sbrk()
扩容 or 缩小都不要越界(下界可以为 0 ,也可以为 stack 之后的首地址,见 kernel/kalloc.c:end[]
);如果 sbrk()
参数为负,在将内存释放之前需要解除其映射关系
iii. Result
手动进入 qemu
make qemu
$echo hi
此时是不能正常运行的,因为 xv6 并未分配实际的内存空间给 echo 应用程序,只是做了简单的内存扩容标注而已(类似于指针变量声明,实际上并未 malloc 内存交给该指针)
V. Lazy allocation (moderate)
i. Motivation
上一个实验,我们仅仅完成了内存空间需要分配的标注工作,实际上 xv6 并未给进程分配空间。所以在 Lab: Lazy allocation 中我们需要补上标注之后的空缺,即进程需要使用内存时(一般为第一次使用内存,缺页中断 page fault ),xv6 将在标注处为其分配物理内存
ii. Solution
主要是修改添加 kernel/trap.c:usertrap()
部分。仔细想想 xv6 的工作流程:当应用进程第一次使用到标注的内存时会发生怎么的操作?xv6 会拿着进程所需使用到的那块内存的虚拟地址,将其翻译成物理地址。如果此时物理内存中存在虚拟地址对应的页块,那么一切正常;但是,事与愿违,因为是 “第一次” ,所以物理内存中一定没有虚拟地址对应的 page ,故不可能存在上述假设,xv6 一定会发生异常(缺页中断),随后陷入中断处理
此时就需要在 usertrap()
中添加为进程在标注处分配新的内存空间的业务逻辑,Lab5: lazy page allocation 实验主页 中的 Lazy allocation 部分的提示很清晰明了,大致如下,
scause
值为13 or 15的是为缺页中断;stval
保存引起缺页中断的虚拟地址,等等细节,需要自己研读
下面,大刀阔斧来改革,代码如下,
usertrap(void)
{
...
uint64 scause = r_scause();
if(scause == 8){
...
} else if(scause==13 || scause==15) {
/** 缺页中断 */
uint64 va = r_stval();
/** 虚拟地址是否合法(在堆区,见xv6 book Figure3.4) */
if(va>=p->sz || va<=p->trapframe->sp)
goto killing;
/** 尝试分配空间(缺页中断handler) */
char* mem = kalloc();
if(mem == 0)
goto killing;
/** 做好新页的空间映射工作 */
memset(mem, 0, PGSIZE);
va = PGROUNDDOWN(va);
if(mappages(p->pagetable, va, PGSIZE, (uint64)mem, PTE_W|PTE_X|PTE_R|PTE_U) != 0)
goto freeing;
/** 顺利handle缺页中断 */
goto rest;
freeing:
kfree(mem);
killing:
p->killed = 1;
rest:
;
} else if((which_dev = devintr()) != 0){
// ok
} else {
...
}
...
}
需要添加 if-else 分支,只是代码流程中不可避免的,请勿滥用 if-else ,否则代码将极其丑陋( goto 在跳出 if-else 分支中是个很好的选择,如果是跳出 for 循环,continue/break 是最好的选择)!
该段代码主要工作就是为进程在标注处分配新的内存空间,并建立映射关系。根据 Lab5: lazy page allocation 实验主页 提示,分配空间和建立映射关系的代码可以借鉴 kernel/vm.c:uvmalloc()
有些细节需要注意,kernel/vm.c:uvmunmap()
会提示我们虚拟地址对应的物理页块不存在 or 未建立映射关系,实验要求 xv6 即使遇到这两种情况也能够顺利运行,所以我们在这里选择忽略 panic 。具体缘由,我将在第三个补漏实验中详细解释,代码如下,
uvmunmap(pagetable_t pagetable, uint64 va, uint64 npages, int do_free)
{
...
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");
...
}
}
iii. Result
手动进入 qemu,
make qemu
$lazytests
进入 xv6 来查看输出结果,此时能通过 alloc 和 unmap 的测试
VI. Lazytests and Usertests (moderate)
i. Motivation
Lab: Lazytests and Usertests 主要的目的,就是想让我们处理一些在缺页中断机制中关键的临界 or 异常问题
ii. Solution
首先,根据 Lab5: lazy page allocation 实验主页 提示,在 kernel/syspro.c:sys_sbrk()
中添加缩小空间的业务处理函数,很简单,代码见 Lab: Eliminate allocation from sbrk() ,其中要点是需要释放物理空间并解除映射关系,
...
/** 堆空间负增长 */
if(n < 0)
uvmdealloc(p->pagetable, addr, p->sz);
...
然后,还需要注意的是,在缺页中断过程中,如果虚拟地址越界 or 分配空间失败,则直接 kill 该进程。在 kernel/trap.c:usertrap()
中添加相应的细节,
usertrap(void)
{
...
uint64 scause = r_scause();
if(scause == 8){
...
} else if(scause==13 || scause==15) {
/** 缺页中断 */
uint64 va = r_stval();
/** 虚拟地址是否合法(在堆区,见xv6 book Figure3.4) */
if(va>=p->sz || va<=p->trapframe->sp)
goto killing;
/** 尝试分配空间(缺页中断handler) */
char* mem = kalloc();
if(mem == 0)
goto killing;
...
killing:
p->killed = 1;
...
} else if((which_dev = devintr()) != 0){
// ok
} else {
...
}
...
}
此外,在调用 fork()
的时候要确保能够避开缺页等错误,顺利将父进程的页表拷贝(可 “深” 可 “浅” ,xv6 选择的是 “深” )至子进程中。kernel/proc.c:fork()
的修改代码如下,
uvmcopy(pagetable_t old, pagetable_t new, uint64 sz)
{
...
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");
...
}
return 0;
...
}
父子进程页表的拷贝工作主要由 kernel/vm.c:uvmcopy()
完成,忽视掉该页块不存在 or 未建立映射关系的情况。这至关重要,因为采用 lazy allocation 机制,这就必然会出现踩空的情况(标注了,但还未分配真实内存空间);以及分配了空间,但还没有建立映射关系(父进程中存在空间已分配,但还未映射的情况?)
在 uvmcopy()
中,如果分配空间 or 建立映射失败,xv6 最终会去调用 uvmunmap()
,解除映射关系。这也就解释了 uvmunmap()
为什么要忽视空间未分配 or 空间未映射两种情况
试想,如果 uvmcopy()
未能成功分配空间,那么就会触发 uvmunmap()
。我们希望 uvmunmap()
很识相地跳过空间未分配问题,继续运行;未能成功建立映射也是同样的情形
最后,要能够应对读 or 写等系统调用可能出现的问题,比如向某个正确的虚拟地址中写数据,但此时在内存中并没有虚拟地址对应的物理空间
此时,我们或许会有疑问,不是 usertrap()
能够 handle 此类缺页中断嘛?等等,在此之前我们要理顺一件事:usertrap()
中的缺页中断处理函数,是应对从用户态切到内核的手段。但读 or 写等系统调用发生之后,xv6 已经进入了内核,根本不会再走一遍 usertrap()
中的缺页中断流程。此时的 xv6 只会想着如何将写系统调用传来的虚拟地址翻译成物理地址
在这期间(无论读 or 写)最终都会调用 walkaddr()
(从读 or 写的 kernel/vm.c:copyin()
和 kernel/vm.c:copyout
入手),所以只需在 kernel/vm.c:walkaddr()
中动点手脚即可,代码如下,
uint64
walkaddr(pagetable_t pagetable, uint64 va)
{
...
if(pte==0 || (*pte & PTE_V)==0) {
/** 针对read或write,内存还未分配 */
struct proc* p = myproc();
/** 虚拟地址是否合法(在堆区,见xv6 book Figure3.4) */
if(va>=p->sz || va<=p->trapframe->sp)
return 0;
char* mem = kalloc();
if(mem == 0)
return 0;
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(mem);
return 0;
}
return (uint64)mem;
}
...
}
跟 usertrap()
中的缺页中断机制如出一辙,越界检查,分配内存,一气呵成
iii. Result
手动进入 qemu,
make qemu
$lazytests
$usertests