这里要实现的就是UNIX标准系统调用中的fork,核心当然是copy on write技术
至于为什么采用copy on write,是因为子进程在被创建之后很可能立刻执行exec()了,之前做的一系列的拷贝是无用功
所以说,当创建一个新的子进程的时候,只需要拷贝父进程的内存映射(页表)就可以了,而且将父进程所有的内存映射页都标记为只读的,这样,当子进程或者父进程尝试去读的时候是安全的,而当尝试去写的时候,就会出发page fault,而在page fault处理例程中,单独将被写入的页(比如说栈)拷贝一份,修改掉发出写行为的进程的页表相应的映射就可以了
所以说,第一步应该先规定或者确立一个page fault处理例程
每个进程需要向内核注册这个处理例程,只需要传递一个函数指针即可
sys_env_set_pgfault_upcall函数
将当前进程的page fault处理例程设置为func指向的函数
Normal and exception stacks in user environments
这里出现了第三个栈,即异常栈
在用户程序正常运行时,出在正常的用户栈上,用户栈顶位于USTACKTOP处
而异常栈则是为了上面设置的异常处理例程设立的。当异常发生时,而且该用户进程注册了该异常的处理例程,那么就会转到异常栈上,运行异常处理例程
到目前位置出现了三个栈:
- [KSTACKTOP, KSTACKTOP-KSTKSIZE]
内核态系统栈
- [UXSTACKTOP, UXSTACKTOP - PGSIZE]
用户态错误处理栈
- [USTACKTOP, UTEXT]
用户态运行栈
- 对于内核态系统栈
是运行内核相关程序的栈,在有中断被触发之后,CPU会将栈自动切换到内核栈上来,而内核栈的设置是在kern/trap.c的trap_init_percpu()中设置的
可以看到内核栈的大小是固定了的,在系统初始化的时候就设定好了的
在中断触发之后,进入kern/trapentry.S代码时,此时所处的栈就已经切换到了内核栈了,而且会在内核栈上压入一系列的内容,手动形成一个trapframe
- 用户运行时栈
是用户运行中使用的栈,是在用户进程创建之初初始化的
实际中的应用进程在初始化的情况下只有一页的大小,如果发生了stack overflow,就会触发页错误,但是用户空间中的页错误是有处理程序的,会将栈顶下方一页映射到当前内存空间
所以说,用户运行栈是一边运行一边生长的,而内核栈是固定大小的,错误栈也是
- 用户态错误栈
用户定义注册了自己的中断处理程序之后,相应的例程运行时的栈
这个过程如下
- 首先陷入到内核,栈位置从用户运行栈切换到内核栈,进入到trap中,进行中断处理分发,进入到page_fault_handler()
- 当确认是用户程序触发的page fault的时候(内核触发的直接panic了),为其在用户错误栈里分配一个UTrapframe的大小
- 把栈切换到用户错误栈,运行响应的用户中断处理程序
- 中断处理程序可能会触发另外一个同类型的中断,这个时候就会产生递归式的处理
- 处理完成之后,返回到用户运行栈
Invoking the user page fault handler
当用户进程运行出错,而且对于这个错误,用户是定义了自己的异常处理例程的时候,按照前面的说法,是需要切换到异常栈上去执行的
那么如何切换过去呢?
可以将用户自己定义的用户处理进程当作是一次函数调用看待,当错误发生的时候,调用一个函数,但实际上还是当前这个进程,并没有发生变化
所以当切换到异常栈的时候,依然运行当前进程,但只是运行的中断处理函数,所以说此时的栈指针发生了变化,而且程序计数器eip也发生了变化,同时还需要知道的是引发错误的地址在哪。这些都是要在切换到异常栈的时候需要传递的信息
和之前从用户栈切换到内核栈一样,这里是通过在栈上构造结构体,传递指针完成的
这里新定义了一个结构体用来记录出现用户定义错误时候的信息Utrapframe
相比于UTrapframe,这里多了utf_fault_va,因为要记录触发错误的内存地址
同时还少了es,ds,ss等。因为从用户态栈切换到异常栈,或者从异常栈再切换回去,实际上都是一个用户进程,所以不涉及到段的切换,不用记录
在实际使用中,Trapframe是作为记录进程完整状态的结构体存在的,也作为函数参数进行传递;而UTrapframe只在处理用户定义错误的时候用到
整体上讲,当正常执行过程中发生了页错误,那么栈的切换是
用户运行栈--->内核栈--->异常栈
而如果在异常处理程序中发生了也错误,那么栈的切换是
异常栈--->内核栈--->异常栈
接下来就是要实现page_fault_handler函数
如果当前已经在用户错误栈上了,那么需要留出4个字节,否则不需要,具体和跳转机制有关系
简单说就是在当前的错误栈顶的位置向下留出保存UTrapframe的空间,然后将tf中的参数复制过来
修改当前进程的程序计数器和栈指针,然后重启这个进程,此时就会在用户错误栈上运行中断处理程序了
当然,中断处理程序运行结束之后,需要再回到用户运行栈中,这个就是异常处理程序需要做的了
这里还有一个问题
如果异常栈发生了overflow怎么办?
看一下memlayout.h就知道了
可以看到,用户异常栈就一页的大小,一旦溢出,访问的就是内核都没有访问权限的空间,会发生内核空间中的page fault,此时会直接panic,不会造成更严重的后果
接下来就是写汇编程序的一部分了,主要实现的功能是:当从用户定义的处理函数返回之后,如何从用户错误栈直接返回到用户运行栈。这部分我比较头疼,直接参考了张弛师兄的代码
还有就是set_pgfault_handler()函数了,主要是为进程设定处理历程,同时分配错误栈
接下来就是最重要的部分:实现copy-on-write fork
与之前的dumbfork不同,fork出一个子进程之后,首先要进行的就是将父进程的页表的全部映射拷贝到子进程的地址空间中去。
这个时候物理页会被两个进程同时映射,但是在写的时候是应该隔离的。采取的方法是,在子进程映射的时候,将父进程空间中所有可以写的页表的部分全部标记为可读,且COW
而当父进程或者子进程任意一个发生了写的时候,因为页表现在都是不可写的,所以会触发异常,进入到我们设定的page fault处理例程,当检测到是对COW页的写操作的情况下,就可以将要写入的页的内容全部拷贝一份,重新映射。
一开始我还在想,在进入到page fault处理例程的时候,在拷贝一份之后,就可以将这一页标记为可以写了啊,这样另外一个进程就不在写的时候触发异常了。但是我还是naive啊,因为fork操作是可以嵌套多层的,所以不知道有多少个进程有向这个页面的映射。
所以可以采取的方法是,当进程触发了page fault,完成了相应页的拷贝之后,需要解出对原来页的映射,此时可以调用page_remove,而这个函数会根据页的引用计数决定是否回收,所以不会出现内存泄漏的情况。
首先看page fault处理例程
这个逻辑还是挺明朗的,这里借用了一个一定不会被用到的位置PFTEMP,专门用来发生page fault的时候拷贝内容用的。
需要注意的是,在map之前一定要先unmap,否则的话,如果子进程和父进程都对同一位置进行了写操作,都复制了一份出来,那么原来的一份就没用了,而又没有进行unmap操作的话,这一页的引用计数永远不归零。
然后再看duppage函数
作用就是将当前进程的第pn页对应的物理页的映射到envid的第pn页上去,同时将这一页都标记为COW
fork函数
需要将映射拷贝过去,这里需要考虑的地址范围就是从UTEXT到UXSTACKTOP为止,而在此之上的范围因为都是相同的,在env_alloc的时候已经设置好了,所以不需要考虑了
首先需要为父进程设定错误处理例程,这里调用set_pgfault_handler函数是因为当前并不知道父进程是否已经建立了错误栈,没有的话就会建立一个
而sys_env_set_pgfault_upcall则不会建立错误站
调用sys_exofork准备出一个和父进程状态相同的子进程,状态暂时设置为ENV_NOT_RUNNABLE
然后进行拷贝映射的部分,在当前进程的页表中所有标记为PTE_P的页的映射都需要拷贝到子进程空间中去
但是有一个例外,是必须要新申请一页来拷贝内容的,就是用户错误栈
因为copy-on-write就是依靠用户错误栈实现的,所以说这个栈要在fork完成的时候每个进程都有一个,所以要硬拷贝过来
这里看着比较别扭,流程就是:
- 申请新的物理页,映射到子进程的(UXSTACKTOP-PGSIZE)位置上去
- 父进程的PFTEMP位置也映射到子进程新申请的物理页上去,这样父进程也可以访问这一页了
- 在父进程空间中,将用户错误栈全部拷贝到子进程的错误栈上去,也就是刚刚申请的那一页
- 然后父进程解除对PFTEMP的映射
最后把子进程的状态设置为可运行就可以了