OS-Lab4

一.预备知识

在前面三个Lab的实验中,我们成功的搭建起了操作系统的内核,建立了内存管理机制和进程调度机制。一般来说,进程是给用户使用的,而用户无法直接对系统内核进行存取。另一方面,进程与进程之间的虚拟地址互相独立,这使得两个进程之间的互相通信变得困难。但是,用户会在有些情况下需要使用只有内核才能进行的操作。为了解决这个问题,操作系统设计了系统调用。

img

指导书上已有的知识,我在此不再赘述。在进行实验之前,我们需要稍微补习一点知识,主要是关于汇编函数方面的东西。这些知识,指导书或者其他地方都有,只不过比较零碎。我稍微聚集了一下这些知识,如果想要了解的更详细,可以深入了解。

1. 汇编函数构造宏(include/asm/asm.h)

为了方便的像C语言一样构造函数,我们的操作系统事先为我们提供了函数的宏,我们可以直接使用。这个宏的代码并非由本校人员开发,应当是较为通用的定义方式。文件中为我们提供了两种函数的宏,即叶函数(LEAF)和嵌套函数(NESTED)。

我们把函数体中没有函数调用语句的函数称为叶函数,自然如果有函数调用语句的函数称为非叶函数。在MIPS 的调用规范中,进入函数体时会通过对栈指针做减法的方式为自身的局部变量、返回地址、调用函数的参数分配存储空间(叶函数没有后两者),在函数调用结束之后会对栈指针做加法来释放这部分空间,我们把这部分空间称为栈帧(Stack Frame)。

——OS指导书

下面是宏的具体实现定义。可以看到,函数定义无非是声明一个全局符号,给定一个标签用于跳转和返回。

下面是文件中部分代码的引用。有些代码后面我没有写注释,是因为我自己也弄不太清楚,不敢乱讲,怕引起误会。如果有同学明白,希望可以给我讲讲。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#define LEAF(symbol)                            \
.globl symbol; \声明"symbol"为全局变量
.align 2; \下一个数据的地址空间按字对齐
.type symbol,@function; \
.ent symbol,0; \告诉汇编器"symbol"函数的起始点,用于调试
symbol: .frame sp,0,ra 提供一个名为"symbol"的标签,将跳转到此处

#define NESTED(symbol, framesize, rpc) \
.globl symbol; \
.align 2; \
.type symbol,@function; \
.ent symbol,0; \
symbol: .frame sp, framesize, rpc 确定栈帧大小以及结束时的返回地址

#define END(function) \
.end function; \指出函数结尾,用于调试
.size function,.-function 在符号表中列出函数名和函数指令字节数

2.C函数和汇编函数的参数、返回值传递

有时候,我们会不可避免的在C语言中调用汇编函数,也会在汇编语言中调用C函数。根据MIPS软件标准(ABI)的定义,函数的参数传递按照如下原则:

  • 如果函数参数个数≤4,则将参数依次存入a0-a3寄存器中,并在栈帧底部保留16字节的空间(即sp的值减去16),但并不一定使用这些空间。
  • 如果函数参数个数>4,则前4个参数依次存入a0-a3寄存器中,从第5个参数开始,依次在前4个参数预留空间之外的空间内存储,即没有寄存器去保存这些值。
  • 举例,如果一个C函数有6个参数,在汇编语言中需要调用的时候,应当将前4个参数存在a0-a3寄存器中,第5个参数存在16(sp)的位置,第6个参数存在20(sp)的位置。区间0-15的空间保留但不使用。

img

而关于函数的返回值,MIPS ABI规定,返回值存在v0寄存器中。某些特殊的情况下也会用到v0寄存器中。某些特殊的情况下也会用到v1寄存器,但不常见。想了解更多关于返回值的知识,请查阅书籍See MIPS Run Linux

3.栈帧方法宏(include/stackframe.h)

