Lab-4实验报告
实验思考题
Thinking 4.1.
思考并回答下面的问题:
-
内核在保存现场的时候是如何避免破坏通用寄存器的?
-
系统陷入内核调用后可以直接从当时的
a0-a3
参数寄存器中得到用户调用msyscall
留下的信息吗? -
我们是怎么做到让
sys
开头的函数“认为”我们提供了和用户调用msyscall
时同样的参数的? -
内核处理系统调用的过程对
Trapframe
做了哪些更改?这种修改对应的用户态的变化是?
-
调用
syscall.S
中的SAVE_ALL
宏来保存通用寄存器和部分CP0
寄存器,将它们存入以sp
为基地址的KERNEL_SP
区域,但这个操作并没有将sp
也存进去。 -
能,调用函数默认前四个参数传入
$a0-$a3
寄存器。但在内核态中可能使用这些寄存器进行一些操作计算,此时寄存器原有值被改变,因此再次以这些参数调用其他函数时需要重新以sp
为基地址,按相应偏移从用户栈中取用这四个寄存器值。 -
将调用函数时都将前四个参数按顺序放入
$a0-$a3
寄存器,后两个参数按顺序存入内核栈中的相同位置(相对sp
偏移相同)。 -
涉及到
Trapframe
修改的部分代码:lw t0, TF_EPC(sp)
addiu t0, t0, 4
sw t0, TF_EPC(sp)将
EPC
加4,使得系统调用恢复后pc
指向调用时的下一条指令。
Thinking 4.2
思考下面的问题,并对这个问题谈谈你的理解: 请回顾 lib/env.c
文件中 mkenvid()
函数的实现,该函数不会返回 0,请结合系统调用和 IPC
部分的实现 与 envid2env()
函数的行为进行解释。
先观察mkenvid
函数:
u_int mkenvid(struct Env *e) {
u_int idx = e - envs;
u_int asid = asid_alloc();
return (asid << (1 + LOG2NENV)) | (1 << LOG2NENV) | idx;
}
这样保证envidenv
不会错误返回当前进程,在IPC
阶段,若待接收进程返回是curenv
,而发送方只能ipc_send
,导致正确的接收方永远沉睡。
Thinking 4.3
思考下面的问题,并对这两个问题谈谈你的理解:
-
子进程完全按照
fork()
之后父进程的代码执行,说明了什么? -
但是子进程却没有执行
fork()
之前父进程的代码,又说明了什么?
-
子进程的代码段与父进程相同(子进程代码段共享了父进程的物理空间)。
-
子进程恢复的上下文位置为
fork
函数接下来的位置。
e->env_tf.pc = e->env_tf.cp0_epc;
因为在fork
中调用的sys_env_alloc()
函数中已经为子进程设置了pc
为父进程陷入syscall
指令的后一条指令的地址。
Thinking 4.4
关于 fork
函数的两个返回值,下面说法正确的是:
A、fork
在父进程中被调用两次,产生两个返回值
B、fork
在两个进程中分别被调用一次,产生两个不同的返回值
C、fork
只在父进程中被调用了一次,在两个进程中各产生一个返回值
D、fork
只在子进程中被调用了一次,在两个进程中各产生一个返回
答案为C.
Thinking 4.5
我们并不应该对所有的用户空间页都使用duppage
进行映射。那么究竟哪些用户空间页应该映射,哪些不应该呢? 请结合本章的后续描述、mm/pmap.c
中 mips_vm_init
函数进行的页面映射以及 include/mmu.h
里的内存布局图进行思考。
在0~USTACKTOP
范围的内存需要进行映射,其上范围的内存要么属于内核,要么是所有用户进程共享的空间,用户模式下只可以读取。可写但不共享的页面都需要设置PTE_COW
进行保护。
Thinking 4.6
在遍历地址空间存取页表项时你需要使用到vpd
和vpt
这两个“指针的指针”,请参考 user/entry.S
和 include/mmu.h
中的相关实现,思考并回答这几个问题:
-
vpt
和vpd
的作用是什么?怎样使用它们? -
从实现的角度谈一下为什么进程能够通过这种方式来存取自身的页表?
-
它们是如何体现自映射设计的?
-
进程能够通过这种方式来修改自己的页表项吗?
这里我们先了解一下vpd
和vpt
:
[UVPT,ULIM]中存储着整个页表结构,故vpt指的就是页表基地址
.globl vpt
vpt:
.word UVPT
UVPT+(UVPT>>10)这是自映射知识,故vpd为页目录的首地址
.globl vpd
vpd:
.word (UVPT+(UVPT>>12)*4)
使用起来也很方便:
得到va对应的页目录项和二级页表对应的项:
页目录项 (*vpd)[va >> 22] or (Pde *)(*vpd)[va >> 22]
二级页表项 (*vpt)[va >> 12] or (Pte *)(*vpt)[va >> 12]
-
**vpd
是夜目录首地址,以vpd
为基地址,加上页目录项偏移数即可指向va
对应页目录项;vpt
是页表首地址,以vpt
为基地址,加上页表项偏移数即可指向va
对应的二级页表项。** -
在
user/entry.S
中定义了vpt
和vpd
,它们分别指向UVPT
和(UVPT + (UVPT >> 12) * 4)
,即用户页表和用户页目录的虚拟地址,有了基地址,和从虚拟地址中获得的偏移,即可实现对自身进程页表的操作。 -
注意到,
vpd
的地址在UVPT
和UVPT + PDMAP
之间,说明将页目录映射到了某一页表位置,即实现了自映射。该页为(UVPT+(UVPT>>12)*4)
。 -
不能。因为用户态只有访问权限,无修改权限。
Thinking 4.7
page_fault_handler
函数中,你可能注意到了一个向异常处理栈复制Trapframe
运行现场的过程,请思考并回答这几个问题:
-
这里实现了一个支持类似于“中断重入”的机制,而在什么时候会出现这种“中断重入”?
-
内核为什么需要将异常的现场
Trapframe
复制到用户空间?
-
中断重入可能是因为在处理缺页中断时又发生了中断。
-
在我们的操作系统中,异常是在用户态处理的,需要根据异常现场的
Trapframe
进行处理;同时,需要保存现场的寄存器值,防止其被破坏。
Thinking 4.8
到这里我们大概知道了这是一个由用户程序处理并由用户程序自身来恢复运行现场的过程,请思考并回答以下几个问题:
-
在用户态处理页写入异常,相比于在内核态处理有什么优势?
-
从通用寄存器的用途角度讨论,在可能被中断的用户态下进行现场的恢复,要如何做到不破坏现场中的通用寄存器
-
尽量减少内核出现错误的可能,即使程序崩溃,也不会影响系统的稳定,同时微内核的模式下,用户态进行新页面的分配映射也更加灵活方便。
-
通用寄存器只是用于
CPU
计算,重要运算结果在恢复现场之前就已经完成了利用或者存在了栈中,因此就算被覆盖,也不会产生影响。
Thinking 4.9
请思考并回答以下几个问题:
-
为什么需要将
set_pgfault_handler
的调用放置在syscall_env_alloc
之前? -
如果放置在写时复制保护机制完成之后会有怎样的效果?
-
子进程是否需要对在
entry.S
定义的字__pgfault_handler
赋值?
-
用于处理在
alloc
过程中发生的缺页中断。 -
写时复制保护机制完成后发生缺页中断不能够被捕捉到,导致无法进入缺页中断异常。
-
不需要。该值已在父进程中设置,父子进程保持一致。
实验难点
Handle_sys
这个函数实现讲用户态参数传入内核态,并进入相应内核态处理函数。
/*** exercise 4.2 ***/
NESTED(handle_sys,TF_SIZE, sp)
SAVE_ALL // 用于保存所有寄存器的汇编宏
CLI // 用于屏蔽中断位的设置的汇编宏
nop
.set at // 恢复$at寄存器的使用
//将Trapframe的EPC寄存器取出,计算一个合理的值存回Trapframe中
lw t0, TF_EPC(sp)
addiu t0, t0, 4 //由于返回时执行系统调用的下一条指令,故+4
sw t0, TF_EPC(sp)
// 将系统调用号“复制”入寄存器$a0,系统调用号存储在4号寄存器
lw a0, TF_REG4(sp)
//t2寄存器获取特定系统调用函数入口函数地址
addiu a0, a0, -__SYSCALL_BASE /* a0 <- “相对”系统调用号 */
sll t0, a0, 2 /* t0 <- 相对系统调用号*4,因为系统调用函数以word为单位 */
la t1, sys_call_table /* t1 <- 系统调用函数的入口表基地址 */
addu t1, t1, t0 /* t1 <- 特定系统调用函数入口表项地址 */
lw t2, 0(t1) /* t2 <- 特定系统调用函数入口函数地址 */
lw t0, TF_REG29(sp) /* t0 <- 用户态的栈指针 */
lw t3, 16(t0) /* t3 <- msyscall的第5个参数 */
lw t4, 20(t0) /* t4 <- msyscall的第6个参数 */
//在当前栈指针分配6个参数的存储空间,并将6个参数安置到期望的位置
//其中a0~a3只需要在stack中留空间即可,不需要存储
lw a0, TF_REG4(sp)
lw a1, TF_REG5(sp)
lw a2, TF_REG6(sp)
lw a3, TF_REG7(sp)
addiu sp, sp, -24
sw t4, 20(sp)
sw t3, 16(sp)
jalr t2 // 进入相应的处理函数
nop
// TODO: 恢复栈指针到分配前的状态
addiu sp, sp, 24
sw v0, TF_REG2(sp) // 将$v0中的sys_*函数返回值存入Trapframe
j ret_from_exception // 从异常中返回(恢复现场)
nop
END(handle_sys)
sys_call_table: // 系统调用函数的入口表
.align 2
.word sys_putchar
.....
.word sys_cgetc
fork
**fork
目的是创建一个子进程。需要子进程复制页表,分配异常处理栈,完成写时复制机制,唤醒子进程的工作,拆分去做,fork
难度恢大大降低。**
hints
:
-
MOS
允许进程访问自身的进程控制块,而在 user/libos.c 的实现中,用户程序在运行时入口会将一个用户空间中的指针变量struct Env *env
指向当前进程的控制块。 -
父进程在调用开始需要为自己分配异常处理栈,在调用
syscall_env_alloc()
中断之后也同样要为其子进程设置异常处理栈,但是调用略有不同(这里的异常处理栈是针对PTE_COW页写入异常的,所以只需要在父进程fork
时才会分配,正常情况下不需要)。 -
父进程调用
syscall_env_alloc()
后返回子进程的envid
,即执行if (newenvid != 0) { ···· }
,为子进程复制页、异常处理栈、唤醒子进程。 -
子进程由于记录了父进程中断时的上下文,即子进程pc为父进程中断时的下一条指令,而且在函数
syscall_env_alloc()
中将子进程的v0
寄存器(函数返回值)设置为0,这样子进程在被调度时,从内核态返回时v0寄存器(函数返回值)为0,即执行if (newenvid == 0) { ···· }
语句。
fork(void)
{
u_int newenvid;
extern struct Env *envs;
extern struct Env *env;
u_int i;
//进程为自身(父进程)分配映射了异常处理栈,
//同时也用系统调用告知内核自身的处理程序是__asm_pgfault_handler
//__pgfault_handle(__asm_pgfault_handler中的页面异常处理函数)赋值为pgfault
set_pgfault_handler(pgfault);
//申请一个子进程,其中newenvid = 0表示在子进程中返回,newenvid != 0表示在父进程中返回
newenvid = syscall_env_alloc();
//newenvid = 0 --------------> 处于子进程中
//需要更新子进程的env(指向当前进程的控制块)
//由于控制块为数组存储,只需要数组首地址加上偏移
//#define ENVX(envid) ((envid) & (NENV - 1))
//syscall_getenvid()获取当前进程的envid,这里利用的是curenv->envid;
if (newenvid == 0) {
env = envs + ENVX(syscall_getenvid());
}
//newenvid != 0 --------------> 处于父进程中
//需要为子进程复制页表,范围[0,USTACKTOP]
if (newenvid != 0) {
for (i = 0;i < VPN(USTACKTOP);i++) {
if ((((Pde *)(*vpd))[i >> 10] & PTE_V) && (((Pte *)(*vpt))[i] & PTE_V)) {
duppage(newenvid, i);
}
}
//为子进程分配异常处理栈,设置缺页异常函数
syscall_mem_alloc(newenvid, UXSTACKTOP - BY2PG, PTE_V|PTE_R);
syscall_set_pgfault_handler(newenvid, __asm_pgfault_handler, UXSTACKTOP);
//唤醒子进程
syscall_set_env_status(newenvid, ENV_RUNNABLE);
}
return newenvid;
}
页写入异常
发生页写入异常的基本流程(注意此过程发生在用户空间*):
-
用户进程触发页写入异常,跳转到
handle_mod
函数; -
再跳转到
page_fault_handler
函数,负责将当前现场保存在异常处理栈中,然后将epc设置为env_pgfault_handler
域存储的异常处理函数__asm_pgfault_handler
(env_pgfault_handler
域存储的异常处理函数的地址是在fork时用set_pgfault_handler
设置的); -
退出中断,跳转到epc对应的异常处理函
__asm_pgfault_handler
,这个函数先跳到pgfault
函数(定义在fork.c
中)进行写时复制处理; -
之后恢复事先保存好的现场,并恢复
sp
寄存器的值; -
子进程恢复执行。
体会与感想
本次lab用时约40h(好久),期间经历了无数的bug。许多流程需要跨函数、跨态去处理,以及各态转换之间对于现场的保护、pc的修改等等,导致编写和理解起来较为困难,这一点也是我认为为什么lab3及以后难度陡增的一大原因。
不过无论如何,通过lab4,我们对于OS系统调用和进程间协作的认识上升了一个维度,在此过程中,也不断提升了coding和系统架构的能力,我认为付出的努力时值得的。
遗留难点
系统调用
许多系统调用中对于envid2env
中参数checkperm
的设置教程中并没有详细的解释,导致编写相应函数时不知道赋什么值。
IPC
进程传递信息一次只能传递一个u_int
大小内容,去需要在进程控制块中设置那么多变量去记录、维护,现代的操作系统还是使用这么低效率的传递方式吗?
fork
-
中断重入,个人感觉即使采取了暂存的策略,但是在跳转函数的过程中,如果再次发生中断也会产生数据丢失。
-
__asm_pgfault_handler
恢复现场与其余中断异常恢复现场不同,要跳转到epc
的地址,处理pgfault
回到用户,再从用户恢复,跳转次数多,有点混乱。