子进程在被创建之后很可能立刻执行exec()了,之前做的一系列的拷贝就是无用功了。
所以说,当创建一个新的子进程的时候,只需要拷贝父进程的内存映射(页表)就可以了,而且将父进程所有的内存映射页都标记为只读的,这样,当子进程或者父进程尝试去读的时候是安全的,而当尝试去写的时候,就会出发page fault,而在page fault处理例程中,单独将被写入的页(比如说栈)拷贝一份,修改掉发出写行为的进程的页表相应的映射就可以了。
User-level page fault handling
1个用户级写时拷贝的fork函数需要在写保护页时触发page fault,所以我们第一步应该先规定或者确立一个page fault处理例程,每个进程需要向内核注册这个处理例程,只需要传递一个函数指针即可,sys_env_set_pgfault_upcall函数将当前进程的page fault处理例程设置为func指向的函数。
Exercise 8:
实现sys_env_set_pgfault_upcall函数。
回答:
static int
sys_env_set_pgfault_upcall(envid_t envid, void *func)
{
struct Env *env;
if (envid2env(envid, &env, 1) < 0)
return -E_BAD_ENV;
env->env_pgfault_upcall = func;
return 0;
}
Normal and Exception Stacks in User Environments
在正常运行期间,用户进程运行在用户栈上,栈顶寄存器ESP指向USTACKTOP处,堆栈数据位于USTACKTOP-PGSIZE 与USTACKTOP-1之间的页。当在用户模式发生1个page fault时,内核将在专门处理page fault的用户异常栈上重新启动进程。
而异常栈则是为了上面设置的异常处理例程设立的。当异常发生时,而且该用户进程注册了该异常的处理例程,那么就会转到异常栈上,运行异常处理例程。
到目前位置出现了三个栈:
[KSTACKTOP, KSTACKTOP-KSTKSIZE]
内核态系统栈
[UXSTACKTOP, UXSTACKTOP - PGSIZE]
用户态错误处理栈
[USTACKTOP, UTEXT]
用户态运行栈
内核态系统栈是运行内核相关程序的栈,在有中断被触发之后,CPU会将栈自动切换到内核栈上来,而内核栈的设置是在kern/trap.c的trap_init_percpu()中设置的。
void
trap_init_percpu(void)
{
// Setup a TSS so that we get the right stack
// when we trap to the kernel.
thiscpu->cpu_ts.ts_esp0 = KSTACKTOP - cpunum() * (KSTKGAP + KSTKSIZE);
thiscpu->cpu_ts.ts_ss0 = GD_KD;
// Initialize the TSS slot of the gdt.
gdt[(GD_TSS0 >> 3) + cpunum()] = SEG16(STS_T32A, (uint32_t) (&thiscpu->cpu_ts),
sizeof(struct Taskstate) - 1, 0);
gdt[(GD_TSS0 >> 3) + cpunum()].sd_s = 0;
// Load the TSS selector (like other segment selectors, the
// bottom three bits are special; we leave them 0)
ltr(GD_TSS0 + sizeof(struct Segdesc) * cpunum());
// Load the IDT
lidt(&idt_pd);
}
用户定义注册了自己的中断处理程序之后,相应的例程运行时的栈,整个过程如下:
首先陷入到内核,栈位置从用户运行栈切换到内核栈,进入到trap中,进行中断处理分发,进入到page_fault_handler()
当确认是用户程序触发的page fault的时候(内核触发的直接panic了),为其在用户错误栈里分配一个UTrapframe的大小
把栈切换到用户错误栈,运行响应的用户中断处理程序中断处理程序可能会触发另外一个同类型的中断,这个时候就会产生递归式的处理。处理完成之后,返回到用户运行栈。
Invoking the user page fault handle
可以将用户自己定义的用户处理进程当作是一次函数调用看待,当错误发生的时候,调用一个函数,但实际上还是当前这个进程,并没有发生变化。所以当切换到异常栈的时候,依然运行当前进程,但只是运行的中断处理函数,所以说此时的栈指针发生了变化,而且程序计数器eip也发生了变化,同时还需要知道的是引发错误的地址在哪。这些都是要在切换到异常栈的时候需要传递的信息。和之前从用户栈切换到内核栈一样,这里是通过在栈上构造结构体,传递指针完成的。
这里新定义了一个结构体用来记录出现用户定义错误时候的信息Utrapframe:
struct UTrapframe {
/* information about the fault */
uint32_t utf_fault_va; /* va for T_PGFLT, 0 otherwise */
uint32_t utf_err;
/* trap-time return state */
struct PushRegs utf_regs;
uintptr_t utf_eip;
uint32_t utf_eflags;
/* the trap-time stack to return to */
uintptr_t utf_esp;
} __attribute__((packed));
相比于UTrapframe,这里多了utf_fault_va,因为要记录触发错误的内存地址,同时还少了es,ds,ss等。因为从用户态栈切换到异常栈,或者从异常栈再切换回去,实际上都是一个用户进程,所以不涉及到段的切换,不用记录。在实际使用中,Trapframe是作为记录进程完整状态的结构体存在的,也作为函数参数进行传递;而UTrapframe只在处理用户定义错误的时候用。到
整体上讲,当正常执行过程中发生了页错误,那么栈的切换是
用户运行栈—>内核栈—>异常栈
而如果在异常处理程序中发生了也错误,那么栈的切换是
异常栈—>内核栈—>异常栈
Exercise 9:
实现page_fault_handler函数来分发page fault到用户模式的处理函数。
回答:
如果当前已经在用户错误栈上了,那么需要留出4个字节,否则不需要,具体和跳转机制有关系。简单说就是在当前的错误栈顶的位置向下留出保存UTrapframe的空间,然后将tf中的参数复制过来。修改当前进程的程序计数器和栈指针,然后重启这个进程,此时就会在用户错误栈上运行中断处理程序了。当然,中断处理程序运行结束之后,需要再回到用户运行栈中,这个就是异常处理程序需要做的了。
void
page_fault_handler(struct Trapframe *tf)
{
uint32_t fault_va;
// Read processor's CR2 register to find the faulting address
fault_va = rcr2();
// Handle kernel-mode page faults.
if (tf->tf_cs == GD_KT)
panic("page_fault in kernel mode, fault address: %d\n", fault_va);
struct UTrapframe *utf;
if (curenv->env_pgfault_upcall) {
if (UXSTACKTOP - PGSIZE <= tf->tf_esp && tf->tf_esp <= UXSTACKTOP - 1)
utf = (struct UTrapframe *)(tf->tf_esp - sizeof(struct UTrapframe) - 4);
else
utf = (struct UTrapframe *)(UXSTACKTOP - sizeof(struct UTrapframe));
user_mem_assert(curenv, (void *)utf, sizeof(struct UTrapframe), PTE_U | PTE_W);
utf->utf_fault_va = fault_va;
utf->utf_err = tf->tf_trapno;
utf->utf_eip = tf->tf_eip;
utf->utf_eflags = tf->tf_eflags;
utf->utf_esp = tf->tf_esp;
utf->utf_regs = tf->tf_regs;
tf->tf_eip = (uint32_t)curenv->env_pgfault_upcall;
tf->tf_esp = (uint32_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);
}
如果异常栈发生了overflow怎么办?看一下memlayout.h就知道了。用户异常栈就一页的大小,一旦溢出,访问的就是内核都没有访问权限的空间,会发生内核空间中的page fault,此时会直接panic,不会造成更严重的后果。
User-mode Page Fault Entrypoint
接下来,就需要汇编实现功能:当从用户定义的处理函数返回之后,如何从用户错误栈直接返回到用户运行栈。
Exercise 10:
实现在lib/pfentry.S中的_pgfault_upcall调用。
回答:
_pgfault_upcall是所有用户页错误处理程序的入口,在这里调用用户自定义的处理程序,并在处理完成后,从错误栈中保存的UTrapframe中恢复相应信息,然后跳回到发生错误之前的指令,恢复原来的进程运行。
.text
.globl _pgfault_upcall
_pgfault_upcall:
// Call the C page fault handler.
pushl %esp // function argument: pointer to UTF
movl _pgfault_handler, %eax
call *%eax
addl $4, %esp // pop function argument
movl 48(%esp), %ebp
subl $4, %ebp
movl %ebp, 48(%esp)
movl 40(%esp), %eax
movl %eax, (%ebp)
// Restore the trap-time registers. After you do this, you can no longer modify any general-purpose registers.
addl $8, %esp
popal
// Restore eflags from the stack. After you do this, you can
// no longer use arithmetic operations or anything else that
// modifies eflags.
addl $4, %esp
popfl
// Switch back to the adjusted trap-time stack.
popl %esp
// Return to re-execute the instruction that faulted.
ret
分析:
重点是调用_pgfault_handler返回时的操作,此时的异常栈结构如下:
这里trap-time esp上的空间有1个4字节的保留空间,是做为中断递归的情形。
然后将栈中的trap-time esp取出减去4,再存回栈中。此时如果是中断递归中,esp-4即是保留的4字节地址;如果不是则是用户运行栈的栈顶。
再将原来出错程序的EIP(即trap-time eip)取出放入保留的4字节,以便后来恢复运行。此时的异常栈布局如下:
紧接着恢复通用寄存器和EFLAG标志寄存器,此时的异常栈结构如下:
然后pop esp切换为原来出错程序的运行栈,最后使用ret返回出错程序。
Exercise 11:
实现C库用户态的page fault处理函数。
回答:
进程在运行前注册自己的页错误处理程序,重点是申请用户异常栈空间,最后添加上系统调用号。
void
set_pgfault_handler(void (*handler)(struct UTrapframe *utf))
{
int r;
if (_pgfault_handler == 0) {
// First time through!
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;
}
Implementing Copy-on-Write Fork
接下来就是最重要的部分:实现copy-on-write fork。
与之前的dumbfork不同,fork出一个子进程之后,首先要进行的就是将父进程的页表的全部映射拷贝到子进程的地址空间中去。这个时候物理页会被两个进程同时映射,但是在写的时候是应该隔离的。采取的方法是在子进程映射的时候,将父进程空间中所有可以写的页表的部分全部标记为可读且COW。而当父进程或者子进程任意一个发生了写的时候,因为页表现在都是不可写的,所以会触发异常,进入到我们设定的page fault处理例程,当检测到是对COW页的写操作的情况下,就可以将要写入的页的内容全部拷贝一份,重新映射。
Exercise 12:
实现在lib/fork.c的fork,duppage和pgfault。
回答:
首先是pgfault处理page fault时的写时拷贝。
static void
pgfault(struct UTrapframe *utf)
{
int r;
void *addr = (void *) utf->utf_fault_va;
uint32_t err = utf->utf_err;
if ((err & FEC_WR) == 0 || (uvpt[PGNUM(addr)] & PTE_COW) == 0)
panic("pgfault: it's not writable or attempt to access a non-cow page!");
// Allocate a new page, map it at a temporary location (PFTEMP),
// copy the data from the old page to the new page, then move the new
// page to the old page's address.
envid_t envid = sys_getenvid();
if ((r = sys_page_alloc(envid, (void *)PFTEMP, PTE_P | PTE_W | PTE_U)) < 0)
panic("pgfault: page allocation failed %e", r);
addr = ROUNDDOWN(addr, PGSIZE);
memmove(PFTEMP, addr, PGSIZE);
if ((r = sys_page_unmap(envid, addr)) < 0)
panic("pgfault: page unmap failed %e", r);
if ((r = sys_page_map(envid, PFTEMP, envid, addr, PTE_P | PTE_W |PTE_U)) < 0)
panic("pgfault: page map failed %e", r);
if ((r = sys_page_unmap(envid, PFTEMP)) < 0)
panic("pgfault: page unmap failed %e", r);
}
在pgfault函数中先判断是否页错误是由写时拷贝造成的,如果不是则panic。借用了一个一定不会被用到的位置PFTEMP,专门用来发生page fault的时候拷贝内容用的。先解除addr原先的页映射关系,然后将addr映射到PFTEMP映射的页,最后解除PFTEMP的页映射关系。
接下来是duppage函数,负责进行COW方式的页复制,将当前进程的第pn页对应的物理页的映射到envid的第pn页上去,同时将这一页都标记为COW。
static int
duppage(envid_t envid, unsigned pn)
{
int r;
void *addr;
pte_t pte;
int perm;
addr = (void *)((uint32_t)pn * PGSIZE);
pte = uvpt[pn];
perm = PTE_P | PTE_U;
if ((pte & PTE_W) || (pte & PTE_COW))
perm |= PTE_COW;
if ((r = sys_page_map(thisenv->env_id, addr, envid, addr, perm)) < 0) {
panic("duppage: page remapping failed %e", r);
return r;
}
if (perm & PTE_COW) {
if ((r = sys_page_map(thisenv->env_id, addr, thisenv->env_id, addr, perm)) < 0) {
panic("duppage: page remapping failed %e", r);
return r;
}
}
return 0;
}
最后是fork函数,将页映射拷贝过去,这里需要考虑的地址范围就是从UTEXT到UXSTACKTOP为止,而在此之上的范围因为都是相同的,在env_alloc的时候已经设置好了。
envid_t
fork(void)
{
uint32_t addr;
int i, j, pn, r;
extern void _pgfault_upcall(void);
if ((envid = sys_exofork()) < 0) {
panic("sys_exofork failed: %e", envid);
return envid;
}
if (envid == 0) {
thisenv = &envs[ENVX(sys_getenvid())];
return 0;
}
for (i = PDX(UTEXT); i < PDX(UXSTACKTOP); i++) {
if (uvpd[i] & PTE_P) {
for (j = 0; j < NPTENTRIES; j++) {
pn = PGNUM(PGADDR(i, j, 0));
if (pn == PGNUM(UXSTACKTOP - PGSIZE))
break;
if (uvpt[pn] & PTE_P)
duppage(envid, pn);
}
}
}
if ((r = sys_page_alloc(envid, (void *)(UXSTACKTOP - PGSIZE), PTE_P | PTE_U | PTE_W)) < 0) {
panic("fork: page alloc failed %e", r);
return r;
}
if ((r = sys_page_map(envid, (void *)(UXSTACKTOP - PGSIZE), thisenv->env_id, PFTEMP, PTE_P | PTE_U | PTE_W)) < 0) {
panic("fork: page map failed %e", r);
return r;
}
memmove((void *)(UXSTACKTOP - PGSIZE), PFTEMP, PGSIZE);
if ((r = sys_page_unmap(thisenv->env_id, PFTEMP)) < 0) {
panic("fork: page unmap failed %e", r);
return r;
}
sys_env_set_pgfault_upcall(envid, _pgfault_upcall);
if ((r = sys_env_set_status(envid, ENV_RUNNABLE)) < 0) {
panic("fork: set child env status failed %e", r);
return r;
}
return envid;
}
首先需要为父进程设定错误处理例程。这里调用set_pgfault_handler函数是因为当前并不知道父进程是否已经建立了异常栈,没有的话就会建立一个,而sys_env_set_pgfault_upcall则不会建立异常栈。
调用sys_exofork准备出一个和父进程状态相同的子进程,状态暂时设置为ENV_NOT_RUNNABLE。然后进行拷贝映射的部分,在当前进程的页表中所有标记为PTE_P的页的映射都需要拷贝到子进程空间中去。但是有一个例外,是必须要新申请一页来拷贝内容的,就是用户异常栈。因为copy-on-write就是依靠用户异常栈实现的,所以说这个栈要在fork完成的时候每个进程都有一个,要硬拷贝过来。
主要流程就是:
1、申请新的物理页,映射到子进程的(UXSTACKTOP-PGSIZE)位置上去。
2、父进程的PFTEMP位置也映射到子进程新申请的物理页上去,这样父进程也可以访问这一页。
3、在父进程空间中,将用户错误栈全部拷贝到子进程的错误栈上去,也就是刚刚申请的那一页。
4、然后父进程解除对PFTEMP的映射。
5、最后把子进程的状态设置为可运行。
至此,Lab4的part B的写时拷贝就完成了。