MIT6.S081 - Lab6 Copy-on-Write(写时复制)

本篇是 MIT6.S081 操作系统课程 Lab6 的实验笔记,目标是完成 fork() 写时复制(Copy-on-Write)的功能,算是上一个懒分配实验的延伸。

未修改的 fork() 会复制父进程所有的内存到子进程,这个过程耗时且浪费内存,写时复制的核心思想是:

  • fork 阶段,新创建的子进程页表,映射到父进程的物理地址,父子进程页表指向的物理地址是相同的
  • 同时,将共享的物理页面映射成 只读。
  • 只有当父子进程中的某一方尝试修改共享的页面时,触发页面错误,OS 才真正为子进程复制该页面。

在开始实验之前,你需要:

  1. 观看 Lecture 8 课程录播视频:Interrupts(中断)
  2. 阅读 《xv6 book》 第五章: 中断和设备驱动

Implement copy-on write(hard)

Your task is to implement copy-on-write fork in the xv6 kernel. You are done if your modified kernel executes both the cowtest and usertests programs successfully.

实现 copy-on-write fork 功能,并通过 cowtest 和 usertests 测试程序。

实验要求不能在上一节懒加载代码的基础上改,请切换到 cow 分支。

实现思路视频中讲得比较清楚:

  1. 延迟复制/释放,把一个物理页的复制(kalloc() then memmove())和释放(kfree())操作,改为记录这个物理页面被多少个进程引用
    • 创建新的物理页,引用数 +1
    • 复制一个物理页面,该物理页的引用数 +1
    • 进程释放一个物理页面,引用计数 -1,只有最后一个进程解除引用时,才可以真正的释放物理内存。
    • 执行写时复制,物理页的引用数也需要 -1
  2. 写时复制,本实验的核心功能,当一个进程尝试向一个共享页写入的时候,执行创建新物理页并复制。
    • 为 PTE 新增标识,用来标记这个物理页是写时复制的共享页面。
    • 触发页面错误,OS 才真正为子进程复制该页面。

1、延迟复制/释放

首先我们需要新增引用记数,用来记录每个物理页面被多少个进程引用,内存的申请释放都在 kalloc.c 文件

c 复制代码
...
struct {
  struct spinlock lock;
  struct run *freelist;
} kmem;

// 物理地址转 index
#define PA2IDX(p) (((uint64)(p)) / PGSIZE)

struct {
  struct spinlock lock; // 保证并发安全
  int ref_arr[PHYSTOP / PGSIZE]; // 每个物理页面的引用次数
} page_ref; // 模仿 kmem 新建页面引用结构
...

新建 page_ref 结构体,里面有个数组用来存储物理页面的引用次数,还有个 lock 自旋锁,用来保证多 CPU 的并发安全。

c 复制代码
void
kinit()
{
  initlock(&kmem.lock, "kmem");
  initlock(&page_ref.lock, "pageref"); // 初始化 page_ref.lock
  freerange(end, (void*)PHYSTOP);
}

初始化 page_ref.lock

c 复制代码
void *
kalloc(void)
{
  ...
  if(r){
    memset((char*)r, 5, PGSIZE); // fill with junk
    // 如果是新分配的有效物理页,引用计数为 1
    page_ref.ref_arr[PA2IDX(r)] = 1;
  }
  return (void*)r;
}

如果是新分配的有效物理页,引用计数需要为 1。

kfree() 释放函数也需要修改,释放一个物理页面,引用计数 -1,只有最后一个进程解除引用,没有进程映射到该物理页了,才真正释放页面

c 复制代码
void
kfree(void *pa)
{
  struct run *r;

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

  acquire(&page_ref.lock);

  // 只有页面的引用计数为 0,没有进程映射到该物理页了,才真正释放页面
  if(--page_ref.ref_arr[PA2IDX(pa)] <= 0) {

    // 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);
  }

  release(&page_ref.lock);
}

同时,还需要给外部提供增加引用次数和复制物理页的函数

c 复制代码
// 如果有必要,克隆一页物理页
void *ktry_pgclone(void *pa) {

    acquire(&page_ref.lock);

    // 这个物理页本来就只有一个地方引用,直接返回
    if(page_ref.ref_arr[PA2IDX(pa)] <= 1) {
        release(&page_ref.lock);
        return pa;
    }

    // 申请一页物理页
    uint64 newpa = (uint64)kalloc();

    if(newpa == 0) {
        release(&page_ref.lock);
        return 0;
    }

    // 复制老物理页内容到新页
    memmove((void*)newpa, (void*)pa, PGSIZE);

    // 老物理页引用减一
    page_ref.ref_arr[PA2IDX(pa)]--;

    release(&page_ref.lock);
    return (void*)newpa;
}

// 增加物理页面的引用次数
void kparef_inc(void *pa) {
    acquire(&page_ref.lock);
    page_ref.ref_arr[PA2IDX(pa)]++;
    release(&page_ref.lock);
}

把原先的 vm.c 里面的 copy 工作,改为对这个物理页的引用次数 +1

