目录
Implement copy-on write (hard)
5.1 Implement copy-on write (hard)
前言:
Lab6只有一个实验,目的是为了实现写时复制。写时复制(Copy-on-write,简称COW)是一种计算机程序设计领域的优化策略。其核心思想是,如果有多个调用者(callers)同时请求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。这过程对其他的调用者都是透明的。此作法主要的优点是如果调用者没有修改该资源,就不会有副本(private copy)被创建,因此多个调用者只是读取操作时可以共享同一份资源。
Lab6的工作是当父进程fork出子进程后,先不给子进程分配内存空间,而是让它和父进程共享内存空间,当子进程使用到其中的几个页面时,CPU将强制产生页面错误,此时再分配空间给子进程的页面,进行复制。
本节实验要求:
Implement copy-on write (hard)
你的工作:
您的任务是在xv6内核中实现copy-on-write fork。如果修改后的内核同时成功执行cowtest
和usertests
程序就完成了。
为了帮助测试你的实现方案,我们提供了一个名为cowtest
的xv6程序(源代码位于user/cowtest.c)。cowtest
运行各种测试,但在未修改的xv6上,即使是第一个测试也会失败。因此,最初您将看到:
$ cowtest
simple: fork() failed
$
“simple”测试分配超过一半的可用物理内存,然后执行一系列的fork()
。fork
失败的原因是没有足够的可用物理内存来为子进程提供父进程内存的完整副本。
完成本实验后,内核应该通过cowtest
和usertests
中的所有测试。即:
$ cowtest
simple: ok
simple: ok
three: zombie!
ok
three: zombie!
ok
three: zombie!
ok
file: ok
ALL COW TESTS PASSED
$ usertests
...
ALL TESTS PASSED
$
这是一个合理的攻克计划:
- 修改
uvmcopy()
将父进程的物理页映射到子进程,而不是分配新页。在子进程和父进程的PTE中清除PTE_W
标志。 - 修改
usertrap()
以识别页面错误。当COW页面出现页面错误时,使用kalloc()
分配一个新页面,并将旧页面复制到新页面,然后将新页面添加到PTE中并设置PTE_W
。 - 确保每个物理页在最后一个PTE对它的引用撤销时被释放——而不是在此之前。这样做的一个好方法是为每个物理页保留引用该页面的用户页表数的“引用计数”。当
kalloc()
分配页时,将页的引用计数设置为1。当fork
导致子进程共享页面时,增加页的引用计数;每当任何进程从其页表中删除页面时,减少页的引用计数。kfree()
只应在引用计数为零时将页面放回空闲列表。可以将这些计数保存在一个固定大小的整型数组中。你必须制定一个如何索引数组以及如何选择数组大小的方案。例如,您可以用页的物理地址除以4096对数组进行索引,并为数组提供等同于kalloc.c中kinit()
在空闲列表中放置的所有页面的最高物理地址的元素数。 - 修改
copyout()
在遇到COW页面时使用与页面错误相同的方案。
提示:
- lazy page allocation实验可能已经让您熟悉了许多与copy-on-write相关的xv6内核代码。但是,您不应该将这个实验室建立在您的lazy allocation解决方案的基础上;相反,请按照上面的说明从一个新的xv6开始。
- 有一种可能很有用的方法来记录每个PTE是否是COW映射。您可以使用RISC-V PTE中的RSW(reserved for software,即为软件保留的)位来实现此目的。
usertests
检查cowtest
不测试的场景,所以别忘两个测试都需要完全通过。- kernel/riscv.h的末尾有一些有用的宏和页表标志位的定义。
- 如果出现COW页面错误并且没有可用内存,则应终止进程。
5.1 Implement copy-on write (hard)
根据实验指导书的提示,我们需要在两个情况下进行写时复制:
- 当用户进程向内存写入时,如果此时的页面还没有被分配到内存,则会触发中断进行分配
- 当在内核态情况下,调用copyout()对页面进行写入时,也需要对页面进行内存分配
5.1.1 准备工作
这一小节主要是完成准备工作,需要创建一个记录了每个页面被引用情况的数组,每当有新的子进程被fork出来,对应的页面引用数都要加1,只有一个页面的引用数归0时该页面才会被放回空闲列表。
首先我们先查阅xv6手册,看看有多少的页面需要创建数组索引:
可以看到, 只有从KERNBASE到PHYSTOP范围内的虚拟内存,才需要映射到物理内存中,因此,我们只需要以这段空间的页面数作为数组大小即可,这一部分在kernel/kalloc.c中完成:
// kernel/kalloc.c
struct {
struct spinlock lock;
struct run *freelist;
char *ref_page;
int page_cnt;
char *end;
} kmem;
void
kinit()
{
initlock(&kmem.lock, "kmem");
// 计算有多少页面需要被索引
kmem.page_cnt = pagecnt(end, (void*)PHYSTOP);
kmem.ref_page = end;
for(int i = 0; i < kmem.page_cnt; ++i)
{
kmem.ref_page[i] = 0;
}
kmem.end = kmem.ref_page + kmem.page_cnt;
freerange(kmem.end, (void*)PHYSTOP);
}
int
pagecnt(void *pa_start, void *pa_end)
{
char *p;
int cnt = 0;
p = (char*)PGROUNDUP((uint64)pa_start);
for(; p + PGSIZE <= (char*)pa_end; p += PGSIZE)
cnt++;
return cnt;
}
之后,需要对pte新增一个标志位,该标志位用来表示是否页面是出于COW(写时复制)的状态:
// kernel/riscv.h
#define PTE_V (1L << 0) // valid
......
#define PTE_COW (1L << 8)
5.1.2 修改uvmcopy()
这一小节需要完成对uvmcopy()函数的修改,在原代码中,这个函数实现的是为子进程申请一个内存空间,并将父进程内存空间的内容复制到子进程中。而写时复制则要求将子进程的虚拟空间映射到父进程的内存空间中,此外,还需要完成对对应页表项的标志位修改:
// kernel/vm.c
int
uvmcopy(pagetable_t old, pagetable_t new, uint64 sz)
{
pte_t *pte;
uint64 pa, i;
uint flags;
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");
// 获取页表项对应的物理地址
pa = PTE2PA(*pte);
// 清除pte的写权限
*pte &= ~PTE_W;
// 令pte的cow标志位置为1
*pte |= PTE_COW;
flags = PTE_FLAGS(*pte);
// if((mem = kalloc()) == 0)//分配物理页面
// goto err;
// memmove(mem, (char*)pa, PGSIZE);//把src的内容复制到dst上
// 将子进程的页面映射到父进程的物理地址上
if (mappages(new, i, PGSIZE, (uint64)pa, flags) != 0) {
goto err;
}
incr((void *)pa);
}
return 0;
err:
uvmunmap(new, 0, i / PGSIZE, 1);
return -1;
}
5.1.3 修改usertrap()
由于此时子进程和父进程共享了同一块物理内存,因此,当子进程需要写入时,就必须要重新将对应的页面映射到一块新的物理内存中,这个函数主要完成的就是在写入时,对于处于共享的页面触发中断,对其分配内存:
// kernel/trap.c
void
usertrap(void)
{
int which_dev = 0;
......
if(r_scause() == 8){
......
} else if((which_dev = devintr()) != 0){
// ok
}
// 如果发生了写中断时触发内存分配
else if (r_scause() == 15) {
// 获取导致中断发生的页面地址
uint64 va = r_stval();
// 判断是否需要写时复制
if (is_cow_fault(p->pagetable, va)) {
// 对页面进行分配
if (cow_alloc(p->pagetable, va) < 0) {
printf("usertrap(): cow_alloc failed!");
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;
}
}
......
usertrapret();
}
// kernel/vm.c
int is_cow_fault(pagetable_t pagetable, uint64 va)
{
// 根据标志位判断当前页表项是否符合条件
va = PGROUNDDOWN(va);
pte_t *pte = walk(pagetable, va, 0);
if(pte == 0)
return 0;
if((*pte & PTE_V) == 0)
return 0;
if((*pte & PTE_U) == 0)
return 0;
if((*pte & PTE_COW))
return 1;
return 0;
}
// 模仿uvmcopy的原代码即可
int cow_alloc(pagetable_t pagetable, uint64 va)
{
va = PGROUNDDOWN(va);
pte_t *pte;
char *mem;
// 返回页表中对应的虚拟地址的页表项
if ((pte = walk(pagetable, va, 0)) == 0) {
panic("uvmcopy: pte should exist");
}
uint64 pa = PTE2PA(*pte);
uint flags = PTE_FLAGS(*pte);
flags &= ~(PTE_COW);
flags |= PTE_W;
// 分配物理页面
if ((mem = kalloc()) == 0) {
goto err;
}
// 把src的内容复制到dst上
memmove(mem, (char*)pa, PGSIZE);
// 先解除映射关系
uvmunmap(pagetable, va, 1, 1);
if (mappages(pagetable, va, PGSIZE, (uint64)mem, flags) != 0) {
kfree(mem);
goto err;
}
return 0;
err:
return -1;
}
5.1.4 修改copyout()
另一种需要写时复制的场景是调用copyout函数,因此我们需要修改copyout函数,由于这个函数不会触发中断,需要已进入函数体就进行判断:
// kernel/vm.c
int
copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len)
{
uint64 n, va0, pa0;
while(len > 0){
va0 = PGROUNDDOWN(dstva);
// 判断是否需要写时复制
if (is_cow_fault(pagetable, va0)) {
if(cow_alloc(pagetable, va0) < 0) {
printf("copyout: cow_alloc fail!\n");
return -1;
}
}
......
}
return 0;
}
5.1.5 修改kfree()
最后就是修改kfree函数了,由于每个页面都存在引用计数,因此不能和原代码一样直接释放,而是需要等到引用计数归0后才可以释放:
// kernel/kalloc.c
void
kfree(void *pa)
{
int index = page_index((uint64)pa);
// 如果计数大于1则减1然后返回
if (kmem.ref_page[index] > 1) {
// 执行计数减1
desc(pa);
return ;
}
// 如果最后一个使用该页面的也被释放了,则释放该页面
if (kmem.ref_page[index] == 1) {
desc(pa);
}
......
}