一、思考题
Thinking 4.1
思考并回答下面的问题:
• 内核在保存现场的时候是如何避免破坏通用寄存器的?
• 系统陷入内核调用后可以直接从当时的
$a0
-$a3
参数寄存器中得到用户调用msyscall
留下的信息吗?• 我们是怎么做到让
sys
开头的函数“认为”我们提供了和用户调用msyscall
时同样的参数的?• 内核处理系统调用的过程对
Trapframe
做了哪些更改?这种修改对应的用户态的变化是什么?
1.内核保存现场使用了include/stackframe.h
中的SAVE_ALL
宏,其中将除了k0
的所有通用寄存器按照顺序放入了栈帧,从而避免了破坏。k0
及k1
寄存器本身就是要被操作系统临时使用的,无需保存。
2.从陷入内核到调用syscall
之间没有对内核空间做任何修改,因此可以直接从寄存器中得到信息。
3.从msyscall
到sys_*
之间,栈指针未出现改变,指向同一个上下文;msyscall
调用时参数入栈的顺序与放入sys_*
函数的传入顺序一致(do_syscall
函数)。
4.将epc
值加4,从而使得从调用返回用户态时,返回syscall
的下一条指令。
Thinking 4.2
思考
envid2env
函数:为什么
envid2env
中需要判断e->env_id != envid
的情况?如果没有这步判断会发生什么情况?
如果一个旧进程被销毁,其进程控制块将被插入env_free_list
,申请新进程时,该进程块可能会被重新取出。envid2env
函数通过ENVX
宏取得目标进程块与envs
之间的偏移量,而ENVX
即是取envid
的末尾10位。mkenvid
时,新envid
的末尾10位仅由envs
与e
的地址偏移决定,因此由于是同一个进程块,地址偏移量一样,假如使用已销毁进程的envid
调用该函数,会取出新进程的进程块,这是不允许出现的。e->env_id
是新进程id,envid
是传入的旧进程id,假如该情况后就杜绝了该问题。
Thinking 4.3
思考下面的问题,并对这个问题谈谈你的理解:请回顾
kern/env.c
文件中mkenvid()
函数的实现,该函数不会返回 0,请结合系统调用和IPC
部分的实现与envid2env()
函数的行为进行解释。
mkenvid()
函数通过拼接一个非0的整数i
与待分配进程块偏移量生成新的envid
,这意味着它生成的envid
永远不为0。envid2env()
函数传入envid
为0时,返回的进程块是当前进程的进程块。
IPC
是进程间通信,需要在两个不同进程间传递信息。如果envid2env()
返回当前进程,IPC
相关系统调用函数就会失效(自己向自己传送信息);另一方面,取消envid2env()
的返回当前进程功能的话,不便于一部分系统调用(如syscall_set_trapframe
)的实现。因此只能这样处理。
Thinking 4.4
关于 fork 函数的两个返回值,下面说法正确的是:
A、fork 在父进程中被调用两次,产生两个返回值
B、fork 在两个进程中分别被调用一次,产生两个不同的返回值
C、fork 只在父进程中被调用了一次,在两个进程中各产生一个返回值
D、fork 只在子进程中被调用了一次,在两个进程中各产生一个返回值
C
Thinking 4.5
我们并不应该对所有的用户空间页都使用
duppage
进行映射。那么究竟哪些用户空间页应该映射,哪些不应该呢?请结合kern/env.c
中env_init
函数进行的页面映射、``include/mmu.h` 里的内存布局图以及本章的后续描述进行思考。
映射 0 ~ USTACKTOP
之间的空间。USTACKTOP
以上到UXSTACKTOP(UTOP)
的空间为异常栈,是保存异常处理信息的位置,无需映射;UXSTACKTOP
再向上的空间不在用户空间内,不应该映射。
Thinking 4.6
在遍历地址空间存取页表项时你需要使用到
vpd
和vpt
这两个指针,请参考user/include/lib.h
中的相关定义,思考并回答这几个问题:•
vpt
和vpd
的作用是什么?怎样使用它们?• 从实现的角度谈一下为什么进程能够通过这种方式来存取自身的页表?
• 它们是如何体现自映射设计的?
• 进程能够通过这种方式来修改自己的页表项吗?
1.vpt
是页表基地址,vpd
是页目录基地址,如果想查询虚拟地址所对应的页目录项/页表项,可以使用vpd[页目录项偏移值]
/vpt[页表项偏移值]
。
2.所有进程共用一个页表,不同进程的页面通过不同的asid
来区分,故可以直接通过vpt
读取自身页表。vpt
的基地址在虚拟地址空间是固定的,为UVPT
。
3.以下是user/include/lib.h
对其的定义:
#define vpt ((const volatile Pte *)UVPT)
#define vpd ((const volatile Pde *)(UVPT + (PDX(UVPT) << PGSHIFT))) //体现自映射设计
4.不可以。页式内存管理对应用程序员透明,修改页表项只能在核心态下进行,必须调用系统调用,陷入内核后进行操作。
Thinking 4.7
在
do_tlb_mod
函数中,你可能注意到了一个向异常处理栈复制Trapframe
运行现场的过程,请思考并回答这几个问题:• 这里实现了一个支持类似于“异常重入”的机制,而在什么时候会出现这种“异常重入”?
• 内核为什么需要将异常的现场
Trapframe
复制到用户空间?
1.当在处理页写入异常的中途中,被时钟中断打断,就会出现异常重入(对于 sp
已经在异常栈中的情况,就不再从异常栈顶开始分配栈帧,即实现“异常重入”)。
2.复制到用户空间是因为MOS
操作系统按照微内核的设计理念,尽可能地将功能实现在用户空间中,其中也包括了页写入异常的处理,因此主要的处理过程是在用户态下完成的,需要保存到用户空间内的异常栈。在完成异常处理之后,cow_entry
要根据该栈中的返回地址来返回异常发生前位置,从而恢复现场。
Thinking 4.8
在用户态处理页写入异常,相比于在内核态处理有什么优势?
微内核设计主张将传统操作系统中的设备驱动、文件系统等可在用户空间实现的功能,移出内核,作为普通的用户程序来实现。这样,即使它们崩溃,也不会影响到整个系统的稳定。事实上,我们的 MOS 操作系统按照微内核的设计理念,尽可能地将功能实现在用户空间中,其中也包括了页写入异常的处理,因此主要的处理过程是在用户态下完成的。这样设计简化了操作系统的复杂度,并且减小处理失误时对操作系统造成的后果,提高了系统的稳定性。
Thinking 4.9
请思考并回答以下几个问题:
• 为什么需要将
syscall_set_tlb_mod_entry
的调用放置在syscall_exofork
之前?• 如果放置在写时复制保护机制完成之后会有怎样的效果?
1.syscall_exofork
后子进程被创建,父子进程都需要执行syscall_set_tlb_mod_entry
,但是父进程已经为子进程写入了异常函数入口位置,所以可以更改位置。如果没有为子进程写入,子进程将不能正确处理异常。
2.父进程运行时在函数调用等情形下会修改栈。在栈空间的页面标记为写时复制之后,父进程继续运行并修改栈,就会触发 TLB Mod
异常。所以在写时复制保护机制完成之前就需要 syscall_set_tlb_mod_entry
。
二、难点分析
有关系统调用的流程。以debugf
这个需要使用系统调用的函数为例。debugf
内与系统调用直接相关的函数调用是debug_output
,作用是字符串输出,其中又调用了syscall_print_cons
,它定义在syscall_lib.c
内。这个文件内存储了所有的syscall_*
函数,每个函数又都在调用msyscall
的同时向其中传递了一个与系统调用功能相关的参数,即系统调用号(在这个例子里,是SYS_print_cons
),并同时将字符串等相关数据传入msyscall
,msyscall
调用syscall
汇编,至此用户态部分结束,核心态部分开始。
异常触发时,程序自动跳转到.text.exc_gen_entry
段(kern/entry.S
),根据异常码在exception_handlers
异常向量组(kern/traps.c
)中寻找到接下来的地址。对于时钟中断会调转到handle_int
(kern/genex.S
)并调用timer_irq
进行时间片调度算法;对于系统调用会跳转到handle_sys
(kern/genex.S
),再分发到do_syscall
(kern/syscall_all.c
),它再根据系统调用号转移到sys_print_cons
(kern/syscall_all.c
)函数进行字符串中字符的输出,完成后进行结果的逐步返回。
有关一些特殊标志位。PTE_D
标志位为0时,当进程尝试写这个页面,会触发TLB Mod
异常。PTE_COW
用来区分真正的“只读”页面与“写时复制”页面,写时复制页面的PTE_COW
为1。写时复制的页面PTE_D
为0,PTE_COW
为1。只读页面PTE_D
为0,PTE_COW
为0。共享页面PTE_LIBRARY
为1。正常页面PTE_D
为1,PTE_LIBRARY
为0,在duppage
后需要设置为写时复制页面。
fork
函数的流程。
- 调用
syscall_set_tlb_mod_entry
系统调用,设置TLB Mod
异常的处理函数为cow_entry
。当触发TLB Mod
异常,异常会被分发到do_tlb_mod
(sp
进入异常栈,a0
存储原上下文信息),结束后回到用户态,跳转至cow_entry
进行处理,原上下文传入cow_entry
。cow_entry
为触发异常的页面建立写时共享(复制父进程的相应页面,并申请新页面重新映射),设置了新的权限位,最后通过syscall_set_trapframe
恢复现场。 - 调用
syscall_exofork
系统调用,创建了子进程,并将父进程的上下文信息复制给了子进程,将其状态设置为NOT_RUNNABLE
,返回值设置为0。此时子进程不可运行。 - 对
0 ~ USTACKTOP
之间的页面使用duppage
函数,它的作用是:为需要保护的页面设置写时共享权限,并且将设置权限后的页面共享给父子进程,这样实现了页面共享机制。 - 设置子进程的
TLB Mod
异常处理函数为cow_entry
。 - 调用
syscall_set_env_status
将子进程状态设定为RUNNABLE
并将其加入调度队列中。返回子进程的envid
。作为父进程fork
的返回值。 - 子进程参与调度,从
fork
中返回0。
三、实验体会
Lab4
的难度明显比之前几个Lab的难度又有提升,完成的耗时也相当长。自感到光凭我自己的理解是无法完成课下任务的,最后求助了同学,并且参阅了往届学长学姐的笔记才完成。系统调用,fork
等功能的实现都很复杂,涉及到多个函数的功能组合,还有多个进程之间的配合。当我第一次看完指导书完全是懵的,因为各种函数比较复杂,我并不明白互相的关系以及它们的具体作用。我看了很多遍博客,代码和指导书,边理解边整理边在上面的难点分析中记录,才在脑中对其有了大体的印象,但是细节仍然不太清晰。还需要在操作系统这门课上下更多的功夫。
我对用户栈USTACKTOP
等的使用仍然不太理解,包括向陷阱帧中传入a0
的具体意义等,希望能在接下来的课程中有进一步理解。
gdb
调试器很重要,但是自认为熟练度不足,这是我在未来的学习过程中能够不断提升的能力。
可以预见,Lab4
乃至Lab5
的上机考试都会有相当的难度,这对我操作系统的学习提出了更高的要求,要对指导书中内容有更深的理解。望自己更加努力。