From cce1ce2426bccd3c3a9a30d6df6e35d76c733d32 Mon Sep 17 00:00:00 2001 From: Shine wOng <1551885@tongji.edu.cn> Date: Mon, 9 Sep 2019 20:47:48 +0800 Subject: [PATCH] finish lab5_report.md --- thu_os/lab5_report.md | 309 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 308 insertions(+), 1 deletion(-) diff --git a/thu_os/lab5_report.md b/thu_os/lab5_report.md index 118fc30..441ecdf 100644 --- a/thu_os/lab5_report.md +++ b/thu_os/lab5_report.md @@ -119,7 +119,7 @@ assert(pgdir_alloc_page(mm->pgdir, USTACKTOP-4*PGSIZE , PTE_USER) != NULL); + `esp`的设置:只有在涉及到特权级切换时,才会由硬件压栈的用户栈栈顶指针`esp`,此时应该指向刚设置的用户栈的栈顶,即`esp = USTACKTOP`。 + 状态字`flags`的设置:由于刚开始执行,也没有什么保存的状态,就设置为可以被中断`FL_IF`就可以了。 -完全这样的设置后,只需要执行到中断返回时的`iret`,就可以实现用户应用程序的执行了。具体的代码如下: +完成这样的设置后,只需要执行到中断返回时的`iret`,就可以实现用户应用程序的执行了。具体的代码如下: ```c //(6) setup trapframe for user environment @@ -224,3 +224,310 @@ copy_range(pde_t *to, pde_t *from, uintptr_t start, uintptr_t end, bool share) { ### `COW`机制实现 简单的版本的话,就是在`do_fork`函数中,只是让子进程拷贝父进程的地址空间。一旦父进程或者子进程试图写它们共享的空间,可以通过`Page Fault`机制,为该页新分配一个空间,并且修改对应进程的页表。这样,被修改的页就只有修改页的那个进程可以看到,对于其他进程修改都是不可见的。当然,要是具体实现的话,需要考虑许多问题,情况比较复杂,我以后再好好专研吧。 + +## 练习3: 阅读分析源代码,理解进程执行`fork/exec/wait/exit`的实现,以及系统调用的实现(不需要编码) + +请在实验报告中简要说明你对`fork/exec/wait/exit`函数的分析。并回答如下问题: + ++ 请分析`fork/exec/wait/exit`在实现中是如何影响进程的执行状态的? ++ 请给出ucore中一个用户态进程的执行状态生命周期图(包执行状态,执行状态之间的变换关系,以及产生变换的事件或函数调用)。(字符方式画即可) + +### 系统调用的实现 + +下面就以`exit`为例,来说明系统调用的实现。其他几个系统调用也是根据同样的方法实现的。 + +我们可以首先找到`user/libs/ulib.c`中的`exit`函数的实现: + +```c +void +exit(int error_code) { + sys_exit(error_code); + cprintf("BUG: exit failed.\n"); + while (1); +} +``` + +可以看到,该函数是通过调用另一个用户态函数`sys_exit`函数来完成它的功能的。可以在`user/libs/syscall.c`中找到该函数: + +```c +static inline int +syscall(int num, ...) { + va_list ap; + va_start(ap, num); + uint32_t a[MAX_ARGS]; + int i, ret; + for (i = 0; i < MAX_ARGS; i ++) { + a[i] = va_arg(ap, uint32_t); + } + va_end(ap); + + asm volatile ( + "int %1;" + : "=a" (ret) + : "i" (T_SYSCALL), + "a" (num), + "d" (a[0]), + "c" (a[1]), + "b" (a[2]), + "D" (a[3]), + "S" (a[4]) + : "cc", "memory"); + return ret; +} + +int +sys_exit(int error_code) { + return syscall(SYS_exit, error_code); +} +``` + +看到这里应该就很明了了,原来在`sys_exit`函数中,是通过系统调用`INT 80`,通过中断机制来请求操作系统的服务。为了实现系统调用的中断机制,需要对中段描述符表(`idt`)做一些修改,使得系统调用`0x80`所对应的门描述符具有用户态访问的权限。此外,系统调用的门描述符类型(`type`)应该是陷阱门(`trap gate`)。修改后的`idt_init`函数如下: + +```c +/* idt_init - initialize IDT to each of the entry points in kern/trap/vectors.S */ +void +idt_init(void) { + extern uintptr_t __vectors[]; + + uint32_t ix; + for(ix = 0; ix < 256; ++ix) + SETGATE(idt[ix], 0, KERNEL_CS, __vectors[ix], DPL_KERNEL); + + lidt(&idt_pd); + SETGATE(idt[T_SYSCALL], 1, KERNEL_CS, __vectors[T_SYSCALL], DPL_USER); +} +``` + +完成中断描述符表的设置后,就可以通过硬件机制找到系统调用的服务程序的入口地址,从而根据系统调用的中断号`#definie T_SYSCALL 0x80`,在`trap_dispatch`函数中找到实际上的处理函数,这里的中断机制在lab1中就已经叙述过了。 + +```c +static void +trap_dispatch(struct trapframe *tf){ + ...... + switch(tf->tf_trapno){ + ...... + case T_SYSCALL: + syscall(); + break; + ...... + } +} +``` + +从而跳转到实际上的系统调用处理函数`syscall`中。那么这里产生了一个问题,所有的系统调用都是通过同一个中断号`T_SYSCALL`来进入系统调用处理函数的,这样又如何识别不同的系统调用呢? + +事实上,在前面的代码就已经有所展示,系统调用所需要的各个参数,都已经保存在各个寄存器中了。因此,在`syscall`函数中,只要读取这几个特定的寄存器,就可以识别系统调用的类型,从而将参数传递给某个特定的系统调用处理函数。以这里的`exit`系统调用为例,通过`syscall`函数, + +```c +static int +sys_exit(uint32_t arg[]) { + int error_code = (int)arg[0]; + return do_exit(error_code); +} + +static int (*syscalls[])(uint32_t arg[]) = { + [SYS_exit] sys_exit, + [SYS_fork] sys_fork, + [SYS_wait] sys_wait, + [SYS_exec] sys_exec, + [SYS_yield] sys_yield, + [SYS_kill] sys_kill, + [SYS_getpid] sys_getpid, + [SYS_putc] sys_putc, + [SYS_pgdir] sys_pgdir, +}; + +#define NUM_SYSCALLS ((sizeof(syscalls)) / (sizeof(syscalls[0]))) + +void +syscall(void) { + struct trapframe *tf = current->tf; + uint32_t arg[5]; + int num = tf->tf_regs.reg_eax; + if (num >= 0 && num < NUM_SYSCALLS) { + if (syscalls[num] != NULL) { + arg[0] = tf->tf_regs.reg_edx; + arg[1] = tf->tf_regs.reg_ecx; + arg[2] = tf->tf_regs.reg_ebx; + arg[3] = tf->tf_regs.reg_edi; + arg[4] = tf->tf_regs.reg_esi; + tf->tf_regs.reg_eax = syscalls[num](arg); + return ; + } + } + print_trapframe(tf); + panic("undefined syscall %d, pid = %d, name = %s.\n", + num, current->pid, current->name); +} +``` + +它将实际上跳转到`sys_exit`函数中,随后调用`kern/process/proc.c`中实现的`do_exit`函数来完成`exit`系统调用的处理。这里我们再看一下`do_exit`是如何真正地实现进程的退出的吧。 + +## `exit`的实现 + +在原理课中讲过,当一个进程执行完毕需要退出时,将执行`exit`系统调用,从而在其中将它所占用的资源几乎全部归还给操作系统。为什么是几乎呢?因为归还资源的操作是它自己进行的,只要这个进程还存在,就至少还有进程控制块`PCB`的内存资源没有归还给操作系统。 + +随后,该进程还不能完全退出,而是进入`ZOMBIE`状态,即僵尸态。这是因为,它在完全退出之前,还需要唤醒父进程,并且等待它的父进程对它的处理,只有父进程完成对它处理,或者父进程不存在,比如已经退出了,该进程才能退出。这也是这里的`do_exit`需要一个`error_code`参数的原因,这样父进程就可以根据这个错误码的不同值,来完成不同的处理结果。在父进程完成对它的处理前,该进程需要调用调度函数`schedule`,让出CPU的控制权。 + +原理课上就讲到这么多,但是在ucore里面还做了进一步操作。因为当前要退出的进程有可能还有子进程,ucore还完成了这些子进程的移交,从而避免它们成为没有父进程的孩子。具体的操作就是将这些子进程【过继】给了第一个内核线程,其中主要就涉及到几个指针`proc_struct.parent/cptr/optr/yptr`的修改。需要指出的是,这里的`cptr`在进程具有多个孩子的情形下,总是指向最小的一个孩子。具体的代码如下: + +```c +// do_exit - called by sys_exit +// 1. call exit_mmap & put_pgdir & mm_destroy to free the almost all memory space of process +// 2. set process' state as PROC_ZOMBIE, then call wakeup_proc(parent) to ask parent reclaim itself. +// 3. call scheduler to switch to other process +int +do_exit(int error_code) { + if (current == idleproc) { + panic("idleproc exit.\n"); + } + if (current == initproc) { + panic("initproc exit.\n"); + } + + struct mm_struct *mm = current->mm; + if (mm != NULL) { + lcr3(boot_cr3); + if (mm_count_dec(mm) == 0) { + exit_mmap(mm); + put_pgdir(mm); + mm_destroy(mm); + } + current->mm = NULL; + } + current->state = PROC_ZOMBIE; + current->exit_code = error_code; + + bool intr_flag; + struct proc_struct *proc; + local_intr_save(intr_flag); + { + proc = current->parent; + if (proc->wait_state == WT_CHILD) { + wakeup_proc(proc); + } + while (current->cptr != NULL) { + proc = current->cptr; + current->cptr = proc->optr; + + proc->yptr = NULL; + if ((proc->optr = initproc->cptr) != NULL) { + initproc->cptr->yptr = proc; + } + proc->parent = initproc; + initproc->cptr = proc; + if (proc->state == PROC_ZOMBIE) { + if (initproc->wait_state == WT_CHILD) { + wakeup_proc(initproc); + } + } + } + } + local_intr_restore(intr_flag); + + schedule(); + panic("do_exit will not return!! %d.\n", current->pid); +} +``` + +## `wait`的实现 + +在原理课中讲到,当父进程需要等待某一个子进程退出,该子进程退出之后,父进程才能继续执行。在这种情况下,就应该使用`wait`系统调用。`wait`系统调用的实现和`exit`一样,都是一步步通过中断机制,分发到系统调用总控函数`syscall`,然后再调用实质性的处理函数`do_wait`,它也在`kern/process/proc.s`中实现。 + +在`do_wait`中,进行的主要工作就是查询当前进程是否有某个子进程进入了`ZOMBIE`态,如果当前就已经有`ZOMBIE`态的子进程,则立即对它进行处理,并且返回退出。如果还没有`ZOMBIE`态的子进程,当前进程就会被挂起,进入等待态(`SLEEPING`),并且不能自己苏醒,必须某个子进程退出才会唤醒该进程。 + +对子进程的处理,主要是释放它占用的内存资源,即它的进程控制块`PCB`,以及内核堆栈。`do_wait`的具体实现如下: + +```c +// do_wait - wait one OR any children with PROC_ZOMBIE state, and free memory space of kernel stack +// - proc struct of this child. +// NOTE: only after do_wait function, all resources of the child process are free. +int +do_wait(int pid, int *code_store) { + struct mm_struct *mm = current->mm; + if (code_store != NULL) { + if (!user_mem_check(mm, (uintptr_t)code_store, sizeof(int), 1)) { + return -E_INVAL; + } + } + + struct proc_struct *proc; + bool intr_flag, haskid; +repeat: + haskid = 0; + if (pid != 0) { + proc = find_proc(pid); + if (proc != NULL && proc->parent == current) { + haskid = 1; + if (proc->state == PROC_ZOMBIE) { + goto found; + } + } + } + else { + proc = current->cptr; + for (; proc != NULL; proc = proc->optr) { + haskid = 1; + if (proc->state == PROC_ZOMBIE) { + goto found; + } + } + } + if (haskid) { + current->state = PROC_SLEEPING; + current->wait_state = WT_CHILD; + schedule(); + if (current->flags & PF_EXITING) { + do_exit(-E_KILLED); + } + goto repeat; + } + return -E_BAD_PROC; + +found: + if (proc == idleproc || proc == initproc) { + panic("wait idleproc or initproc.\n"); + } + if (code_store != NULL) { + *code_store = proc->exit_code; + } + local_intr_save(intr_flag); + { + unhash_proc(proc); + remove_links(proc); + } + local_intr_restore(intr_flag); + put_kstack(proc); + kfree(proc); + return 0; +} +``` + +要注意`do_wait`要传入一个`pid`参数,当`pid`不等于零时,表示等待具有该`pid`的某个特定进程;当`pid`等于零时,任何子进程的退出都会受到处理。 + +在前面已经分析过`do_fork`以及`do_execve`的实现了,这里不再赘述。关于它们是如何影响进程的状态的,可以看看我丑陋的字符画。 + +``` + child exit + |--------------------SLEEPING + | ^ + | | wait + fork V schedule | +UNINIT --------> RUNNABLE <----------> RUNNING + exec | + | exit + V + ZOMBIE +``` + +## 进程的切换与退出 + +之前我一直很迷惑,这里ucore的进程怎么实现切换的,最后又是怎么退出的,一开始感觉非常神奇,后来发现原来是最朴素的策略。原来这里根本就没有什么所谓的时间片轮转算法,没有抢占机制,一切的切换都是进程自己主动通过系统调用执行的。 + +例如一个进程切换到另一个进程,只有下面几种可能性: + ++ 主动调用`schedule`,实现进程的切换。 ++ 通过`do_wait`函数,在没有子进程退出时,会调用`schedule`来完成切换。 ++ 通过`do_exit`函数,进入`ZOMBIE`状态后,会调用`schedule`来完成切换。 + +此外,进程的退出也是一样,都是自己调用了`exit`系统调用来退出的,这和我们正常的系统不一样,所以一开始不能理解。我们实际用的系统,对于我平时写的沙雕程序,应该是系统或者编译器自动在程序结束时添加了系统调用`exit`,来退出的。