lab4体会到了OS难度的飞升。实验需要掌握的重点有以下:
-
系统调用流程
-
进程通信机制
-
fork
本lab理解难度较高,接下来将以以上三部分分别梳理。
概念
一般情况下,进程不能存取内核数据。但有的场景必须通过内核执行,因此操作系统设计了陷入异常后调用特定内核函数的过程。
系统调用流程
系统调用的具体层次结构为:
按照这个流程,首先来看syscall_lib.c中的函数们。
1 void syscall_putchar(char ch) 2 { 3 msyscall(SYS_putchar, (int)ch, 0, 0, 0, 0); 4 }
以此函数为例,在调用用户空间的syscall_*()函数后,该函数会将传入的参数,连带系统调用号SYS_*()一起,传入msyscall函数。msyscall函数比较简单,将调用syscall。
1 LEAF(msyscall) 2 syscall 3 jr ra 4 nop 5 END(msyscall)
调用syscall指令后,陷入内核态,pc将被指向一个内核异常入口,即handle_sys()函数。该函数作用为将传入的参数安置到合适的位置,然后调用对应的内核态系统调用函数。这就出现了系统调用部分最难理解的区块:传入参数的位置。
1 #include2 #include 3 #include 4 #include 5 #include 6 7 /*** exercise 4.2 ***/ 8 NESTED(handle_sys,TF_SIZE, sp) 9 SAVE_ALL // Macro used to save trapframe 10 CLI // Clean Interrupt Mask 11 nop 12 .set at // Resume use of $at 13 14 // TODO: Fetch EPC from Trapframe, calculate a proper value and store it back to trapframe. 15 lw t0, TF_EPC(sp) 16 addiu t0, t0, 4 17 sw t0, TF_EPC(sp) 18 // TODO: Copy the syscall number into $a0. 19 lw a0, TF_REG4(sp) 20 addiu a0, a0, -__SYSCALL_BASE // a0 <- relative syscall number 21 sll t0, a0, 2 // t0 <- relative syscall number times 4 22 la t1, sys_call_table // t1 <- syscall table base 23 addu t1, t1, t0 // t1 <- table entry of specific syscall 24 lw t2, 0(t1) // t2 <- function entry of specific syscall 25 26 lw t0, TF_REG29(sp) // t0 <- user's stack pointer 27 lw t3, 16(t0) // t3 <- the 5th argument of msyscall 28 lw t4, 20(t0) // t4 <- the 6th argument of msyscall 29 30 // TODO: Allocate a space of six arguments on current kernel stack and copy the six arguments to proper location 31 lw a0, TF_REG4(sp) 32 lw a1, TF_REG5(sp) 33 lw a2, TF_REG6(sp) 34 lw a3, TF_REG7(sp) 35 addiu sp, sp, -24 36 sw t3, 16(sp) 37 sw t4, 20(sp) 38 39 40 jalr t2 // Invoke sys_* function 41 nop 42 43 // TODO: Resume current kernel stack 44 addiu sp, sp, 24 45 sw v0, TF_REG2(sp) // Store return value of function sys_* (in $v0) into trapframe 46 47 j ret_from_exception // Return from exeception 48 nop 49 END(handle_sys) 50 51 sys_call_table: // Syscall Table 52 .align 2 53 .word sys_putchar 54 .word sys_getenvid 55 .word sys_yield 56 .word sys_env_destroy 57 .word sys_set_pgfault_handler 58 .word sys_mem_alloc 59 .word sys_mem_map 60 .word sys_mem_unmap 61 .word sys_env_alloc 62 .word sys_set_env_status 63 .word sys_set_trapframe 64 .word sys_panic 65 .word sys_ipc_can_send 66 .word sys_ipc_recv 67 .word sys_cgetc
在进入handle_sys函数时,原先的寄存器都是被以trapframe的形式传入的,因此参数也都保存在trapframe中。msyscall函数的前四个参数(即系统调用号+前三个参数)分别被存储在trapframe的a0-a3寄存器,即需要用TF_REG4-7(sp)进行获取。而我们的目标为,将这四个参数装入a0-a3寄存器。第5、6个参数,分别被安置在16(TF_REG29(sp))和20(TF_REG29(sp)),我们的目标为,sp自减24,后将他们转移到16(sp)和20(sp)。可以理解为,为了装入这六个参数,栈指针下降了24字节来保存他们,而他们根据顺序由地址小到地址大存放。但由于前四个函数在a0-a3中已经存储,所以0(TF_REG29(sp))到12(TF_REG29(sp))空余即可,而5、6个参数依旧需要被存放在16(TF_REG29(sp))和20(TF_REG29(sp))。
理解了这些,handle_sys函数的操作就比较明显了。首先,需要将TF_EPC(sp)+4,让系统调用后进程能返回下一条指令继续执行;从TF_REG4(sp)取出a0,用以跳转到对应的sys_*函数;在按照以上分析的,将参数从tf中取出,安置到对应的位置。
系统调用函数
系统调用函数的实现,即不全syscall_all.c中的各函数,没有什么理解难度,在此就不赘述了。
进程通信
进程间通信机制IPC,需要通过系统调用来实现进程之间的数据交流。由于进程的地址空间都是独立的,要想把数据从一个地址空间转移到另一个空间,需要利用各个进程都共享的空间——内核的2G空间(具体原因lab3中已阐述)。
因此,选择使用内核中的进程控制块来实现进程通信,即修改PCB的某些属性。至此,也没有什么理解难度了。
fork
首先需要直到,从顶层来看,fork函数执行后的效果,就是产生了一个和原本进程几乎一模一样的子进程,但他们相互独立。
fork 在不同的进程中返回值不一样,在父进程中返回值不为0(返回子进程的id),在子进程中返回值为0。
调用fork之后的具体流程如下图,也是一个理解fork的保命图:
父进程正常执行之上的部分主要展示了fork()函数的流程,而之下有关缺页中断的部分主要涉及写时复制机制,也是这部分的理解难点。
写时复制
在fork时,父进程会为子进程分配新的虚拟地址空间,但是父子进程实际上共用物理空间。在父进程或子进程需要修改内存时,需要调用写时复制机制,为发生修改的页单独分配新的物理空间,父进程指向新的空间,而子进程依旧指向原来的空间。
对于每一页,都会用PTE_COW标志位保护起来,即表示当它被修改时,需要进行写时复制。
与写时复制相关的函数主要有以下。
1 void 2 page_fault_handler(struct Trapframe *tf) 3 { 4 struct Trapframe PgTrapFrame; 5 extern struct Env *curenv; 6 // printf("start page fault handler\n"); 7 8 bcopy(tf, &PgTrapFrame, sizeof(struct Trapframe)); 9 10 if (tf->regs[29] >= (curenv->env_xstacktop - BY2PG) && 11 tf->regs[29] <= (curenv->env_xstacktop - 1)) { 12 tf->regs[29] = tf->regs[29] - sizeof(struct Trapframe); 13 bcopy(&PgTrapFrame, (void *)tf->regs[29], sizeof(struct Trapframe)); 14 } else { 15 tf->regs[29] = curenv->env_xstacktop - sizeof(struct Trapframe); 16 bcopy(&PgTrapFrame,(void *)curenv->env_xstacktop - sizeof(struct Trapframe),sizeof(struct Trapframe)); 17 } 18 // TODO: Set EPC to a proper value in the trapframe 19 tf->cp0_epc=curenv->env_pgfault_handler; 20 // printf("end page fault handler\n"); 21 return; 22 }
该函数主要进行写时复制前的一些处理,返回前需要将cp0_epv指向env_pgfault_handler函数入口。而env_pgfault_handler指向的函数,就是pgfault(),即真正处理缺页异常的函数。(写时复制依赖于缺页异常实现)。
1 static void 2 pgfault(u_int va) 3 { 4 u_int *tmp; 5 u_int ret; 6 u_int perm=(*vpt)[VPN(va)]&0xfff; 7 if((perm&PTE_COW)==0){ 8 user_panic("not a copy-on-write page\n"); 9 return; 10 } 11 tmp=USTACKTOP; 12 u_int round_va=ROUNDDOWN(va,BY2PG); 13 ret=syscall_mem_alloc(0,tmp,PTE_V|PTE_R); 14 if(ret<0){ 15 user_panic("alloc error\n"); 16 } 17 //map the new page at a temporary place 18 user_bcopy((void*)round_va,(void*)tmp, BY2PG); 19 //map the page on the appropriate place 20 ret=syscall_mem_map(0,tmp,0,round_va,PTE_V|PTE_R); 21 if(ret<0){ 22 user_panic("map error\n"); 23 } 24 //unmap the temporary place 25 ret=syscall_mem_unmap(0,tmp); 26 if(ret<0){ 27 user_panic("unmap error\n"); 28 } 29 }
该函数首先判断是否为写时复制页,如果是,则先分配新的内存页到临时位置,将要复制的内容拷贝到刚刚分配的页中,再将临时位置上的内容映射到发生缺页中断的虚拟地址上,注意设定好对应的页面权限,然后解除临时位置对内存的映射。至此,完成缺页异常的处理。
fork函数
解决完缺页异常和写时复制问题,我们再来看一下fork函数的具体流程。
1 extern void __asm_pgfault_handler(void); 2 int 3 fork(void) 4 { 5 // Your code here. 6 u_int newenvid; 7 extern struct Env *envs; 8 extern struct Env *env; 9 u_int i,j; 10 u_int ret; 11 12 //The parent installs pgfault using set_pgfault_handler 13 //alloc a new alloc 14 set_pgfault_handler(pgfault); 15 16 newenvid=syscall_env_alloc(); 17 if(newenvid == 0){ 18 // writef("start son\n"); 19 env = &envs[ENVX(syscall_getenvid())]; 20 // writef("son fork end\n"); 21 return 0; 22 } 23 24 for(i=0;iBY2PG){ 25 // writef("0x%x\n",i); 26 if((Pde*)(*vpd)[i>>PDSHIFT]){ 27 // writef("start duppage\n"); 28 if((Pte*)(*vpt)[i>>PGSHIFT]){ 29 duppage(newenvid,VPN(i)); 30 } 31 } 32 } 33 34 // writef("duppage end\n"); 35 // writef("start alloc in fork\n"); 36 ret=syscall_mem_alloc(newenvid,UXSTACKTOP-BY2PG,PTE_V|PTE_R); 37 // writef("end alloc in fork\n"); 38 if(ret<0) { 39 return ret; 40 } 41 ret=syscall_set_pgfault_handler(newenvid,__asm_pgfault_handler,UXSTACKTOP); 42 // writef("end pgdault\n"); 43 if(ret<0) { 44 return ret; 45 } 46 ret=syscall_set_env_status(newenvid,ENV_RUNNABLE); 47 // writef("end status\n"); 48 if(ret<0) { 49 return ret; 50 } 51 52 return newenvid; 53 }
-
设置缺页异常处理函数pgfault。
-
使用syscall_env_alloc()创建新进程
-
如果是子进程,将env设为该进程,直接返回
-
如果是父进程,将地址空间使用duppage复制一份给子进程
-
为子进程alloc出一块异常处理栈,位置为UXSTACKTOP-BY2PG
-
为子进程设置异常处理函数
-
设置子进程状态为可执行
以上fork流程在流程图中已有展现,需要特别强调的是duppage函数。
duppage函数对于操作的具体要求如下:
对于可写页面,给父进程和子进程都加PTE_COW的时候要注意顺序。必须要先给子进程加,再给父进程加。至于原因,下图展现了如果先给父进程加可能会造成的问题。
如果先给父进程加PTE_COW,然后修改了该页,该页将进行写时复制,父进程指向新的页,而新页没有被加上PTE_COW。此时再map子进程,子进程该页加上PTE_COW位而父进程没有。在随后程序运行中,若父进程进行修改,由于缺失PTE_COW,导致无法进行写时复制,因此子进程的运行出现错误(子进程该页本来不该被改,但却由于父进程被改而一起改了)。
(代码仓库位于右上角Github)