xv6 中的 fork() 系统调用将父进程的所有用户空间内存复制到子进程中。如果父进程很大,复制可能需要很长的时间。更糟糕的是,这项工作通常是无用的:fork() 之后通常是子进程的 exec(),它放弃了复制的内存,没有使用复制过来的大部分内存。如果父代和子代都使用了复制的页面,并且其中一个或两个都写了它,那么这个复制才是真正需要的。

所以写时复制(copy on write, cow) 这个技术变得十分的重要。在这个 lab 中,我们需要实现一个写时复制的 fork()。我们需要修改原本的 fork() 程序中做内存申请的模块,同时我们还需要实现 usertrap 来在写时(在实际写的时候碰到 cow flag,抛出分页错误)的时候处理这个 trap。

因为一个程序很可能被 fork() 了很多的分支,所以我们需要一个计数器来确定这个 page 是要被释放还是保留。

一般来说,一个正常运行的程序在写时复制的时候会有下面几个流程:

  • fork() 出一个子进程 child。 child 拥有父进程的页的引用,并且页的 flag 有 cow 标识。
    • 并且要把 页 引用 ++
  • child 需要向自己的页中写入新的数据. 此时需要重新分配内存,页引用 –。
  • 当 child 返回的时候,可能有内存需要由 kernel 转换到 user。
  • 当父进程销毁的时候,如果计数器为 0,则销毁,反之,不变。

每一页都需要一个计数器来进行计数,所以我们需要在 kernel 中加入一个数组来记录,查阅 xv6 book,我们发现有如下的图:

XV6 手册
Fig 1. memlayout

XV6 手册

我们需要在 kernel data 之后的区域内申请一块内存来作为计数器存储的数组。我们可以在 kalloc.c 中实现。

// ./kernel/kalloc.c
struct {
  struct spinlock lock;
  struct run *freelist;
  // lab 5
  uint* ref_cnt;
  struct spinlock ref_cnt_lock;
  char * pa_start;
} kmem;

在这个 kmem 结构体中加入了一个 ref_cnt_lock 来保证计数器的正确引用。一个 ref_cnt pointer 来指示 ref array 的起始位置,使用 pa_start 来表示实际的 free memory 的起始位置。我们还需要修改初始化程序来正确的申请计数器数组,并且对 free memory 填充上一个初始值。

// ./kernel/kalloc.c
void
kinit()
{
  initlock(&kmem.lock, "kmem");

  // lab 5
  initlock(&kmem.ref_cnt_lock, "kmemrefcnt");
  uint64 pg_size = (((PHYSTOP - (uint64)end)) >> PGSHIFT) + 1; // get the page size
  uint64 ref_array_size = ((pg_size * sizeof(uint)) >> PGSHIFT) + 1; // caculate how many page wes need to store ref cnt.
  kmem.ref_cnt = (uint*)end; // the start of ref cnt array
  kmem.pa_start = end + (ref_array_size << PGSHIFT); // the start of user memory space.

  char *p;
  p = (char*)PGROUNDUP((uint64)kmem.pa_start);
  for (; p + PGSIZE <= (char*)PHYSTOP; p+= PGSIZE) {
    acquire(&kmem.ref_cnt_lock);
    uint64 idx = (uint64)((PGROUNDDOWN((uint64)p) - PGROUNDUP((uint64)kmem.pa_start)) / PGSIZE);
    kmem.ref_cnt[idx] = 1; // set to 1, force freerange(.., ...) function to remove all mem in start->end scope.
    release(&kmem.ref_cnt_lock);
  }

  freerange((void*)kmem.pa_start, (void*)PHYSTOP); // free memory in those scope.
}

PGSHIFT, PHYSTOP 是定义在 riscv.h 中便于使用的 macro。下面是 kfree 的代码,需要在 ref > 1 的时候减少引用,在 ref == 1 的时候释放这块内存,并且把计数器的引用次数置成 0。

void
kfree(void *pa)
{
  struct run *r;

  // lab 5
  // If ref is not 0
  acquire(&kmem.ref_cnt_lock);
  // get the ref cnt location of pa in ref cnt array
  uint64 idx = (uint64)((PGROUNDDOWN((uint64)pa) - PGROUNDUP((uint64)kmem.pa_start)) / PGSIZE);
  if (kmem.ref_cnt[idx] > 1) {
    kmem.ref_cnt[idx] --;
    release(&kmem.ref_cnt_lock);
    return;
  }

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

  // Fill with junk to catch dangling refs.
  memset(pa, 1, PGSIZE);

  // lab 5
  kmem.ref_cnt[idx] = 0;
  release(&kmem.ref_cnt_lock);

  r = (struct run*)pa;

  acquire(&kmem.lock);
  r->next = kmem.freelist;
  kmem.freelist = r;
  release(&kmem.lock);
}

在申请内存的同时,也要加上计数器:

void *
kalloc(void)
{
  struct run *r;

  acquire(&kmem.lock);
  r = kmem.freelist;
  if(r)
    kmem.freelist = r->next;
  release(&kmem.lock);

  // lab 5
  if (r) {
    acquire(&kmem.ref_cnt_lock);
    uint64 idx = (uint64)((PGROUNDDOWN((uint64)r) - PGROUNDUP((uint64)kmem.pa_start)) / PGSIZE);
    kmem.ref_cnt[idx] = 1;
    release(&kmem.ref_cnt_lock);
  }

  if(r)
    memset((char*)r, 5, PGSIZE); // fill with junk
  return (void*)r;
}

在 vm.c 中我们需要修改 uvmcopy 函数,在这个函数中,我们要注释掉原来的内存申请的代码,然后使用 cow 策略来“申请”内存

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);
  // clear write flag and add read flag.
  *pte = ((*pte) & (~PTE_W)) | PTE_COW;
  flags = PTE_FLAGS(*pte);
  // Map to child process's pagetable directly. Make both read-only.
  if (mappages(new, i, PGSIZE, pa, flags) != 0) {
    goto err;
  }
  // increase ref of page old.
  kincre_ref(pa);
}
return 0;

在上面的这段代码中,我将 flag 由原来的可写转变成了 cow(PTE_COW 需要在 reiscv.h 中定义),然后将页共享,如果共享成功,则将 reference ++。

当用户需要写内存的时候,会触发一个 trap,同上一个实验,我们需要在 usertrap 中加入代码来处理这个 trap。

else if (r_scause() == 15) {
     uint64 va = r_stval();
     if (va > p->sz) p->killed = 1;
     else if (kalloc_cow(p->pagetable, va) != 0) p->killed = 1;
   }
int kalloc_cow(pagetable_t pagetable, uint64 va) {
  va = PGROUNDDOWN(va);
  if (va >= MAXVA) return -1;
  pte_t *pte = walk(pagetable, va, 0);
  if (!pte) return -1;
  uint64 pa = PTE2PA(*pte);
  if (!pa) return -1;
  uint64 flags = PTE_FLAGS(*pte);
  if (flags & PTE_COW) {
    uint64 mem = (uint64)kalloc();
    if (!mem) return -1;
    memmove((char*)mem, (char*)pa, PGSIZE);
    uvmunmap(pagetable, va, 1, 1);
    flags = (flags | PTE_W) & (~PTE_COW);
    if (mappages(pagetable, va, PGSIZE, mem, flags) != 0) {
      kfree((void*)mem);
      return -1;
    }
  }
  return 0;
}

当用户所访问的内存需要写时复制的时候,由 trap 处理程序来进行处理,kalloc_cow 程序负责申请出与原来的内存区块相同大小的区间,然后把内存 copy 过去,并且把 flag 改为可写而不是 cow。

当 kernel 需要把内存交换到 user 区时,也需要对 cow 特殊对待。在 copyout 函数中,需要加入下述代码:

if (kalloc_cow(pagetable, va0) != 0) return -1;