操作系统lab4实验报告
实验文档-lab4
一、思考题汇总
思考1:
思考并回答下面的问题:
- 内核在保存现场的时候是如何避免破坏通用寄存器的?
- 系统陷入内核调用后可以直接从当时的a0-a3 参数寄存器中得到用户调用
msyscall
留下的信息吗? - 我们是怎么做到让
sys
开头的函数“认为”我们提供了和用户调用msyscall
时同样的参数的? - 内核处理系统调用的过程对
Trapframe
做了哪些更改?这种修改对应的用户态的变化是?
答:
-
内核在保存现场时,会将除了sp以外的其他所有通用寄存器存的值都保存到栈中,并进行维护。当系统调用结束后,又会重新将这些值从栈中恢复到相应的通用寄存器。
-
用户态和内核态共用一套通用寄存器,因此可以直接取得。
-
先取出a0到a3,再从用户栈中取出其他的参数,最后将这些参数保存到内核栈中,使得内核态的
sys
函数可以正常将这些参数传入到函数中。 -
将
epc
设置到正确的位置,使得系统调用结束后能正常执行。并将系统调用函数的返回值传递到v0
寄存器,并将返回值传递到用户态的v0
寄存器。
思考2:思考下面的问题,并对这两个问题谈谈你的理解:
- 子进程完全按照
fork()
之后父进程的代码执行,说明了什么? - 但是子进程却没有执行
fork()
之前父进程的代码,又说明了什么?
答:
- 子进程与父进程共享内存空间,从而程序代码完全相同。
- 子进程在被
fork()
之后,pc
值被修改为父进程执行fork()
之后的pc
,从而子进程在执行时直接从被修改过的地方开始。
思考3:关于fork 函数的两个返回值,下面说法正确的是:
A. fork 在父进程中被调用两次,产生两个返回值
B. fork 在两个进程中分别被调用一次,产生两个不同的返回值
C. fork 只在父进程中被调用了一次,在两个进程中各产生一个返回值
D. fork 只在子进程中被调用了一次,在两个进程中各产生一个返回值
答:
正确答案为C。
思考4:
如果仔细阅读上述这一段话, 你应该可以发现, 我们并不是对所有的用户空间页都使用duppage 进行了保护。那么究竟哪些用户空间页可以保护,哪些不可以呢,请结合include/mmu.h 里的内存布局图谈谈你的看法。
答:从内存布局图来看,我们需要保护的用户空间页为UTEXT
到USTACKTOP
的这一段,因为从USTACKTOP
再往上到UXSTACKTOP
这一段属于异常栈和无效内存的范围,因此不需要被duppage
保护。
与此同时,在UTEXT
到USTACKTOP
这一段中,也并不是所有页都要被保护。
- 首先,只读的页不需要被保护。
- 其次,用
PTE_LIBRARY
标识的页为共享页,同样不需要被保护。 - 其他的页无论是否已含有
PTE_COW
,都要用PTE_COW
标记以作为保护。
思考5:
在遍历地址空间存取页表项时你需要使用到vpd 和vpt 这两个“指针的指针”,请思考并回答这几个问题:
- vpt 和vpd 的作用是什么?怎样使用它们?
- 从实现的角度谈一下为什么能够通过这种方式来存取进程自身页表?
- 它们是如何体现自映射设计的?
- 进程能够通过这种存取的方式来修改自己的页表项吗?
答:
-
vpt
与vpd
都是在用户态被定义的,在user/entry.S
中被声明。vpd
为用户页目录指针,*vpd=7fdff000=(UVPT+(UVPT>>12)*4)
vpt
为用户页表指针,*vpt=7fc00000=UVPT
在用户态函数中,主要给定一个虚拟地址
va
,就能找到相应的页目录项与页表项,具体的查询方式与lab2内存管理相关的虚拟地址解析相同。即(*vpd)[(VPN(va)>>10)
得到页目录项,(*vpt)[VPN(va)]
得到页表项。 -
vpt
与vpd
本质上是通过宏定义的方式来对用户态的一段内存地址进行映射,因此使用这种方式实际上就是在使用MMU
内存布局图中的地址指针,所以可以通过这种方式来存取进程自身页表。 -
vpd
本身处于vpt
段中,说明页目录本身处于其所映射的页表中的一个页面里面。所以这两个指针的设计中运用了自映射。 -
进程本身处于用户态,不可以修改自身页表项,这两个指针仅供页表和页目录访问所用。
思考6:
page_fault_handler
函数中,你可能注意到了一个向异常处理栈复制Trapframe
运行现场的过程,请思考并回答这几个问题:
- 这里实现了一个支持类似于“中断重入”的机制,而在什么时候会出现这种“中断重入”?
- 内核为什么需要将异常的现场Trapframe 复制到用户空间?
答:
- 当有
COW
标识符的页面要被修改时,会出现“中断重入”。 - 因为缺页中断的异常处理函数本身处于用户态,因此需要将异常现场保留到用户态来处理。
思考7:
到这里我们大概知道了这是一个由用户程序处理并由用户程序自身来恢复运行现场的过程,请思考并回答以下几个问题:
- 用户处理相比于在内核处理写时复制的缺页中断有什么优势?
- 从通用寄存器的用途角度讨论用户空间下进行现场的恢复是如何做到不破坏通用寄存器的?
答:
- 陷入内核会增添操作系统内核的工作量;且让用户进程实现内核功能体现了微内核思想,全方位保证操作系统正常运行。
- 通用寄存器(除栈寄存器)的原值全部被压入栈空间中,在恢复之后只要能找到栈指针的位置,就能将所有通用寄存器恢复原状。
思考8:
请思考并回答以下几个问题:
- 为什么需要将
set_pgfault_handler
的调用放置在syscall_env_alloc
之前? - 如果放置在写时复制保护机制完成之后会有怎样的效果?
- 子进程需不需要对在entry.S定义的字
__pgfault_handler
赋值?
答:
- 这样放置可以让子进程不会去调动这个函数,与此同时这样设置也能让
syscall_env_alloc
过程中的缺页也一同捕获。 - 父子进程都调用一遍此函数,导致函数行为错乱,无法正常处理缺页错误。
- 不需要,因为子进程中该值已经被父进程赋好,因此可以直接拿来使用。
二、实验难点图示
难点1:理解“用户态”与“内核态”
在我们的小操作系统中,对内核态与用户态最直观的区分就是:user
文件夹中的内容(如fork.c
,syscall_lib.c
)为用户态的内容,而其他文件夹(如lib
)为内核态的内容。
在内存布局图中,以ULIM
为分界线,其上的内存空间属于内核态,其下的内存空间属于用户态。
o KERNBASE -----> +----------------------------+----|-------0x8001 0000 |
o | Interrupts & Exception | \|/ \|/
o ULIM -----> +----------------------------+------------0x8000 0000-------
o | User VPT | PDMAP /|\
o UVPT -----> +----------------------------+------------0x7fc0 0000 |
在系统调用这一章节中,一个需要我们掌握的难点就是理解每一步操作是在用户态还是内核态执行的。
msyscall
:用于让程序陷入内核,属于用户态函数。handle_sys
:汇编函数,用于将用户态的系统调用的请求正确传递至内核态的系统调用函数,从而能正确的完成。属于内核态函数。sys_mem_alloc
:用于分配内存的系统调用函数,属于内核态函数。sys_mem_map
:用于实现两个进程间地址空间映射的系统调用函数,属于内核态函数。sys_mem_unmap
:用于解除某个进程空间虚拟内存与物理内存映射的系统调用函数,属于内核态函数。sys_yield
:用于实现用户进程对CPU的放弃的系统调用函数,属于内核态函数。sys_ipc_recv
:用于实现进程间通信的接收的系统调用函数,属于内核态函数。sys_ipc_can_send
:用于实现进程间通信的发送的系统调用函数,属于内核态函数。sys_env_alloc
:用于创建当前进程的子进程的系统调用函数,属于内核态函数。duppage
:用于对用户空间页中的可写入页进行保护,属于用户态函数。page_fault_handler
:用于将当前现场保存至异常处理栈并设置EPC
,属于内核态函数。sys_set_pgfault_handler
:用于设置中断处理函数的系统调用函数,属于内核态函数。pgfault
:用于处理缺页中断,属于用户态函数。sys_set_env_status
:用于设置子进程的状态的系统调用函数,属于内核态函数。fork
:用于创建一个子进程并分别运行,属于用户态函数。
难点2:顺利进入并实现系统调用
系统调用的难点在于要实现用户态与内核态的链接,此时需要用到汇编函数进行正确的跳转。
实现一个系统调用需要进行下面几步:
- 用户态进程调用位于
user/syscall_lib.c
中的syscall_xxx
函数。 syscall_xxx
将相关的参数传入msyscall
中。msyscall
跳转至handle_sys
中,将用户栈与相关寄存器转移到内核栈与寄存器中,并跳转到位于lib/syscall_all.c
的相应的sys_xxx
函数。- 执行
sys_xxx
函数并返回,并恢复栈。 - 返回用户态。
若要增加新的系统调用,需要更改的位置有:
lib/syscall.S
中最后的.word
字段。include/unistd.h
中的系统调用号。lib/syscall_all.c
中的sys_xxx
。user/syscall_lib.c
中的syscall_xxx
。- 参数增加时需要修改
msyscall
的声明(user/lib.h
)。
难点3:fork
的执行
fork
函数的实现如下:
int
fork(void)
{
// Your code here.
u_int newenvid;
extern struct Env *envs;
extern struct Env *env;
u_int i;
int r;
//The parent installs pgfault using set_pgfault_handler
set_pgfault_handler(pgfault);
//alloc a new alloc
newenvid = syscall_env_alloc();
if (newenvid == 0) {
env = envs + ENVX(syscall_getenvid());
return 0;
} else {
for (i = 0; i < USTACKTOP; i += BY2PG) {
if (((*vpd)[i>>PDSHIFT] & PTE_V) && ((*vpt)[i>>PGSHIFT] & PTE_V)) {
duppage(newenvid, i>>PGSHIFT);
}
}
if ((r = syscall_mem_alloc(newenvid, UXSTACKTOP - BY2PG, PTE_V | PTE_R)) < 0) {
return r;
}
if ((r = syscall_set_pgfault_handler(newenvid, __asm_pgfault_handler, UXSTACKTOP)) < 0) {
return r;
}
if ((r = syscall_set_env_status(newenvid, ENV_RUNNABLE)) < 0) {
return r;
}
}
return newenvid;
}
在这段代码中我们可以看出fork
函数的执行流程:
-
利用
set_pgfault_handler
函数将pgfault
设置为中断处理函数。set_pgfault_handler
本质上是改变__pgfault_handler
的值,使其变为fn
也就是传入的pgfault
函数指针,并为父进程分配缺页函数。 -
利用
syscall_env_alloc
创建一个子进程,并将当前进程的部分信息直接映射到子进程。 -
对于子进程,在执行到这一步时将
env
设置为对应的进程控制块,即可直接从fork
函数中返回。 -
对于父进程:
- 首先要用
duppage
对指定范围内的所有可写入非共享页进行保护,添加PTE_COW
标识符。 - 为子进程分配缺页函数。
- 将子进程的状态变为
ENV_RUNNABLE
,从而激活子进程。
- 首先要用
难点4:缺页中断的处理
缺页中断的流程如下:
-
捕获到缺页时进入
trap_init
函数中的handle_mod
函数中,陷入内核。set_except_vector(0, handle_int); set_except_vector(1, handle_mod); set_except_vector(2, handle_tlb); set_except_vector(3, handle_tlb); set_except_vector(8, handle_sys);
-
该函数跳转到
lib/trap.c
中的page_fault_handler
中。 -
在该函数中保存现场,并设置正确的
epc
值,使其跳转到异常处理函数中。 -
异常处理函数已在
set_pgfault_handler
中被设置为pgfault
函数。 -
进程跳转到的实际上是一个名叫
__asm_pgfault_handler
的汇编函数,该函数负责跳转至__pgfault_handler
(即被set_pgfault_handler
设置的pgfault
),并在执行结束后恢复现场。 -
pgfault
用于真正处理缺页中断。
三、体会与感想
之前听老师说很多同学的难点集中在lab2与lab3中,因此刚拿到lab4的时候并没有认为它很难,只是按照指导书中的要求开始填写一个个函数。但是在填写过程中,已经感觉到了一些地方的理解吃力,主要原因在于我没有完全理解有关用户态与内核态之间的关系和切换等操作。直到debug的过程中,才逐渐开始去读操作系统中的代码,去一步步的推系统调用的流程。
到了lab4-2即fork部分,基本上所有的函数都在满足一件事情,因此形成了一个横跨了许多文件,文件夹,甚至横跨了用户态与内核态的“函数链”,在阅读代码的时候需要来回切换文件,为此耗费了很大一段时间。
在lab4中,“祖传bug”的问题被无限放大,大家在debug过程中,从lab2到lab3的BUG层出不穷,当时没有暴露出来的细节操作终于在这次一个接一个的暴露。因此在填写代码时不应当只为通过测试,一定要考虑各种细节(例如有许多种情况需要考虑的l_i_m
)函数,否则到后面的实验部分再来找这些bug就会十分吃力。
再者就是在课上测试中一定要十分细致。lab4-1的测试因为增加系统调用时忘记在汇编函数的最后加上.word字段导致exam无法通过,十分可惜。
四、残留难点
对进程如何捕获缺页中断,进入trap_init
不理解。