这个 Lab 要求我们实现 cow fork,这是一个常见的节省内存的手段。
总而言之,就是在 fork 时把父子的虚拟内存都映射到原本的父进程的物理内存上,并将其设为“仅读”。当有进程尝试写它会触发 page fault,这时我们才新建物理页,然后调整尝试写的进程的页表,将对应虚拟页映射到新建的物理页上。
这个 Lab 似乎没有太 tricky 的地方,接下来我会自问自答一下,只要能把这里的答案想明白基本就能写对代码了:
怎么让只读页正确只读,而不能被写?
我们通过给 PTE 设置一个 COW 位来判断是否是只读. COW 为 1 表示这个 PTE 原本应该是可写的,但现在被临时标记为只读,因为对应的物理页正在被多个进程共享。
什么时候新建物理页?我们会不会多建了不必要的物理页?
我们维护对每个物理页的引用计数,当引用计数为 1 时就把 COW 重设为 0(因为这个物理页不再被多个进程共享),把 W 位重新设为 1.
什么时候释放物理页?
调用 kfree 时,如果引用计数大于 1,将引用计数 -1 然后不动;如果引用计数 == 1,释放物理页。
然后就上代码吧。
物理内存层的基建代码
首先在 kalloc.c 里新建一个数据结构:
struct {
struct spinlock lock;
int refcnt[PHYSTOP / PGSIZE];
} kref;
这里有锁是因为我们不希望多个进程同时读写 refcnt. 我们要在 kinit 里初始化锁:
void kinit() {
initlock(&kmem.lock, "kmem");
initlock(&kref.lock, "kref");
freerange(end, (void *)PHYSTOP);
}
然后我们提供三个接口:
void kaddref(uint64 pa) {
acquire(&kref.lock);
kref.refcnt[pa / PGSIZE] += 1;
release(&kref.lock);
}
void kdecref(uint64 pa) {
acquire(&kref.lock);
if (kref.refcnt[pa / PGSIZE] > 0) {
kref.refcnt[pa / PGSIZE] -= 1;
}
release(&kref.lock);
}
int kgetref(uint64 pa) {
int ref;
acquire(&kref.lock);
ref = kref.refcnt[pa / PGSIZE];
release(&kref.lock);
return ref;
}
之后就能修改 kfree 和 kalloc 了,这里我提供的 kfree 是有并发风险的,具体可以看注释:
void kfree(void *pa) {
struct run *r;
if (((uint64)pa % PGSIZE) != 0 || (char *)pa < end || (uint64)pa >= PHYSTOP)
panic("kfree");
// 这里有潜在的并发风险, 如果多个共享了物理页的进程同时 kfree 这个物理页,
// 那么 if (kgetref((uint64)pa) == 0) 下的语句就有可能被多个进程进入.
// 想修正也不难, 不调用函数而是手动请求锁, 在和 refcnt 交互完后释放锁就行.
// 不过介于测试代码已经过了, 我们就不改动了
kdecref((uint64)pa);
// Free physical memory only when no other reference exists
if (kgetref((uint64)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);
}
}
void *kalloc(void) {
struct run *r;
acquire(&kmem.lock);
r = kmem.freelist;
if (r)
kmem.freelist = r->next;
release(&kmem.lock);
if (r)
kaddref((uint64)r);
if (r)
memset((char *)r, 5, PGSIZE); // fill with junk
return (void *)r;
}
COW 策略实现
然后我们看向 trap.c,在 usertrap 里加入页错误的分支:
if (r_scause() == 8) {
// ...
} else if ((which_dev = devintr()) != 0) {
// ...
}
// 这里是加入的分支
else if ((r_scause() == 15) && writefault(p->pagetable, r_stval()) != 0) {
// copy on write
} else {
// ...
}
接着我们到 vm.c 里实现 writefault:
// return 0 if error, and physical address if successful.
uint64 writefault(pagetable_t pagetable, uint64 va) {
pte_t *pte;
uint64 pa;
uint flags;
int refcnt;
struct proc *p = myproc();
if (va >= p->sz) {
return 0;
}
va = PGROUNDDOWN(va);
pte = walk(pagetable, va, 0);
if (pte == 0)
return 0;
flags = PTE_FLAGS(*pte);
if (!(flags & PTE_COW)) {
// read-only page
return 0;
}
pa = PTE2PA(*pte);
if ((refcnt = kgetref(pa)) == 0) {
// no process ref this page
return 0;
} else if (refcnt == 1) {
// only one process ref this page, change it to a regular writeable page
*pte &= (~PTE_COW);
*pte |= (PTE_W);
return pa;
} else {
// copy on write
char *mem;
if ((mem = kalloc()) == 0) {
return 0;
}
memmove(mem, (char *)pa, PGSIZE);
*pte &= (~PTE_V); // make mappages work
if (mappages(pagetable, va, PGSIZE, (uint64)mem, flags) != 0) {
kfree(mem);
return 0;
}
*pte &= (~PTE_COW);
*pte |= (PTE_W);
kdecref(pa);
return pa;
}
}
然后在 uvmcopy 里加入把多个虚拟内存映射到相同物理内存的机制:
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");
pa = PTE2PA(*pte);
flags = PTE_FLAGS(*pte);
if (mappages(new, i, PGSIZE, (uint64)pa, flags) != 0) {
goto err;
}
if (flags & PTE_W) {
pte_t *new_pte = walk(new, i, 0);
*pte &= (~PTE_W);
*new_pte &= (~PTE_W);
*pte |= (PTE_COW);
*new_pte |= (PTE_COW);
}
kaddref(pa);
}
return 0;
err:
uvmunmap(new, 0, i / PGSIZE, 1);
return -1;
}
最后改改 copyout 就可以下班了:
int copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len) {
uint64 n, va0, pa0;
pte_t *pte;
while (len > 0) {
va0 = PGROUNDDOWN(dstva);
if (va0 >= MAXVA)
return -1;
pte = walk(pagetable, va0, 0);
if (pte == 0 || (*pte & PTE_V) == 0 || (*pte & PTE_U) == 0)
return -1;
if ((*pte & PTE_W) == 0) {
if ((*pte & PTE_COW) && writefault(pagetable, va0) != 0) {
// copy on write
} else {
return -1;
}
}
pa0 = PTE2PA(*pte);
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;
}
等等,先别急着下班,copyout 是做什么的?为什么它看起来独树一帜,要我们手动调用处理写故障的函数,而不是自动触发写故障然后自动被处理?
看看代码可以发现,它没有通过用户页表来“写”内存,而是通过用户页表拿到物理地址,再通过内核页表写内存。在这里,硬件只检查内核页表的权限,而不检查用户页表 PTE 的 PTE_W/COW,因此自然不会触发写故障。
总之就下班了,然后放张图图吧
