MIT6.828学习之Lab4_Part B: Copy-on-Write Fork

代码运行流程(以forktree为例)

在这里插入图片描述

//forktree.c/umain()
forktree("")

进程 "":
-->forkchild(cur, '0');
	-->r=fork() 完成后父子进程的下一条指令都是if(r==0)
		-->set_pgfault_handler(pgfault);设好用户级页面错误处理程序
		-->who = sys_exofork();此时有两个进程,现场信息都一样,且下一条语句都是if(who==0)
			分配一个env,'UTOP以上的内存空间''内核该部分空间'一样。(所有进程这部分都一样)
			新进程env_tf与父进程完全一样,包括eip即下一条指令也一样,除了reg_eax即返回值不同
			新进程状态设为ENV_NOT_RUNNABLE,所以还不能运行,继续父进程
		-->for (i: 0~ PGNUM(USTACKTOP) duppage(who, i);
			将父进程'内存空间USTACKTOP以下的页面''复制映射'给子进程(UTOP以下=USTACKTOP以下+UXSTACKTOP)
			如果页面是可写或者COW的,则复制映射给子进程也是COW的,并重新把父进程的也映射成COW
				(当要往里面写时再调用'用户级页面错误处理程序'分配一个物理页,并重新映射到该处)
			否则就单纯复制映射就行(注意,复制映射不是复制内容)
		-->sys_page_alloc(who, (void *)(UXSTACKTOP-PGSIZE), PTE_W|PTE_U);为子进程用户异常栈分配物理页('必须')-->sys_env_set_pgfault_upcall(who, _pgfault_upcall);注意此时的'who是子进程的id'
		-->sys_env_set_status(who, ENV_RUNNABLE);子进程内存空间、页面错误处理程序都设好了,可以mark它可运行了
		-->return who;父进程里返回的是子进程id
-->forkchild(cur, '1');
	同上述操作一样

此时整个用户环境空间有三个可运行的环境:父进程"",子进程"0",子进程"1"
具体CPU运行哪一个,按轮转调度程序sched_yield()

子进程"0"(或进程"1":
-->if(r==0){...}假设此时fork()的返回值是r,r确实为0
	-->forktree("0");操作同上面的forktree
		-->forkchild('0', '0');
		-->forkchild('1', '1');
	所以又会fork出两个新进程,进程"00",进程"01" (或者进程"10",进程"11")
	
父进程"":因为r!=0,所以退出forkchild(),退出forktree(),退出umain()
-->exit() exit gracefully!

当cur长度等于3时,就不会再fork出新子进程了。而完成两次forkchild()的进程都会eixt()
当所有进程都exit()后,CPU就会进入monitor
-->sched_yield() 
-->sched_halted()
-->monitor()

实验过程

如之前提到的,Unix提供了fork()系统调用作为其主要的进程创建原语。fork()系统调用将调用者进程(父进程)的地址空间复制到一个新创建的进程(子进程)。

xv6 Unix 通过复制父进程物理页所有数据到分配给子进程的物理页。dumbfork()也是这么做的。将父地址空间复制到子地址空间是fork()操作中开销最大的部分。

然而,调用fork()后几乎里面会在子进程中调用一个exec(),它会用新程序代替子进程的内存。这是shell通常会做的。这样的话,花在复制父进程地址空间的时间就是极大的浪费,因为子进程在调用exec()之前只会使用很少的内存。

出于这个原因,Unix的后续版本利用虚拟内存硬件,允许父进程和子进程共享映射到各自地址空间的内存,直到其中一个进程实际修改它。这种技术称为“copy-on-write”(写时复制)。为此,内核将在fork()上将地址空间映射从父节点复制到子节点,而不是将映射页面的内容复制到子节点,同时将当前共享的页面标记为read-only。当两个进程中的一个试图写入其中一个共享页面时,该进程将接受一个page fault。此时,Unix内核意识到页面实际上是一个“virtual”或“copy-on-write”副本,因此它为故障处理过程创建了一个新的、私有的、可写的页面副本。这样,单个页面的内容在实际写入之前不会被复制。这种优化使得fork()后面紧跟的子进程的exec()花销减少:子进程在调用exec()之前可能只需要复制一个页面(the current page of its stack)。

在本lab的下一部分中,您将实现一个“proper”类unix fork(),它具有copy-on-write功能,作为用户空间库例程。在用户空间中实现fork()和copy-on-write支持的好处

  • 内核保持非常简单,因此更有可能是正确的。
  • 它还允许各个用户模式程序为fork()定义自己的语义。
    如果一个程序想要一个稍微不同的实现(例如,像dumbfork()这样昂贵的总是复制的版本,或者父级和子级在以后共享内存的版本),则可以很容易地提供它自己的实现。

User-level page fault handling

用户级copy-on-write fork()需要知道write-protected页面上的页面错误,所以这是您首先要实现的。写时复制只是用户级页面错误处理的众多可能用途之一。

通常设置一个地址空间,以便页面错误指示何时需要执行某些操作。例如,大多数Unix内核最初只映射新进程堆栈区域中的一个页面,然后“按需”来分配和映射其他堆栈页面。典型的Unix内核必须跟踪当页面错误发生在进程空间的每个区域时应该采取什么操作。例如,堆栈区域中的页面错误通常会分配和映射物理内存的新页面。程序的BSS区域中的页面错误通常会分配一个新页面,用0填充它并映射它。在具有请求分页可执行(demand-paged executables)程序的系统中,text区域中的错误将从磁盘读取二进制文件的对应页面,然后将其映射。

这是内核需要跟踪的大量信息。您将决定如何处理用户空间中的每个页面错误,而不是采用传统的Unix方法,因为在用户空间中,错误的破坏性较小。这种设计的另一个好处是允许程序在定义内存区域时具有很大的灵活性;稍后,您将使用用户级的页面错误处理来映射和访问基于磁盘的文件系统上的文件。

Setting the Page Fault Handler

为了处理自己的页面错误,用户环境需要向JOS内核注册(register)一个page fault handler entrypoint(页面错误处理程序入口点)。用户环境通过新的sys_env_set_pgfault_upcall系统调用注册其页面错误入口点。我们在Env结构中添加了一个新成员env_pgfault_upcall来记录该信息。

Exercise 8. 实现sys_env_set_pgfault_upcall系统调用。在查找目标环境的环境ID时,请确保启用权限检查,因为这是一个“危险的”系统调用。

Normal and Exception Stacks in User Environments

在正常执行期间,JOS中的用户环境将运行在正常的用户堆栈上:它的ESP寄存器从指向USTACKTOP开始,它推送的堆栈数据驻留在USTACKTOP- PGSIZE到USTACKTOP-1的页面上。然而,当页面错误在用户模式下发生时,内核将重新启动用户环境,在另一个堆栈上运行指定的用户级页面错误处理程序,即用户异常堆栈。在本质上,我们将使JOS内核实现代表用户环境的自动“stack switching”,这与x86处理器tf在从用户模式转换到内核模式时已经代表JOS实现堆栈切换的方式非常相似!

The JOS user exception stack 也是一个页面(PGSIZE)大小,它的顶部定义为虚拟地址UXSTACKTOP,因此用户异常堆栈的有效字节来自UXSTACKTOP- PGSIZE到UXSTACKTOP-1。在这个异常堆栈上运行时,用户级的页面错误处理程序可以使用JOS的常规系统调用来映射新页面或调整映射,从而修复最初导致页面错误的任何问题。然后,用户级页面错误处理程序通过汇编语言stub返回到原始堆栈上的错误代码。

希望支持用户级页面错误处理的每个用户环境都需要使用第A部分中介绍的sys_page_alloc()系统调用为自己的异常堆栈分配内存

Invoking the User Page Fault Handler

现在需要更改kern/trap.c中的页面错误处理代码,以处理用户模式下的页面错误,如下所示。我们将把故障发生时用户环境的状态称为trap-time状态。

如果没有注册页面错误处理程序,JOS内核将像以前一样使用消息破坏用户环境。否则,内核将在异常堆栈上设置一个trap frame,就像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中的代码,该代码用于向用户模式处理程序分派页面错误。在写入异常堆栈时,请确保采取适当的预防措施。(如果用户环境耗尽异常堆栈上的空间会发生什么?)

答:不停递归就可能耗尽异常堆栈的空间,也木有关系,在page_fault_handler()里的user_mem_asser()会检查是否overflow,如果是就会直接destroy这个env,问题不大。不过这个函数的实现是真的很难。

  • 第一是必须要看懂上面UTrapframe的结构,弄清楚跟Trapframe的区别
    从下面可以看到,UTrapframe与Trapframe的区别在于是否保存段寄存器值。我想是因为UTrapframe用户处理程序在用户态下,所以段寄存器值不需要变。而Trapframe需要进入内核栈,所以需要把发生陷入时的段寄存器状态保存。

    struct Trapframe {
    	struct PushRegs tf_regs;
    	uint16_t tf_es;
    	uint16_t tf_padding1;
    	uint16_t tf_ds;
    	uint16_t tf_padding2;
    	uint32_t tf_trapno;
    	/* below here defined by x86 hardware */
    	uint32_t tf_err;
    	uintptr_t tf_eip;
    	uint16_t tf_cs;
    	uint16_t tf_padding3;
    	uint32_t tf_eflags;
    	/* below here only when crossing rings, such as from user to kernel */
    	uintptr_t tf_esp;
    	uint16_t tf_ss;
    	uint16_t tf_padding4;
    } __attribute__((packed));
    
    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放入UXSTACKTOP区域(入用户异常栈),尤其是弄清为什么要留空32bit字,什么时候留。
    这里要弄明白,入栈操作其实就是对内存进行操作。所以把utf指向用户异常栈的正确虚拟地址,然后把需要存储的值依次存入对应内存空间,最后让esp指向utf就完成了用户异常栈上建立UTrapframe栈帧操作。当tf->tf_esp本身就在用户异常栈时就说明现在时递归发生页面错误,如果不是递归应该用户异常栈是空的,tf->tf_esp会从别处指过来。递归发生页面错误就留个空白字,然后在pfentry.S中会把trap-time eip存到这个空白字中,这样等UTrapframe出栈后,ret指令就可以读出eip来继续执行。如果是非递归,那么普通栈可以存eip(先把trap-time esp减4然后存eip),所以不需要留空白字。

  • 第三就是怎么把栈从用户普通栈转成用户异常栈,以及让下一条指令执行pfentry.S/_pgfault_upcall呢。
    栈的转换主要就是看SS:ESP,由于都是用户态下,所以ss不变。陷入内核态只是完成UTrapframe,并把栈转换下,以及修改用户下一条指令为_pgfault_upcall。保存trap-time esp到utf_esp中,然后把tf->esp指向用户异常栈tf->eip指向_pgfault_upcall,然后env_run(curenv)回到用户环境就完成栈的转换以及处理函数入口调用。

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.

	// LAB 3: Your code here.
	if((tf->tf_cs & 3)==0)
		panic("a page fault happened in kernel mode!\n");
	// LAB 4: Your code here.
	struct UTrapframe *utf;
	//uintptr_t uxEsp=UXSTACKTOP-1;
	if(curenv->env_pgfault_upcall != NULL){
		//发生异常时,用户环境已经在用户异常堆栈上运行,应该在当前tf->tf_esp下启动新的堆栈帧
		//您应该首先推送一个空的32位word,然后是struct UTrapframe
		if(tf->tf_esp<=UXSTACKTOP-1 && tf->tf_esp >=UXSTACKTOP-PGSIZE) 
			utf = (struct UTrapframe *)(tf->tf_esp - sizeof(struct UTrapframe) -4);
		else //否则,应该在UXSTACKTOP启动新的堆栈帧
			utf = (struct UTrapframe *)(UXSTACKTOP - sizeof(struct UTrapframe));

		// 检查是否the exception stack overflows
		user_mem_assert(curenv, (const void *)utf, sizeof(struct UTrapframe), PTE_W);

		// Set up a page fault stack frame on the user exception stack 
		utf->utf_fault_va = fault_va;
		utf->utf_err = tf->tf_trapno; //要区分tf_trapno与tf_err
		utf->utf_regs = tf->tf_regs;
		utf->utf_eflags = tf->tf_eflags;
		utf->utf_eip = tf->tf_eip;
		utf->utf_esp = tf->tf_esp; 	

		//这里我就确实有点想不到
		tf->tf_esp = (uintptr_t)utf;

		//branch to curenv->env_pgfault_upcall
		tf->tf_eip = (uintptr_t)curenv->env_pgfault_upcall;
		env_run(curenv);	
	}else{
		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页面错误处理程序并在原始(original)错误处理指令上恢复执行。这个assembly routine是将使用sys_env_set_pgfault_upcall()在内核中注册的处理程序。

Exercise 10. 在lib/pfentry.S中实现_pgfault_upcall例程。有趣的部分是返回到导致页面错误的用户代码中的原始点。您将直接返回那里,而不需要通过内核返回。最难的部分是同时切换堆栈重新加载EIP

在这里主要就是把UTrapframe从用户异常栈中pop出来,以便恢复现场。正如上面要求所说,最难的是切换堆栈和重新加载EIP。先把trap-time esp减4,然后trap-time eip放入。这要pop trap-time esp后就完成了切换堆栈,而此时esp指着的就是eip,通过ret指令,就完成了重新加载eip寄存器

.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			//注意,是在这里调用的_pgfault_handler
	addl $4, %esp			// pop function argument
	// LAB 4: Your code here.

	//Push trap-time %eip onto the trap-time stack.
	// trap-time esp point to trap-time stack
	addl $8, %esp  //pass the fault_va and tf_err
	movl 0x20(%esp), %eax // 0x20(%esp)==*(%esp+32)==trap-time eip;
	
	/* 这样写trap-time esp没修改,虽然eip放对了,但是esp还指着上边,是不对的
	movl 0x28(%esp), %ebx // 0x28(%esp)==*(%esp+40)==trap-time esp;
	subl $4, %ebx // point to the blank word*/

	subl $4, 0x28(%esp)
	movl 0x28(%esp), %ebx
	movl %eax, (%ebx) // Push trap-time %eip onto the blank word of the trap-time stack

	
	// Restore the trap-time registers.  After you do this, you
	// can no longer modify any general-purpose registers.
	// LAB 4: Your code here.
	popal // Restore the trap-time registers
	
	// Restore eflags from the stack.  After you do this, you can
	// no longer use arithmetic operations or anything else that
	// modifies eflags.
	// LAB 4: Your code here.
	addl $4, %esp // pass the trap-time eip
	popfl //将标志寄存器的值出栈
	
	// Switch back to the adjusted trap-time stack.
	// LAB 4: Your code here.
	popl %esp 
	
	// Return to re-execute the instruction that faulted.
	// LAB 4: Your code here.
	ret //将当前(%esp)内容pop到eip寄存器

最后,你需要实现用户级页面错误处理机制的C用户库端(C user library side)

Exercise 11. Finish set_pgfault_handler() in lib/pgfault.c.

void set_pgfault_handler(void (*handler)(struct UTrapframe *utf))
{
	int r;
	if (_pgfault_handler == 0) {
		// First time through!
		// LAB 4: Your code here.
		r=sys_page_alloc(thisenv->env_id, (void *)(UXSTACKTOP-PGSIZE), PTE_W|PTE_U);
		if(r!=0)
			panic("fail to alloc a page for UXSTACKTOP!\n");
		r=sys_env_set_pgfault_upcall(thisenv->env_id, _pgfault_upcall);
		if(r!=0)
			panic("fail to set pgfault upcall!\n");
		//panic("set_pgfault_handler not implemented");
	}

	// Save handler pointer for assembly to call.
	_pgfault_handler = handler;
}

sys_env_set_pgfault_upcall注册成_pgfault_upcall而不是handler不仅可以调用handler,还能将UTrapframe出栈,完成“栈的切换”,直接回到页面错误发生处

Testing

Run user/faultread (make run-faultread). You should see:

...
[00000000] new env 00001000
[00001000] user fault va 00000000 ip 0080003a
TRAP frame ...
[00001000] free env 00001000

Run user/faultdie. You should see:

...
[00000000] new env 00001000
i faulted at va deadbeef, err 6
[00001000] exiting gracefully
[00001000] free env 00001000

Run user/faultalloc. You should see:

...
[00000000] new env 00001000
fault deadbeef
this string was faulted in at deadbeef
fault cafebffe
fault cafec000
this string was faulted in at cafebffe
[00001000] exiting gracefully
[00001000] free env 00001000

如果只看到第一个“this string”行,则意味着没有正确处理递归页面错误
就只有这里出错了,原因是pfentry.S中放eip错了。我写成了

 这样写trap-time esp没修改,虽然eip放对了,但是esp还指着上边,这样栈切换后,ret指令会把eip上边内容加载到eip寄存器,就错了
	movl 0x28(%esp), %ebx // 0x28(%esp)==*(%esp+40)==trap-time esp;
	subl $4, %ebx // point to the blank word
	
正确写法是:
	subl $4, 0x28(%esp)
	movl 0x28(%esp), %ebx

Run user/faultallocbad. You should see:

...
[00000000] new env 00001000
[00001000] user_mem_check assertion failure for va deadbeef
[00001000] free env 00001000

确保您理解为什么user/faultalloc和user/faultallocbad行为不同。
答:在user/faultallocbad中,是sys_cputs((char*)0xDEADBEEF, 4);这会直接系统调用lib/sys_cputs,然后在kern/sys_cputs中的user_mem_assert()发现缺页而panic
而在user/faultalloc中的cprintf("%s\n", (char*)0xDeadBeef);会先产生page fault,这样进入trap()然后到page_fault_handler()最后通过user/faultalloc.c/handler()分配物理页,然后再去输出就不会被panic

Implementing Copy-on-Write Fork

现在,您已经拥有了完全在用户空间中实现copy-on-write fork()的内核工具。

我们在lib/fork.c中为您的fork()提供了一个框架。与dumbfork()类似,fork()应该创建一个新环境,然后扫描父环境的整个地址空间,并在子环境中设置相应的页面映射。关键的区别在于,dumbfork()复制了页面,但是fork()最初只复制页面映射(映射不是内容)。

fork()的基本控制流程如下:

  1. 父进程使用上面实现的set_pgfault_handler()函数将pgfault()安装为 C-level page fault handler

  2. 父环境调用sys_exofork()来创建子环境

  3. 对于UTOP下面地址空间中的每个可写页面或写时复制页面,父类调用duppage, duppage应该将写时复制的页面映射到子进程的地址空间,然后在自己的地址空间中重新映射写时复制的页面。[注:此处的顺序实际上很重要!(即,先把子进程中该页面标记为COW,再把父进程中该页面也标记为COW)你知道为什么吗?试着想想一个具体的例子,在这种情况下,颠倒顺序可能会引起麻烦]。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代码必须参考environment的页表才能执行上面的几个操作(例如,页面的PTE标记为PTE_COW)。内核将环境的页表映射到UVPT正是出于这个目的。它使用了一个聪明的映射技巧使其更容易地查找用户代码的ptelib/entry.S设置uvpt和uvpd,以便您可以轻松地在lib/fork.c中查找页表信息。

那下面就来看看怎么个聪明的映射技巧吧。

UVPT

page table的一个很好的概念上的模型是一个2^20条目数组,它可以通过物理页number索引。x86 2级分页方案通过将巨大的页表分割为多个页表和一个页目录打破了这个简单的模型。在内核中,我们使用pgdir_walk()通过遍历两级页表来查找条目(也就是说在lib文件下不能用pgdir_walk函数的,尴尬)。如果能以某种方式恢复这个巨大的简单页表就好了——JOS中的进程将会查看它,以确定它们的地址空间中发生了什么。但如何?

这个页面描述了JOS通过利用分页硬件使用的一个简单技巧——分页硬件非常适合将一组不连续的页面放在一个连续的地址空间中。原来我们已经有了一个表,其中包含指向所有片段页表的指针:就是page directory!

因此,我们可以使用 page directory 作为页表,将概念上的巨大的2^ 22字节的页表(由1024页表示)映射到虚拟地址空间中相邻的2^22字节范围。我们可以将PDE条目标记为只读来确保用户进程不能修改它们的页表。

我们之前学习过X86将虚拟地址转换为物理地址。处理器只是遵循指针:pd = lcr3();pt = * (pd + 4 * PDX);页面= * (pt + 4 * PTX);因为处理器没有页面目录、页面表和页面的概念,页面只是普通内存。所以并没有说内存中的特定页面不能同时充当其中的两三个。

从图上看,它从CR3开始,跟随三个箭头,然后停止。如果我们在页面目录中放入一个指针,该指针在索引V处的指向自身(真秀),如下图图片出自此处
在这里插入图片描述

然后,当我们试图转换一个PDX和PTX等于V的虚拟地址时,那么这三个箭头都会指向page directory自己。因此,虚拟页面转换为包含页面目录的页面。在JOS中,V是0x3BD,所以UVPD(User Virtual Page Directory)的虚拟地址是(0x3BD<<22)|(0x3BD<<12)。[因为virtual address=高10位的PDX + 中间10位PTX + 低12位的0]

现在,如果我们试图转换一个PDX = V但任意PTX != V的虚拟地址,那么第三个箭头就会指向page tables,而不是pages。因此,PDX=V的虚拟页面集形成一个4MB的区域,就处理器而言,该区域的页面内容就是page tables本身。在JOS中,V是0x3BD,所以UVPT(User Virtual Page Table)的虚拟地址是(0x3BD<<22)

因此,由于“no-op”箭头被巧妙地插入到页目录表中(页目录表中条目V指向页目录表自身),我们将用作page directorypage tables页面(通常是不可见的)也映射到虚拟地址空间中。 确实秀。

Exercise 12. Implement fork, duppage and pgfault in lib/fork.c.
使用 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'

这里主要是要对虚拟内存的页面进行权限判断。但是page_walk函数是内核下的,不能再用户环境下调用,通过上面那个巧妙的映射技巧,就可以通过uvpd与uvpt找到所有的pde与pte。uvpd是有2^ 10个pde的一维数组,uvpt是有2^20个pte的一维数组。

物理页面号为pn时,物理地址为pn*PGSIZE,位于页表号为pn/1024,对应pde为uvpd[pn/1024],因为pn跟pte是一一对应的,所以对应的pte为uvpt[pn]

pgfault()
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.
	/*pte_t *pte;
	pte = pgdir_walk(thisenv->env_pgdir, addr, 0);傻了吧,不能用的*/
	if(!((err & FEC_WR)&&(uvpt[PGNUM(addr)] & (PTE_W | PTE_COW))))
		panic("pgfault conditions wrong!\n");
	//cprintf("utf_fault_va:%08x thisenv_id:%d env_id:%d\n",addr,thisenv->env_id,sys_getenvid());
	//thisenv->env_id跟sys_getenvid()还真不一样。。。难道我fork里thisenv更新错了?

	envid_t envid = sys_getenvid();
	r = sys_page_alloc(envid, (void *)PFTEMP, PTE_P|PTE_W|PTE_U);
	if(r!=0) 
		panic("page alloc fault: %e",r);
	addr = ROUNDDOWN(addr, PGSIZE); //这里也是没想到
	memcpy((void *)PFTEMP, (const void *)addr, PGSIZE);
	r=sys_page_map(envid, (void *)PFTEMP, envid, (void *)addr, PTE_P|PTE_W|PTE_U);
	if(r!=0) 
		panic("page map fault: %e",r);
	r=sys_page_unmap(envid, (void *)PFTEMP); //忘了还得解除映射
	if(r!=0) 
		panic("page unmap fault: %e",r);
	// LAB 4: Your code here.

	//panic("pgfault not implemented");
}

duppage()
static int
duppage(envid_t envid, unsigned pn)
{
	int r;
	//pte_t *pte;
	//pte = pgdir_walk(thisenv->env_pgdir, (const void *)pn*PGSIZE, 0);用不了这个函数
	void *addr=(void *)(pn<<12);
	envid_t fu_id = sys_getenvid();
	if(uvpt[pn] & (PTE_W | PTE_COW)){		
		r = sys_page_map(fu_id, (void *)addr, envid, (void *)addr, PTE_COW|PTE_U);
		if(r!=0)
			return r;
		//父进程这里要重新映射一遍,区别是之前有可能是writable,现在只能是COW,so?
		//难道是因为现在父子进程都映射着同一个物理页,如果父进程还是可写的话,就会影响子进程
		r = sys_page_map(fu_id, (void *)addr, fu_id, (void *)addr, PTE_COW|PTE_U);
		if(r!=0) 
			return r;
	}else{
		r = sys_page_map(fu_id, (void *)addr, envid, (void *)addr, uvpt[pn] & PTE_SYSCALL);
		if(r!=0)
			return r;
	}
	return 0;
	panic("duppage not implemented");
}
fork()
envid_t
fork(void)
{
	// LAB 4: Your code here.
	envid_t who;
	int i,r;
	set_pgfault_handler(pgfault);
	// fork a child process
	who = sys_exofork();
	if (who < 0)
		panic("sys_exofork: %e", who);
	
	if (who == 0) {
		// We're the child.
		// The copied value of the global variable 'thisenv'
		// is no longer valid (it refers to the parent!).
		// Fix it and return 0.
		thisenv = &envs[ENVX(sys_getenvid())];
		return 0;
	}

	// Copy our address space to the child.
	// 父进程虚拟地址空间UTOP以下的每一页都应该在子进程中有所映射
	// 关键是要找到该虚拟地址在页表中对应的pte,才能知道权限
	// 这是在用户空间,page_walk是内核空间的,所以巧妙的通过uvpt、uvpd来找到pte、pde
	// 我们可以知道,uvpd是有1024个pde的一维数组,而uvpt是有2^20个pte的一维数组,与物理页号刚好一一对应
	for (i = 0; i < PGNUM(USTACKTOP); i ++){ 
		//这里只需要到USTACKTOP,后面只有UXSTACKTOP,不能是COW,必须分配物理内存
		//不然老报错[00001000] user_mem_check assertion failure for va eebfffcc
		//报错的位置应该是page_fault_handler()里的user_mem_asser()检查用户异常栈权限
		if((uvpd[i/1024] & PTE_P) && (uvpt[i] & PTE_P)){ //i跟pte一一对应,而i/1024就是该pte所在的页表
			r=duppage(who, i); //区分是不是COW与W都放到duppage中。我之前这里也区分也是乱了
			if(r!=0)
				panic("duppage fault:%e\n",r);
		}
	}

	//   Neither user exception stack should ever be marked copy-on-write,
	//   任何用户异常堆栈都不应该标记为写时复制
	//   so you must allocate a new page for the child's user exception stack.
	r=sys_page_alloc(who, (void *)(UXSTACKTOP-PGSIZE), PTE_W|PTE_U);
	if(r!=0)
		panic("page alloc fault:%e\n",r);
	
	//Copy page fault handler setup 
	extern void _pgfault_upcall(void);
	r=sys_env_set_pgfault_upcall(who, _pgfault_upcall);
	if(r!=0)
		panic("set pgfault upcall fault:%e\n",r);

	// Then mark the child as runnable and return.
	r=sys_env_set_status(who, ENV_RUNNABLE);
	if(r!=0)
		panic("env set status fault:%e\n",r);
	return who; //return 0 我居然在fork里一直返回0???
	panic("fork not implemented");
}

Part B到这就结束了。当你运行make grade的时候,你应该通过所有Part B的测试。像往常一样,你应该用make handin提交你的代码。

提问

1.还是不太明白,子进程UTOP以上的内存空间是什么时候初始化的?为什么在fork.c/fork()中for (i = 0; i < PGNUM(USTACKTOP); i ++)只需要到USTACKTOP,后面是什么时候赋值的?
答:在sys_exofork系统调用中,调用了env_alloc,在里面又调用了env_setup_vm,这个函数就把新创建的进程的虚拟内存空间UTOP以上部分复制了kern_pgdir中的对应部分。所以我们对子进程内存空间的操作只要管UTOP以下。
因为USTACKTOP后面只剩一个UXSTACKTOP了呀,UXSTACKTOP是不能是COW的,一定得给子进程的异常栈分配物理内存。

2.forktree中什么时候进入、怎么进入pgfault()处理的?
答:我认为就是forkchild里char nxt[DEPTH+1];这里要分配内存,结果发现是COW页面,引发页面错误。然后陷入内核,进入kern/page_fault_handler()(完成UTrapframe,将用户进程从普通栈切换到用户异常栈,设置用户进程下一条指令为_pgfault_upcall),然后env_run()回到当前环境,进入pfentry.S,然后调用了pgfault()

3.我在fork.c中很多地方用了thisenv->env_id,后面发现thisenv->env_id于sys_getenvid()居然多处不一样,导致很多错误,这是为什么?
答:好像就libmain()跟fork()中涉及到了thisenv的修改。而在fault()中,我发现thisenv->env_id是00001000,而sys_getenvid()是000010001,这说明执行fault()的是子进程,因为子进程想往COW的页面写东西才发生的页面错误。但是fork()中明明当envid==0时将thisenv修改成了子进程env,所以为什么thisenv还是指向的父进程?不懂

4.为什么fork仅仅被调用一次,却能够返回两次?
答:创建新进程成功后,系统中出现两个基本完全相同的进程,这两个进程执行没有固定的先后顺序,哪个进程先执行要看系统的进程调度策略,但是子进程起码得等父进程完成对子进程内存空间的映射并把子进程状态设为runnable后才能开始运行。根据我们写的sys_exofork,父子进程唯一的区别就是eax中保存的返回值不同,也就是说两个进程连eip都相同,所以父子进程都会执行fork()中envid=sys_exofork()语句后面那条语句,也就是if(envid==0){...},因此会返回两次。(我猜的)

5.为什么fpid的值在父子进程中不同?(引用别人答案)
答:fpid的值为什么在父子进程中不同。“其实就相当于链表,进程形成了链表,父进程的fpid(p 意味point)指向子进程的进程id, 因为子进程没有子进程,所以其fpid为0.

6.为什么set_pgfault_handler里不直接把 sys_env_set_pgfault_upcall设成handler呢?
答:设成_pgfault_upcall不仅可以调用handler,还能将UTrapframe出栈,完成“栈的切换”,直接回到页面错误发生处,非常优秀的操作。

7.如果有一个物理页,同时映射在父进程与子进程内存空间,且权限是COW,那如果父子进程都要写,所以都调用用户级页面错误处理程序新分配了一个物理页映射在相应内存地址,那最初的物理页怎么办?
答:在fork.c/pgfault()里会分配新页面并重新映射。当该内存地址已经映射了物理页,就会调用page_remove,将原物理页的引用次数pp_ref减一。所以当父子进程都重新映射后,原物理页会因为pp_ref=0而被page_free掉。

8.由于页面错误处理程序将执行实际的复制,并且页面错误处理程序运行在异常堆栈上,因此不能让异常堆栈是 copy-on-write:谁将复制它?
答:没太看懂这个问题。异常堆栈的复制是父进程给子进程在fork()中分配了一个物理页。而页面错误处理程序中的复制,则是子进程调用页面错误处理程序,先复制错误页面,在映射回错误页面处。

9.注:此处的顺序实际上很重要!(即,先把子进程中该页面标记为COW,再把父进程中该页面也标记为COW)你知道为什么吗?试着想想一个具体的例子,在这种情况下,颠倒顺序可能会引起麻烦
答:首先要把父进程相应页面也标记成COW,我想是因为父子进程现在都映射着同一页面,如果父进程可能是writable,那他的修改可能会影响到子进程,这样肯定不行。至于这个顺序,我只能猜想先把父进程页面设为COW,那复制映射到子进程时在sys_page_map()中会因为父进程srcva权限不可写而return -E_INVAL,导致duppage、fork都返回error,所以fork出的进程少很多,但是为什么会输出其他字符,我就不太懂了,就算父进程COW进入页面错误,也会在fault中处理好,不应该乱码。

// 颠倒顺序后的结果
[00000000] new env 00001000
1000: I am ''
[00001000] new env 00001001
[00001000] new env 00001002
[00001000] exiting gracefully
[00001000] free env 00001000
1001: I am '1'
[00001001] new env 00002000
[00001001] new env 00001003
[00001001] exiting gracefully
[00001001] free env 00001001
1002: I am '1'
[00001002] new env 00002001
[00001002] new env 00001004
[00001002] exiting gracefully
[00001002] free env 00001002
2001: I am '11'
[00002001] new env 00002002
[00002001] new env 00001005
[00002001] exiting gracefully
[00002001] free env 00002001
2002: I am '111'
[00002002] exiting gracefully
[00002002] free env 00002002
1003: I am 'i�'
[00001003] exiting gracefully
[00001003] free env 00001003
1004: I am 'i�'
[00001004] exiting gracefully
[00001004] free env 00001004
1005: I am '�߿�1'
[00001005] exiting gracefully
[00001005] free env 00001005
2000: I am '11'
[00002000] new env 00002005
[00002000] new env 00002004
[00002000] exiting gracefully
[00002000] free env 00002000
2004: I am '�߿�1'
[00002004] exiting gracefully
[00002004] free env 00002004
2005: I am '111'
[00002005] exiting gracefully
[00002005] free env 00002005
No runnable environments in the system!

参考

talin2010的Lab4,有水平
感谢Small_Pond

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
实验概述 本次实验是MIT 6.828操作系统课程的第一次实验,主要内容是编写一个简单的操作系统内核,并在QEMU虚拟机上运行。本次实验共有9个练习,其中练习9要求实现一个简单的用户程序并运行。 练习9要求我们实现一个简单的用户程序,该程序能够在屏幕上输出一些信息,并等待用户输入,输入结束后将输入内容输出到屏幕上。用户程序的具体要求如下: - 输出一些信息,例如“Hello World!”。 - 等待用户输入,可以使用getchar()函数实现。 - 将用户输入内容输出到屏幕上。 实验过程 1. 编写用户程序 我们首先在lab1目录下创建一个user文件夹,用于存放用户程序。然后创建一个名为“test.c”的文件,编写用户程序的代码如下: ``` #include <stdio.h> int main() { printf("Hello World!\n"); char c = getchar(); printf("You entered: %c\n", c); return 0; } ``` 这段代码的功能是输出“Hello World!”并等待用户输入,输入结束后将输入内容输出到屏幕上。 2. 修改Makefile文件 为了能够编译用户程序,我们需要修改Makefile文件。具体修改如下: ``` UPROGS=\ _cat\ _echo\ _forktest\ _grep\ _init\ _kill\ _ln\ _ls\ _mkdir\ _rm\ _sh\ _stressfs\ _usertests\ _wc\ _test\ # 添加用户程序的名称 $(OBJDIR)/_test: $(OBJDIR)/test.o $(LIBDIR)/ulib.o | $(OBJDIR) $(LD) $(LDFLAGS) -N -e main -Ttext 0 -o $@ $^ $(OBJDIR)/test.o: test.c | $(OBJDIR) $(CC) $(CFLAGS) -c -o $@ $< ``` 在UPROGS变量中添加上刚刚编写的用户程序的名称“_test”,然后在Makefile文件的末尾添加如上代码。 3. 编译内核和用户程序 在终端运行命令“make”,编译内核和用户程序。 4. 运行QEMU虚拟机 在终端运行命令“make qemu”,启动QEMU虚拟机。 5. 运行用户程序 在QEMU虚拟机中,输入“test”,即可运行刚刚编写的用户程序。运行结果如下: ``` Hello World! This is a test. You entered: T ``` 可以看到,程序首先输出了“Hello World!”这个信息,然后等待用户输入。我们输入了“This is a test.”这个字符串,然后按下回车键,程序将输入内容输出到了屏幕上。 实验总结 本次实验要求我们实现一个简单的用户程序并运行。通过编写代码、修改Makefile文件、编译内核和用户程序、启动虚拟机以及运行用户程序等步骤,我们成功地完成了本次实验,并学会了如何在操作系统内核中运行用户程序。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值