MIT6.S081课程实验最详解析与知识点归纳——lab6:Copy-on-Write


好消息,本次lab6只有一个小实验
坏消息,一个顶俩

(一)COW Fork

  • 来源:父进程fork出子进程后,99%的情况下会执行exec替换内存,故子进程拷贝的父进程内存绝大部分是多余的。一个办法是直接共享父子进程的内存,但这会导致对共享内存的干扰,这时就需要利用page fault来实现灵活的共享内存。
  • 原理:父子进程保持共享内存,只有当子进程需要该页时,才触发page fault,子进程进行内存拷贝,否者维持共享
  • 步骤:开始时父子进程共享内存页,但是都为只读模式 -> 子进程尝试往某页写入 ,触发page fault -> 内核复制该页并为子进程添加映射 ->对子进程开放该复制页的写权限 ->返回导致page fault的指令重新运行;
    在这里插入图片描述

(二)Implement copy-on write

(1)整体思路

  • 修改uvmcopy(),将内存拷贝该为内存共享
  • 修改usertrap()和copyout(),处理page,fault,处理方法为复制该页,重新映射
  • 添加对共享内存页的计数,只有当计数为0时才真正释放该页
  • 用RSW标志位记录是否为COW的共享页面
  • 注意读/写权限的更改

(2)共享页计数结构

由于在Cow fork中,每个物理页面有n个进程共享,所以要为每个页面维护一个cow_ref==n,当该页面有新进程共享时,++cow_ref。发生page fault后,复制页面,该进程映射到了新的页面,故原页面的共享进程数–cow_ref。当cow_ref == 0时候,才表示这个页面已经没有映射,需要销毁。
因为后面步骤都要涉及共享页的计数,所以率先编写计数结构。
1.计数数组
很显然,计数结构是一个数组,对每个物理页面,维护共享进程的个数,
要考虑的问题如下:

  • 如何通过数组下标索引到物理页面
  • 数组的大小如何选取
  • 数组的类型
  • 数组放在哪里好

下标:
下标索引好说,hints中已经给出,可以由pa/PGSIZE索引。

数组大小:
接着找到kinit()里freerange的范围,可以看到物理页面(RAM)的地址范围是end~PHYSTOP,但是end的注释为first address after kernel,随内核代码段和数据段的大小而变化,是个变量,不能用于确定数组的大小。
在这里插入图片描述
所以,这里我们将物理页面的范围看作最大值:KERNBASE~PHYSTOP,那么数组的大小就是((PHYSTOP - KERNBASE) / PGSIZE)

数组类型:
考虑到数组的大小很大,为了尽可能的节约内存,我们想让数组的类型所占字节尽可能的小。
param.h中定义了最大进程数

#define NPROC        64  // maximum number of processes

那么共享进程数就一定小于64,uint8就可以满足我们的需求。

数组位置:
关于数组放在哪里,它显然是一个内核的全局变量,可以放在vm.c中,在要用到的地方用extern声明,但这其实不是最好的方案,我们稍后再讨论。

2.计数的锁结构
计数数组是一个临界资源,访问时要加锁(设计锁的保守性原则)。参考对空闲链表的加锁方案,可以加自旋锁维护每个计数数组。
将锁与计数整合到一个结构体中,如下就有了我们的计数结构:

struct COW{
    uint8 cow_ref;
    struct spinlock lock;
} cow[(PHYSTOP - KERNBASE) / PGSIZE];

对每个锁需要初始化,添加初始化函数initlock_cow()

void 
initlock_cow(void){
    int i;
    for (i = 0; i < (PHYSTOP - KERNBASE) / PGSIZE; ++i)
        initlock(&cow[i].lock, "cow");
}

初始化可以和空闲链表的锁结构初始化放在一起,也就是kinit()

void
kinit()
{
  initlock(&kmem.lock, "kmem");
  initlock_cow();
  freerange(end, (void *)PHYSTOP);
}

3. 计数结构的增减
自增自减前都需要上锁,这里设计成了两个函数。有人可能会觉得设计成函数多此一举,别急,接着看下面的4.设计思路

void 
inc_cowref(uint64 pa){
    pa = (pa - KERNBASE) / PGSIZE;
    acquire(&cow[pa].lock);
    ++cow[pa].cow_ref;
    release(&cow[pa].lock);
}

uint8
dec_cowref(uint64 pa){
    uint8 ref;
    pa = (pa - KERNBASE) / PGSIZE;
    acquire(&cow[pa].lock);
    ref = --cow[pa].cow_ref;
    release(&cow[pa].lock);
    return ref;
}

