Mit6.S081:lab COW
实验:Lab: COW
实验开始之前需要将git分支切换到cow分支不然有些文件你是没有的
1 | $ git fetch |
COW
简介
普通fork的问题
一个进程调用fork后会创建和父进程一样大的物理内存空间,并拷贝父进程的物理内存的内容。这样会造成物理空间的浪费,以shell程序为例运行一个命令首先会调用fork拷贝父进程,然后使用exec将程序重新写入子进程的空间。
在这种情况下,首先创建和父进程一样大的空间会造成空间的浪费,其次将父进程的内容拷贝到子进程有使用exec覆盖内存会浪费时间。
copy-on-write fork
copy-on-write fork是缺页(异常操作)的一种应用场景,几乎在所有操作系统中都实现了这个功能。
==COW==怎样解决上述普通fork的问题?
像是惰性分配(lazy allocation)的方式,先不创建物理页表而是进程,当进程需要使用这部分内存时再分配。
所以COW fork的具体实现就是,在使用fork创建子进程时,只是给子进程创建一个页表,将子进程的虚拟页映射到父进程的物理页,并且将子进程的PTE与父进程的PTE设置为不可读(清空PTE_W位)。那么当运行子进程时,需要写入对应的物理页时就会触发缺页异常。trap处理程序会识别缺页异常,在cow处理程序中新建物理页,并重新设置页表的PTE映射到该物理页并设置标志位。
Q:为什么要将父进程也设置为不可读?
A:我们要保持父进程与子进程的隔离性,那么父进程如果可以直接写这个物理页,那么子进程就可以看见了父进程的数据了。
COW实验
要求
本次实验是在xv6中完成cow fork功能。需要通过cowtest的测试与usertests -q测试。
实验的输出结果如下:使用
make grade
便可以
1 | $ cowtest |
提示
建议观看lecture8,这节课介绍了缺页错误的基本处理方式与COW的实现原理。
当usertrap发现错误时,可以通过scause
的参数看如下的表中关于引起trap类型。在实验中我们主要是处理写入物理页错误(主要是因为PTE_W没有设置会被mmu检测出来),那么相应的scause数值是0xf=15
,代表载入缺页(store page fault),通过scause参数我们也可以发现出现了这条命令不是中断触发的,因此这里的缺页是属于一种异常操作(读取了非法的物理地址,由mmu检查得出)。
实现
开始
本次实验主要是修改fork的拷贝方式,那么首先我们要看看fork是如何拷贝的,看下述代码我们可以知道fork主要是使用了uvmcopy实现了内存的拷贝。
1 | int |
接着就是修改uvmcopy函数的拷贝逻辑了。
修改fork拷贝方式
在下述注释的代码中,xv6为子进程新建了物理页,拷贝父进程的内存内容,并将子进程的的虚拟地址映射到新的物理页上。
根据cow fork的原理则是将子进程的虚拟地址映射到父进程的物理页上。需要注意以下几点
- 设置父进程与子进程的pte标志位,清空PTE_W,添加cow fork识别位(PTE_F=1L << 8)
- 增加对于物理页的引用计数
- mappages将子进程的虚拟地址映射到父进程的物理地址
Q:为什么要添加PTE_F位?
A:cow fork是一种特殊的缺页处理方式,将pte的写位清空后会触发缺页异常,那么当其他物理页也是只读也触发了缺页异常,显然不能够与cow fork的处理方式一样所以,需要标记这个位。
1 | ----vm.c |
改完uvmcopy函数后会出现了没有处理scause=15的缺页异常的处理函数的问题。
处理COW缺页错误(trap.c)
当uvmcopy建立好映射后,当子进程或者父进程写入物理页时就会触发缺页异常,那么根据scause的值xv6需要对15这个值进行对应的处理。
编写一个函数称为cowfault通过输入进程的页表与错误的虚拟地址便可以处理cow fork的缺页异常了。如果cowfault返回值为-1说明有非法参数,或是pte的设置错误,那么xv6会杀死对应的进程。返回值为0则是成功。
cowfault的执行流程
- 检测虚拟地址是否合法
- 通过walk获得缺页错误的虚拟地址pte,并检测pte的权限位。
- 生成物理地址(子进程与父进程共同使用的物理页地址)
- 获得这个物理页的引用计数,如果引用计数为1说明此时只有一个进程在使用这个物理页,我们只需要修改权限位即可(添加PTE_W与清空PTE_F)。
- 反之我们有多个进程在使用一个物理页,因此我们需要重新分配新的物理页,拷贝原理的物理页的内容。
- 调用kfree:这个函数会在kalloc被重新调整,通过引用计数判断是否能够释放内存。
- 判断虚拟地址的数据,如果
va == 0
的话说明这个页是代码段页,那么我们是不能修改这个页面的,也就是不能添加写位。修改pte的值将虚拟内存的地址指向新建的物理。
Q:我们为什么不用在cowfault处理函数修改父进程或子进程的pte权限位
A:首先两个进程是隔离的,我们不能获取另一个进程的页表数据。其次,如果是父进程或子进程的pte中没有写权限,那么我们仍然会触发cow fault,这样的话在cowfault函数中新建物理页并拷贝,或是新增pte的权限(上述第4点)
1 | ----trap.c |
引用计数(kalloc.c)
kalloc是关于物理页的分配。那么对cow的功能实现后我们需要添加物理页引用计数,不能让kfree随意的释放物理页(像是c++的share_ptr)。
实现流程
- 添加一个物理页编号的数组,作为物理页的引用计数。所以物理页都需要编号包括硬件设备的物理页,不然kfree释放或是kalloc分配时会因为引用计数的问题出现错误。
- kalloc函数:初始化物理页的引用计数,当有函数调用kalloc时说明对应的物理页已经被应用了并且将引用计数设置为1。
为什么需要加锁,因为在多核cpu下对于物理页的分配或是引用计数的操作会产生竞争
1 | ----kalloc.c |
- kfree函数:减少物理页的引用计数,当引用计数降为1时释放这个物理页。
1 |
|
添加引用计数变量的接口(封装RC变量):
getRC:便于在cowfault中判断是否只有一个进程使用该物理页。
increRC:便于在uvmcopy函数中调用并增加该物理页的引用计数。
1 | //获得物理页的引用计数 |
- 在xv6最开始时,初始化都会调用kfree清空物理内存,最开始RC数组内的变量都为0,调用kfree就会出现panic,这时只需要将RC初始为1即可。
1 | void |
cow遗漏之处
Fault: 在cowtest测试中对于file测试点会出现问题:
因为pipe调用copyout将内核内存(文件描述符)拷贝到用户空间会出现了问题。
内核进程也不能够使用用户进程的虚拟地址去修改用户数据,xv6绕过mmu直接使用物理地址修改了物理页的数据,因此缺乏硬件的支持(检测虚拟地址的PTE)导致在不能写入物理页的情况下通过物理地址写入了数据,从而打破了子进程与父进程的隔离性。
处理方法:检测用户进程虚拟地址与页表对应的PTE的标志位,如果没有PTE_W并且有PTE_F位,那么说明用户进程(cow fork)与其他进程共享物理内存,就应该触发cowfault的处理函数,这样的话就实现了完善的cow fork。
1 | ----vm.c |
NOTE: 上述代码中需要在def.h中补上一些函数的接口
在vm.c中要调用trap.c的cowfault函数,在def.h中声明
int cowfault(pagetable_t,uint64)
如果需要更强的封装性就需要使用void指针,进一步的进行类型强转。
int cowfault(void*,void*)
在trap.c与vm.c中调用了kalloc.c中的RC变量的接口也需要声明
int getRC(void*)
void increRC(void*)
上述void*对应的是物理地址这个uint64变量。
总结
本次实验相对于前面的实验算是比较困难的实验,前期在做的时候实在识别不了报出的错误(没有做引用计数的管理),于是照着看了一遍lecture12,知道了为什么有这些问题出现,并进一步的优化了代码(父进程不需要重新分配物理并将之前的页释放)。
看着教授做完,自己没有怎么过脑子。但是自己在做的过程中也遇到了一些问题,例如textwrite测试中,不能在代码段写入数据的问题。以及引用计数中,擅自释放了物理页的问题。
做完这个实验熟悉了:
- c语言中面对对象的封装思想:
- 在kalloc.c中对于RC(引用计数)变量,在def.h文件不能暴露RC变量,而是提供接口如increRC(增加引用计数)、getRC(获得引用计数)。
- 使用void指针进行类型强转实现更加健硕的封装性,如在def.h中定义这个接口getRC(void *),实际上我们传入的参数是物理地址(uint64)。
- 提高安全代码书写的意识,检测非法变量。
- 操作系统如何管理内存:通过页表这一个抽象实现了隔离与高性能的内存运用。