xv6实验课程--xv6的写时拷贝(COW)(2021)

本文来源:

https://mp.weixin.qq.com/s/GzWllrExZc_pf-R6Wp83WQ

虚拟内存提供了一个间接层(a level of indirection):内核可以通过将PTE标记为无效或只读来拦截内存引用,从而导致页面错误,并可以通过修改PTE来更改地址的含义。在计算机系统中有一种说法,任何系统问题都可以通过间接层来解决。懒页分配实验提供了一个例子,本实验探索了另一个例子:写时拷贝。

注:懒页分配实验是2020年课程中的一个实验,目前,在2021年课程中缺少了这个实验,感兴趣的读者可在本公众号中找到。

请切换到cow分支,开始本次实验。

$ git fetch

$ git checkout cow

$ make clean

1. 问题

xv6中的fork()系统调用将父进程的所有用户空间内存复制到子进程中。如果父进程的用户空间很大,复制可能需要很长时间。更糟糕的是,这项工作的大部分往往被浪费了,例如,fork()后的子进程紧接着执行exec()函数,这将导致子进程丢弃复制的内容,也就是说,这些复制的内容根本就没有被用到。另一方面,当父进程和子进程使用同一个页面时,若其中的一个或两个进程要对页面进行写操作,此时确实需要一个副本。

2. 解决方案

写时拷贝(copy-on-write,COW)的目标是推迟由fork()创建的子进程分配和复制物理内存页,直到实际需要时才拷贝(如果需要的话)。

写时拷贝fork()只为子进程创建一个页表,用户内存的PTE指向父进程的物理页。写时拷贝fork()将父和子进程中的所有用户PTE标记为不可写。当任一进程试图写入其中一个COW页时,将强制CPU执行页错误。内核页错误处理程序检测到这种情况,为出错进程分配一页物理内存,将原始页复制到新页中,并修改出错进程中的相关PTE以引用新页,并标记父子进程相应的PTE对应的页为可写。当页面错误处理程序返回时,用户进程将能够写入其页面副本。

写时拷贝fork()使得释放实现用户内存的物理页变得有点棘手。给定的物理页可以由多个进程的页表引用,并且只有在最后一个引用消失时才能释放。

3. 实验:实现写时拷贝(难度:困难)

你的任务是在xv6内核中实现写时拷贝fork。如果修改后的内核能同时通过cowtest和usertests的测试,那就算完成任务了。

为了帮助你测试,我们提供了一个名为cowtest的xv6程序(源代码位于user/cowtest.c)。cowtest进行各种测试,但在未修改的xv6上,即使是第一个测试也会失败。因此,最初,你将看到:

$cowtest

simple: fork() failed

$

“简单”测试分配了一半以上的可用物理内存,然后fork()。fork失败,因为没有足够的空闲物理内存,无法为子进程提供父进程内存的完整副本。

当你完成任务后,内核应该通过cowtest和UserTest中的所有测试。即:

xv6实验课程--xv6的写时拷贝(COW)(2021)_第1张图片

一个合理的实验步骤如下:

1. 修改uvmcopy()将父进程的物理页映射到子进程的物理页,而不是分配新页面。清除父子进程PTE的PTE_W位。

2. 修改usertrap()以识别页面错误。当COW页面出现页面错误时,使用kalloc()分配一个新页面,将旧页面复制到新页面,然后将新页面设置到PTE中并设置PTE_W位。

3. 确保每个物理页在最后一个对它的PTE引用移除时被释放。一个好的解决方案是设置一个“引用计数器”,记录每个物理页被引用的用户页表数。当kalloc()分配页时,将页的引用计数器设置为1。当fork导致子进程共享页时,增加页的引用计数,每当任何进程从其页表中删除页时,减少页的计数。kfree()只应在引用计数为零时将页放回空闲列表。可以将这些计数器保存在一个固定大小的整数数组中。你必须制定一个如何索引数组以及如何选择数组大小的方案。例如,可以用页面的物理地址除以4096作为数组的索引,并为数组提供若干元素,这些元素等于kalloc.c中的kinit()放置在空闲列表中的任意页面的最高物理地址。

4. 修改copyout(),使其在遇到COW页面时使用与页面错误相同的方案。

一些提示

● 在懒页分配实验中你可能已经熟悉了许多与copy-on-write相关的xv6内核代码。但是,你不应该将这个实验建立在你的懒页分配解决方案的基础上,相反,请按照上面的说明从一个新的xv6开始。

● 有一种方法可以记录每个PTE是否是COW映射,这可能很有用。你可以使用RISC-V PTE中的RSW(保留给软件使用)位来实现此目的。

● Usertests探索了cowtest没有测试的场景,所以不要忘记检查两者的所有测试是否通过。

● kernel/riscv.h的末尾有一些有用的宏和页表标志的定义。

● 如果出现COW页错误并且没有可用内存,则应终止进程。

5. 实验步骤

