操作系统 Lab4 内核线程管理 实验报告
课程信息所在网址: https://github.com/chyyuu/os_course_info
实验目的
实验内容
基本练习
练习 0: 填写已有实验
练习 1: 分配并初始化一个进程控制块(需要编码)
设计实现
问题回答
练习 2: 为新创建的内核线程分配资源(需要编码)
设计实现
问题回答
练习 3: 阅读代码, 理解 proc_run 函数和它调用的函数如何完成 进程切换的.(无编码工作)
分析
问题回答
实验结果
参考答案分析
实验中涉及的知识点列举
实验中未涉及的知识点列举
实验目的
了解内核线程创建 / 执行的管理过程
了解内核线程的切换和基本调度过程
实验内容
实现内核线程的创建与管理;
基本练习
练习 0: 填写已有实验
在本练习中将 LAB1/2/3 的实验内容移植到了 LAB4 的实验框架内, 由于手动进行内容移植比较烦杂, 因此考虑使用 diff 和 patch 工具进行自动化的移植, 具体使用的命令如下所示:(对于 patch 工具进行合并的时候产生冲突的少部分内容, 则使用 *.rej, *.orig 文件来手动解决冲突问题)
- diff -r -u -P lab2_origin lab3> lab3.patch
- cd lab4
- patch -p1 -u <../lab3.patch
练习 1: 分配并初始化一个进程控制块(需要编码)
alloc_proc 函数 (位于 kern/process/proc.c 中) 负责分配并返回一个新的 struct proc_struct 结 构, 用于存储新建立的内核线程的管理信息. ucore 需要对这个结构进行最基本的初始化, 你 需要完成这个初始化过程.
设计实现
本练习的编码工作集中在 proc.c 中的 alloc_proc 函数中, 该函数的具体含义为创建一个新的进程控制块, 并且对控制块中的所有成员变量进行初始化, 根据实验指导书中的要求, 除了指定的若干个成员变量之外, 其他成员变量均初始化为 0, 取特殊值的成员变量如下所示:
- proc->state = PROC_UNINIT;
- proc->pid = -1;
- proc->cr3 = boot_cr3; // 由于是内核线程, 共用一个虚拟内存空间
对于其他成员变量中占用内存空间较大的, 可以考虑使用 memset 函数进行初始化, 最终初始化所使用的代码如下所示:
- struct proc_struct *proc = kmalloc(sizeof(struct proc_struct)); // 为线程控制块分配空间
- if (proc != NULL) {
- proc->state = PROC_UNINIT; // 初始化具有特殊值的成员变量
- proc->cr3 = boot_cr3;
- proc->pid = -1;
- proc->runs = 0; // 对其他成员变量清零处理
- proc->kstack = 0;
- proc->need_resched = 0;
- proc->parent = NULL;
- proc->mm = NULL;
- memset(&proc->context, 0, sizeof(struct context)); // 使用 memset 函数清零占用空间较大的成员变量, 如数组, 结构体等
- proc->tf = NULL;
- proc->flags = 0;
- memset(proc->name, 0, PROC_NAME_LEN);
- }
至此, 本练习中的编码任务完成;
问题回答
请说明 proc_struct 中 struct context context 和 struct trapframe *tf 成员变量含义和在本实验中的作用是啥?(提示通过看代码和编程调试可以判断出来)
struct context context 的作用:
首先不妨查看 struct context 结构体的定义, 可以发现在结构体中存储这除了 eax 之外的所有通用寄存器以及 eip 的数值, 这就提示我们这个线程控制块中的 context 很有可能是保存的线程运行的上下文信息;
- struct context {
- uint32_t eip;
- uint32_t esp;
- uint32_t ebx;
- uint32_t ecx;
- uint32_t edx;
- uint32_t esi;
- uint32_t edi;
- uint32_t ebp;
- };
接下来使用 find grep 命令查找在 ucore 中对 context 成员变量进行了设置的代码, 总共可以发现两处, 分别为 Swtich.S 和 proc.c 中的 copy_thread 函数中, 在其他部分均没有发现对 context 的引用和定义(除了初始化); 那么根据 Swtich 中代码的语义, 可以确定 context 变量的意义就在于内核线程之间进行切换的时候, 将原先的线程运行的上下文保存下来这一作用, 那么为什么没有对 eax 进行保存呢? 注意到在进行切换的时候调用了 switch_to 这一个函数, 也就是说这个函数的里面才是线程之间切换的切换点, 而在这个函数里面, 由于 eax 是一个 caller-save 寄存器, 并且在函数里 eax 的数值一直都可以在栈上找到对应, 因此没有比较对其进行保存.
struct trapframe *tf 的作用:
接下来同样在代码中寻找对 tf 变量进行了定义的地方, 最后可以发现在 copy_thread 函数中对 tf 进行了设置, 但是值得注意的是, 在这个函数中, 同时对 context 变量的 esp 和 eip 进行了设置, 前者设置为 tf 变量的地址, 后者设置为 forkret 这个函数的指针, 接下来观察 forkret 函数, 发现这个函数最终调用了__trapret 进行中断返回, 这样的话 tf 变量的作用就变得清晰起来了: tf 变量的作用在于在构造出了新的线程的时候, 如果要将控制权交给这个线程, 是使用中断返回的方式进行的(跟 lab1 中切换特权级类似的技巧), 因此需要构造出一个伪造的中断返回现场, 也就是 trapframe, 使得可以正确地将控制权转交给新的线程; 具体切换到新的线程的做法为, 调用 switch_to 函数, 然后在该函数中进行函数返回, 直接跳转到 forkret 函数, 最终进行中断返回函数__trapret, 之后便可以根据 tf 中构造的中断返回地址, 切换到新的线程了;
练习 2: 为新创建的内核线程分配资源(需要编码)
创建一个内核线程需要分配和设置好很多资源. kernel_thread 函数通过调用 do_fork 函数完成 具体内核线程的创建工作. do_kernel 函数会调用 alloc_proc 函数来分配并初始化一个进程控 制块, 但 alloc_proc 只是找到了一小块内存用以记录进程的必要信息, 并没有实际分配这些资 源. ucore 一般通过 do_fork 实际创建新的内核线程. do_fork 的作用是, 创建当前内核线程的 一个副本, 它们的执行上下文, 代码, 数据都一样, 但是存储位置不同. 在这个过程中, 需 要给新内核线程分配资源, 并且复制原进程的状态. 你需要完成在 kern/process/proc.c 中的 do_fork 函数中的处理过程.
设计实现
在本次练习中, 主要需要实现的代码位于 proc.c 的 do_fork 函数中, 该函数的语义为为内核线程创建新的线程控制块, 并且对控制块中的每个成员变量进行正确的设置, 使得之后可以正确切换到对应的线程中执行; 接下来将结合具体的代码来说明本次练习的具体实现过程:
- proc = alloc_proc(); // 为要创建的新的线程分配线程控制块的空间
- if (proc == NULL) goto fork_out; // 判断是否分配到内存空间
- assert(setup_kstack(proc) == 0); // 为新的线程设置栈, 在本实验中, 每个线程的栈的大小初始均为 2 个 Page, 即 8KB
- assert(copy_mm(clone_flags, proc) == 0); // 对虚拟内存空间进行拷贝, 由于在本实验中, 内核线程之间共享一个虚拟内存空间, 因此实际上该函数不需要进行任何操作
- copy_thread(proc, stack, tf); // 在新创建的内核线程的栈上面设置伪造好的中端帧, 便于后文中利用 iret 命令将控制权转移给新的线程
- proc->pid = get_pid(); // 为新的线程创建 pid
- hash_proc(proc); // 将线程放入使用 hash 组织的链表中, 便于加速以后对某个指定的线程的查找
- nr_process ++; // 将全局线程的数目加 1
- list_add(&proc_list, &proc->list_link); // 将线程加入到所有线程的链表中, 便于进行调度
- wakeup_proc(proc); // 唤醒该线程, 即将该线程的状态设置为可以运行
- ret = proc->pid; // 返回新线程的 pid
至此, 本练习中需要的编码任务全部完成;
问题回答
请说明 ucore 是否做到给每个新 fork 的线程一个唯一的 id? 请说明你的分析和理由.
可以. ucore 中为新的 fork 的线程分配 pid 的函数为 get_pid, 接下来不妨分析该函数的内容:
在该函数中使用到了两个静态的局部变量 next_safe 和 last_pid, 根据命名推测, 在每次进入 get_pid 函数的时候, 这两个变量的数值之间的取值均是合法的 pid(也就是说没有被使用过), 这样的话, 如果有严格的 next_safe> last_pid + 1, 那么久可以直接取 last_pid + 1 作为新的 pid(需要 last_pid 没有超出 MAX_PID 从而变成 1);
如果在进入函数的时候, 这两个变量之后没有合法的取值, 也就是说 next_safe> last_pid + 1 不成立, 那么进入循环, 在循环之中首先通过
if (proc->pid == last_pid)
这一分支确保了不存在任何进程的 pid 与 last_pid 重合, 然后再通过
if (proc->pid> last_pid && next_safe> proc->pid)
这一判断语句保证了不存在任何已经存在的 pid 满足: last_pid<pid<next_safe, 这样就确保了最后能够找到这么一个满足条件的区间, 获得合法的 pid;
之所以在该函数中使用了如此曲折的方法, 维护一个合法的 pid 的区间, 是为了优化时间效率, 如果简单的暴力的话, 每次需要枚举所有的 pid, 并且遍历所有的线程, 这就使得时间代价过大, 并且不同的调用 get_pid 函数的时候不能利用到先前调用这个函数的中间结果;
练习 3: 阅读代码, 理解 proc_run 函数和它调用的函数如何完成 进程切换的.(无编码工作)
分析
接下来对 proc_run 函数进行分析:
首先注意到在本实验框架中, 唯一调用到这个函数是在线程调度器的 schedule 函数中, 也就是可以推测 proc_run 的语义就是将当前的 CPU 的控制权交给指定的线程;
接下来结合代码分析函数的内部构成:
- 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); // 设置 TSS
- lcr3(next->cr3); // 修改当前的 cr3 寄存器成需要运行线程 (进程) 的页目录表
- switch_to(&(prev->context), &(next->context)); // 切换到新的线程
- }
- local_intr_restore(intr_flag);
- }
- }
可以看到 proc_run 中首先进行了 TSS 以及 cr3 寄存器的设置, 然后调用到了 swtich_to 函数来切换线程, 根据上文中对 switch_to 函数的分析可以知道, 在调用该函数之后, 首先会恢复要运行的线程的上下文, 然后由于恢复的上下文中已经将返回地址 (copy_thread 函数中完成) 修改成了 forkret 函数的地址(如果这个线程是第一运行的话, 否则就是切换到这个线程被切换出来的地址), 也就是会跳转到这个函数, 最后进一步跳转到了__trapsret 函数, 调用 iret 最终将控制权切换到新的线程;
问题回答
在本实验的执行过程中, 创建且运行了几个内核线程?
总共创建了两个内核线程, 分别为:
idleproc: 最初的内核线程, 在完成新的内核线程的创建以及各种初始化工作之后, 进入死循环, 用于调度其他线程;
initproc: 被创建用于打印 "Hello World" 的线程;
语句 local_intr_save(intr_flag);....local_intr_restore(intr_flag); 说明理由在这里有何作用? 请说明理由.
该语句的左右是关闭中断, 使得在这个语句块内的内容不会被中断打断, 是一个原子操作;
这就使得某些关键的代码不会被打断, 从而不会一起不必要的错误;
比如说在 proc_run 函数中, 将 current 指向了要切换到的线程, 但是此时还没有真正将控制权转移过去, 如果在这个时候出现中断打断这些操作, 就会出现 current 中保存的并不是正在运行的线程的中断控制块, 从而出现错误;
实验结果
最终的实验结果符合预期, 并且能够通过 make grade 脚本的检查, 如下图所示:
result.PNG
参考答案分析
首先比较本实验中在练习 1 中的实现与参考答案的区别, 由于该编码任务的内容仅仅是初始化 TCB, 比较简单, 因此内容与参考答案基本一致;
接下来比较练习 2 中实现与参考答案的区别:
参考答案多了在分配内存, 设置栈的过程中失败的错误处理;
参考答案中设置 fork 的线程的关键信息以及将其加入 proc 列表的时候关闭了中断, 防止被中断打断操作, 从而导致存储线程的信息的不一致;
通过与参考答案的对比, 可以发现本实验中的实现还是忽略了许多必要的错误处理, 以及没有考虑到中断打断当前的操作是否会带来错误, 这使得本实验的实现的鲁棒性不如参考答案, 需要吸取教训;
实验中涉及的知识点列举
在本次实验中设计到的知识点有:
线程控制块的概念以及组成;
切换不同线程的方法;
对应到的 OS 中的知识点有:
对内核线程的管理;
对内核线程之间的切换;
这两者之间的关系为, 前者为后者在 OS 中的具体实现提供了基础;
实验中未涉及的知识点列举
在本次实验中未涉及的知识点有:
OS 的启动过程;
OS 中对物理, 虚拟内存的管理;
OS 中对用户进程的管理;
OS 中对线程 / 进程的调度;
实验代码
https://github.com/AmadeusChan/ucore_os_lab/tree/master/lab4
来源: http://www.jianshu.com/p/50dd281a82f0