本文来源:
https://mp.weixin.qq.com/s/GzWllrExZc_pf-R6Wp83WQ
虚拟内存提供了一个间接层(a level of indirection):内核可以通过将PTE标记为无效或只读来拦截内存引用,从而导致页面错误,并可以通过修改PTE来更改地址的含义。在计算机系统中有一种说法,任何系统问题都可以通过间接层来解决。懒页分配实验提供了一个例子,本实验探索了另一个例子:写时拷贝。
注:懒页分配实验是2020年课程中的一个实验,目前,在2021年课程中缺少了这个实验,感兴趣的读者可在本公众号中找到。
请切换到cow分支,开始本次实验。
$ git fetch
$ git checkout cow
$ make clean
xv6中的fork()系统调用将父进程的所有用户空间内存复制到子进程中。如果父进程的用户空间很大,复制可能需要很长时间。更糟糕的是,这项工作的大部分往往被浪费了,例如,fork()后的子进程紧接着执行exec()函数,这将导致子进程丢弃复制的内容,也就是说,这些复制的内容根本就没有被用到。另一方面,当父进程和子进程使用同一个页面时,若其中的一个或两个进程要对页面进行写操作,此时确实需要一个副本。
2. 解决方案
写时拷贝(copy-on-write,COW)的目标是推迟由fork()创建的子进程分配和复制物理内存页,直到实际需要时才拷贝(如果需要的话)。
写时拷贝fork()只为子进程创建一个页表,用户内存的PTE指向父进程的物理页。写时拷贝fork()将父和子进程中的所有用户PTE标记为不可写。当任一进程试图写入其中一个COW页时,将强制CPU执行页错误。内核页错误处理程序检测到这种情况,为出错进程分配一页物理内存,将原始页复制到新页中,并修改出错进程中的相关PTE以引用新页,并标记父子进程相应的PTE对应的页为可写。当页面错误处理程序返回时,用户进程将能够写入其页面副本。
写时拷贝fork()使得释放实现用户内存的物理页变得有点棘手。给定的物理页可以由多个进程的页表引用,并且只有在最后一个引用消失时才能释放。
3. 实验:实现写时拷贝(难度:困难)
你的任务是在xv6内核中实现写时拷贝fork。如果修改后的内核能同时通过cowtest和usertests的测试,那就算完成任务了。
为了帮助你测试,我们提供了一个名为cowtest的xv6程序(源代码位于user/cowtest.c)。cowtest进行各种测试,但在未修改的xv6上,即使是第一个测试也会失败。因此,最初,你将看到:
$cowtest
simple: fork() failed
$
“简单”测试分配了一半以上的可用物理内存,然后fork()。fork失败,因为没有足够的空闲物理内存,无法为子进程提供父进程内存的完整副本。
当你完成任务后,内核应该通过cowtest和UserTest中的所有测试。即:
一个合理的实验步骤如下:
1. 修改uvmcopy()将父进程的物理页映射到子进程的物理页,而不是分配新页面。清除父子进程PTE的PTE_W位。
2. 修改usertrap()以识别页面错误。当COW页面出现页面错误时,使用kalloc()分配一个新页面,将旧页面复制到新页面,然后将新页面设置到PTE中并设置PTE_W位。
3. 确保每个物理页在最后一个对它的PTE引用移除时被释放。一个好的解决方案是设置一个“引用计数器”,记录每个物理页被引用的用户页表数。当kalloc()分配页时,将页的引用计数器设置为1。当fork导致子进程共享页时,增加页的引用计数,每当任何进程从其页表中删除页时,减少页的计数。kfree()只应在引用计数为零时将页放回空闲列表。可以将这些计数器保存在一个固定大小的整数数组中。你必须制定一个如何索引数组以及如何选择数组大小的方案。例如,可以用页面的物理地址除以4096作为数组的索引,并为数组提供若干元素,这些元素等于kalloc.c中的kinit()放置在空闲列表中的任意页面的最高物理地址。
4. 修改copyout(),使其在遇到COW页面时使用与页面错误相同的方案。
一些提示
● 在懒页分配实验中你可能已经熟悉了许多与copy-on-write相关的xv6内核代码。但是,你不应该将这个实验建立在你的懒页分配解决方案的基础上,相反,请按照上面的说明从一个新的xv6开始。
● 有一种方法可以记录每个PTE是否是COW映射,这可能很有用。你可以使用RISC-V PTE中的RSW(保留给软件使用)位来实现此目的。
● Usertests探索了cowtest没有测试的场景,所以不要忘记检查两者的所有测试是否通过。
● kernel/riscv.h的末尾有一些有用的宏和页表标志的定义。
● 如果出现COW页错误并且没有可用内存,则应终止进程。
5. 实验步骤
步骤1:在kalloc.c中kmem结构中增加物理内存引用计数器uint *ref_count,并对该计数器定义互斥锁struct spinlock reflock,在kinit函数中对其进行初始化。
kinit函数用来初始化内存分配器。对系统中的每个物理页面以链表的形式进行管理,空闲链表保存了内核和PHYSTOP之间的每个物理页面。初始化kmem.ref_count如下。
struct {
struct spinlock lock;
struct run *freelist;
struct spinlock reflock;
uint *ref_count;
} kmem;
void
kinit()
{
initlock(&kmem.lock, "kmem");
initlock(&kmem.reflock,"kmemref");
// end:内核之后的第一个可以内存单元地址,它在kernel.ld中定义
uint64 rc_pages = ((PHYSTOP - (uint64)end) >> 12) +1; // 物理页数
// 计算存放页面引用计数器占用的页数
rc_pages = ((rc_pages * sizeof(uint)) >> 12) + 1;
// 从end开始存放页引用计数器,需要rc_pages页
kmem.ref_count = (uint*)end;
// 存放计数器的存储空间大小为:
uint64 rc_offset = rc_pages << 12;
freerange(end + rc_offset, (void*)PHYSTOP);
}
// 将地址转换为物理页号
inline int
kgetrefindex(void *pa)
{
return ((char*)pa - (char*)PGROUNDUP((uint64)end)) >> 12;
}
void
freerange(void *pa_start, void *pa_end)
{
char *p;
p = (char*)PGROUNDUP((uint64)pa_start);
for(; p + PGSIZE <= (char*)pa_end; p += PGSIZE) {
// 初始化kmem.ref_count
kmem.ref_count[kgetrefindex((void *)p)] = 1;
kfree(p);
}
}
问:为什么在初始化时设置引用计数器为1?提示:在freerange函数中调用了kfree(p)。
步骤2:修改kalloc函数,使其在分配页面时将引用计数器设置为1。
步骤3:释放内存时查看引用计数是否为0,为0释放内存,否则返回,由其他进程继续使用。
步骤4:增加一些辅助函数(kernel/kalloc.c)。
int
kgetref(void *pa){
return kmem.ref_count[kgetrefindex(pa)];
}
void
kaddref(void *pa){
kmem.ref_count[kgetrefindex(pa)]++;
}
inline void
acquire_refcnt(){
acquire(&kmem.reflock);
}
inline void
release_refcnt(){
release(&kmem.reflock);
}
注意,要在defs.h中说明这些函数的原型。
步骤5:增加COW标志位。
在页表项中预留了2位给操作系统,这里用第8位,即#define PTE_COW (1L << 8))
riscv.h
步骤6:对fork函数进行修改,使其不对地址空间进行拷贝。fork函数会调用vm.c里的uvmcopy进行拷贝,因此只需要修改uvmcopy函数即可:删去uvmcopy中的kalloc函数,将子进程页表映射到父进程的物理地址,去掉写标志位,增加COW标志位,增加物理内存引用计数。
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");
// 设置父进程的PTE_W为不可写,且为COW页
*pte = ((*pte) & (~PTE_W)) | PTE_COW;
flags = PTE_FLAGS(*pte);
pa = PTE2PA(*pte);
// 不为子进程分配内存,指向pa,页表属性设置为flags即可
if(mappages(new, i, PGSIZE, pa, flags) != 0) {
goto err;
}
kaddref((void*)pa);
}
return 0;
err:
// 当发生错误时,是否需要恢复错误之前对父进程
// 页表的修改?如果不恢复,后面的程序是否能够纠正?
// 在设计后面的程序时需要考虑到这一点
uvmunmap(new, 0, i / PGSIZE, 1);
return -1;
}
步骤7:在usertrap函数(trap.c)中增加页面错误的处理,需判断是否是COW。这里只需要处理scause==15的情况,因为13是页面读错误,而COW是不会引起读错误的。
在这段程序数中先判断COW标志位,当该页面是COW页面时,就可以根据引用计数来进行处理。如果计数大于1,那么就需要通过kalloc申请一个新页面,然后拷贝内容,之后对该页面进行映射,映射的时候清除COW标志位,设置PTE_W标志位;而如果引用计数等于1,那么就不需要申请新页面,只需要对这个页面的标志位进行修改就可以了。
在该程序中声明:extern pte_t* walk(pagetable_t, uint64, int);
intr_on();
syscall();
} else if(r_scause() == 15) { // 写页面错
uint64 va = PGROUNDDOWN(r_stval());
pte_t *pte;
if(va >= MAXVA) { // 虚拟地址错
printf("va is larger than MAXVA!\n");
p->killed = 1;
goto end;
}
if(va > p->sz){ // 虚拟地址超出进程的地址空间
printf("va is larger than sz!\n");
p->killed = 1;
goto end;
}
if((pte = walk(p->pagetable, va, 0)) == 0) {
printf("usertrap(): page not found\n");
p->killed=1;
goto end;
}
// 分配一个新页面
if(((*pte) & PTE_COW) == 0 ||((*pte) & PTE_V) == 0 || ((*pte) & PTE_U) == 0) {
printf("usertrap: pte not exist or it's not cow page\n");
p->killed = 1;
goto end;
}
uint64 pa = PTE2PA(*pte);
acquire_refcnt();
uint ref = kgetref((void*)pa);
if(ref == 1) { // 引用次数为1,直接使用该页
*pte = ((*pte) & (~PTE_COW)) | PTE_W;
} else { // 引用次数大于1,分配物理页
char* mem = kalloc();
if(mem == 0) {
printf("usertrap(): memery alloc fault\n");
p->killed = 1;
release_refcnt();
goto end;
}
// 将旧页面复制到新页面,并用PTE_W和(~PTE_COW)设置新页的PTE
memmove(mem, (char*)pa, PGSIZE);
uint flag = (PTE_FLAGS(*pte) | PTE_W) & (~PTE_COW);
if(mappages(p->pagetable, va, PGSIZE, (uint64)mem, flag) != 0) {
kfree(mem);
printf("usertrap(): can not map page\n");
p->killed = 1;
release_refcnt();
goto end;
}
kfree((void*)pa); //旧页引用次数减1
}
release_refcnt();
} else if((which_dev = devintr()) != 0){
// ok
} 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;
}
end:
if(p->killed)
exit(-1);
步骤8:最后在copyout(kernel/vm.c)中也要增加页面错误处理,因为copyout是在内核中调用的,缺页不会进入usertrap。注意:要声明外部函数walk。
extern pte_t* walk(pagetable_t, uint64, int);
// 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)
{
uint64 n, va0, pa0;
pte_t* pte; // add
while(len > 0){
va0 = PGROUNDDOWN(dstva);
if(va0 >= MAXVA)
return -1;
if((pte = walk(pagetable, va0, 0)) == 0)
return -1;
if (((*pte & PTE_V) == 0) || ((*pte & PTE_U)) == 0)
return -1;
pa0 = PTE2PA(*pte);
if(((*pte & PTE_W) == 0) && (*pte & PTE_COW)) {
acquire_refcnt();
if(kgetref((void*)pa0) == 1) {
*pte = (*pte | PTE_W) & (~PTE_COW);
} else {
char* mem = kalloc();
if(mem == 0) {
printf("copyout(): memery alloc fault\n");
release_refcnt();
return -1;
}
memmove(mem, (void*)pa0, PGSIZE);
uint newflags = (PTE_FLAGS(*pte) & (~PTE_COW)) | PTE_W;
if(mappages(pagetable, va0, PGSIZE, (uint64)mem, newflags) != 0) {
kfree(mem);
release_refcnt();
return -1;
}
kfree((void*)pa0);
}
release_refcnt();
}
pa0 = walkaddr(pagetable, va0);
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;
}
在mappages函数中会触发一个remap的panic,注释掉这条语句即可,因为COW就是要对页面进行重新映射的。
测试:
执行usertests。
现在运行make grade,看能得多少分。110分!
参考文献:
[1]https://pdos.csail.mit.edu/6.828/2021/labs/cow.html
[2]https://blog.csdn.net/pige666/article/details/108741723
[3]https://www.cnblogs.com/weijunji/p/xv6-study-9.html
[4]https://blog.csdn.net/u013577996/article/details/111972075
[5]https://blog.csdn.net/weixin_44465434/article/details/111566139