MIT-6.S081-2020实验(xv6-riscv64)六:cow


实验文档

概述

这次实验实现copy on write功能,和上次实验一样也是缺页中断的应用,但不同的是,这次实验涉及的物理内存和虚拟地址的操作要比上个实验多不少,因此难度也更大一些。

内容

首先是uvmcopy的部分,原来的操作是从老页表中获得虚拟地址对应的物理地址,创建一个新物理页,然后将老物理地址的内容复制到新物理页,再把新物理页通过新页表映射到虚拟地址,现在就要改成直接将老物理地址通过新页表映射到虚拟地址,同时需要将老页表和新页表对应底层pte抹去PTE_W为并添加PTE_C位,这里的PTE_C是我自己定义的一个标志位。根据Riscv的标准,pte的低10位作为标志位,其中的0-7位是包括PTE_V、PTE_W之类已经被用掉的标志位,8-9位是可供用户自定义使用的标志位,这里我选取第8位,即PTE_C = 1L << 8。0表示该pte没有用在copy on write中,1表示有,这样在处理缺页中断的时候就比较方便了,只要该pte的PTE_C位为0,说明这次缺页中断的原因不是copy on write,而是真的缺页,就可以直接返回错误:

  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_W;
    *pte |= PTE_C;
    flags = PTE_FLAGS(*pte);
    // if((mem = kalloc()) == 0)
    //   goto err;
    // memmove(mem, (char*)pa, PGSIZE);
    if(mappages(new, i, PGSIZE, pa, flags) != 0) goto err;
    add_count(pa);
  }
  return 0;

 err:
  uvmunmap(new, 0, i / PGSIZE, 1);
  panic("uvmcopy: map page failed");
  return -1;

这里我映射失败就直接让程序panic了,因为如果真的要处理的话还得把之前所有新老页表里的底层pte的标志位改回去,事实上我也想不出mappages失败且内部没有panic的情况。

然后是缺页中断处理,和上次实验一样,单独抽象成一个函数,主要过程就是获取缺页的物理地址,如果物理地址不存在或者PTE_C不为1就返回错误,然后创建新页,把老页的内容复制过来,并修改新页对应pte的标志位,注意下一步需要尝试释放老页,防止内存泄漏。这里的“尝试释放”指的是让老页的引用计数-1,如果引用计数为0了就真的释放。“尝试释放”的过程直接就写在kfree函数里面,因为加入copy on write机制后,所有对物理内存的操作都需要受引用计数的制约:

int handle_page(uint64 va, pagetable_t pgtbl) {
    pte_t *pte; char *mem; uint flags;
    if ((pte = walk(pgtbl, va, 0)) == 0) return -1;
    if ((*pte & PTE_C) == 0) return -1;
    if ((mem = kalloc()) == 0) return -1;
    flags = PTE_FLAGS((*pte & (~PTE_C)) | PTE_W);
    uint64 pa = PTE2PA(*pte);
    memmove(mem, (char*)pa, PGSIZE);
    *pte = PA2PTE((uint64)mem) | flags;
    kfree((void *)pa); return 0;
}

然后就是copyout函数的修改,为什么不需要修改copyin和copyinstr函数呢,因为fork涉及的都是用户区的内存,所以缺页也只会在写用户内存的情况下发生,copyout是内核内存写到用户内存,所以需要处理,另外两个函数是用户内存写到内核内存,是读用户内存,所以不需要处理。另一个和上次实验不同的地方是,上次实验之所以copyout函数需要修改,是因为在对虚拟地址调用walkaddr函数的时候,因为实际的物理地址不存在,所以返回错误,因此只要在walkaddr返回不存在的物理地址时进行缺页处理即可;而这次实验walkaddr是可以得到合法的物理地址的,只是这个物理地址不能被写,所以错误会在memmove到这个物理地址的时候才发生,而且这个缺页中断是在内核态发生的,走的也是kerneltrap函数,因此我们定义在usertrap函数里的处理代码捕获不到它。因此我们要做的,就是在调用walkaddr函数后对pte进行检查,如果PTE_C位为1,则进行缺页处理。

  while(len > 0){
    va0 = PGROUNDDOWN(dstva);
    pa0 = walkaddr(pagetable, va0);
    if(pa0 == 0)
      return -1;
    if (*(walk(pagetable, va0, 0)) & PTE_C) handle_page(va0, pagetable);
    pa0 = walkaddr(pagetable, va0);
    ......

这里我的代码写的比较矬,为了检查标志位还重新walk一遍,最后要获得新物理地址又walkaddr一遍,实际上可以定义另一个版本的walkaddr直接返回pte,handle_page也可以改写让其返回新物理地址,后面有时间再改。trap.c的代码和上次实验几乎一样,就不贴了。

然后是物理内存的处理,为了节省空间,我没有直接用物理地址模4096,而是先将物理地址减掉内核的地址空间,再模4096,因为fork不涉及物理内存,即数组索引为(pa - KERNBASE) >> PGSHIFT,当然代价就是每次进行处理引用计数的时候需要先判断pa必须大于等于KERNBASE,不然内核申请或释放物理内存的时候一减变成负数,就访问非法内存了。数组大小就可以根据memlayout.h里的值进行计算,发现物理内存的最大值PHYSTOP减KERNBASE等于128*1024*1024`,因此总页数为128*1024/4=32768,这就是数组的大小。另外很重要的一点是引用数组的声明:

struct {
    struct spinlock lock;
    uint a[32768];
} count;

需要用到锁,这个实验文档没讲,略坑,我也是看了别人的代码才知道,不用锁的话会内存泄漏,应该是多进程竞争扰乱了引用计数的加减,目前还没看到xv6文档里关于锁的部分,所以也不知道哪些地方可能产生资源竞争。了解这一点后面就很容易了,kfree函数:

  if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
    panic("kfree");

  if ((uint64)pa >= KERNBASE) {
      acquire(&count.lock);
      if (count.a[((uint64)pa - KERNBASE) >> PGSHIFT] > 1) {
          count.a[((uint64)pa - KERNBASE) >> PGSHIFT]--;
          release(&count.lock); return;
      } else {
          count.a[((uint64)pa - KERNBASE) >> PGSHIFT] = 0;
          release(&count.lock);
      }
  }

alloc函数里直接在返回物理地址前使用add_count函数让计数加1(初始时和释放后引用计数都为0,所以加1后就是1),这里代码不贴了,add_count函数:

void add_count(uint64 pa) {
  if (pa >= KERNBASE) {
      acquire(&count.lock);
      count.a[(pa - KERNBASE) >> PGSHIFT]++;
      release(&count.lock);
  }
}