xv6学习,Lab5-Copy-on-Write Fork for xv6
五、Lab5: Copy-on-Write Fork for xv6
虚拟内存提供了一种间接性:内核可以通过将页表项标记为无效或只读来拦截内存引用,从而导致页面错误,并且可以通过修改页表项来改变地址的含义。在 xv6 中,fork()
系统调用会将父进程的所有用户空间内存复制到子进程中。如果父进程很大,复制过程可能会花费很长时间。更糟糕的是,这项工作往往在很大程度上是浪费的:在子进程中,fork()
之后通常会跟着 exec()
,它会丢弃复制的内存,而且通常大部分都不会被使用。另一方面,如果父进程和子进程都使用一个复制的页面,并且其中一个或两个进程对其进行写入操作,那么这个复制确实是有必要的。
您在实现写时复制(COW)的 fork()
函数时的目标是,将物理内存页面的分配和复制推迟到实际需要这些副本时(如果需要的话)。 写时复制的 fork()
函数只为子进程创建一个页表,子进程的用户内存页表项(PTE)指向父进程的物理页面。写时复制的 fork()
函数将父进程和子进程中的所有用户 PTE 标记为只读。当任一进程试图写入这些写时复制页面中的一个时,CPU 将强制产生一个页面错误。内核的页面错误处理程序会检测到这种情况,为出错的进程分配一个物理内存页面,将原始页面的内容复制到新页面中,并修改出错进程中相关的 PTE,使其指向新页面,这次将 PTE 标记为可写。当页面错误处理程序返回时,用户进程将能够写入其页面副本。 写时复制的 fork()
函数使得释放实现用户内存的物理页面变得稍微复杂一些。一个给定的物理页面可能会被多个进程的页表引用,并且只有当最后一个引用消失时才应该被释放。
1.Implement copy-on-write fork()
您的任务是在 xv6 内核中实现上述写时复制的 fork 功能。为了帮助测试,提供了 user/cowtest.c ,cowtest 会运行各种测试。
根据提示,为每个PTE有记录其是否为COW映射,可以使用 RISC-V PTE 中的 RSW位来实现此目的。根据下图,RSW位为第8和第9位,我们只需要使用第8位,去riscv.h中进行定义。
1
2// kernel/riscv.h
#define PTE_RSW (1L << 8)修改
uvmcopy()
函数,将父进程的物理页映射到子进程中,而不是分配新的页面。对于设置了PTE_W
的页面,清除子进程和父进程的页表项(PTE)中的PTE_W
标志,这样这些页面就变成了一个“只读”页面,当触发写操作时就会进入中断。基本上有关页表的操作都在kernel/vm.c中, 现在对uvmcopy()
进行修改,如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36// kernel/vm.c
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");
// lab5-1,对PTE_W置位进行清除
flags = PTE_FLAGS(*pte);
pa = PTE2PA(*pte);
// PTE_RSW置位
if(*pte & PTE_W){
flags = (flags & (~PTE_W)) | PTE_RSW;
*pte = (*pte & (~PTE_W)) | PTE_RSW;
}
// 这里原先直接分配了物理页面,我们把这一部分删除
// 由于引用数组位于kalloc.c,我们在kalloc.c中编写一个额外的方法
// 最后把页表映射给子进程
if(mappages(new, i, PGSIZE, (uint64)pa, flags) != 0){
goto err;
}
// 这里要把引用数组对应的值加一
refarrayadd((uint64)pa);
}
return 0;
err:
uvmunmap(new, 0, i / PGSIZE, 1);
return -1;
}确保当最后一个指向某个物理页面的页表项引用消失时(且仅在此时),该物理页面才会被释放。一个好的方法是为每个物理页面维护一个“引用计数”,表示有多少个用户页表引用了该页面。当
kalloc()
分配一个页面时,将其引用计数设置为 1。当fork
操作导致子进程共享该页面时,增加该页面的引用计数,当任何进程从其页表中删除该页面时,减少该页面的计数。只有当页面的引用计数为 0 时,kfree()
才应将该页面放回空闲列表。将这些计数保存在一个固定大小的整数数组中,为此定义了一个ref结构体和refarray数组。可以自由修改kalloc.c
(例如,kalloc()
和kfree()
)来维护引用计数。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88// kernel/kalloc.c
// 定义引用数组
// 数组大小是页面的个数,对应第三章页表中学习过的PHYSTOP - KERNBASE这部分地址
// 再除以页面大小就是数组的上限
// 除此之外还得定义这个数组的自旋锁
// 防止同一个页面被申请两次
struct {
struct spinlock lock;
struct run *freelist;
} kmem;
// lab5-1
// 类似kmem,定义一个ref结构体
// 定义锁和引用数组
struct {
struct spinlock reflock;
int refarray[(PHYSTOP -KERNBASE) / PGSIZE];
} ref;
void
kinit()
{
// 初始化刚才定义的
initlock(&kmem.lock, "kmem");
initlock(&ref.reflock, "ref");
freerange(end, (void*)PHYSTOP);
}
void
kfree(void *pa)
{
struct run *r;
if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
panic("kfree");
// 如果引用数组大于1,我们不需要真正的释放这片内存,只需要把对应数组减一即可
if(ref.refarray[((uint64)pa -KERNBASE) / PGSIZE] > 1){
acquire(&ref.reflock);
ref.refarray[((uint64)pa -KERNBASE) / PGSIZE]--;
release(&ref.reflock);
}else{
memset(pa, 1, PGSIZE);
r = (struct run*)pa;
acquire(&kmem.lock);
r->next = kmem.freelist;
kmem.freelist = r;
release(&kmem.lock);
}
}
// 物理地址pa的引用数组对应值加1
void
refarrayadd(uint64 pa){
acquire(&ref.reflock);
ref.refarray[(pa - KERNBASE) / PGSIZE]+=1;
release(&ref.reflock);
}
// 获取物理地址pa对应引用数组的值
int
getrefnum(uint64 pa){
int num;
acquire(&ref.reflock);
num = ref.refarray[(pa -KERNBASE) / PGSIZE];
release(&ref.reflock);
return num;
}
void *
kalloc(void)
{
struct run *r;
acquire(&kmem.lock);
r = kmem.freelist;
if(r){
kmem.freelist = r->next;
// lab5-1
// 引用数组对应值初始化为1
acquire(&ref.reflock);
ref.refarray[((uint64)r -KERNBASE) / PGSIZE] = 1;
release(&ref.reflock);
}
release(&kmem.lock);
if(r)
memset((char*)r, 5, PGSIZE);
return (void*)r;
}修改
usertrap()
函数,使其能够识别页面错误。当对原本可写的COW页面发生写页面错误时,使用kalloc()
分配一个新页面,将旧页面的内容复制到新页面,并将新页面安装到设置了PTE_W
的 PTE 中。原本只读的页面(未映射PTE_W
,如文本段中的页面)应保持只读状态,并在父进程和子进程之间共享;试图写入这样页面的进程应被终止。在官方文档第49页中有如下说明:RISC-V 区分三种页面错误:加载页面错误(当加载指令无法转换其虚拟地址时)、存储页面错误(当存储指令无法转换其虚拟地址时)和指令页面错误(当程序计数器中的地址无法转换时)。scause 寄存器指示页面错误的类型,stval 寄存器包含无法转换的地址。
也就是说,应该先判断SCAUSE寄存器的值,如下图所示,其存储发生中断的原因,是写页面时发生的错误,SCAUSE应为15。之后再从STVAL寄存器中取出无法转换的地址进行处理。
实现这一部分是这个实验的难点,kernel/vm.c中是涉及虚拟地址相关的操作,我们在trap中获取了写页面失败时的地址,但不能直接对其进行操作,应该在vm.c中定义一个新的函数对这个地址进行处理,使用walk函数获取地址对应PTE,并判断其合法性以及是否为COW页,若为COW页就为其分配物理内存并更改引用数组。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82// kernel/vm.c
// 这个函数用于处理传入的异常地址
// 返回-1说明出错,返回0说明正常
int tacklecowaddr(pagetable_t pagetable,uint64 addr)
{
// 一定要确保页面对齐
uint64 va = PGROUNDDOWN(addr);
if(va>=MAXVA)
return -1;
// 使用walk找到异常虚拟地址对应的PTE
pte_t *pte = walk(pagetable, va, 0);
if(pte==0)
return -1;
uint64 flags = PTE_FLAGS(*pte);
//转换物理地址
uint64 pa = PTE2PA(*pte);
if(pa==0)
return -1;
//如果是COW页面(PTE_RSW置位)
if(flags&PTE_RSW)
{
// 用kalloc分配空间
uint64 mem = (uint64)kalloc();
if(mem==0){
printf("tacklecowaddr : kalloc error!");
return -1;
}
// 用memmove将pa处的页面复制道新申请的地址处
memmove((char *)mem, (char *)pa, PGSIZE);
// 解除原先的页面映射
uvmunmap(pagetable, va, 1, 1);
flags = (flags | PTE_W) & ~PTE_RSW;
// 为新的映射创建PTE
if(mappages(pagetable,va,PGSIZE,mem,flags)!=0)
{
printf("tacklecowaddr : mappage error!");
kfree((void *)mem);
return -1;
}
}
return 0;
}
// kernel/trap.c
void
usertrap(void)
{
int which_dev = 0;
if((r_sstatus() & SSTATUS_SPP) != 0)
panic("usertrap: not from user mode");
w_stvec((uint64)kernelvec);
struct proc *p = myproc();
p->trapframe->epc = r_sepc();
if(r_scause() == 8){
if(killed(p))
exit(-1);
p->trapframe->epc += 4;
intr_on();
syscall();
} else if((which_dev = devintr()) != 0){
// ok
} else if(r_scause() == 15){
// lab5-1
// COW页面发生写页面错误,先获取其错误的页面地址
uint64 faultaddr = r_stval();
// 调用我们在vm.c中编写的tacklecowaddr函数处理地址
if(tacklecowaddr(p->pagetable,faultaddr) < 0 ){
setkilled(p);
}
}else {
printf("usertrap(): unexpected scause %d pid=%d\n", r_scause(), p->pid);
printf(" sepc=%p stval=%p\n", r_sepc(), r_stval());
setkilled(p);
}
if(killed(p))
exit(-1);
if(which_dev == 2)
yield();
usertrapret();
}修改
copyout()
函数,使其在遇到写时复制页面时使用与页面错误处理相同的方案。除此之外要把新定义的几个函数写进defs.h里。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27int
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;
// lab5-1
if(tacklecowaddr(pagetable,va0)<0){
printf("copyout : tacklecowaddr error!");
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;
}解决各种编译报错后运行qemu,发现仍有各种报错,解决各种指针问题后无报错信息。但运行 cowtest 测试后仍然报错,发现了kfree 和 tacklecowaddr 中出现了错误。
上方代码已修改。