本系列文章为MIT6.S081的学习笔记,包含了参考手册、课程、实验三部分的内容,前面的系列文章链接如下
操作系统MIT6.S081:[xv6参考手册第1章]->操作系统接口
操作系统MIT6.S081:[xv6参考手册第2章]->操作系统组织结构
操作系统MIT6.S081:[xv6参考手册第3章]->页表
操作系统MIT6.S081:[xv6参考手册第4章]->Trap与系统调用
操作系统MIT6.S081:P1->Introduction and examples
操作系统MIT6.S081:P2->OS organization and system calls
操作系统MIT6.S081:P3->Page tables
操作系统MIT6.S081:P4->RISC-V calling conventions and stack frames
操作系统MIT6.S081:P5->Isolation & system call entry/exit
操作系统MIT6.S081:P6->Page faults
操作系统MIT6.S081:P7->Interrupts
操作系统MIT6.S081:P8->Multiprocessors and locking
操作系统MIT6.S081:Lab1->Unix utilities
操作系统MIT6.S081:Lab2->System calls
操作系统MIT6.S081:Lab3->Page tables
操作系统MIT6.S081:Lab4->Trap
操作系统MIT6.S081:Lab5->Lazy allocation
前言
前言
----xv6中的fork()系统调用将父进程的所有用户空间内存复制一份给子进程。如果父进程的内存空间很大,则复制操作可能需要很长时间。更糟糕的是,复制操作经常会造成资源的大量浪费。例如,在执行fork()创建一个子进程后,若子进程执行exec(),这将导致子进程丢弃复制的内存,因此这个过程中可能不会使用复制的内存中的大部分。另一方面,如果父子进程都使用一个页面,并且这一个或两个进程都要写它,那么这时就确实需要一个副本。
----虚拟内存提供了一层抽象:内核可以通过将PTE标记为无效或只读来拦截内存引用,从而导致page fault,并且可以通过修改PTE来更改地址的含义。计算机系统中有一种说法,任何系统问题都可以通过一层抽象来解决。Lazy allocation实验为该说法提供了一个示例。本实验探讨另一个示例:copy-on write fork。
要启动实验,请切换到cow分支:
copy-on-write fork
----copy-on-write(COW) fork()的目标是推迟为子进程分配和复制物理内存页面,直到真正需要副本。
----COW fork()只为子进程创建一个页表,用户内存的PTE指向父进程的物理页面。COW fork()将父进程和子进程中的所有用户PTE标记为不可写。当任一进程尝试写入这些COW页之一时,CPU将强制发生page fault。内核的page fault处理程序检测到这种情况,为出错进程分配物理内存页面,将原始页面复制到新页面中,并修改出错进程中的相关PTE以引用新页面,将这次使用的PTE标记为可写。当page fault处理程序返回时,用户进程将能够写入它的页面副本。
----COW fork()使实现用户内存的物理页面的释放变得有点棘手。一个给定的物理页可以被多个进程的页表引用,并且只有在最后一个引用消失时才应该被释放。
实验目的
任务: 你的任务是在xv6内核中实现copy-on-write fork。如果你修改的内核成功地执行了cowtest和usertests程序,你就成功完成了该任务。
测试条件: 为了帮助你测试你的方案,我们提供了一个名为cowtest的xv6程序(源代码在user/cowtest.c中)。cowtest运行各种测试,但即使是第一个测试也会在未修改的xv6上失败。因此,最初你将看到:
“simple”测试分配了一半以上的可用物理内存,然后执行fork()。fork失败是因为没有足够的可用物理内存为子进程保存完整的父进程内存副本。
当你完成后,你的内核应该通过了cowtest和usertests中的所有测试,如下所示:
合理的攻克计划
①修改uvmcopy()以将父进程的物理页面映射到子进程,而不是分配新页面。在子级和父级的PTE中清除PTE_W。
②修改usertrap()以识别page fault。当COW页面发生缺页时,使用kalloc()分配新页面,将旧页面复制到新页面,并将新页面安装到PTE中并设置PTE_W。
③确保每个物理页面在对它的最后一个PTE引用消失时被释放。做到这一点的一个好方法是为每个物理页保留一个“引用计数”,该“引用计数”是指引用该页的用户页表的数量。当 kalloc() 分配页面时,将页面的引用计数设置为1。当fork导致子进程共享页面时增加页面的引用计数,并在每次任何进程从其页表中删除页面时减少页面的计数。kfree() 只应在其引用计数为0时将页面放回空闲列表。可以将这些计数保存在固定大小的整数数组中。你必须制定一个方案来确定如何索引数组以及如何选择其大小。例如,你可以使用页的物理地址除以4096来索引数组,并为数组提供等于kalloc.c 中 kinit() 放置在空闲列表中的任何页的最高物理地址的元素数。
④修改copyout()以在遇到COW页面时使用与页面错误相同的方案。
实验提示
①lazy page allocation实验可能让您熟悉与写时复制相关的大部分xv6内核代码。但是,你不应该基于lazy page allocation实验来完成本实验的解决方案。相反,请按照上面的说明从xv6的新副本开始。
②对于每个PTE,有一种方法来记录它是否是COW映射可能很有用。为此,你可以使用RISC-V PTE中的RSW(为软件保留)位。
③usertests探索了cowtest没有测试的场景,所以不要忘记检查所有测试是否都通过了。
④一些有用的宏和页表标志定义在kernel/riscv.h的末尾。
⑤如果发生COW页面错误并且没有可用内存,则应终止该进程。
整体思路
①标记COW页面
要实现COW机制,我们首先需要标记一个页面是否为COW页面。
②引用计数(准备工作)
要实现COW机制,还需要做一些准备工作。
----COW机制推迟为子进程分配和复制物理内存页面,直到真正需要时才会调用kalloc进行分配。使用完成后不需要该物理内存页面时,调用kfree进行释放。
----值得注意的是,一个给定的物理页可以被多个进程的页表引用,并且只有在最后一个引用消失时才应该被释放。因此,不能直接调用kalloc和kfree进行分配与释放,还需要标记物理页的引用数。在进行kalloc分配时,将引用数初始化为1。在进行kfree释放时,只能等引用数为0时才能释放。
----当有一个新的进行引用物理页时,需要将引用数加1。
----由于对物理页的引用计数必须是原子操作,所以需要使用锁。
总结: 需要定义一个引用计数数据结构,包含一个成员数组用于记录所有物理页面的引用数,同时包含一把锁用于保证引用数增减操作的原子性。需要定义物理页面引用数的增减操作。需要在kalloc和kfree中增加对引用数的操作。需要对锁进行初始化。
③uvmcopy(完成COW机制的延迟分配物理页面)
fork系统调用通过uvmcopy将父进程的内存复制给子进程,现在修改uvmcopy将父进程的物理页面映射给子进程,而不是新分配内存,同时将这些物理页面标记为COW页面且不可写。
④usertrap、copyout(完成COW机制的page fault处理程序)
当用户态使用COW页面时,在usertrap完成对page fault的处理
当内核态使用COW页面时,在copyout完成对page fault的处理
RSW标志位
根据实验提示②,对于COW机制下的物理页,需要根据对应的虚拟页的PTE标记位进行区分。在引发page fault时识别出是COW机制,并进行新物理页的分配。根据实验提示④,PTE标志位的定义在kernel/riscv.h末尾,除此之外还有一些有用的宏。
查看RISC V的PTE标志位,可以看到RSW在第9、10位,我们这里使用第9位。
因此,添加#define PTE_COW (1L << 8)
用来标记是否为COW Fork页面。
思考
根据攻克计划③的提示,可以使用数组记录每个物理页面对应的引用数量。同时,多个进程有可能对同一父进程进行fork()等操作,从而引起引用计数的变化,因此需要锁结构进行数据一致性的保护。基于以上分析,我们在kernel/kalloc.c中定义一个全局的结构体,里面包含引用计数数组和锁。
引用计数数据结构
①引用计数数组
----数组的容量
将最大物理地址PHYSTOP
右移12位(相当于除以4096,即一个物理页面的大小)即可,即数组的容量为PHYSTOP >> 12
。
注: 实际上物理地址KERNBASE
以下映射的是外设,不涉及COW机制,可以将数组容量缩小至(PHYSTOP-KERNBASE)>>12
节省空间,我这里没有进行缩小。
----数组的数据类型
数组中每个元素需要记录对应物理页面的引用数量,xv6最多可分配的进程数NPROC
为64,所以使用8 bit的uint8
数据类型存储每个物理页面的引用数量即可。
②自旋锁
由于此处引用计数的变化比较简单,且考虑到实现的难度,因此考虑使用全局的自旋锁。在XV6中,引用计数数组中的不同元素(即不同物理页)的引用计数之间不存在并发问题,因此不再对每一个物理页的引用计数对应一个自旋锁,节省内存开销。
引用计数数据结构代码
struct pagerefcnt {
struct spinlock lock;
uint8 refcount[PHYSTOP / PGSIZE];
} ref;
当引用物理页面的用户页表的数量增加或减少时,需要在引用计数数组中进行记录,在kernel/kalloc.c中定义对应的函数。
void incref(uint64 va) {
acquire(&ref.lock);
if(va < 0 || va > PHYSTOP) panic("wrong virtual address");
ref.refcount[va / PGSIZE]++;
release(&ref.lock);
}
由于后续要在uvmcopy函数中使用该函数,因此将该函数的声明放在kernel/defs.h中。
修改kernel/kalloc.c中的kfree,当引用计数为0时才将页面放回空闲列表,否则直接返回。
void
kfree(void *pa)
{
//......
// Fill with junk to catch dangling refs.
//添加的内容
acquire(&ref.lock);
if(--ref.refcount[(uint64)pa / PGSIZE] > 0) {
release(&ref.lock);
return;
}
release(&ref.lock);
//结束
memset(pa, 1, PGSIZE);
//......
}
修改kernel/kalloc.c中的kalloc,在分配页面时,将引用计数初始化为1。
void *
kalloc(void)
{
//......
if(r)
{
kmem.freelist = r->next;
//添加
acquire(&ref.lock);
ref.refcount[(uint64)r / PGSIZE] = 1;
release(&ref.lock);
//结束
}
release(&kmem.lock);
//......
}
调用kernel/kalloc.c中kinit函数中的initlock()函数对锁进行初始化。
void
kinit()
{
initlock(&kmem.lock, "kmem");
//添加的内容
initlock(&ref.lock, "ref"); //初始化自旋锁
//结束
freerange(end, (void*)PHYSTOP);
}
因为kfree语义变了,一开始在没有kalloc之前,freerange调用kfree就会造成 引用计数初始化为 -1。因为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) {
//添加
ref.refcount[(uint64)p / PGSIZE] = 1; //这里设置为1再kfree就变成0了
//结束
kfree(p);
}
}
思路
COW机制并非进行实际拷贝,而是将子进程虚拟页同样映射在与父进程相同的物理页上。因此,根据实验提示②、攻克计划可知,需要修改kernel/vm.c中的
uvmcopy
,完成以下功能:
①将原本的kalloc()分配内存功能删除,同时将子进程的虚拟页映射到父进程的物理页上。
②对父进程和子进程对应的虚拟页PTE的标志位进行处理,移除原本的写标志位PTE_W,并添加COW标志位PTE_COW。
③在最后需要调用incref()对当前物理页的引用计数加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){
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、flag
flags = (flags & ~PTE_W) | PTE_COW;
*pte = (~(*pte ^ ~PTE_W)) | PTE_COW;
// if((mem = kalloc()) == 0) //取消实际的内存分配
// goto err;
// memmove(mem, (char*)pa, PGSIZE);
if(mappages(new, i, PGSIZE, (uint64)pa, flags) != 0){ //进行内存映射
//kfree(mem);
goto err;
}
incref(pa);
}
return 0;
err:
uvmunmap(new, 0, i / PGSIZE, 1);
return -1;
}
思路
完成cow页面修改后,接着去完成page fault处理程序。有两种情况会导致page fault:
①在用户态访问cow页面会由于缺页发生page fault,在usertrap中处理。
②内核缓存复制到用户空间(如read系统调用)会在copy_out中写入cow页面引起page fault。
大致处理思路为:
----判断虚拟地址的合理性
----将虚拟地址对应的pte映射到新的物理内存
----取消原物理地址的映射(减少引用计数)
在usertrap中添加page_fault的检查,并且添加相应的处理程序。
void
usertrap(void)
{
//......
} else if((which_dev = devintr()) != 0){
// ok
//添加page fault时的处理代码
}else if(r_scause() == 15) {
uint64 va = r_stval(); //获取虚拟地址
pte_t *pte = walk(p->pagetable, va, 0); //获取PTE
if (!(PTE_FLAGS(*pte) & PTE_COW)) {
p->killed = 1;
} else {
va = PGROUNDDOWN(va); //虚拟地址向下取整
uint64 ka = (uint64)kalloc(); //分配内存
if(ka == 0) {
p->killed = 1;
} else {
uint64 flags = PTE_FLAGS(*pte);
flags = flags & ~PTE_COW; //标记为非COW页面
uint64 pa = walkaddr(p->pagetable, va); //物理地址
memmove((void*)ka, (void *)pa, PGSIZE); //内存复制
uvmunmap(p->pagetable, va, 1, 1);
mappages(p->pagetable, va, 1, ka, flags | PTE_W); //进行映射,权限设置为可写
}
}
}
//结束
else {
printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
//......
}
由于page fault处理程序会调用kernel/vm.c中的walk函数,所以将walk()的声明放在kernel/defs.h中
在copyout中添加page_fault的检查,并且添加相应的处理程序。
int
copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len)
{
uint64 n, va0, pa0;
if(dstva > MAXVA - len) return -1; //添加
while(len > 0){
va0 = PGROUNDDOWN(dstva);
//添加
pte_t *pte = walk(pagetable, va0, 0);
if(pte == 0) return -1;
if(!(PTE_FLAGS(*pte)&PTE_W)) {
if(!(PTE_FLAGS(*pte)&PTE_COW)) {
return -1;
}
uint64 va = va0;
uint64 ka = (uint64)kalloc();
if(ka == 0) {
return -1;
} else {
uint64 flags = PTE_FLAGS(*pte);
flags = flags & ~PTE_COW;
uint64 pa = walkaddr(pagetable, va);
memmove((void*)ka, (void *)pa, PGSIZE);
uvmunmap(pagetable, va, 1, 1);
mappages(pagetable, va, 1, ka, flags | PTE_W);
}
}
//结束
pa0 = walkaddr(pagetable, va0);
//......
}
测试结果