我们在进行用户态和内核态之间的切换,或者进程之间的切换时,需要保存现场。所谓现场,就是include/trap.h中所定义的trap结构体,其中包含的信息有:

  • 32个寄存器的值
  • CP0部分寄存器的值
  • HI、LO两个乘除法寄存器的值
  • 程序的指令计数器PC

但是这个文件中只有结构体的定义,没有将数据存入结构体的操作。将寄存器中的值存入内存,显然要用汇编语言去完成。stackframe.h中定义了一些汇编函数的宏,方便我们对现场进行存取操作。下面摘录了其中的宏,并作出相应的解释。

1
2
3
4
5
6
7
8
9
10
//TF_SIZE是Trapframe寄存器的字节大小
.macro STI //Set Interrupt,打开全局中断使能(允许中断)
.macro CLI //Close Interrupt,关闭全局中断使能(屏蔽中断)
.macro SAVE_ALL //保存所有现场,将数据以Trapframe结构体形式存在sp为开头的空间中
.macro RESTORE_SOME //恢复部分现场,此处的“部分”仅不包括sp的值
.macro RESTORE_ALL //恢复所有现场,包括栈顶的位置
.macro RESTORE_ALL_AND_RET //恢复现场并从内核态中返回
.macro get_sp //获取栈顶位置,此函数会判断当前的状态是异常还是中断,
//从而决定栈顶是TIMESTACK还是KERNEL_SP。
//系统调用是编号为8的异常,进程切换是时钟中断信号。

二.系统调用

1.什么是系统调用

在硬件实现上,用户态的进程无法访问内核的地址空间,这意味着:

  • 无法存取内核内存数据
  • 无法调用内核函数

而所有对硬件的操作都是内核函数,因此用户需要使用系统调用来调用内核的函数。

2.进入系统调用

一件事情在脑海中浮现,在MIPS编程中我们是这样进行输入输出——向特定寄存器存放特殊值并调用syscall。而MOS中我们也是这样做的,系统调用的关键就在于用户态和内核态的切换,而这个切换就是在我们调用syscall指令时产生的。

而就在syscall指令调用后,CPU在硬件层面陷入内核态,其将触发异常分发机制,并最终调用到handle_sys()函数。该函数相当于系统调用的分发,其根据某特定寄存器的值从而找到需要调用的内核函数。

你将见到这几种函数:

  • syscall_……:用户空间内的函数,与sys_……成对存在

  • msyscall:设置系统调用号并让系统陷入内核态的函数

  • sys_……:内核函数

    有趣的是,在这里我们会发现msyscall需要6个参数,这引起了我们的一个新知识点:大量的参数是如何进行传递的?
    对于nn个参数的传递,栈帧sp会保留n∗4n∗4个字节的空间,而前4个参数会被放在a0到a3这四个寄存器中,但是栈帧中对应空间还是会被预留,其余参数存储在前四个参数的预留空间之上的区域。

注意到一个问题,多于四个的参数会被放到内存中,而这个空间是存在于用户态的,因此我们需要在内核中将这些参数转移到内核空间内,这步工作需要在handle_sys()函数的汇编代码实现了。

我们先来整理一下在MOS中进行系统调用的流程:

  1. 调用一个封装好的用户空间的库函数(如writef)
  2. 调用用户空间的syscall_* 函数
  3. 调用msyscall,用于陷入内核态
  4. 陷入内核,内核取得信息,执行对应的内核空间的系统调用函数(sys_*)
  5. 执行系统调用,并返回用户态,同时将返回值“传递”回用户态
  6. 从库函数返回,回到用户程序调用处

msyscall

msyscall执行的职能只是陷入内核态,并不涉及系统调用的分发。

1
2
3
syscall
jr ra
nop

handle_sys

syscall发生后,OS根据中断向量发现是调用了系统调用,从而通过中断分发到handle_sys函数。

handle_sys函数通过分析传入的参数来找到具体的系统调用目标函数,并将传入的参数放到寄存器中,然后进入目标函数。