步骤1:在kalloc.c中kmem结构中增加物理内存引用计数器uint *ref_count,并对该计数器定义互斥锁struct spinlock reflock,在kinit函数中对其进行初始化。

kinit函数用来初始化内存分配器。对系统中的每个物理页面以链表的形式进行管理,空闲链表保存了内核和PHYSTOP之间的每个物理页面。初始化kmem.ref_count如下。

struct {
  struct spinlock lock;
  struct run *freelist;
  struct spinlock reflock;
  uint *ref_count;
} kmem;

void
kinit()
{
  initlock(&kmem.lock, "kmem");
  initlock(&kmem.reflock,"kmemref");
  // end:内核之后的第一个可以内存单元地址,它在kernel.ld中定义
  uint64 rc_pages = ((PHYSTOP - (uint64)end) >> 12) +1; // 物理页数
  // 计算存放页面引用计数器占用的页数
  rc_pages = ((rc_pages * sizeof(uint)) >> 12) + 1;
  // 从end开始存放页引用计数器,需要rc_pages页
  kmem.ref_count = (uint*)end;
  // 存放计数器的存储空间大小为:
  uint64 rc_offset = rc_pages << 12;  
  freerange(end + rc_offset, (void*)PHYSTOP);
}

// 将地址转换为物理页号
inline int
kgetrefindex(void *pa)
{
   return ((char*)pa - (char*)PGROUNDUP((uint64)end)) >> 12;
}

void
freerange(void *pa_start, void *pa_end)
{
  char *p;
  p = (char*)PGROUNDUP((uint64)pa_start);
  for(; p + PGSIZE <= (char*)pa_end; p += PGSIZE) {
    // 初始化kmem.ref_count
     kmem.ref_count[kgetrefindex((void *)p)] = 1;
    kfree(p);
  }
}

问:为什么在初始化时设置引用计数器为1?提示:在freerange函数中调用了kfree(p)。

步骤2:修改kalloc函数,使其在分配页面时将引用计数器设置为1。

xv6实验课程--xv6的写时拷贝(COW)(2021)_第2张图片

步骤3:释放内存时查看引用计数是否为0,为0释放内存,否则返回,由其他进程继续使用。

xv6实验课程--xv6的写时拷贝(COW)(2021)_第3张图片

步骤4:增加一些辅助函数(kernel/kalloc.c)。

int
kgetref(void *pa){
  return kmem.ref_count[kgetrefindex(pa)];
}

void
kaddref(void *pa){
  kmem.ref_count[kgetrefindex(pa)]++;
}

inline void
acquire_refcnt(){
  acquire(&kmem.reflock);
}

inline void
release_refcnt(){
  release(&kmem.reflock);
}

注意,要在defs.h中说明这些函数的原型。

xv6实验课程--xv6的写时拷贝(COW)(2021)_第4张图片

步骤5:增加COW标志位。

在页表项中预留了2位给操作系统,这里用第8位,即#define PTE_COW (1L << 8))

xv6实验课程--xv6的写时拷贝(COW)(2021)_第5张图片

riscv.h

xv6实验课程--xv6的写时拷贝(COW)(2021)_第6张图片

步骤6:对fork函数进行修改,使其不对地址空间进行拷贝。fork函数会调用vm.c里的uvmcopy进行拷贝,因此只需要修改uvmcopy函数即可:删去uvmcopy中的kalloc函数,将子进程页表映射到父进程的物理地址,去掉写标志位,增加COW标志位,增加物理内存引用计数。

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");
    // 设置父进程的PTE_W为不可写,且为COW页
    *pte = ((*pte) & (~PTE_W)) | PTE_COW; 
    flags = PTE_FLAGS(*pte);
    pa = PTE2PA(*pte);  
    // 不为子进程分配内存,指向pa,页表属性设置为flags即可
    if(mappages(new, i, PGSIZE, pa, flags) != 0) {
      goto err;
    }
    kaddref((void*)pa);
  }
  return 0;

 err:
  // 当发生错误时,是否需要恢复错误之前对父进程
  // 页表的修改?如果不恢复,后面的程序是否能够纠正?
  // 在设计后面的程序时需要考虑到这一点
  uvmunmap(new, 0, i / PGSIZE, 1);
  return -1;
}

步骤7:在usertrap函数(trap.c)中增加页面错误的处理,需判断是否是COW。这里只需要处理scause==15的情况,因为13是页面读错误,而COW是不会引起读错误的。

在这段程序数中先判断COW标志位,当该页面是COW页面时,就可以根据引用计数来进行处理。如果计数大于1,那么就需要通过kalloc申请一个新页面,然后拷贝内容,之后对该页面进行映射,映射的时候清除COW标志位,设置PTE_W标志位;而如果引用计数等于1,那么就不需要申请新页面,只需要对这个页面的标志位进行修改就可以了。