c 复制代码
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);

    // 如果页面是可写权限,那把它标记为 COW 并移除写权限
    if (*pte & PTE_W) {
        *pte = (*pte & ~PTE_W) | PTE_COW;
    }

    flags = PTE_FLAGS(*pte);

    // 这里取消申请内存并复制的操作
//    if((mem = kalloc()) == 0)
//      goto err;
//    memmove(mem, (char*)pa, PGSIZE);

    // 将爸爸的物理地址映射到新的页表
    if(mappages(new, i, PGSIZE, (uint64)pa, flags) != 0){
      goto err;
    }
    // 将物理页的引用次数增加 1
      kparef_inc((void*)pa);
  }
  return 0;

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

2、写时复制

内存复制延迟到写操作,那就需要一种方来标记,哪些页面是写时复制的共享页面,我们这里用的是 PTE 的保留位来实现

arduino 复制代码
kernel/riscv.h
...
#define PTE_U (1L << 4) // 1 -> user can access
#define PTE_COW (1L << 8)  // 第8位保留位用于标记 COW 页面

提供个方法,针对 cow 页面,执行创建新物理页并复制。

c 复制代码
kernel/vm.c
// 处理 COW 页错误
int cow_handler(pagetable_t pagetable, uint64 va)
{
    pte_t *pte;

    // 拿到 va 对应的页表项
    if((pte = walk(pagetable, va, 0)) == 0)
        panic("uvmcowcopy: walk");

    uint64 pa = PTE2PA(*pte);
    uint64 newpa;
    // 转为 pa 丢到 kalloc 里面处理,因为涉及到物理页的记数操作
    if((newpa = (uint64)ktry_pgclone((void*)pa)) != 0){
        // 重新修改为可写,并删除 cow 标识
        uint64 flags = (PTE_FLAGS(*pte) | PTE_W) & ~PTE_COW;
        // 解除原来的映射,注意最后一个参数要传0,不释放物理页
        uvmunmap(pagetable, PGROUNDDOWN(va), 1, 0);
        // 映射到新的物理页
        if(mappages(pagetable, va, 1, newpa, flags) == -1) {
            panic("uvmcowcopy: mappages");
        }
        return 0;
    }
    return -1;
}

然后,如果发现是 cow 页面,就调用 cow_handler() 来处理。

c 复制代码
kernel/vm.c
#include "spinlock.h"
#include "proc.h"
int
copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len)
{
  uint64 n, va0, pa0;
  pte_t *pte;
  struct proc *p = myproc();

  while(len > 0){
    va0 = PGROUNDDOWN(dstva);

    // 如果是 cow 页,执行复制
    if(va0 < p->sz && (pte = walk(pagetable, va0, 0))!=0 && *pte & PTE_V&& *pte & PTE_COW)
        cow_handler(pagetable,va0);
    pa0 = walkaddr(pagetable, va0);
    ...
}
scss 复制代码
kernel/trap.c
void
usertrap(void) {
    ...
    if (r_scause() == 8) {
        // system call
        ...
    } else if ((which_dev = devintr()) != 0) {
        // ok
    } else if (r_scause() == 13 || r_scause() == 15) { // 和上一节实验一样,响应页错误
        pte_t *pte;
        uint64 va = r_stval();
        // 检查地址是否合法
        if (va >= p->sz)
            exit(-1);
        pte = walk(p->pagetable, va, 0);
        // 检查 pte 是否有效且是个 cow 页,不满足条件直接干掉进程
        if (pte == 0 || (*pte & PTE_V) == 0 || (*pte & PTE_COW) == 0)
            exit(-1);
        // 如果是 cow 页面,尝试为它完成写时复制
        if (cow_handler(p->pagetable, va) == -1) {
            exit(-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;
    }
    ...
}

最后来执行测试用例 cowtest

测试通过,完整代码在:github.com/yibaoshan/x...

参考资料

相关推荐
odoo-卜永18 分钟前
ubuntu22.04连接爱普生打印机型号L385
linux·经验分享·ubuntu
小麦嵌入式1 小时前
Linux驱动开发实战(十一):GPIO子系统深度解析与RGB LED驱动实践
linux·c语言·驱动开发·stm32·嵌入式硬件·物联网·ubuntu
刘若水1 小时前
Linux: 进程信号初识
linux·运维·服务器
共享家95273 小时前
深入剖析Linux常用命令,助力高效操作
linux·运维·服务器
Zfox_4 小时前
【C++项目】从零实现RPC框架「四」:业务层实现与项目使用
linux·开发语言·c++·rpc·项目
吃旺旺雪饼的小男孩4 小时前
Ubuntu 22.04 安装和运行 EDK2 超详细教程
linux·运维·ubuntu
IT小馋猫4 小时前
Linux 企业项目服务器组建(附脚本)
linux·服务器·网络
阿政一号4 小时前
Linux进程间通信:【目的】【管道】【匿名管道】【命名管道】【System V 共享内存】
linux·运维·服务器·进程间通信
又过一个秋4 小时前
【sylar-webserver】7 定时器模块
linux·c++
啊哦1115 小时前
配置防火墙和SELinux(1)
linux·服务器·网络