前置:
许多内核使用页面错误来实现写时拷贝版本的fork
——copy on write (COW) fork。要解释COW fork,请回忆第3章内容:xv6的fork
通过调用uvmcopy
(kernel/vm.c:309) 为子级分配物理内存,并将父级的内存复制到其中,使子级具有与父级相同的内存内容。如果父子进程可以共享父级的物理内存,则效率会更高。然而武断地实现这种方法是行不通的,因为它会导致父级和子级通过对共享栈和堆的写入来中断彼此的执行。
由页面错误驱动的COW fork可以使父级和子级安全地共享物理内存。当CPU无法将虚拟地址转换为物理地址时,CPU会生成页面错误异常。Risc-v有三种不同的页面错误: 加载页面错误 (当加载指令无法转换其虚拟地址时),存储页面错误 (当存储指令无法转换其虚拟地址时) 和指令页面错误 (当指令的地址无法转换时)。scause
寄存器中的值指示页面错误的类型,stval
寄存器包含无法翻译的地址。
COW fork中的基本计划是让父子最初共享所有物理页面,但将它们映射为只读。因此,当子级或父级执行存储指令时,risc-v CPU引发页面错误异常。为了响应此异常,内核复制了包含错误地址的页面。它在子级的地址空间中映射一个权限为读/写的副本,在父级的地址空间中映射另一个权限为读/写的副本。更新页表后,内核会在导致故障的指令处恢复故障进程的执行。由于内核已经更新了相关的PTE以允许写入,所以错误指令现在将正确执行。
COW策略对fork
很有效,因为通常子进程会在fork
之后立即调用exec
,用新的地址空间替换其地址空间。在这种常见情况下,子级只会触发很少的页面错误,内核可以避免拷贝父进程内存完整的副本。此外,COW fork是透明的: 无需对应用程序进行任何修改即可使其受益。
问题:
xv6中的fork()
系统调用将父进程的所有用户空间内存复制到子进程中。如果父进程较大,则复制可能需要很长时间。更糟糕的是,这项工作经常造成大量浪费;例如,子进程中的fork()
后跟exec()
将导致子进程丢弃复制的内存,而其中的大部分可能都从未使用过。另一方面,如果父子进程都使用一个页面,并且其中一个或两个对该页面有写操作,则确实需要复制。
copy-on-write (COW) fork()的目标是推迟到子进程实际需要物理内存拷贝时再进行分配和复制物理内存页面。
COW fork()只为子进程创建一个页表,用户内存的PTE指向父进程的物理页。COW fork()将父进程和子进程中的所有用户PTE标记为不可写。当任一进程试图写入其中一个COW页时,CPU将强制产生页面错误。内核页面错误处理程序检测到这种情况将为出错进程分配一页物理内存,将原始页复制到新页中,并修改出错进程中的相关PTE指向新的页面,将PTE标记为可写。当页面错误处理程序返回时,用户进程将能够写入其页面副本。
COW fork()将使得释放用户内存的物理页面变得更加棘手。给定的物理页可能会被多个进程的页表引用,并且只有在最后一个引用消失时才应该被释放。
Implement copy-on write
在kernel/riscv.h中选取PTE中的保留位定义标记一个页面是否为COW Fork页面的标志位
// 记录应用了COW策略后fork的页面
#define PTE_COW (1L << 8)
修改uvmcopy()
,使得父进程在调用该函数时将父进程的物理页映射到子进程的页表,而不是直接给子进程的页表分配新的物理页。要设置PTE_COW
(1L >> 8
)来表明这是一个copy-on-write页,在陷入page fault时需要进行特殊处理。将PTE_W
置零,将该物理页的refc
设置为1.
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){
// 获取父进程页表中虚拟地址i对应的页表项指针
if((pte = walk(old, i, 0)) == 0)
panic("uvmcopy: pte should exist"); // 如果页表项不存在,则panic
// 检查页表项是否有效
if((*pte & PTE_V) == 0)
panic("uvmcopy: page not present"); // 如果页表项无效,则panic
pa = PTE2PA(*pte); // 获取物理地址
flags = PTE_FLAGS(*pte); // 获取页表项标志
// 如果页表项有写标志,则处理写时复制(COW)
if (flags & PTE_W) {
flags = (flags | PTE_COW) & (~PTE_W); // 添加COW标志,移除写标志
*pte = PA2PTE(pa) | flags; // 更新页表项
}
// if((mem = kalloc()) == 0)
// goto err;
// memmove(mem, (char*)pa, PGSIZE); // 复制内存数据到新分配的内存
// 将页映射到子进程的页表中
if(mappages(new, i, PGSIZE, pa, flags) != 0){
// kfree(mem); // 释放已分配的内存(如果有)
goto err; // 如果映射失败,跳转到错误处理
}
}
return 0; // 成功返回0
err:
uvmunmap(new, 0, i / PGSIZE, 1); // 取消映射子进程中已映射的页
return -1; // 失败返回-1
}
在usertrap()
中用scause() == 13 || scause() == 15
来判断是否为page fault,当发现是page fault并且r_stval()
的物理页是COW页时,说明需要分配物理页,并重新映射到这个页表相应的虚拟地址上,当无法分配时,需要kill这个进程。注意:需要判断虚拟地址是否是有效的,其中包括需要判断这个虚拟地址是不是处在stack的guard page上,通过va <= PGROUNDDOWN(p->trapframe->sp) && va >= PGROUNDDOWN(p->trapframe->sp) - PGSIZE
进行判断。按道理guard page的PTE应该是设置了PTE_V为0的,但是我尝试判断了这个条件,发现无法通过stacktest,这个问题需要后续确认。
// in trap.c usertrap()
} else if((which_dev = devintr()) != 0){
// ok
} else if (r_scause() == 13 || r_scause() == 15) {
uint64 va = r_stval();
if (va >= MAXVA || (va <= PGROUNDDOWN(p->trapframe->sp) && va >= PGROUNDDOWN(p->trapframe->sp) - PGSIZE)) {
//va不超过最大地址,以及比栈顶指针小,比上一栈顶指针大
p->killed = 1;
} else if (cow_alloc(p->pagetable, va) != 0) {
p->killed = 1;
}
} else {
p->killed = 1;
}
将为COW物理页分配地址的步骤包装成了函数cow_alloc()
,方便后面copyout
的使用。在将新的物理地址映射给页表之前,需要注意设置PTE_W为1,PTE_COW为0,设置完成之后尝试kfree
掉旧的物理页,从而保证当没有任何进程的页表引用这个物理页的情况下这个物理页被释放掉。
int cow_alloc(pagetable_t pagetable, uint64 va) {
uint64 pa;
pte_t *pte;
uint flags;
// 检查虚拟地址是否超过最大虚拟地址范围
if (va >= MAXVA) return -1;
// 将虚拟地址向下舍入到页面边界
va = PGROUNDDOWN(va);
// 找到虚拟地址 va 在页表中的页表项 (PTE)
pte = walk(pagetable, va, 0);
// 如果页表项不存在,返回错误
if (pte == 0) return -1;
// 检查页表项是否有效
if ((*pte & PTE_V) == 0) return -1;
// 获取物理地址
pa = PTE2PA(*pte);
// 如果物理地址为0,返回错误
if (pa == 0) return -1;
// 获取页表项的标志位
flags = PTE_FLAGS(*pte);
// 检查页表项是否标记为写时复制 (COW)
if (flags & PTE_COW) {
// 分配一个新的物理页面
char *mem = kalloc();
if (mem == 0) return -1;
// 将原页面的内容复制到新页面
memmove(mem, (char*)pa, PGSIZE);
// 更新页表项的标志位,将 COW 标志清除,添加可写标志 (PTE_W)
flags = (flags & ~PTE_COW) | PTE_W;
// 将页表项更新为新的物理地址和新的标志位
*pte = PA2PTE((uint64)mem) | flags;
// 释放原来的物理页面
kfree((void*)pa);
return 0;
}
return 0;
}
为了记录每个物理页被多少进程的页表引用,需要在kalloc.c
中定义一个结构体refc
,其中有一个大小为PGROUNDUP(PHYSTOP)/PGSIZE
的int array来存放每个物理页的引用数。由于这个结构体是被所有的进程共享的,因此需要用一个spinlock进行保护
// struct to maintain the ref counts
struct {
struct spinlock lock;
int count[PGROUNDUP(PHYSTOP) / PGSIZE];
} refc;
初始化这个结构体,初始化这个锁,以及将数组所有元素置0。定义一些增加/减少/获取refc.count
的函数,最后将refc
的初始化函数放在kinit
里
void
refcinit()
{
initlock(&refc.lock, "refc");
for (int i = 0; i < PGROUNDUP(PHYSTOP) / PGSIZE; i++) {
refc.count[i] = 0;
}
}
void
refcinc(void *pa)
{
acquire(&refc.lock);
refc.count[PA2IDX(pa)]++;
release(&refc.lock);
}
void
refcdec(void *pa)
{
acquire(&refc.lock);
refc.count[PA2IDX(pa)]--;
release(&refc.lock);
}
int
getrefc(void *pa)
{
return refc.count[PA2IDX(pa)];
}
void
kinit()
{
refcinit();
initlock(&kmem.lock, "kmem");
freerange(end, (void*)PHYSTOP);
}
在进行kalloc
时,将对该物理页的引用加一
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
refcinc((void*)r);
}
return (void*)r;
}
相应的,在进行kfree
时,要对该物理页的引用减一,然后再判断对该物理页的引用是否已经为0,如果已经为0,则将该物理页push回freelist
// Free the page of physical memory pointed at by v,
// which normally should have been returned by a
// call to kalloc(). (The exception is when
// initializing the allocator; see kinit above.)
void
kfree(void *pa)
{
struct run *r;
if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
panic("kfree");
refcdec(pa);
if (getrefc(pa) > 0) return;
// 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);
}
最后要注意,一开始进行kinit
的时候调用了freerange(end, (void*)PHYSTOP)
,里面对所有物理页都进行了一次kfree
,由于之前没有进行过kalloc
,所以会导致每一页的初始refc.count
都变成-1,因此需要在kinit
时再给每一个物理页的refc.count
加1。
void
kinit()
{
refcinit();
initlock(&kmem.lock, "kmem");
freerange(end, (void*)PHYSTOP);
char *p;
p = (char*)PGROUNDUP((uint64)end);
for(; p + PGSIZE <= (char*)PHYSTOP; p += PGSIZE) {
refcinc((void*)p);
}
}
最后, 由于copyout
函数直接将kernel中的物理地址的内容复制给了用户进程中的物理地址, 没有经过mmu, 也无法进入page fault, 因此当将内容复制到用户进程的虚拟地址时,会将原来的内容覆盖掉而不是进行cow,因此需要修改copyout
,调用cow_alloc
// in kernel/vm.c copyput()
uint64 n, va0, pa0;
while(len > 0){
va0 = PGROUNDDOWN(dstva);
if (cow_alloc(pagetable, va0) != 0) {
return -1;
}
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);