在该程序中声明:extern pte_t* walk(pagetable_t, uint64, int);

    intr_on();

    syscall();
  } else if(r_scause() == 15) { // 写页面错
    uint64 va = PGROUNDDOWN(r_stval());
    pte_t *pte;
    if(va >= MAXVA) { // 虚拟地址错
      printf("va is larger than MAXVA!\n");
      p->killed = 1;
      goto end;
    }
    if(va > p->sz){ // 虚拟地址超出进程的地址空间
      printf("va is larger than sz!\n");
      p->killed = 1;
      goto end;
    }
    if((pte = walk(p->pagetable, va, 0)) == 0) {
      printf("usertrap(): page not found\n");
      p->killed=1;
      goto end;
    } 
    // 分配一个新页面
    if(((*pte) & PTE_COW) == 0 ||((*pte) & PTE_V) == 0 || ((*pte) & PTE_U) == 0) {
      printf("usertrap: pte not exist or it's not cow page\n");
      p->killed = 1;
      goto end;
    }
    uint64 pa = PTE2PA(*pte);
    acquire_refcnt();
    uint ref = kgetref((void*)pa);
    if(ref == 1) { // 引用次数为1,直接使用该页
      *pte = ((*pte) & (~PTE_COW)) | PTE_W;
    } else { // 引用次数大于1,分配物理页
      char* mem = kalloc();
      if(mem == 0) {
        printf("usertrap(): memery alloc fault\n");
        p->killed = 1;
        release_refcnt();
        goto end;
      }
      // 将旧页面复制到新页面,并用PTE_W和(~PTE_COW)设置新页的PTE
      memmove(mem, (char*)pa, PGSIZE);
      uint flag = (PTE_FLAGS(*pte) | PTE_W) & (~PTE_COW);
      if(mappages(p->pagetable, va, PGSIZE, (uint64)mem, flag) != 0) {
        kfree(mem);
        printf("usertrap(): can not map page\n");
        p->killed = 1;
        release_refcnt();
        goto end;
      }
      kfree((void*)pa); //旧页引用次数减1
    }
    release_refcnt();
  } else if((which_dev = devintr()) != 0){
    // ok
  } 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;
  }
end:
  if(p->killed)
    exit(-1);

步骤8:最后在copyout(kernel/vm.c)中也要增加页面错误处理,因为copyout是在内核中调用的,缺页不会进入usertrap。注意:要声明外部函数walk。


extern pte_t* walk(pagetable_t, uint64, int);

// Copy from kernel to user.
// Copy len bytes from src to virtual address dstva in a given page table.
// Return 0 on success, -1 on error.
int
copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len)
{
  uint64 n, va0, pa0;
  pte_t* pte;    // add

  while(len > 0){
    va0 = PGROUNDDOWN(dstva);
    if(va0 >= MAXVA)  
      return -1;
    if((pte = walk(pagetable, va0, 0)) == 0)
      return -1;
    if (((*pte & PTE_V) == 0) || ((*pte & PTE_U)) == 0) 
      return -1;
    pa0 = PTE2PA(*pte);
    if(((*pte & PTE_W) == 0) && (*pte & PTE_COW)) {
      acquire_refcnt();
      if(kgetref((void*)pa0) == 1) {
        *pte = (*pte | PTE_W) & (~PTE_COW);       
      } else {        
        char* mem = kalloc();
        if(mem == 0) {
          printf("copyout(): memery alloc fault\n");
          release_refcnt();
          return -1;
        }
        memmove(mem, (void*)pa0, PGSIZE);
        uint newflags = (PTE_FLAGS(*pte) & (~PTE_COW)) | PTE_W;
        if(mappages(pagetable, va0, PGSIZE, (uint64)mem, newflags) != 0) {
          kfree(mem);
          release_refcnt();
          return -1;
      }
      kfree((void*)pa0);
      }    
      release_refcnt();
    }
    pa0 = walkaddr(pagetable, va0);
    if(pa0 == 0)
      return -1;
    n = PGSIZE - (dstva - va0);
    if(n > len)
      n = len;
    memmove((void *)(pa0 + (dstva - va0)), src, n);

    len -= n;
    src += n;
    dstva = va0 + PGSIZE;
  }
  return 0;
}

在mappages函数中会触发一个remap的panic,注释掉这条语句即可,因为COW就是要对页面进行重新映射的。

xv6实验课程--xv6的写时拷贝(COW)(2021)_第7张图片

测试:

xv6实验课程--xv6的写时拷贝(COW)(2021)_第8张图片

执行usertests。

xv6实验课程--xv6的写时拷贝(COW)(2021)_第9张图片

现在运行make grade,看能得多少分。110分!

xv6实验课程--xv6的写时拷贝(COW)(2021)_第10张图片

参考文献:

[1]https://pdos.csail.mit.edu/6.828/2021/labs/cow.html

[2]https://blog.csdn.net/pige666/article/details/108741723

[3]https://www.cnblogs.com/weijunji/p/xv6-study-9.html

[4]https://blog.csdn.net/u013577996/article/details/111972075

[5]https://blog.csdn.net/weixin_44465434/article/details/111566139

你可能感兴趣的:(操作系统,xv6,操作系统,MIT实验课程,Mit6.S081)