4.设计思路
有个问题貌似很棘手,计数结构体与设计的这些函数放在哪呢?vm.c中?vm.c中是存放虚拟内存相关函数的,虽说COW也是属于虚拟内存,但放进去,和里面的一系列函数总感觉很违和。
联想起C++面向对象的设计思路,这里的COW其实不就是一个类吗?cow[(PHYSTOP - KERNBASE) / PGSIZE]是类的私有数据成员,initlock_cow(void)inc_cowref(uint64 pa)dec_cowref(uint64 pa)三个函数是三个对外的接口,成员函数。
计数结构是私密的,只能通过对外接口来初始化和访问,具有良好的封装性。
C中没有类,有结构体,结构体中虽然也能存放我们的计数结构和三个函数,但显得有些臃肿了。我们不妨将它们都置于一个cow.c文件中,对外提供头文件,也能实现我们的封装性。
故最终,cow.c如下:

#include "types.h"
#include "memlayout.h"
#include "riscv.h"
#include "defs.h"
#include "spinlock.h"

struct COW{
    uint8 cow_ref;
    struct spinlock lock;
} cow[(PHYSTOP - KERNBASE) / PGSIZE];

void 
initlock_cow(void){
    int i;
    for (i = 0; i < (PHYSTOP - KERNBASE) / PGSIZE; ++i)
        initlock(&cow[i].lock, "cow");
}

void 
inc_cowref(uint64 pa){
    pa = (pa - KERNBASE) / PGSIZE;
    acquire(&cow[pa].lock);
    ++cow[pa].cow_ref;
    release(&cow[pa].lock);
}

uint8
dec_cowref(uint64 pa){
    uint8 ref;
    pa = (pa - KERNBASE) / PGSIZE;
    acquire(&cow[pa].lock);
    ref = --cow[pa].cow_ref;
    release(&cow[pa].lock);
    return ref;
}

Makefile中将cow.c加入编译

OBJS = \
  $K/entry.o \
  $K/start.o \
  $K/console.o \
  $K/printf.o \
  $K/uart.o \
  $K/kalloc.o \
  $K/spinlock.o \
  $K/string.o \
  $K/main.o \
  $K/vm.o \
  $K/proc.o \
  $K/swtch.o \
  $K/trampoline.o \
  $K/trap.o \
  $K/syscall.o \
  $K/sysproc.o \
  $K/bio.o \
  $K/fs.o \
  $K/log.o \
  $K/sleeplock.o \
  $K/file.o \
  $K/pipe.o \
  $K/exec.o \
  $K/sysfile.o \
  $K/kernelvec.o \
  $K/plic.o \
  $K/virtio_disk.o \
  $K/cow.o

def.h中添加原型

//cow.c
void            initlock_cow(void);
void            inc_cowref(uint64);
uint8           dec_cowref(uint64);

(3)COW标志位

hints中提到,可以用PTE中的RSW位作为COW页面的标记
RSW位即为“软件预留位”,提供给软件,用来标记额外需要的信息,一共有两位,我们取一位即可。
在这里插入图片描述
按照规范,riscv.h中define一下

#define PTE_COW (1L << 8) // flag for COW folk

(4)修改 uvmcopy()

COW中,uvmcopy()并不实际分配内存,只是共享内存,并且修改标志位为只读,等待page fault后再实际拷贝。

  • 注释掉分配物理内存的代码
  • 为父子进程都添加PTE_W与PTE_COW标志位
  • inc_cowref(pa),表示该页面共享进程数+1
int
uvmcopy(pagetable_t old, pagetable_t new, uint64 sz)
{
  pte_t *pte;
  uint64 pa, i;
  uint flags;
  // char *mem;

  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; // 将父子进程的pte置为不可写,同时添加COW标志位
    flags = PTE_FLAGS(*pte);
    inc_cowref(pa);
    // 注释掉分配物理内存的代码
    // if((mem = kalloc()) == 0)
    //   goto err;
    // memmove(mem, (char*)pa, PGSIZE);
    if(mappages(new, i, PGSIZE, pa, flags) != 0){
      goto err;
    }
  }
  return 0;

 err:
  uvmunmap(new, 0, i / PGSIZE, 1);
  return -1;
}

(5)构造物理内存分配函数

由lazy alloction的实验我们知道了,有两个地方需要重新分配物理内存:一个是发生page fault后在usertrap()中捕捉到,另一个是在系统调用中,这里不会发生page fault,要手动捕捉页面异常并分配内存。

但需要特别注意,本实验中只捕捉写入标志为COW页面的异常,从而引出一个问题:

lazy alloction实验中,对于系统调用的异常场景,我们修改的是walkaddr()函数。但此处只有copyout()是 写入 COW 页面,需要处理COW异常,若修改了walkaddr(),则copyin()等一类函数调用walkaddr()时,存在将其它情况误判成写入COW页面异常的风险。比如copyin()中 COW页面也会被判异常从而分配新内存,因为在walkaddr中无法区别到底是读还是写(对于系统调用,没有page fault,r_scause()也不会保存错误的指令类型)

显然,这并不是我们想看到的,故处理方式是:构造一个全新的walkaddr_cow()函数,只在写入页面时调用。更具体来说,只在usertrap()r_scause() == 15的情况与copyout()中调用。

综上所述,让我们来明确一下walkaddr_cow()函数的作用:

  1. 只处理COW页面的写入异常(只判断是否为COW页面,是否为写入交给函数外判断):1.解除旧映射。2.分配新的物理内存并添加新映射。3.添加PTE_W并删除PTW_COW标志。
  2. 难免捕捉到写入其它页面的异常(比如写入无PTE_V标志的页面),由于朴素xv6并不处理这些异常,只是单纯地杀死进程,所以该函数要做的也只是返回0表示失败。
  3. 为了copyout()的需要,返回新分配的物理地址

如上,可以给出walkaddr_cow()函数的代码:

// 如果是COW页面,则复制物理内存并映射
// 返回va对应的物理地址pa
// 0表示失败
uint64
walkaddr_cow(pagetable_t pagetable, uint64 va){
  uint64 pa;
  pte_t *pte;
  uint flags;
  char *mem;

  if(va >= MAXVA)
    return 0;

  pte = walk(pagetable, va, 0);
  if(pte == 0)
    return 0;
  if((*pte & PTE_V) == 0)
    return 0;
  if((*pte & PTE_U) == 0)
    return 0;
  pa = PTE2PA(*pte);

  // 如果是COW页面
  if((*pte & PTE_COW) != 0){
    if((mem = kalloc()) == 0)
      return 0;
    memmove(mem, (void *)pa, PGSIZE);
    
    // 置为可写并清除COW标记
    flags = (PTE_FLAGS(*pte) | PTE_W) & ~PTE_COW;
    // 注意最后再取消映射,否则前面释放了pte和pa,这里就要操作空内存了
    uvmunmap(pagetable, PGROUNDDOWN(va), 1, 1);
    if(mappages(pagetable, PGROUNDDOWN(va), PGSIZE, (uint64)mem, flags) != 0){
      kfree(mem);
      return 0;
    }
    return (uint64)mem;
  }

  return pa;
}
  • 请注意,解除旧映射要在操作完pte最后进行,若先解除了映射,pte置为0,那就是操作无效内存了
  • uvmunmap()的do_free参数置为1,其实就相当于dec_cowref(pa),只不过我们将操作放在了kree()中做
  • 注:观看视频lec12后补充一种写法,不需要解除旧映射并添加新映射,直接修改pte即可(但仍然要kfree一下物理页面),如下:
*pte = PA2PTE((uinta64)mem) | PTE_W | PTE_R | PTE_X | PTE_V | PTE_U;
kfree(pa);

在usertrap()中调用:

else if(r_scause() == 15){
    // COW fork导致的page fault
    if(walkaddr_cow(p->pagetable, r_stval()) == 0)
      p->killed = 1;
  }

在copyout()中调用:

int
copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len)
{
  uint64 n, va0, pa0;

  while(len > 0){
    va0 = PGROUNDDOWN(dstva);
    pa0 = walkaddr_cow(pagetable, va0); // 改为新函数,处理COW页面的写入
    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;
}

(6)释放物理内存

调用kfree(pa)时,进行dec_cowref(pa),共享进程数减1。只当该页面计数为0时,才真正释放页面

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

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

  //  修改计数,不为0则返回
  if(dec_cowref((uint64)pa))
    return;

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

  r = (struct run*)pa;

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

kalloc()初始化的时候计数为1

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

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

  if(r){
    memset((char*)r, 5, PGSIZE); // fill with junk
    inc_cowref((uint64)r); // 初始化时计数为1
  }
  return (void*)r;
}

freerange()的修改
这一点很坑人,内核启动时kinit()会调用freerange()将页面全部kfree()一遍,而我们的cow数组一开始都是0,kfree()一遍就全部变成-1了……
所以要先加一遍。

void
freerange(void *pa_start, void *pa_end)
{
  char *p;
  p = (char*)PGROUNDUP((uint64)pa_start);
  for(; p + PGSIZE <= (char*)pa_end; p += PGSIZE){
    inc_cowref((uint64)p); // 防止计数变为-1
    kfree(p);
  }
}
  • 15
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值