如前所述,Unix提供fork()系统调用作为其主要的进程创建原语。fork()系统调用复制调用进程(父进程)的地址空间以创建一个新进程(子进程)。
xv6 Unix通过将父页面的所有数据复制到为孩子分配的新页面中来实现fork()。这基本上与dumbfork()所采用的方法相同。将父级地址空间复制到子级是fork()操作中最昂贵的部分。
然而,在调用fork()之后,通常会立即调用子进程中的exec(),这将用一个新程序替换子进程的内存。例如,这就是shell所做的事。在这种情况下,花费在复制父进程地址空间上的时间在很大程度上是浪费的,因为子进程在调用exec()之前只会使用很少的内存。
因此,Unix的新版本利用虚拟内存硬件,允许父进程和子进程共享映射到各自地址空间的物理内存,直到其中一个进程实际修改它。这中技术称之为copy-on-write(写时复制)。为此,在fork()中,内核将把地址空间映射从父页面复制到子页面,而不是映射页面的内容,同时将现在共享的页面标记为只读。当两个进程中的一个试图写入其中一个共享页面时,该进程将出现页面错误。此时,Unix内核意识到该页面实际上是一个“虚拟”或者“写时复制”副本,因此它为故障进程创建了一个新的、私有的、可写的页面副本。通过这种方式,在实际写入页面之前,不会真正复制单个页面的内容。这种优化使得fork()和exec()在子进程中更便宜:子进程在调用exec()之前可能只需要复制一个页面(其堆栈的当前页面)。
在这个实验的下一部分中,将实现一个适当的类unix的fork(),它具有写时复制功能,作为用户空间库历程。在用户空间中实现fork()和写时复制支持的好处是,内核仍然更简单,因此更可能是正确的。它还允许各个用户模式程序为fork()定义它们自己的语义。如果一个程序需要稍微不同的实现(例如昂贵的总复制版本,如dumbfork(),或者父程序和子程序之后实际上共享内存),那么它可以很容易提供自己的实现。
(官网翻译出来的东西,本来是简单的,硬是被这冗余的说法搞得复杂了...下次不傻傻翻译了,用自己的话阐述。)
User-level page fault handling
1个用户级写时拷贝的fork函数需要在写保护页时触发page fault,所以我们第一步应该先规定或者确立一个page fault处理例程,每个进程需要向内核注册这个处理例程,只需要传递一个函数指针即可。
通常设置一个地址空间,以便页面错误指示何时需要进行某些操作。例如,大多数Unix内核最初只映射新进程的堆栈区域中的单个页面,然后在进程的堆栈消耗增加并在尚未映射的堆栈地址上导致页面错误时按需分配和映射其他堆栈页面。典型的Unix内核必须跟踪在进程空间的每个区域发生页面错误时应该采取的操作。例如,堆栈区域中的错误通常会分配和映射物理内存的新页。程序的BSS区域中的错误通常会分配一个新页,用0填充它并映射它。在具有请求分页可执行文件的系统中,文本区域中的错误将从磁盘中读取二进制文件的对应页,然后对其进行映射。
内核需要跟踪大量信息。与采用传统的Unix方法不同,在用户空间内处理用户空间中每个页面错误,在用户空间中,错误的破坏性较小。这种设计的另一个好处是允许程序在定义其内存区域时具有很大的灵活性;稍后将使用用户级页面错误处理来映射和访问基于磁盘的文件系统上的文件。
Setting the Page Fault Handler
为了处理自己的页面错误,用户环境将需要向JOS内核注册一个页面错误处理程序入口点。用户环境通过新的sys_env_set_pgfault_upcall系统调用注册其页面错误入口点。我们已经在Env结构中添加了一个新成员Env_pgfault_upcall来记录该信息。
Exercise 8. 实现sys_env_set_pgfault_upcall系统调用。在查找目标环境的环境ID时,一定要启用权限检查,因为这是一个危险的系统调用。
static int
sys_env_set_pgfault_upcall(envid_t envid, void *func)
{
// LAB 4: Your code here.
//panic("sys_env_set_pgfault_upcall not implemented");
struct Env* env;
if(envid2env(envid,&env,1)<0)
return -E_BAD_ENV;
env->env_pgfault_upcall = func;
return 0;
}
记得要在syscall()中添加这个系统调用,因为官网没给出提出,所以我差点漏了这个。
case SYS_env_set_pgfault_upcall:
return sys_env_set_pgfault_upcall(a1,(void *)a2);
Normal and Exception Stacks in User Environments
在正常执行期间,JOS中的用户环境将运行在普通的用户堆栈上:它的ESP寄存器从一开始就指向USTACKTOP,它推入的堆栈数据驻留在USTACKTOP-PGSIZE和USTACKTOP-1之间的页面上。但是,但用户模式下出现页面错误时,内核将重新启动用户环境,在另一个堆栈(即用户异常堆栈)上运行指定的用户级页面错误处理程序。本质上,我们将使JOS内核实现代表用户环境的自动“堆栈切换”,就像x86处理器在从用户模式转换到内核模式时已经实现了代表JOS的堆栈切换一样。
JOS用户异常堆栈也是一个页面大小,它的顶部被定义在虚拟地址UXSTACKTOP,因此用户异常堆栈的有效字节从UXSTACKTOP-PGSIZE到UXSTACKTOP-1。在这个异常堆栈上运行时,用户级页面处理错误程序可以使用JOS的常规系统调用来映射新页面或调整映射,从而修复最初导致页面错误的任何问题。然后,用户级页面错误处理程序通过汇编语言stub返回到原始堆栈上的错误代码。
希望支持用户级页面错误处理的每个用户环境都需要使用Part A部分中介绍的sys_page_alloc()系统调用为自己的异常堆栈分配内存。
Invoking the User Page Fault Handler
现在需要更改kern/trap.c中的页面错误处理代码,以处理用户模式下的页面错误,如下所示。我们将把故障发生时用户环境的状态称为trap-time状态。
内核态系统栈是运行内核相关程序的栈,在有中断被触发之后,CPU会将栈自动切换到内核栈上来。内核栈的设置是在kern/trap.c的trap_init_percpu()中完成的。
如果没有注册页面错误处理程序,JOS内核将像以前一样使用消息破坏环境。否则,内核将在异常堆栈上设置一个trapframe,就像inc/trap.h中的一个struct UTrapframe:
<-- UXSTACKTOP
trap-time esp
trap-time eflags
trap-time eip
trap-time eax start of struct PushRegs
trap-time ecx
trap-time edx
trap-time ebx
trap-time esp
trap-time ebp
trap-time esi
trap-time edi end of struct PushRegs
tf_err (error code)
fault_va <-- %esp when handler is run
然后内核安排用户环境使用该堆栈帧在异常堆栈上运行的页面错误处理程序恢复执行;必须想办法让这一些发生。fault_va是导致页面错误的虚拟地址。
如果发生异常时,用户环境已经在用户异常堆栈上运行,则页面错误处理程序本身已经发生错误。在这种情况下,应该在当前tf-tf-esp下启动新的堆栈帧,而不是在UXSTACKTOP上,您应该首先推入一个空的32位word,然后是struct UTrapframe.
要测试tf->tf_esp是否已经位于用户异常堆栈上,请检查它是否位于UXSTACKTOP-PGSIZE和UXSTACKTOP-1之间(包括UXSTACKTOP-1)
Exercise 9. 实现kern/trap.c中page_fault_handler中的代码,将页面错误dispatch给用户模式处理程序。在写入异常堆栈时,请确保采取适当的预防措施(如果用户环境耗尽异常堆栈上的空间,会发生什么情况)
这里有一个地方我原来不懂,就是最后为啥会出现env_destroy(curenv)?后来看了别人的博客才理解,因为tf->tf_eip改变,所以env_run()不会返回,也就不会执行到env_destroy()了。不过这其中原理不太理解。
if(curenv->env_pgfault_upcall){
//如果异常是在用户异常栈发生,则推入4字节
if(tf->tf_esp>=UXSTACKTOP-PGSIZE && tf->tf_esp < UXSTACKTOP)
utf = (struct UTrapframe *)(tf->tf_esp-4-sizeof(struct UTrapframe));
//否则则是在用户运行栈发生的异常,直接把utf压入栈
else
utf = (struct UTrapframe *)(UXSTACKTOP-sizeof(struct UTrapframe));
//user_mem_assert()函数的作用是检查环境是否被允许进入内存(va,va+len);
user_mem_assert(curenv,(void *)utf,sizeof(struct UTrapframe),PTE_U|PTE_W|PTE_P);
utf->utf_fault_va = fault_va;
utf->utf_err = tf->tf_trapno;
utf->utf_regs = tf->tf_regs;
utf->utf_eip = tf->tf_eip;
utf->utf_eflags = tf->tf_eflags;
utf->utf_esp = tf->tf_esp;
tf->tf_eip = (uintptr_t)curenv->env_pgfault_upcall;
tf->tf_esp = (uintptr_t)utf;
env_run(curenv);
}
// Destroy the environment that caused the fault.
cprintf("[%08x] user fault va %08x ip %08x\n",
curenv->env_id, fault_va, tf->tf_eip);
print_trapframe(tf);
env_destroy(curenv);
User-mode Page Fault Entrypoint
接下来,您需要实现assembly routine, 该例程负责调用C页面错误处理程序并在原始错误处理指令上恢复执行。这个assembly routine是将使用sys_env_set_pgfault_upcall()在内核中注册的处理程序。
Exercise 10. 在lib/pfentry.S中实现_pgfault_upcall例程。有趣的部分是返回到导致页面错误的用户代码中的原始点,将直接返回那里,而不是需要通过内核返回,最难的部分是同时切换堆栈和重新加载EIP。
对于这个练习,我参考了这篇博客
.text
.globl _pgfault_upcall
_pgfault_upcall:
pushl %esp
movl _pgfault_handler, %eax
call *%eax
addl $4, %esp
movl 48(%esp), %ebp
subl $4, %ebp
movl %ebp, 48(%esp)
movl 40(%esp), %eax
movl %eax, (%ebp)
addl $8, %esp
popal
addl $4, %esp
popfl
popl %esp
ret
最后,你需要实现用户级页面错误处理机制的C用户库端(C user library side)
Exercise 11. 完成在lib/pgfault.c中的set_pgfault_handler().
进程在运行前注册自己的页错误处理程序,重点是申请用户异常栈空间。
set_pgfault_handler(void (*handler)(struct UTrapframe *utf))
{
int r;
if (_pgfault_handler == 0) {
// First time through!
// LAB 4: Your code here.
//panic("set_pgfault_handler not implemented");
if((r=sys_page_alloc(thisenv->env_id,(void *)UXSTACKTOP-PGSIZE,PTE_P|PTE_W|PTE_U))<0)
panic("set_pgfault_handler %e",r);
sys_env_set_pgfault_upcall(thisenv->env_id,_pgfault_upcall);
}
// Save handler pointer for assembly to call.
_pgfault_handler = handler;
}
整理
以上四个练习结合用户程序串起来理解。我去查看了一下faultdie.c用户程序。用户就是在这里定义注册了自己的中断处理程序——set_pgfault_handler()函数(Exercise 11),在这个函数里分配一个页面的用户异常栈。
然后进行了sys_env_set_pgfault_upcall系统调用(Exercise 8),于是陷入进内核,栈位置从用户运行栈切换到内核栈,注册了页错误处理的入口_pgfault_upcall。进入到trap中,进行中断处理dispatch,进入到page_fault_handler()
当确认是用户程序触发的page fault时,为其在用户异常栈里分配一个UTrapframe的大小。
把栈切换到用户异常栈,运行相应的用户中断处理程序(tf->tf_eip = (uintptr_t)curenv->env_pgfault_upcall),这个中断处理程序可能会触发另外一个同类型的中断,这个时候就会产生递归式的处理,处理完成后,返回到用户运行栈。
难以理解的是_pgfault_upcall汇编代码部分。我理解的是在page_fault_handler()中定义tf->tf_eip = (uintptr_t)curenv->env_pgfault_upcall后执行env_run()便开始在用户异常栈执行这段汇编代码,_pgfault_handler是其中的一部分,在_pgfault_upcall中还要把异常栈切换到用户运行栈。
结果
Implementing Copy-on-Write Fork
现在已经拥有了完全在用户空间中实现copy-on-write fork()的内核工具。
lib/fork.c中已经为fork()提供了一个框架。与dumbfork()类似,fork()应该创建一个新环境,然后扫描父环境的整个地址空间,并在子环境中设置相应的页面映射。关键的区别在于,dumbfork()复制了页面,但是fork()最初只复制页面映射(不是内容)。
fork()的基本控制流程如下:
1. 父进程使用上面实现的set_pgfault_handler()函数将pgafult()安装为C-level page fault handler
2.父环境调用sys_exofork()来创建子环境
3. 对于UTOP下面地址空间中的每个可写页面或写时复制页面,父类调用duppage,duppage应该将写时复制的页面映射到子进程的地址空间,然后在自己的地址空间中重新映射写时复制的页面【注:此处的顺序实际上很重要(即,先把子进程中该页面标记为COW,再把父进程中该页面也标记为COW)Why?】duppage设置两个pte,使页面不可写,并在"avail"字段中包含PTE_COW,以便区分写时复制的页面和真正的只读页面。
但是异常堆栈不会以这种方式重新映射。相反,需要在子堆栈中为异常堆栈分配一个新的页面。由于页面错误处理程序将实行实际的复制,并且页面错误处理程序运行在异常堆栈上,因此异常堆栈不能执行copy-on-write:谁将复制它?
fork()还需要处理当前页面,但这些页面不是可写的,也不是写时复制的。
4. 父进程将子进程的用户页面错误入口点设置为与自己的相似。
5. 子进程可以运行了,所以父进程将它标记为runnable.
每当一个环境编写尚未写入的写时复制页面时,都会出现页面错误。下面是用户页面错误处理程序的控制流:
1. 内核将页面错误传到_pgfault_upcall,后者调用fork()的pgfault处理程序。
2. pgfault()检查错误是否为写(检查错误代码中的FEC_WR),并且页面的PTE标记为PTE_COW,如果不是,panic。
3. pgfault()分配一个映射到临时位置的新页面,并将故障页面的内容复制到其中。然后故障处理程序将新页面映射到具有读写权限的适当地址,以替代旧的只读映射。
用户级lib/fork.c代码必须参考环境的页表才能执行上面几个操作(例如,页面的PTE标记为PTE_COW)。内核将环境的页表映射到UVPT正是出于这个目的。它使用了一个聪明的映射技巧使其更容易地查找用户代码pte. lib/entry.S设置uvpt和uvpd,以便可以轻松地在lib/fork.c中查找页表信息。
Exercise 12. 在lib/fork.c中实现fork,dupage,和pgfault函数。使用forktree程序测试代码。它应该生成以下消息,其中穿插着“new env”,"free env"和“exiting gracefully”,消息可能不会以这种顺序出现,并且环境id可能不同。
1000: I am '' 1001: I am '0' 2000: I am '00' 2001: I am '000' 1002: I am '1' 3000: I am '11' 3001: I am '10' 4000: I am '100' 1003: I am '01' 5000: I am '010' 4001: I am '011' 2002: I am '110' 1004: I am '001' 1005: I am '111' 1006: I am '101'
简单来说,就是fork()出一个子进程之后,父进程的页表的全部映射全部复制到子进程的地址空间中去。然后,父进程空间中所有可以写的页表的部分全部标记为可读且COW,也就是重新映射一遍。当父进程或者子进程需要写入的时候,因为页表不可写,所有会触发异常。进入到设定好的page fault处理例程。当检测是对COW页的写操作,就可以将要写入的页的内容全部复制一份,重新映射。
实验过程
首先要搞清楚的是这三个函数的作用。主函数fork就不必说了,创建一个新进程。
在fork()函数中,uvpd[]和uvpt[]的作用分别是获取页目录和页表的虚拟地址。对于在UTOP下的页面映射需要区分在用户异常栈和用户运行栈。根据inc/memlayout.h中的布局可以知道,USTACKTOP下是用户运行栈(正常用户栈)
envid_t
fork(void)
{
// LAB 4: Your code here.
//panic("fork not implemented");
set_pgfault_handler(pgfault);
//创建一个新进程,但是只为其分配虚拟空间结构,不分配物理内存。
envid_t envid = sys_exofork();
uintptr_t addr;
if(envid<0) panic("sys_exofork failed\n");
if(envid==0){
thisenv = &envs[ENVX(sys_getenvid())];
return 0;
}
//这个for循环是仿写的dumpfork(),言外之意就是我不知道UTEXT是咋来的...
//把父进程的所有页面映射到子进程,注意这个范围只在用户运行栈。
for(addr=(uintptr_t)UTEXT; addr<USTACKTOP;addr+=PGSIZE){
if((uvpd[PDX(addr)]&PTE_P) && uvpt[PGNUM(addr)] & PTE_P)
duppage(envid,PGNUM(addr));
}
int r;
//给子进程在用户异常栈里分配子栈
if((r=sys_page_alloc(envid,(void *)UXSTACKTOP-PGSIZE,PTE_U|PTE_P|PTE_W))<0)
panic("fork:page alloc failed %e\n",r);
extern void _pgfault_upcall();
//这里结合上面的Exercise就很好理解了,可以就把fork()理解成上面测试用的用户程序。
if((r = sys_env_set_pgfault_upcall(envid,_pgfault_upcall)))
panic("fork:set upcall failed %e\n",r);
if((r = sys_env_set_status(envid,ENV_RUNNABLE))<0)
panic("fork:set status failed %e\n",r);
return envid;
}
duppage()把父进程的映射复制到子进程的地址空间。
static int
duppage(envid_t envid, unsigned pn)
{
int r;
// LAB 4: Your code here.
//panic("duppage not implemented");
envid_t parent_envid = sys_getenvid();
void *va = (void *)(pn*PGSIZE);
int perm = PTE_P | PTE_U;
//把可写或者写时复制页面标记为COW和不可写
if(uvpt[pn] & PTE_W || uvpt[pn] & PTE_COW){
perm |= PTE_COW;
perm &= ~PTE_W;
}
//将父进程的页面映射复制到子进程地址空间,实际上就是父进程和子进程共享一个物理页面,所以虚
//拟地址同一个。
if((r=sys_page_map(parent_envid,va,envid,va,perm))<0)
panic("duppage:map01 failed %e",r);
//更新一下父进程页面映射的权限。
if((r=sys_page_map(parent_envid,va,parent_envid,va,perm))<0)
panic("duppage:map02 failed %e",r);
return 0;
}
pgfault()是_pgfault_upcall调用的页错误处理函数。在调用之前,父子进程的页错误地址都引用同一页物理内存,这个函数作用的是分配一个物理页面使得两者独立。
static void
pgfault(struct UTrapframe *utf)
{
void *addr = (void *) utf->utf_fault_va;
uint32_t err = utf->utf_err;
int r;
// LAB 4: Your code here.
envid_t envid = sys_getenvid();
//分配一个页面,映射到了交换区PFTEMP这个虚拟地址
r = sys_page_alloc(envid,(void *)PFTEMP,PTE_P|PTE_W|PTE_U);
if(r<0) panic("pgfault:page allocation failed %e",r);
//addr是发生错误的虚拟页的起始地址
addr = ROUNDDOWN(addr,PGSIZE);
//将addr页面所在内容拷贝到PFTEMP,此时有两个物理页保存了同样的内容
memcpy((void *)PFTEMP,(const void *)addr,PGSIZE);
//将addr映射到PFTEMP对应的物理页,然后接触PFTEMP对应的映射,此时addr就指向新分配的物理
页
if(sys_page_map(envid,(void *)PFTEMP, envid,addr,PTE_P|PTE_W|PTE_U)<0)
panic("pgfault:page map failed\n");
if(sys_page_unmap(envid,(void *)PFTEMP)<0)
panic("pgfault: page upmap failed\n");
// panic("pgfault not implemented");
}
实验结果
总结
天呐这个练习好难,我只能说是照着代码注释和官方提示还有大神们的博客勉强完成勉强理解(代码中的注释部分是我的理解),只知其然不知其所以然,还是迷迷糊糊的。先这样吧,等我掌握好更多理论知识再回来重看。