实验内容网址:https://xv6.dgs.zone/labs/requirements/lab6.html
本实验的代码分支:https://gitee.com/dragonlalala/xv6-labs-2020/tree//
Implement copy-on write
关键点: 内存引用计数、usertrap()、页表
思路:
Copy on write 是为了优化在fork()时,需要申请大量的物理内存但可能不使用的情况。这样就浪费了不必要的申请内存的时间以及浪费了内存(虽然进程杀死时会回收)。copy on write 的思路就是fork()时,子进程不申请新的物理内存,而是将子进程的页表映射到父进程的物理内存,同时将这段物理内存设置为不可写。当任一进程试图写入其中一个物理页时,CPU将强制产生页面错误。与上个实验一样,会使得usertrap中r_scause为13或15。如何区分是copy on write还是lazy allocation呢,教程提示我们使用RSW标志位(如下图所示),RSW标志位占2个bit,当然我们本题中只使用1位便可以。RSW标志位就是保留给supervisor software使用,即内核使用。
usertrap需要做的就是根据内存引用计数申请新的物理内存,然后将出错的虚拟地址映射到新的物理地址上,并将数据拷贝到新的物理内存上。
这里说到内存引用计数。因为COW fork()将使得释放用户内存的物理页面变得更加棘手。给定的物理页可能会被多个进程的页表引用,并且只有在最后一个引用消失时才应该被释放。因此需要有一个全局的数组来记录每一页物理内存的被引用数量,如果数量为0,才能进行释放。
usertrap中,需要根据内存引用计数的情况来决定是否需要申请物理内存,如果计数器不为1,可能有多个进程引用了同一段物理内存,那么需要申请新的物理内存、内存拷贝、页表映射等操作。如果计数器为1,则可能是父进程产生了页面错误,因为内存只剩1个引用,这时需要对物理内存进行恢复写权限,清除RSW标志位等操作。
步骤&代码:
- 修改
uvmcopy()
将父进程的物理页映射到子进程,而不是分配新页。在子进程和父进程的PTE中清除PTE_W
标志。- 修改
usertrap()
以识别页面错误。当COW页面出现页面错误时,使用kalloc()
分配一个新页面,并将旧页面复制到新页面,然后将新页面添加到PTE中并设置PTE_W
。- 确保每个物理页在最后一个PTE对它的引用撤销时被释放——而不是在此之前。这样做的一个好方法是为每个物理页保留引用该页面的用户页表数的“引用计数”。当
kalloc()
分配页时,将页的引用计数设置为1。当fork
导致子进程共享页面时,增加页的引用计数;每当任何进程从其页表中删除页面时,减少页的引用计数。kfree()
只应在引用计数为零时将页面放回空闲列表。可以将这些计数保存在一个固定大小的整型数组中。你必须制定一个如何索引数组以及如何选择数组大小的方案。例如,您可以用页的物理地址除以4096对数组进行索引,并为数组提供等同于kalloc.c中kinit()
在空闲列表中放置的所有页面的最高物理地址的元素数。- 修改
copyout()
在遇到COW页面时使用与页面错误相同的方案。
上面是官方给出的解决方案的步骤,我们也可以按照这个步骤进行编程。
- 修改
uvmcopy()
将父进程的物理页映射到子进程,而不是分配新页。原来uvmcopy()
是将虚拟地址[0, sz]这个区间对应的物理内存的数据拷贝到新的物理内存中。现在不需要在这里申请新的物理内存,只需要将页表与父进程的物理内存进行映射就行,同时在子进程和父进程的PTE中清除PTE_W
标志,设置RSW标志位。
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);
// 清除PTE_W标志
flags &= (~PTE_W);
// 添加PTE_RSW标志
flags |= PTE_RSW;
// 清除父进程PTE的PTE_W标志
*pte &= (~PTE_W);
// 父进程PTE添加PTE_RSW标志
*pte |= PTE_RSW;
// 将父进程的物理内存映射到子进程的虚拟内存
if(mappages(new, i, PGSIZE, (uint64)pa, flags) != 0){
// kfree((void*)pa);
goto err;
}
// 映射成功,父进程的物理内存引用计数增加
mem_count_up(pa);
}
return 0;
err:
uvmunmap(new, 0, i / PGSIZE, 1);
return -1;
}
上面的代码中出现了PTE_RSW
标志位,需要在riscv.h
中进行定义
#define PTE_X (1L << 3)
#define PTE_U (1L << 4) // 1 -> user can access
#define PTE_RSW (1L << 8) // 用这个标志位来表示cow的页面错误
上面的代码还调用了mem_count_up(pa);
函数,这个是增加物理内存引用计数。是自定义的函数,接下来将进行讲解。
题目提示我们,可以用页的物理地址除以4096对数组进行索引,并为数组提供PHYSTOP/PGSIZE
个元素。为什么是PHYSTOP/PGSIZE
呢?笔者认为这样有利于索引,xv6中内存是进行页式管理的,这对虚拟内存或者物理内存都一样。使用数组时只需要将物理地址/PGSIZE便可以得到数组下标,从而调整该物理地址(内存)的引用计数。参考kmem结构体对内存引用结构体进行定义。在kalloc.c
文件中定义如下。同时定义了一些可能会用到的函数,比如增加引用计数、减少引用计数(若减少到0函数会返回真)、将引用计数设置为1,获取引用计数值。需要注意的是锁的使用,使用锁后需要及时释放锁。笔者认为封装这些函数除了方便调用,还可以避免写代码时锁忘记释放的情况。
kalloc.c文件中:
// 内存引用计数的结构体
struct
{
struct spinlock lock;// 若有多个进行同时对数组进行操作,需要上锁
int mem_count[PHYSTOP/PGSIZE];
}mem_ref_struct;
int get_mem_count(uint64 pa){
int count;
acquire(&mem_ref_struct.lock);
count = mem_ref_struct.mem_count[(uint64)pa / PGSIZE];
release(&mem_ref_struct.lock);
return count;
}
void mem_count_up(uint64 pa){
acquire(&mem_ref_struct.lock);
++ mem_ref_struct.mem_count[(uint64)pa / PGSIZE];
release(&mem_ref_struct.lock);
}
int mem_count_down(uint64 pa){
int flag = 0;
acquire(&mem_ref_struct.lock);
if((-- mem_ref_struct.mem_count[(uint64)pa / PGSIZE]) == 0){
flag = 1;
}
release(&mem_ref_struct.lock);
return flag;
}
void mem_count_set_one(uint64 pa){
acquire(&mem_ref_struct.lock);
mem_ref_struct.mem_count[(uint64)pa / PGSIZE] = 1;
release(&mem_ref_struct.lock);
}
-
修改
usertrap()
以识别页面错误。usertrap中,首先需要确定出错的虚拟地址是否来自cow,如果是则需要根据内存引用计数的情况来决定是否需要申请物理内存,如果计数器不为1,可能有多个进程引用了同一段物理内存,那么需要申请新的物理内存、内存拷贝、页表映射等操作。如果计数器为1,则可能是父进程产生了页面错误,因为内存只剩1个引用,这时需要对物理内存进行恢复写权限,清除RSW标志位等操作。- 需要注意的第一点是:这个过程中如果出现了失败则需要立即设置p->killed为1,然后goto到end处,退出并杀死进程。第二点是申请的新物理内存想要进行映射前需要将虚拟地址与旧的物理内存进行解绑,使用
uvmunmap
函数。
- 需要注意的第一点是:这个过程中如果出现了失败则需要立即设置p->killed为1,然后goto到end处,退出并杀死进程。第二点是申请的新物理内存想要进行映射前需要将虚拟地址与旧的物理内存进行解绑,使用
void
usertrap(void)
{
int which_dev = 0;
if((r_sstatus() & SSTATUS_SPP) != 0)
panic("usertrap: not from user mode");
// send interrupts and exceptions to kerneltrap(),
// since we're now in the kernel.
w_stvec((uint64)kernelvec);
struct proc *p = myproc();
// save user program counter.
p->trapframe->epc = r_sepc();
if(r_scause() == 8){
// system call
if(p->killed)
exit(-1);
// sepc points to the ecall instruction,
// but we want to return to the next instruction.
p->trapframe->epc += 4;
// an interrupt will change sstatus &c registers,
// so don't enable until done with those registers.
intr_on();
syscall();
} else if((which_dev = devintr()) != 0){
// ok
} else if(r_scause() == 13 || r_scause() == 15){
// 页面错误处理
// 获取出现页面错误的虚拟地址
uint64 va = r_stval();
// 获取当前进程
struct proc* p = myproc();
uint64 fault_pa;
pte_t* pte;
// 检验页面错误是否来自cow,如果是,那么父进程的物理地址中的PTE_W标志位复位,子进程要申请新的物理内存
if((pte = cow_walk(p->pagetable, PGROUNDDOWN(va))) == 0){
printf("usertrap: not cow page fault\n");
p->killed = 1;
goto end;
}
fault_pa = PTE2PA(*pte);
if(get_mem_count(fault_pa) == 1){
// 说明是父进程(可以这么理解,cow的子进程和父进程都会产生page fault ,子进程页面错误后经过新分配物理
// 内存,后会将内存的计数减1,假设只有父进程,子进程,则计数器为1,那么父进程产生页面错误时计数器就为1,
// 即进入分支。这里要做的是将pte中的标志位复位。
*pte |= PTE_W;
// 去除PTE_RSW标志位
*pte &= ~PTE_RSW;
}else{
// 分配新的物理地址
char* child_pa = kalloc();
if(child_pa == 0){
printf("alloc physical memory failed");
p->killed = 1;
goto end;
}
// 将旧物理内存copy到新的物理内存
memmove(child_pa, (char*)fault_pa, PGSIZE);
// 子进程页表解除与原来父进程的物理内存映射
uvmunmap(p->pagetable, PGROUNDDOWN(va), 1, 0);
// 映射, 将出错的虚拟地址向下舍入到页面边界,因为va所在的这一页还没有对应的物理内存
if(mappages(p->pagetable, PGROUNDDOWN(va), PGSIZE, (uint64)child_pa, PTE_W|PTE_X|PTE_R|PTE_U) != 0){
kfree(child_pa);
p->killed = 1;
goto end;
}
// 子进程有新的物理内存了,需要对父进程物理内存的引用计数减1
kfree((void*)fault_pa);
}
}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);
// give up the CPU if this is a timer interrupt.
if(which_dev == 2)
yield();
usertrapret();
}
上面代码中使用了pte = cow_walk(p->pagetable, PGROUNDDOWN(va))
函数,是一个自定义的函数,在vm.c
文件中进行定义。仿照walkaddr函数写一个函数用来检验虚拟地址是否是来自copy on write。注意其中添加了一个对PTE_RSW
位的检查,这是关键。
// 仿照walkaddr函数写一个函数用来检验虚拟地址是否是来自copy on write
pte_t*
cow_walk(pagetable_t pagetable, uint64 va)
{
pte_t *pte;
if(va >= MAXVA)
return 0;
pte = walk(pagetable, va, 0);
if(pte == 0)
return 0;
if((*pte & PTE_V) == 0)
return 0;
if((*pte & PTE_U) == 0)
return 0;
// 检查是否来自cow的页面错误
if((*pte & PTE_RSW) == 0)
return 0;
return pte;
}
上面代码中还使用了 kfree((void*)fault_pa);
函数。这是一个经过修改的函数,具体将在下面讲解。
- 内存引用计数相关的步骤在第一步已经做了相关的定义,接下来是一些使用的地方。
首先要对内存引用锁进行初始化,在kalloc.c
文件中修改kinit()
函数,初始化自旋锁。
void
kinit()
{
initlock(&kmem.lock, "kmem");
// 初始化mem_ref_struct的锁
initlock(&mem_ref_struct.lock, "mem_ref");
freerange(end, (void*)PHYSTOP);
}
在申请内存kalloc()
函数,释放内存kfree()
函数中,进行如下修改:
void *
kalloc(void)
{
struct run *r;
acquire(&kmem.lock);
r = kmem.freelist;
if(r){
kmem.freelist = r->next;
// 设置内存引用为1
mem_count_set_one((uint64)r);
}
release(&kmem.lock);
if(r)
memset((char*)r, 5, PGSIZE); // fill with junk
return (void*)r;
}
void
kfree(void *pa)
{
struct run *r;
if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
panic("kfree");
r = (struct run*)pa;
if(mem_count_down((uint64)pa) == 1){
// 说明内存引用为0, 需要释放物理内存
// Fill with junk to catch dangling refs.
memset(pa, 1, PGSIZE);
acquire(&kmem.lock);
r->next = kmem.freelist;
kmem.freelist = r;
release(&kmem.lock);
}
}
坑:
freerange函数中调用了kfree,这个函数在系统内存初始化的时候调用,而且是在没有kalloc的前提下调用的,因为我们修改了kfree函数的逻辑,所以freerange函数中要先将内存引用计数置1。
void
freerange(void *pa_start, void *pa_end)
{
char *p;
p = (char*)PGROUNDUP((uint64)pa_start);
for(; p + PGSIZE <= (char*)pa_end; p += PGSIZE){
// 系统初始化时会将内存引用减1,所以这里先设为1
mem_count_set_one((uint64)p);
kfree(p);
}
}
- 下面对copyout函数进行修改。这里就是将内核物理内存copy到用户物理内存前需要检查一下用户物理内存(dst)是不是COW页面,如果时则需要申请新的用户物理内存。
这里只需要改动
copyout
而不需要改copyin
是因为前者是内核拷贝到用户,是会对一个用户页产生写的操作,而后者是用户拷到内核,只是去读这个用户页的内容,COW页允许读。
// 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;
uint64 fault_pa;
// 检验页面错误是否来自cow,若不为0,则为cow,需要分配新的物理内存
if((pte = cow_walk(pagetable, PGROUNDDOWN(dstva))) != 0){
fault_pa = PTE2PA(*pte);
// 分配新的物理地址
char* child_pa = kalloc();
if(child_pa == 0){
printf("copyout: alloc physical memory failed");
return -1;
}
// 将旧物理内存copy到新的物理内存
memmove(child_pa, (char *)fault_pa, PGSIZE);
// 子进程页表解除与原来父进程的物理内存映射
uvmunmap(pagetable, PGROUNDDOWN(dstva), 1, 0);
// 映射, 将出错的虚拟地址向下舍入到页面边界,因为va所在的这一页还没有对应的物理内存
if(mappages(pagetable, PGROUNDDOWN(dstva), PGSIZE, (uint64)child_pa, PTE_W|PTE_X|PTE_R|PTE_U) != 0){
kfree(child_pa);
return -1;
}
// 内存计数减1,这里可能发生内存释放
kfree((void*)fault_pa);
}
while(len > 0){
va0 = PGROUNDDOWN(dstva);
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;
}
最后,一些函数需要在defs.h中进行声明。如下:
int get_mem_count(uint64 pa);
void mem_count_up(uint64 pa);
int mem_count_down(uint64 pa);
void mem_count_set_one(uint64 pa);
pte_t* cow_walk(pagetable_t , uint64 );
参考:
Lab6: Copy-on-Write Fork for xv6 详解_lab: copy-on-write fork for xv6-CSDN博客