三.进程通信 IPC

进程间通信机制是基于系统调用来实现的。通信的本质就是交换数据,而交换数据的最大问题在于:在进程间,用户地址空间相互独立。

因此,我们需要通过以内核的2g空间来作为传递信息的媒介,同时我们可以发现,进程控制块是存储在内核空间内的,因此我们完全可以将需要传递的数据放在目标的进程控制块内,然后目标进程在从中读取。

image

值得一提的是,由于在我们的用户程序中,会大量使用srcva 为0 的调用来表示不需要传递物理页面,因此在编写相关函数时也需要注意此种情况。

这两个过程是通过系统调用中的sys_ipc_recvsys_ipc_can_send来实现。

前者需要将当前接收者的进程控制块的相应域设置好,并使用sys_yield使得当前进程放弃CPU。

后者需要检查目标是否准备好接受,并修改目标进程的进程控制块,将需要的信息放到他们的进程控制块内。

需要注意,如果需要传递物理页面信息,需要调用sys_mem_map函数将当前进程srcva对应位置的页面映射到目标进程的dstva处

四.Fork函数

fork函数能够从一个进程生成另一个进程,使得子进程拥有和旧进程绝大部分相同的信息。同时,fork会在父子进程中拥有不同的返回值。

  • 在fork 之前的代码段只有父进程会执行。
  • 在fork 之后的代码段父子进程都会执行。
  • fork 在不同的进程中返回值不一样,在父进程中返回值不为0,在子进程中返回值为0。
  • 父进程和子进程虽然很多信息相同,但他们的env_id 是不同的。

image

写时复制机制

父进程会为子进程设置虚拟空间,但是我们通过上图能够发现,实际的分配过程其实是通过duppage复制页表,并设置PTE_COW。COW就是写时复制的意思(Copy On Write)。

只有当父子进程中有修改内存的举动时,内核会根据PTE_COW捕获中断(一般指缺页中断,Page Fault),并单独为修改内存的进程分配物理页面,然后将该页面复制过去后再实行修改。

区分父子进程的理论基础

fork()能够通过返回值来区别当前进程是否是子进程,若返回值为0则为子进程,否则为父进程。

而实现返回值差异性的函数是syscall_env_alloc函数,其属于用户函数,其触发系统调用后进行sys_env_alloc来创建和初始化一个新进程块。

sys_env_alloc

这个函数需要利用当前进程为模板来填写一个新的子进程块。其工作包括复制一份当前的运行现场复制一下当前的PC值修改子进程状态为阻塞、以及初始化其他进程控制块信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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函数退出了(虽然当前处于阻塞状态)。

1
2
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用于指定异常处理的函数,使得用户能够自定义处理过程。

处理写时复制异常的流程为:

  1. page_fault_handler将当前现场保存在异常处理栈中,设置epc的值,以使得中断退出后跳转到指定用户进程指定的异常处理函数中。
  2. 退出中断,此时根据epc地址跳转到指定函数(注意这个函数是fork.c中的pgfault函数,这意味着它是用户态下执行的)中,处理缺页,然后恢复现场和sp寄存器,令进程恢复执行。

五.实验难点图解

1.MIPS调用规范(ABI)

MIPS ABI规定寄存器传参不需要复制到堆栈内
在这里插入图片描述

2.进程间通信机制

进程间通信机制是基于系统调用来实现的。通信的本质就是交换数据。

这是通过系统调用中的sys_ipc_recvsys_ipc_can_send来实现的。

前者需要将当前接收者的进程控制块的相应域设置好,并使用sys_yield使得当前进程放弃CPU。

后者需要检查目标是否准备好接受,并修改目标进程的进程控制块,将需要的信息放到他们的进程控制块内。
在这里插入图片描述

3.缺页中断的处理流程

在这里插入图片描述


OS-Lab4
http://example.com/2022/07/17/OS-Lab4/
作者
Wei Xia
发布于
2022年7月17日
许可协议