task
cow的目标就是延迟分配,并且直到必须要复制的时候才会分配物理内存
- cow的fork只为child创造了一个页表,其中的PTE指向了父进程的物理页面cow的fork将父进程和孩子进程的用户态的PTE都标记为不可写
- 当某个进程想要去写一个cow的页面时,cpu会执行一个页错误
- 内核的页错误处理函数会检测到
- 然后为错误的进程分配一个物理页面
- 将原始的页面拷贝的新的页面,
- 修改错误进程的PTE指向新的页面,并在PTE中标记为可写
- 当页面处理函数返回的时候,用户进程能够正常地写了
- 一个物理页面只有在所有的引用都消失时,才会被free掉
如果能够通过cowtest
和usertest
,则通过这个lab
推荐完成路线
-
修改
uvmcopy
-
使其不是分配一个物理页面,而是将父进程的物理页面映射到子进程的页表中
-
将父进程和子进程的PTE中的PTE_W都清空
补充:加入cow标识:
#define PTE_C (1L<<8)
-
-
修改
usertrap
-
使其能够识别出页错误
-
当一个页错误发生在cow的page上时
- 用kalloc分配一个新的页
- 将旧的页拷贝到新的页
- 将新的页的地址更新到PTE中,并且设置PTE_W标志
-
保证每个物理页面都是在完全没有进程引用的时候再被free,不可以提前
一个好的实现方法是未每个物理界面都维护一个引用count
- 当kalloc时设置引用计数为1
- 当一个进程调用fork的时候,给引用计数+1
- 任何一个进程free掉某一页的时候都讲引用计数减1
- kfree应该只将引用计数为0的页面放到free链表上
你可以将引用计数记录在一个固定大小的数组中
-
你需要想出一个映射的策略,以及决定它的size
你可以将一个物理地址除以4096来得出索引的下标
并且通过kinit能够给出的最大的物理地址得到最大的数组大小(size):12810241024/4096
// the kernel expects there to be RAM // for use by the kernel and user pages // from physical address 0x80000000 to PHYSTOP. #define KERNBASE 0x80000000L #define PHYSTOP (KERNBASE + 128*1024*1024)
-
修改
copyout
,让它在遇到cow的page时,使用和页错误相同的策略
-
hints
-
用PTE中的RSWbits来标记这个页面是否是cow
-
一个对页表的标志有帮助的宏和定义在
kernel/riscv.h
-
如果一个cow的页错误发生了,并且没有多余的内存,这个进程应该被杀死
思路
按照实验文档推荐的路线来即可,但是还是有一些小坑的
页引用计数
这一部分实验文档没有给出非常具体的指导没有直接把饭喂到我这种菜鸡嘴里,所以有许多具体实现的方式
我是将这一部分的代码全部放在了kalloc.c
文件中。可以想一下,我们什么时候会用到这个页引用计数呢?
- 当我们free一个页面的时候,会使用
- 当我们kalloc一个页面的时候,我们需要给它初始化为1
- 当我们遇到某个cow的页面被写的时候,需要重新分配并更改引用计数
- 当我们fork的时候,需要增加这个引用计数
前2点已经足够让我们把相关的定义放到kalloc.c
文件里,这里用到了一些宏,主要是为了后面使用其他方便
这里我们的count数组的大小,是由PHYSTOP和KERNBASE计算出来的,一个是可以分配的物理内存的最大值,一个是最小值。因此将它们相减,再除以页面的大小,就可以得到页数,也就是数组的大小。
通过PA2INDEX可以快速得出当前地址位于数组的哪个下标
下面的四个宏分别是求出这个地址对应的页面的引用计数值,以及初始化,减1和加1的操作
在对这个数组操作时,要用lock将其夹住
// KERNBASE 不是 end
#define PA2INDEX(pa) ((((uint64)pa) - KERNBASE) / PGSIZE)
struct {
struct spinlock lock;
int count[PA2INDEX(PHYSTOP)];
} ref_count;
#define PA2REFCOUNT(pa) (ref_count.count[PA2INDEX(pa)])
#define PAINITRC(pa) (ref_count.count[PA2INDEX(pa)] = 1)
#define PADEC(pa) (ref_count.count[PA2INDEX(pa)]--)
#define PAINC(pa) (ref_count.count[PA2INDEX(pa)]++)
接下来分别在kinit
,kfree
和kalloc
时将引用计数的逻辑加入
kinit比较简单,初始化这个锁就行了
void kinit() {
initlock(&kmem.lock, "kmem");
// 初始化计数数组的锁
initlock(&ref_count.lock, "ref_count");
freerange(end, (void *)PHYSTOP);
}
kfree只在这个页面引用计数为0时才真的free它。按理说,应该是用==0去判断,可是这样的话xv6都启动不起来。找出问题了,因为最开始freerange的时候,引用计数没有值,你走来就给它减1,就是负数了,结果导致所有的页面都没有放到freelist中。
void kfree(void *pa) {
struct run *r;
if (((uint64)pa % PGSIZE) != 0 || (char *)pa < end || (uint64)pa >= PHYSTOP)
panic("kfree");
acquire(&ref_count.lock);
PADEC(pa);
if (PA2REFCOUNT(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(&ref_count.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
// 初始化这个物理地址的引用数
PAINITRC((char *)r);
}
return (void *)r;
}
同时,我们还需要两个函数,一个是在cow被写时用来分配一个物理页面并将原来的页面拷贝过去,一个是在fork的时候增加引用计数,分别叫做kcopy
和kinc
// 发生了对cow页面的写操作,必须要分配一个物理页面了
void *kcopy(void *pa) {
acquire(&ref_count.lock);
// 如果自己就是唯一的拥有者了,那么就不用申请页面,直接用就完事了
if (PA2REFCOUNT(pa) == 1) {
release(&ref_count.lock);
return pa;
}
void *npa = kalloc();
// 没有可用页面,返回0
if (npa == 0) {
release(&ref_count.lock);
return NULL;
}
// 将当前页面的计数减1,并复制新的页面
PADEC(pa);
memmove(npa, pa, PGSIZE);
release(&ref_count.lock);
return npa;
}
// 给某个页面增加一个计数
void kinc(void *pa) {
acquire(&ref_count.lock);
PAINC(pa);
release(&ref_count.lock);
}
uvmcopy
fork时,不真正分配,只增加引用计数,并修改标志位
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);
flags = PTE_FLAGS(*pte);
// 如果这个页面可写,才需要设置成cow,因为后面会直接给其write的权利,所以如果只读,那就不用cow了
if (*pte & PTE_W) {
*pte |= PTE_C;
*pte &= ~PTE_W;
}
// 更新flags,下面mappages要用
flags = PTE_FLAGS(*pte);
// 将父进程的物理地址映射到子进程的页表上
if (mappages(new, i, PGSIZE, (uint64)pa, flags) != 0) {
goto err;
}
// 增加引用计数
kinc((void *)pa);
}
return 0;
err:
uvmunmap(new, 0, i / PGSIZE, 1);
return -1;
}
usertrap
对页错误的trap进行捕获,然后排除以下情况
- 地址本来就不合法,超出了最大范围,这可能引起页错误
- 如果这一页的W权限位为1,说明不是因为不能写导致的trap,那我们也处理不了
- 如果这一页的V标志位为0,那也不是我们能处理的
- 同样,如果这一页不是COW页,那我们也处理不了
第2,3,4在xv6里面其实是有点重复的判断,但是小心点反正不会出bug
还有一个细节就是在uvmunmap的时候,dofree必须是0,因为我们在kcopy的时候已经给这个页面的引用减1了,如果dofree=1,待会还得减1,就会出bug
} else if ((which_dev = devintr()) != 0) {
// ok
} else if (r_scause() == 12 || r_scause() == 13 || r_scause() == 15) {
// 地址越界
if (r_stval() >= p->sz) {
p->killed = 1;
} else {
// 分配新的一页
pte_t *pte = walk(p->pagetable, r_stval(), 0);
// 不存在,或者不是cow页
if ((*pte & PTE_W) != 0 || ((*pte) & PTE_V) == 0 || ((*pte) & PTE_C) == 0) {
p->killed = 1;
} else {
void *pa = (void *)PTE2PA(*pte);
void *npa = kcopy(pa);
// 申请内存失败
if (npa == 0) {
p->killed = 1;
} else {
// 已经获得了一块属于自己的物理内存,将地址和标志位更新到页表中
int flag = PTE_FLAGS(*pte);
flag |= PTE_W;
flag &= ~PTE_C;
uvmunmap(p->pagetable, PGROUNDDOWN(r_stval()), 1, 0);
mappages(p->pagetable, PGROUNDDOWN(r_stval()), PGSIZE, (uint64)npa, flag);
}
}
}
}
copyout
整体逻辑和trap捕获差不多
int copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len) {
uint64 n, va0, pa0;
while (len > 0) {
va0 = PGROUNDDOWN(dstva);
pa0 = walkaddr(pagetable, va0);
if (pa0 == 0)
return -1;
// 到了这里,肯定是一个合法的值了,但是不一定可以写啊
pte_t *pte = walk(pagetable, va0, 0);
// 如果是cow并且不可以写
if (*pte & PTE_C && !(*pte & PTE_W)) {
// 请求获得一块物理内存
void *npa = kcopy((void *)pa0);
if (npa == 0) {
return -1;
}
// 这个物理内存可用
int flag = PTE_FLAGS(*pte);
flag &= ~PTE_C;
flag |= PTE_W;
uvmunmap(pagetable, va0, 1, 0);
mappages(pagetable, va0, PGSIZE, (uint64)npa, flag);
pa0 = (uint64)npa;
}
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;
}
收获
- 如果需要维护一个全局性的变量,你不用让所有文件都能直接访问到它。可以在全局提供一个可以操作和查询它的函数即可。
- 能放在一个函数内部解决的事情就放在一个函数内部解决。特别是对已有的函数增加某些新的判断或者功能时,以全局改动最少的做法为标准。
- 对于临界变量,只要在可能访问或者修改它的前后加锁和解锁即可。如果提前return,记得解锁。
- 如果要修改一个函数或者某个属性,那要充分考虑到它可能被使用的场景,比如这里的kfree,除了正常的调用,还有freerange。