xv6 Lab5 Copy on-write - MIT 6.1810 Fall 2025 Operating System
本文最后更新于 2026年8月9日 中午
阅读
这个 lab 的 guidance 罕见地没有给出任何 Reading xv6 book 的指示。但是我还是看一看课程的文档:8.1 Page Fault Basics | MIT6.S081
这一章节事实上涵盖了原本有的 Lazy Allocation Lab, Copy on-write Lab, 以及 mmap lab。
这里只简单概述一下 Copy on-write 的思想:
一个进程 fork 之后,创建的是父进程的一个完整拷贝。在目前的实现中,是直接复制物理空间;而如果 fork 之后还立刻执行 exec,则这个地址空间还会被丢弃,很浪费。
cow 的思想就是子进程和父进程的虚拟地址空间映射到同一个物理地址,但是 PTE 设置为只读。如果有写操作发生(无论是父还是子进程执行的写),则触发 page fault,拷贝相应的页,PTE 设置为可读写,再继续执行。
将一个页标记为 cow 页,使用的是 PTE 的最后一个 bit。
同时,由于之前的实现中不存在一个物理地址对应多个虚拟地址这样的映射,导致进程结束时的释放也会存在问题。解决方案是对物理页框添加引用计数。
Implement copy-on-write fork
其实 lab 这里对于什么是 copy on-write 的说明都很清晰了,比课程文档还清晰易懂一点。
根据提供的步骤,一步一步来即可,不过需要先实现一些基础设施(也就是 hint 的内容):
为 PTE 记录 COW 映射
在 riscv.h 里面
#define PTE_COW (1L << 8)之后改 flags 的时候里面随便改。
修改 uvmcopy()
这个函数原本的作用是在创建子进程时,把父进程的页表完全拷贝进新进程(在 Page table Lab 里面分析得很清楚了)。
这里需要:不要再为子进程分配新的物理页面并复制数据,改成直接把父进程的物理页面映射进子进程页表,并且将父进程和子进程的页中的 PTE_W 都清空。
代码
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)
continue; // page table entry hasn't been allocated
if((*pte & PTE_V) == 0)
continue; // physical page hasn't been allocated
pa = PTE2PA(*pte);
flags = PTE_FLAGS(*pte);
if(flags & PTE_W) {
flags &= ~PTE_W;
flags |= PTE_COW;
*pte = PA2PTE(pa) | flags;
}
if(mappages(new, i, PGSIZE, pa, flags) != 0)
goto err;
}
// TODO: 是否要刷新 TLB?留待之后分析
return 0;
err:
uvmunmap(new, 0, i / PGSIZE, 1);
return -1;
}修改 vmfault() 处理 COW page fault
关于 vmfault() 函数的作用,参考 Page table Lab 里面就有说明,主要是和 Lazy Alloaction 相关的。
目前的 vmfault() 函数,read 参数是没有用到的,我猜测作用是区分不同 page fault 的类型,询问 LLM 得到的答案:
引用
vmfault()的read参数用于区分这次 page fault 是由“读取”还是“写入”引起的。在
trap.c中:vmfault(p->pagetable, r_stval(), (r_scause() == 13) ? 1 : 0)对应关系是:
scause含义 read13 Load page fault,读取失败 1 15 Store page fault,写入失败 0 你当前的
vmfault()只处理 lazy allocation:只要页面未映射,就分配一个零页。读取和写入的处理相同,所以read目前没有被使用。到 COW 阶段,它就有意义了:
read == 0,并且页面带 PTE_COW → 这是写 COW 页面 → 分配新页、复制内容、恢复 PTE_W read == 1 → 不能当作 COW 写入处理因为 COW 页面只清除了
PTE_W,仍然保留PTE_R:读取 COW 页面:正常,不应 fault 写入 COW 页面:产生 store page fault因此,如果一个已经映射的页面发生读取 fault,通常说明它不是合法的 COW 写入,不能把它复制成可写页面。
另外,
copyout()是内核向用户空间写数据,所以之后调用 COW 处理逻辑时,应按照写操作处理,也就是相当于:read = 0;总结:
read当前在 lazy allocation 中暂时没用,是为区分读写 page fault、特别是后面的 COW 写 fault 准备的。
然后我大致有思路但是有点懒得自己手敲(实则是不太会),喂给 LLM 这样的 prompt:
关于修改 vmfault 的这步,我大概是有个思路了:首先,如果是read=1,就和原来的逻辑一模一样去进行处理;如果是read=0,那么首先检查这个页面,检查页面的方式是walk() 一下找到 pte 然后检查 flags,然后 kalloc 分配一个新的物理页面,修改原来pte的flags(主要是write部分,顺便去掉cow),然后复制,最后让 pte 指向这个新的页面。因为只有原本设置为可写的页面才会被打上cow的标记,所以不需要做是否可写的判定。但是我脑子里有点难以组织这个逻辑如何和现有的懒分配的逻辑进行耦合,所以请你直接帮我修改一下这个代码,顺便讲解一下。
代码实现:
代码
uint64
vmfault(pagetable_t pagetable, uint64 va, int read)
{
uint64 mem, pa;
uint flags;
pte_t *pte;
struct proc *p = myproc();
if (va >= p->sz)
return 0;
va = PGROUNDDOWN(va);
if(ismapped(pagetable, va)) {
pte = walk(pagetable, va, 0);
if(read || (*pte & PTE_U) == 0 || (*pte & PTE_COW) == 0)
return 0;
pa = PTE2PA(*pte);
flags = PTE_FLAGS(*pte);
mem = (uint64)kalloc();
if(mem == 0)
return 0;
memmove((void *)mem, (void *)pa, PGSIZE);
flags = (flags | PTE_W) & ~PTE_COW;
*pte = PA2PTE(mem) | flags;
return mem;
}
mem = (uint64) kalloc();
if(mem == 0)
return 0;
memset((void *) mem, 0, PGSIZE);
if (mappages(p->pagetable, va, PGSIZE, mem, PTE_W|PTE_U|PTE_R) != 0) {
kfree((void *)mem);
return 0;
}
return mem;
}几个核心点:
-
va >= p->sz和ismapped()检查的是两个完全不同的问题:va >= p->sz:这个虚拟地址在不在进程合法的地址范围内?ismapped():这个合法虚拟地址目前有没有对应的物理页面?
-
原本的懒分配逻辑是,检查是否
ismapped(),如果有映射说明是存在对应的物理页面的,无视。但是目前需要耦合 COW 的逻辑,而且 COW 页一定是存在对应的物理页的(考虑到uvmcopy的逻辑就是建立虚拟页对实际存在的物理页的映射,循环使用了mappages()),因此,这里 COW 的逻辑,就全部落在了原本直接return 0的ismapped部分。 -
这里继续检查,如果是只读,或者不是 COW 页,或者
(*pte & PTE_U) == 0即用户模式下不允许访问此页(如 trampoline 页等)也就直接返回掉。 -
然后,获取这个页对应的所有信息,和我之前给的 prompt 不同,首先复制旧页面,然后计算新 flags,最后才替换 PTE,这是为了防止
kalloc()失败,原来的有效映射也可能被破坏。 -
所谓的 flags 只有虚拟页也就是 pte 才有,物理页是没有的!
-
之后 COW 部分的逻辑就结束了,其余的丢给懒分配的逻辑。
实现物理页面引用计数
必须确保每个物理页面只在最后一个 PTE 引用消失后才被释放。
我开始认为,维护引用计数的规则:
kalloc()分配页面时,初始化置 1fork()让子进程共享物理页面时,引用计数 +1,虽然说是fork(),但是实际上是在uvmcopy()里面实现,和下面的uvmunmap()对应。- 某个进程从页表中移除此页面(
uvmunmap())时,引用计数 -1。 kfree()的逻辑:只有当引用计数为 0 时,才能放回空闲列表。- 所谓的物理地址,也就是
pa。由于物理页框地址总是PGSIZE对齐的,可以使用pa / PGSIZE作为数组的下标,记录某个页的引用计数(实际上,这个数字就是第几个物理页框)。数组的大小,就是利用PHYSTOP就行了。 - 这里务必需要注意一点,这个数组不能随意修改,最好是加一把锁,然后通过设置好的函数
krefinc()krefdec()进行加减访问,防止并发冲突。
但是实际上存在几个问题:
krefdec()其实不应该存在,而是把引用数的削减全部放在kfree()里面。因为解除虚拟页和物理页的引用的地方远远不止uvmunmap()一处。比如vmfault()就直接把旧的物理页改成了新的物理页,相当于是解引用,但是没有经过uvmunmap()。但是无论如何,这里都相当于是减少了引用计数。- 因此,这里需要修改
kfree()的语义。应当是,先负责引用计数 -1,然后检查,如果真的减到 0 了,才执行释放(放回空闲列表中)。
- 因此,这里需要修改
int phy_ref[PHYSTOP / PGSIZE];比较浪费,不如用int phy_ref[(PHYSTOP - KERNBASE) / PGSIZE],然后写一个index的映射函数。freerange()需要特殊处理,因为freerange()开始的时候页面的初始计数都是 0,但是kfree()会搞成 -1,所以先赋值为 1
摘一些核心的代码:
代码
#define NPAGE ((PHYSTOP - KERNBASE) / PGSIZE)
struct {
struct spinlock lock;
int count[NPAGE];
} kref;
static int
paindex(uint64 pa)
{
return (pa - KERNBASE) / PGSIZE;
}
void
freerange(void *pa_start, void *pa_end)
{
char *p;
p = (char*)PGROUNDUP((uint64)pa_start);
for(; p + PGSIZE <= (char*)pa_end; p += PGSIZE){
// kfree() drops a reference, so give each boot-time page one
// reference before adding it to the free list.
acquire(&kref.lock);
kref.count[paindex((uint64)p)] = 1;
release(&kref.lock);
kfree(p);
}
}
void
kfree(void *pa)
{
struct run *r;
if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
panic("kfree");
int index = paindex((uint64)pa);
acquire(&kref.lock);
if(kref.count[index] < 1)
panic("kfree: no reference");
kref.count[index]--;
if(kref.count[index] > 0){
release(&kref.lock);
return;
}
release(&kref.lock);
// 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);
}
void
krefinc(uint64 pa)
{
if((pa % PGSIZE) != 0 || (char*)pa < end || pa >= PHYSTOP)
panic("krefinc");
int index = paindex(pa);
acquire(&kref.lock);
if(kref.count[index] < 1)
panic("krefinc: free page");
kref.count[index]++;
release(&kref.lock);
}修改 copyout()
首先解析一下 copyout() 是什么:
// 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)从这个函数签名可以看出,作用是把内核空间中的数据,安全地复制到某个进程的用户虚拟地址中。
目前的 copyout() 的逻辑,有关于 lazy allocation 的处理逻辑:
pa0 = walkaddr(pagetable, va0);
if(pa0 == 0) {
if((pa0 = vmfault(pagetable, va0, 0)) == 0) {
return -1;
}
}如果这个用户地址尚未映射,当前代码会尝试通过 vmfault() 分配页面。
但是目前还无法正确处理 COW 逻辑,因为 COW 页没有 PTE_W,但是实际上是可写的,只是要模拟一次写 pagefault,其实就是调用一次 vmfault()即可。
代码
int
copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len)
{
uint64 n, va0, pa0;
pte_t *pte;
int needfault;
while(len > 0){
va0 = PGROUNDDOWN(dstva);
if(va0 >= MAXVA) {
goto err;
}
needfault = 0;
pa0 = walkaddr(pagetable, va0);
if(pa0 == 0){
needfault = 1;
} else {
pte = walk(pagetable, va0, 0);
if((*pte & PTE_W) == 0){
if((*pte & PTE_COW) == 0) {
goto err;
}
needfault = 1;
}
}
// 这里运用了布尔语句的短路特性
if(needfault && (pa0 = vmfault(pagetable, va0, 0)) == 0) {
goto err;
}
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;
err:
return -1;
}通过所有测试:
