diff --git a/thu_os/lab4_report.md b/thu_os/lab4_report.md index 9d07e09..48acb95 100644 --- a/thu_os/lab4_report.md +++ b/thu_os/lab4_report.md @@ -21,7 +21,7 @@ Lab4 Report 对实验报告的要求: -+基于markdown格式来完成,以文本方式为主 ++ 基于markdown格式来完成,以文本方式为主 + 填写各个基本练习中要求完成的报告内容 + 完成实验后,请分析`ucore lab`中提供的参考答案,并请在实验报告中说明你的实现与参考答案的区别 + 列出你认为本实验中重要的知识点,以及与对应的OS原理中的知识点,并简要说明你对二者的含义,关系,差异等方面的理解(也可能出现实验中的知识点没有对应的原理知识点) @@ -35,7 +35,7 @@ lab4 相对于前面的实验,就是增加了一个内核线程,即`kern/pro ## 建立第零个内核线程 -在原理课中有讲到,在实际的操作系统中,例如在`linux`中,当一个用户进程希望创建一个子进程时,可以通过`fork`系统调用,由操作系统代劳为其建立一个子进程。在`fork`系统调用中,主要进行的操作是,将当前的进程拷贝到新创建的进程中,即占用的各类资源,一些状态信息,复制到新创建的进程中,然后再在后续的操作中,更新其地址空间,更新子进程要执行的代码等。 +在原理课中有讲到,在实际的操作系统中,例如在`linux`中,当一个用户进程希望创建一个子进程时,可以通过`fork`系统调用,由操作系统代劳为其建立一个子进程。在`fork`系统调用中,主要进行的操作是,将当前的进程拷贝到新创建的进程中,即将占用的各类资源,一些状态信息,复制到新创建的进程中,然后再在后续的操作中,更新其地址空间,更新子进程要执行的代码等。 但是这些操作都不适用于第零号内核线程,因为上面进程的创建,是在已经有了一个进程的基础上进行的,对于第零号进程则不然。我们这里讨论的,就是如何实现这种由零到一的转变。 @@ -124,7 +124,7 @@ proc_init(void) { + runs: 运行时间,为零。 + kstack: 内核堆栈。这个进程还刚在初始化啊,相关资源还没有分配,内核堆栈也还没有建立好,设置为空指针`NULL = 0`。此外的资源信息都没有,即`mm = NULL`。 + need_resched: 不可调度。 -+ parent:设置为当前进程`current`。 ++ parent:它这里是设置为`NULL`,然后推迟到后面的`do_fork`中再设置为当前进程`current`。我觉得直接设置为`current`应该也没问题吧。 + context和tf和flags:是用于恢复进程现场的信息,这还没有现场啊,都设置为零。 + cr3:由于是内核线程,和操作系统共用页表,设置为`boot_cr3`。 @@ -158,4 +158,290 @@ alloc_proc(void) { } ``` -这只是我的版本,其中是存在问题的,比如我没有设置`parent`信息,上面也说了应该设置为`current`。不过其他的都差不多,只是我的实现不优雅而已,比如说老师的代码里面对`context`的设置只有一句`memset(&(proc->context), 0, sizeof(struct context));`就完成了,我就比较蠢了。 +这只是我的版本,基本和老师的标准答案差不多,只是我的实现不优雅而已,比如说老师的代码里面对`context`的设置只有一句 + +```c +memset(&(proc->context), 0, sizeof(struct context)); +``` + +就完成了,我就比较蠢了。当然了,我不蠢谁蠢啊。 + +### `tf`和`context`的作用 + +这俩的作用在第零号内核线程还看不出来啊,其实主要是为了完成线程的切换的,只有合理设置了这两个字段,才能使新建立的线程被正确调度运行;以及可以在被其他进程打断以后,可以正确恢复现场。具体的还是在练习3里面会详细说明。 + +## 建立第一个内核线程 + +在有了第零个内核线程后,我们就可以通过该线程,来建立新的内核线程了。创建内核线程在函数`kern/process/proc.c::kernel_thread`中实现: + +```c +// kernel_thread - create a kernel thread using "fn" function +// NOTE: the contents of temp trapframe tf will be copied to +// proc->tf in do_fork-->copy_thread function +int +kernel_thread(int (*fn)(void *), void *arg, uint32_t clone_flags) { + struct trapframe tf; + memset(&tf, 0, sizeof(struct trapframe)); + tf.tf_cs = KERNEL_CS; + tf.tf_ds = tf.tf_es = tf.tf_ss = KERNEL_DS; + tf.tf_regs.reg_ebx = (uint32_t)fn; + tf.tf_regs.reg_edx = (uint32_t)arg; + tf.tf_eip = (uint32_t)kernel_thread_entry; + return do_fork(clone_flags | CLONE_VM, 0, &tf); +} +``` + +可以看到,`kernel_thread`函数采用了局部变量`tf`来放置保存内核线程的临时中断帧,并把中断帧的指针传递给`do_fork`函数,而`do_fork`函数会调用`copy_thread`函数来在新创建的进程内核栈上专门给进程的中断帧分配一块空间。以下对`tf`的字段设置的值进行一定的解释: + ++ 由于这里是创建内核线程,该内核线程中断恢复后仍然是在内核中的运行,因此将`tf.tf_cs`设置为`KERNEL_CS`,`tf.tf_df`设置为`KERNEL_DS`,表示它在内核空间中运行。 ++ `tf.tf_eip`表示该内核线程中断恢复后,应该执行的下一条语句。由于它这不还没开始执行吗,因此这里设置为它要执行的第一条语句,即`kern/process/entry.S`中定义的全局函数`kernel_thread_entry`。 ++ 转到`kernel_thread_entry`,我们就可以看懂`tf.tf_regs.reg_ebx(edx)`为什么要这样设置了: + +```asm +kernel_thread_entry: # void kernel_thread(void) + + pushl %edx # push arg + call *%ebx # call fn + + pushl %eax # save the return value of fn(arg) + call do_exit # call do_exit to terminate current thread +``` + +可以看到,在`kernal_thread_entry`中,我们的第一个内核线程是调用`*%ebx`,即`fn`函数,该函数作为`kernel_thread`的参数传入;该`fn`函数的参数,即刚刚被压栈的`%edx`,就是这里作为`kernel_thread`的参数传入的`arg`。至此,在`kernel_thread`的上半部分,就已经设置好了第一个内核线程的中断恢复点,不过此时这些信息还保存在局部变量`tf`中,需要在后续的`copy_thread`函数中,才能将相关信息拷贝到该进程自己的`proc_struct.tf`中。 + +创建第一个内核线程的接下来的操作都在`do_fork`中完成。 + +### 为新创建的内核线程分配资源(需要编码) + +创建一个内核线程需要分配和设置好很多资源。`kernel_thread`函数通过调用`do_fork`函数完成具体内核线程的创建工作。`do_fork`函数会调用`alloc_proc`函数来分配并初始化一个进程控制块,但`alloc_proc`只是找到了一小块内存用以记录进程的必要信息,并没有实际分配这些资源。`ucore`一般通过`do_fork`实际创建新的内核线程。`do_fork`的作用是,创建当前内核线程的一个副本,它们的执行上下文、代码、数据都一样,但是存储位置不同。在这个过程中,需要给新内核线程分配资源,并且复制原进程的状态。你需要完成在`kern/process/proc.c`中的`do_fork`函数中的处理过程。它的大致执行步骤包括: + ++ 调用`alloc_proc`,首先获得一块用户信息块。 ++ 为进程分配一个内核栈。 ++ 复制原进程的内存管理信息到新进程(但内核线程不必做此事) ++ 复制原进程上下文到新进程 ++ 将新进程添加到进程列表 ++ 唤醒新进程 ++ 返回新进程号 + +请在实验报告中简要说明你的设计实现过程。请回答如下问题: + ++ 请说明ucore是否做到给每个新fork的线程一个唯一的id?请说明你的分析和理由。 + +> `do_fork`函数的实现 + +在前面的`alloc_proc`函数中,只是建立的一个新的线程控制块(TCB),并且对其中的字段做了一些预初始化操作,并没有实际地为新建立的线程分配资源。相关的操作留到`do_fork`函数中完成。所以`do_fork`函数的主要功能就是为新建立的线程分配资源,并且设置相关的标识与状态信息。按理说,`do_fork`函数创建的新线程应该是当前线程`current`的一个拷贝,它们应该具有相同的数据,代码,执行上下文等,只有pid标志符不同而已,但是事实好像并不是这样,我将慢慢分析。 + +`do_fork`函数就是进行资源的分配,这些资源包括 + ++ 为新的线程控制块分配内存空间,调用`alloc_proc`来完成。 ++ 为该线程分配内核地址空间,来作为它的内核堆栈。这个步骤可以通过调用`setup_kstack`来完成。 ++ 为新的线程分配虚拟用户地址空间,这个步骤可以通过`copy_mm`来完成。`copy_mm`可以选择新的线程是共享父线程的地址空间(`CLONE_VM`),还是将父线程的地址空间拷贝到一个新的虚拟用户地址空间。当然,由于这里还只有内核线程,它们都没有用户地址空间,这里还不需要关注这个问题。 + +在分配这些内存空间的时候,都有可能因为内存空间不足而失败,因此应该对失败的情况进行检测,一旦发生失败,即将已经分配的资源归还给操作系统。 + +此外,需要设置新线程的状态标志,如pid,并且将state转变为就绪态`PROC_RUNNABLE`,然后将新的线程添加到线程队列中,以供操作系统统一管理。至此,已经将`do_fork`的具体实现阐述清楚,代码如下: + +```c +int +do_fork(uint32_t clone_flags, uintptr_t stack, struct trapframe *tf) { + int ret = -E_NO_FREE_PROC; + struct proc_struct *proc; + if (nr_process >= MAX_PROCESS) { + goto fork_out; + } + ret = -E_NO_MEM; + proc = alloc_proc(); + setup_kstack(proc); + proc->pid = get_pid(); + copy_mm(clone_flags, proc); + copy_thread(proc, stack, tf); + hash_proc(proc); + list_add(&proc_list, &(proc->list_link)); + wakeup_proc(proc); + ++nr_process; + ret = proc->pid; +fork_out: + return ret; + +bad_fork_cleanup_kstack: + put_kstack(proc); +bad_fork_cleanup_proc: + kfree(proc); + goto fork_out; +} +``` + +我这里的代码也是不对的,首先是我忘了设置`proc->parent = current`,还好测试用例里面没有相关的测试......此外,就是没有做失败情况的判断以及资源的回收。这是非常致命的问题啊,操作系统越用内存空间越少,需要引起重视。此外,还有一个问题是参考答案中的某些操作,是取消了中断的,即是不允许被中断的。这个我还没有什么研究啊,不知道为什么要这样进行。 + +所以,最终把参考答案也贴一下: + +```c +int +do_fork(uint32_t clone_flags, uintptr_t stack, struct trapframe *tf) { + int ret = -E_NO_FREE_PROC; + struct proc_struct *proc; + if (nr_process >= MAX_PROCESS) { + goto fork_out; + } + ret = -E_NO_MEM; + if ((proc = alloc_proc()) == NULL) { + goto fork_out; + } + + proc->parent = current; + + if (setup_kstack(proc) != 0) { + goto bad_fork_cleanup_proc; + } + if (copy_mm(clone_flags, proc) != 0) { + goto bad_fork_cleanup_kstack; + } + copy_thread(proc, stack, tf); + + bool intr_flag; + local_intr_save(intr_flag); + { + proc->pid = get_pid(); + hash_proc(proc); + list_add(&proc_list, &(proc->list_link)); + nr_process ++; + } + local_intr_restore(intr_flag); + + wakeup_proc(proc); + + ret = proc->pid; +fork_out: + return ret; + +bad_fork_cleanup_kstack: + put_kstack(proc); +bad_fork_cleanup_proc: + kfree(proc); + goto fork_out; +} +``` + +> 每个新`fork`的线程是否获得唯一的id? + +的确是的,新创建的线程,是通过`get_pid`函数,来生成一个新的pid的。并且由于`MAX_PID > MAX_PROCESS`,即可用的pid数量大于可以存在的进程数,每个线程都可以获得一个唯一的pid。具体的可以查看`get_pid`函数的具体实现。 + +## 练习3:阅读代码,理解`proc_run`函数和它调用的函数如何完成进程切换的。(无编码工作) + +请在实验报告中简要说明你对`proc_run`函数的分析。并回答如下问题: + ++ 在本实验的执行过程中,创建且运行了几个内核线程? ++ 语句`local_intr_save(intr_flag);....local_intr_restore(intr_flag);`在这里有何作用?请说明理由 + +### 线程切换过程分析 + +第一个内核线程创建好了以后,第零个内核线程就可以通过调用调度函数`schedule`来将CPU的控制权交给第一个内核线程了。接下来,就是慢慢分析这个过程。 + +> 调度函数`schedule` + +`schedule`的主要功能,就是根据预先设置好的进程调度算法(这里是先入先出的`FIFO`算法),来从就绪队列中选择一个进程,并且调用`proc_run`函数使该进程开始运行。 + +> `proc_run`函数 + +`proc_run`函数的功能是可以使一个线程开始在CPU上运行。为了让CPU开始运行一个新的线程,需要做哪些工作呢? + +首先,运行一个新的线程,肯定需要更新`ss:eip`寄存器,使其指向新的线程要执行的下一条语句,对于还未投入运行的线程,即指向它的第一条语句;对于执行了一段时间被调度出CPU的线程,就是指向它上一次执行的中断点。此外,其他的寄存器也要做相应的更新,更新为该线程上一次执行被中断时的情形,即恢复它的执行上下文。所有这些信息都保存在`proc_struct.context`里面。 + +除了一些寄存器的信息以外,运行一个新的线程,还需要切换到该线程的用户空间堆栈中,为此需要更新`cr3`寄存器的值为该线程的`proc_struct.cr3`。除了用户空间,该线程应该还具有一个虚拟内核空间,用来保存中断时所需要的信息,线程的切换还需要加载该线程的虚拟内核空间,`ts.esp0`即使保存这个信息的寄存器。 + +最后,将`current`赋值为被切换的进程,标志进程切换完成。至此,我们已经分析出`proc_run`的主要实现步骤,具体的代码如下: + +```c +// proc_run - make process "proc" running on cpu +// NOTE: before call switch_to, should load base addr of "proc"'s new PDT +void +proc_run(struct proc_struct *proc) { + if (proc != current) { + bool intr_flag; + struct proc_struct *prev = current, *next = proc; + local_intr_save(intr_flag); + { + current = proc; + load_esp0(next->kstack + KSTACKSIZE); + lcr3(next->cr3); + switch_to(&(prev->context), &(next->context)); + } + local_intr_restore(intr_flag); + } +} +``` + +由于在做线程切换时,不允许被调度的,否则会产生进程的嵌套调度,这里的操作需要首先关闭中断。可以看到,其中的语句就是上面分析的那些,其中的上下文切换在`switch_to`中实现。 + +> `switch_to`的实现 + +`switch_to`是完成了进程上下文的切换。由于我们说上下文的切换主要就是更新一些寄存器里的值,都是比较底层的操作,因此`switch_to`用汇编代码来实现。具体的代码如下: + +```asm +switch_to: # switch_to(from, to) + + # save from's registers + movl 4(%esp), %eax # eax points to from + popl 0(%eax) # save eip !popl + movl %esp, 4(%eax) + movl %ebx, 8(%eax) + movl %ecx, 12(%eax) + movl %edx, 16(%eax) + movl %esi, 20(%eax) + movl %edi, 24(%eax) + movl %ebp, 28(%eax) + + # restore to's registers + movl 4(%esp), %eax # not 8(%esp): popped return address already + # eax now points to to + movl 28(%eax), %ebp + movl 24(%eax), %edi + movl 20(%eax), %esi + movl 16(%eax), %edx + movl 12(%eax), %ecx + movl 8(%eax), %ebx + movl 4(%eax), %esp + + pushl 0(%eax) # push eip + + ret +``` + +`switch_to`包含两部分的内容,即保存被切换的线程的上下文,以及将新的线程的上下文加载到寄存器中。它们两个的上下文信息保存在`4(%esp)`和`8(%esp)`中,因为之前是函数调用`switch_to(&(prev->context), &(next->context))`的参数,在`eip`之前被压入到栈中。还需要注意的是,这里压入栈的并不是整个`context`结构体,而是两个`context`结构体的地址,因此后面都是利用这个地址做内存访问。 + +还需要注意的点是,在将`context.eip`压入栈中后`pushl 0(%eax)`,`switch_to`调用了`ret`。这其实是手动模拟了函数调用返回,因为在执行`ret`命令时,硬件会将刚刚压栈的`0(%eax)`弹栈到`eip`中,随后就跳转到`cs:eip`指示的位置去执行。那么问题来了,第一个内核线程的`context.eip`是被设置为了多少呢? + +这个可以在`copy_thread`中找到答案,其代码如下: + +```c +static void +copy_thread(struct proc_struct *proc, uintptr_t esp, struct trapframe *tf) { + proc->tf = (struct trapframe *)(proc->kstack + KSTACKSIZE) - 1; + *(proc->tf) = *tf; + proc->tf->tf_regs.reg_eax = 0; + proc->tf->tf_esp = esp; + proc->tf->tf_eflags |= FL_IF; + + proc->context.eip = (uintptr_t)forkret; + proc->context.esp = (uintptr_t)(proc->tf); +} +``` + +可以看到,在`copy_thread`中,首先是将当前线程的中断帧`tf`设置在了其内核堆栈的顶部,并且将此前设置的`tf`拷贝到了线程自己的中断帧中。此外,第一个内核线程的`context.eip`是被设置为了`forkret`,`forkret`转而调用了`forkrets`,这个函数可以在`kern/trap/trapentry.S`中找到。 + +> `forkrets` + +在`forkrets`中的操作非常简单,代码如下: + +```asm +forkrets: + # set stack to this new process's trapframe + movl 4(%esp), %esp + jmp __trapret +``` + +即将栈顶指针指向我们已经设置好了的`proc_struct.tf`,然后跳转到`__trapret`,这里的思想和前面手动模拟函数调用返回一样,只不过这里是手动模拟了中断返回。此后,在`__trapret`中,就可以通过中断返回,将程序的控制权转交到中断帧中所指示的`ss:eip`,并且将堆栈切换到中断帧设置好了的用户堆栈(如果是用户线程的话)。这样,中断返回后,执行的语句就是`tf`中设置好了的`kernel_thread_entry`了,在前面我们已经对它进行了分析。至此,已经完成了CPU控制权的切换。 + +> 线程切换总结 + +线程的切换,主要有依赖`proc_struct.context`和`proc_struct.tf`,即上下文信息和中断帧信息。首先通过上下文信息,跳转到了中断返回函数,在其中通过模拟中断返回机制,使程序的控制权最终转交到中断帧中所指示的位置。值得注意的是,这是线程第一个投入运行时的情况,在经过调度后重新恢复运行时,只需要上下文信息`context`就足够了,因为在此前的被切换的过程中,`context`已经保存了恢复的地址和状态信息了。