我的lab4总结
我打算从当前lab开始进行OS学习总结的分享。
(前几个Lab因为缺乏形式化表述,所以总是有些bug,怕发出来误人子弟。lab4对之前的lab都做了一次检查,目前代码总体上相对较为规范)
其中配图有些是cscore上扒的,不过觉得表述模糊的或者缺乏配图的地方都是自己画的
本单元主要实现:
- 系统调用的概念和流程
- 进程间的通讯(IPC)
- fork函数实现
- 缺页中断的处理流程
系统调用
什么是系统调用
在硬件实现上,用户态的进程无法访问内核的地址空间,这意味着:
- 无法存取内核内存数据
- 无法调用内核函数
而所有对硬件的操作都是内核函数,因此用户需要使用系统调用来调用内核的函数。
进入系统调用
一件事情在脑海中浮现,在MIPS编程中我们是这样进行输入输出——向特定寄存器存放特殊值并调用syscall。而MOS中我们也是这样做的,系统调用的关键就在于用户态和内核态的切换,而这个切换就是在我们调用syscall指令时产生的。
而就在syscall指令调用后,CPU在硬件层面陷入内核态,其将触发异常分发机制,并最终调用到handle_sys()
函数。该函数相当于系统调用的分发,其根据某特定寄存器的值从而找到需要调用的内核函数。
你将见到这几种函数:
syscall_……:用户空间内的函数,与sys_……成对存在
msyscall:设置系统调用号并让系统陷入内核态的函数
sys_……:内核函数
有趣的是,在这里我们会发现msyscall需要6个参数,这引起了我们的一个新知识点:大量的参数是如何进行传递的?
对于$n$个参数的传递,栈帧sp会保留$n * 4$个字节的空间,而前4个参数会被放在a0到a3这四个寄存器中,但是栈帧中对应空间还是会被预留,其余参数存储在前四个参数的预留空间之上的区域。
注意到一个问题,多于四个的参数会被放到内存中,而这个空间是存在于用户态的,因此我们需要在内核中将这些参数转移到内核空间内,这步工作需要在handle_sys()
函数的汇编代码实现了。
我们先来整理一下在MOS中进行系统调用的流程:
- 调用一个封装好的用户空间的库函数(如writef)
- 调用用户空间的syscall_* 函数
- 调用msyscall,用于陷入内核态
- 陷入内核,内核取得信息,执行对应的内核空间的系统调用函数(sys_*)
- 执行系统调用,并返回用户态,同时将返回值“传递”回用户态
- 从库函数返回,回到用户程序调用处
msyscall
msyscall执行的职能只是陷入内核态,并不涉及系统调用的分发。
syscall
jr ra
nop
handle_sys
syscall发生后,OS根据中断向量发现是调用了系统调用,从而通过中断分发到handle_sys
函数。
handle_sys
函数通过分析传入的参数来找到具体的系统调用目标函数,并将传入的参数放到寄存器中,然后进入目标函数。
具体系统调用函数
所有的系统调用目标函数都在lib/syscall_all.c中定义,他们执行相应的功能,包括对页表的操作、进程的状态转换等等,在此按下不表。
进程通信 IPC
进程间通信机制是基于系统调用来实现的。通信的本质就是交换数据,而交换数据的最大问题在于:在进程间,用户地址空间相互独立。
因此,我们需要通过以内核的2g空间来作为传递信息的媒介,同时我们可以发现,进程控制块是存储在内核空间内的,因此我们完全可以将需要传递的数据放在目标的进程控制块内,然后目标进程在从中读取。
值得一提的是,由于在我们的用户程序中,会大量使用srcva 为0 的调用来表示不需要传递物理页面,因此在编写相关函数时也需要注意此种情况。
这两个过程是通过系统调用中的sys_ipc_recv
与sys_ipc_can_send
来实现。
前者需要将当前接收者的进程控制块的相应域设置好,并使用sys_yield
使得当前进程放弃CPU。
后者需要检查目标是否准备好接受,并修改目标进程的进程控制块,将需要的信息放到他们的进程控制块内。
需要注意,如果需要传递物理页面信息,需要调用sys_mem_map函数将当前进程srcva对应位置的页面映射到目标进程的dstva处。
Fork函数
fork函数能够从一个进程生成另一个进程,使得子进程拥有和旧进程绝大部分相同的信息。同时,fork会在父子进程中拥有不同的返回值。
- 在fork 之前的代码段只有父进程会执行。
- 在fork 之后的代码段父子进程都会执行。
- fork 在不同的进程中返回值不一样,在父进程中返回值不为0,在子进程中返回值为0。
- 父进程和子进程虽然很多信息相同,但他们的env_id 是不同的。
写时复制机制
父进程会为子进程设置虚拟空间,但是我们通过上图能够发现,实际的分配过程其实是通过duppage复制页表,并设置PTE_COW。COW就是写时复制的意思(Copy On Write)。
只有当父子进程中有修改内存的举动时,内核会根据PTE_COW捕获中断(一般指缺页中断,Page Fault),并单独为修改内存的进程分配物理页面,然后将该页面复制过去后再实行修改。
区分父子进程的理论基础
fork()能够通过返回值来区别当前进程是否是子进程,若返回值为0则为子进程,否则为父进程。
而实现返回值差异性的函数是syscall_env_alloc
函数,其属于用户函数,其触发系统调用后进行sys_env_alloc
来创建和初始化一个新进程块。
sys_env_alloc
这个函数需要利用当前进程为模板来填写一个新的子进程块。其工作包括复制一份当前的运行现场、复制一下当前的PC值、修改子进程状态为阻塞、以及初始化其他进程控制块信息。
int sys_env_alloc(void)
{
int r;
struct Env *e;
r = env_alloc(&e, curenv->env_id);
if (r < 0) return r;
e->env_status = ENV_NOT_RUNNABLE;
e->env_pri = curenv->env_pri;
bcopy((void *)KERNEL_SP - sizeof(struct Trapframe), (void *)&(e->env_tf), sizeof(struct Trapframe));
e->env_tf.pc = e->env_tf.cp0_epc;
e->env_tf.regs[2] = 0;
return e->env_id; // 注意这个返回值是返回到父进程的
}
在分道扬镳后,父子各自的工作
子进程
子进程当前虽然拥有了一个进程控制块,但是仍然存在着几个问题:
- 子进程被第一次调度时,其处在fork函数中(准确来说,是syscall_env_alloc返回后),此时函数中的各个变量仍然指向父进程中对应数据结构,子进程如何替换掉这些变量?
- 子进程的用户空间没有初始化,如何实现COW的设想?
我们将在子进程中解决第一个问题,而第二个问题交由父进程解决
设置进程控制块
当从syscall_env_alloc
返回后,子进程需要将当前函数内的进程控制块指针改为自己的。这一步通过调用syscall_getenvid
这一系统调用实现。这一步后,子进程就能够从fork函数退出了(虽然当前处于阻塞状态)。
newenvid = syscall_env_alloc();
if(newenvid==0) {env = envs + ENVX(syscall_getenvid());return 0;}
父进程
父进程需要为子进程进行很多初始化工作,包括遍历进程空间并合理设置空间权限,实现空间共享、实现写时复制的缺页中断机制
进程映射
通过遍历当前页目录,将页面按以下规则进行设置:
- 只读页面 按照相同权限(只读)映射给子进程即可
- 共享页面 即具有PTE_LIBRARY 标记的页面,这类页面需要保持共享的可写的状态
- 写时复制页面 即具有PTE_COW 标记的页面,这类页面是上一次的fork 的duppage的结果
- 可写页面 需要给父进程和子进程的页表项都加上PTE_COW 标记
这个功能由duppage
函数实现。
缺页中断
MIPS下存在两种缺页中断。一种是TLB缺失导致的缺页中断,其会触发trap并分发到handle_tlb
下,然后按照正常逻辑进行查表、重填等,此处按下不表。
另一种是写时复制触发的缺页中断,其会触发trap分发到另一个处理函数handle_mod
下。这个函数会跳转到page_fault_handler
下,处理当前写时复制异常。
注意!MOS系统在此处应用了微内核的思想,将处理异常的方式交由用户进程自身,即在进程控制块内定义了一个域env_pgfault_handler
用于指定异常处理的函数,使得用户能够自定义处理过程。
处理写时复制异常的流程为:
-
page_fault_handler
将当前现场保存在异常处理栈中,设置epc的值,以使得中断退出后跳转到指定用户进程指定的异常处理函数中。 - 退出中断,此时根据epc地址跳转到指定函数(注意这个函数是fork.c中的
pgfault
函数,这意味着它是用户态下执行的)中,处理缺页,然后恢复现场和sp寄存器,令进程恢复执行。