BUAAOS-Lab4 实验报告
思考题
Thinking 4.1
思考并回答下面的问题:
- 内核在保存现场的时候是如何避免破坏通用寄存器的?
- 系统陷入内核调用后可以直接从当时的 $a0-$a3 参数寄存器中得到用户调用 msyscall留下的信息吗?
- 我们是怎么做到让 sys 开头的函数“认为”我们提供了和用户调用 msyscall 时同样
的参数的?- 内核处理系统调用的过程对 Trapframe 做了哪些更改?这种修改对应的用户态的变化是什么?
答:
内核通过使用
SAVE_ALL
宏来保存现场,该宏的实现是这样的:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21move k0, sp
bltz sp, 1f
li sp, KSTACKTOP
1:
subu sp, sp, TF_SIZE
sw k0, TF_REG29(sp) // tf中的栈指针
mfc0 k0, CP0_STATUS
sw k0, TF_STATUS(sp)
mfc0 k0, CP0_CAUSE
sw k0, TF_CAUSE(sp)
mfc0 k0, CP0_EPC
sw k0, TF_EPC(sp)
mfc0 k0, CP0_BADVADDR
sw k0, TF_BADVADDR(sp)
mfhi k0
sw k0, TF_HI(sp)
mflo k0
sw k0, TF_LO(sp)
sw $0, TF_REG0(sp)
// ...
// 保存其它通用寄存器($0-$28, $30-$31)具体来说,该宏首先将现场的栈指针保存在了k0(为内核保留的寄存器)中,接着,将栈指针寄存器指向了内核栈顶部并为
Trap_frame
结构体分配了栈空间,最后将CPU所有的现场信息全部保存在内核栈上的TF
结构体中。不可以,就拿$a0来说,在
handle_exception
函数中,有语句move a0 sp
,这句语句的意义是将指向内核栈上的TF
结构体的指针作为传递给do_syscall
这个handler函数的参数,也就是说,现场已经遭到破坏,我们想要访问调用msyscall
时传入的参数,需要通过内核栈上的TF结构体来实现。根据我们前面所述,调用
msyscall
时的所有现场信息已经保存到内核栈上的TF
结构体中了,对于$a0-$a3四个参数我们可以直接通过访问TF
结构体中对应的寄存器信息取得:1
2
3
4int sysno = tf->regs[4];
u_int arg1 = tf->regs[5];
u_int arg2 = tf->regs[6];
u_int arg3 = tf->regs[7];然而对于保存在函数栈帧中的参数,则需要通过
TF
结构体中保存的现场栈指针来访问:1
2
3u_int *stack_pointer = (u_int *)(tf->regs[29]);
arg4 = *(stack_pointer + 4);
arg5 = *(stack_pointer + 5);也就是说,通过保存现场并使用现场数据中保存的参数就可以让
sys_*
函数在调用时认为自己被传入了和调用msyscall
时相同的参数。内核处理系统调用的过程中对
Trapframe
做了两点改变:1
2tf->cp0_epc += 4;
tf->regs[2] = return_val;修改epc是为了在返回用户态后程序从
syscall
指令的下一条开始运行,不进行该操作会重复运行syscall
进入内核态;而修改TF
中的v0寄存器则是为了向用户态传递返回值。
Thinking 4.2
思考 envid2env 函数: 为什么 envid2env 中需要判断 e->env_id != envid的情况?如果没有这步判断会发生什么情况?
在该函数中,我们是这样从envs
数组中取出进程控制块的:
1 | e = &envs[ENVX(envid)]; |
而使用到的ENVX
宏以及用来生成进程id的mkenvid
函数是这样实现的:
1 |
|
也就是一个32位进程编号的结构是:低10位索引该编号对应进程控制块在envs
中的偏移量,而高22位则表示该进程被分配的序号,也就是说可能会出现使用一个envid
在envs
中索引到的进程控制块中的envid
和我们预期的不符的情况(哈希冲突)。
Thinking 4.3
思考下面的问题,并对这个问题谈谈你的理解:请回顾 kern/env.c 文件中 mkenvid() 函数的实现,该函数不会返回 0,请结合系统调用和 IPC 部分的实现与envid2env() 函数的行为进行解释。
首先,envid2env
函数提供了一个重要的功能,即:向该函数传入参数0时,该函数返回指向当前运行进程的进程控制块的指针,具体实现如下:
1 | if(envid == 0){ |
而站在用户的角度,在使用诸如syscall_mem_map
等系统调用时,会通过传入0这个id来对当前进程进行操作。而若mkenvid
在生成进程id时使某个进程的id为0了,这样的调用行为就存在二义性了,即:无法明确用户制定的进程是当前进程还是那个id为0的进程。因此,该函数不会返回0。
Thinking 4.4
关于 fork 函数的两个返回值,下面说法正确的是:
A、fork 在父进程中被调用两次,产生两个返回值
B、fork 在两个进程中分别被调用一次,产生两个不同的返回值
C、fork 只在父进程中被调用了一次,在两个进程中各产生一个返回值
D、fork 只在子进程中被调用了一次,在两个进程中各产生一个返回值
应当选择C,fork
函数的特点就是“调用一次,返回两次”,且在父进程中被调用。
Thinking 4.5
我们并不应该对所有的用户空间页都使用 duppage 进行映射。那么究竟哪些用户空间页应该映射,哪些不应该呢?请结合 kern/env.c 中 env_init 函数进行的页面映射、include/mmu.h 里的内存布局图以及本章的后续描述进行思考。
在函数env_init
对所有进程控制块的初始化过程中,使用map_segment
函数构造了一个名为base_pgdir
的“模板页目录”:
1 | base_pgdir = (Pde *)page2kva(p); |
base_pgdir
作为一个模板,以PTE_G
(无写权限)的权限映射了[UENVS, UVPT)之间的地址空间(即UPAGES和UENVS两个段)。
而在创建一个新进程,并进行env_setup_vm
的过程中,对于每个新创建进程,我们都要将前面所述的base_pgdir
这个模板页目录复制到这个新进程的页目录中来,这是为了保证每个进程都能够只读地访问到pages
数组和envs
数组:
1 | memcpy(e->env_pgdir + PDX(UTOP), base_pgdir + PDX(UTOP), |
也就是说,用户空间中的[UPAGES, UVPT)这一段全部进程共享的页面段不需要使用duppage
进行复制映射。
Thinking 4.6
在遍历地址空间存取页表项时你需要使用到 vpd 和 vpt 这两个指针,请参考 user/include/lib.h 中的相关定义,思考并回答这几个问题:
- vpt 和 vpd 的作用是什么?怎样使用它们?
- 从实现的角度谈一下为什么进程能够通过这种方式来存取自身的页表?
- 它们是如何体现自映射设计的?
- 进程能够通过这种方式来修改自己的页表项吗?
vpt
和vpd
是定义在user\lib.h
下的一对宏,具体如下:1
2它们的作用是将用户进程地址空间中的页表基址和页目录基址以指针的形式封装起来,方便编程时使用以访问页表和页目录。使用上可以当作一般的
Pte*
指针和Pde*
指针使用。根据
mmu.h
中的地址空间布局图,我们可以知道,在所有进程看来,它自身的页表均分布在[UVPT, ULIM)这段地址空间上,更具体地,所有的进程的页表基址均为UVPT
。根据这种统一性,我们可以通过这样一个经由宏定义得到的指针来对进程自身的页表进行存取。vpd
的基址是由自映射机制推导而来的,这体现出了自映射的设计。不能,用户态下无权修改页表项。
Thinking 4.7
在 do_tlb_mod 函数中,你可能注意到了一个向异常处理栈复制 Trapframe运行现场的过程,请思考并回答这几个问题:
- 这里实现了一个支持类似于“异常重入”的机制,而在什么时候会出现这种“异常重
入”?- 内核为什么需要将异常的现场 Trapframe 复制到用户空间?
- 即:在处理A进程的
tlb_mod
异常时另一进程又触发了该异常。 - 我们的MOS操作系统采用的是微内核架构,按照微内核的设计理念,我们应当尽可能地将功能实现在用户空间中,精简内核的功能范围。对于页写入异常的处理,也应当遵循这个设计原则。而将异常现场的
Trapframe
由内核空间复制到用户空间的异常处理栈UXSPACE
上的主要目的就是为了让我们用户空间的处理函数cow_entry
能够获取到异常现场信息并进行异常处理。
Thinking 4.8
在用户态处理页写入异常,相比于在内核态处理有什么优势?
在用户态处理页写入异常相较于在内核态进行处理的优势在我看来有如下几点:
- 遵循了微内核设计原则,将异常处理的操作交给用户进程自己来完成,精简了内核的功能范围;
- 将处理的核心逻辑从内核下方到用户态,降低了处理失败对整个操作系统造成的危害与损失。
Thinking 4.9
请思考并回答以下几个问题:
- 为什么需要将 syscall_set_tlb_mod_entry 的调用放置在 syscall_exofork 之前?
- 如果放置在写时复制保护机制完成之后会有怎样的效果?
因为在父进程调用
syscall_exofork
的过程中也可能会触发tlb_mod
异常。假设这样的情况发生,也就是说父进程的
env->env_user_tlb_mod_entry
在调用syscall_exofork
时未被正确设置为cow_entry
,那么当在exofork的过程中发生了tlb_mod
异常,根据如下代码段:1
2
3
4
5
6
7if (curenv->env_user_tlb_mod_entry) {
tf->regs[4] = tf->regs[29];
tf->regs[29] -= sizeof(tf->regs[4]);
tf->cp0_epc = curenv->env_user_tlb_mod_entry;
} else {
panic("TLB Mod but no user handler registered");
}操作系统会无法处理这个异常并报错。
本次实验的难点
相较于前几次实验较为顺利的完成过程(在细致阅读指导书以及注释后就能进行正确的函数补全),本次实验还是踩了一些坑的。
envid2env
在补全envid2env
函数的时候没怎么带脑子读函数,注意到注释是这么写的:
1 | /* Step 1: Assign value to 'e' using 'envid'. */ |
于是乎,我眼疾手快,直接写下了:
1 | struct Env *e; |
然而在本地测试时出现了疯狂panic报错unreachable code
的问题,最终调试时发现,如果这么写,由于没有进程的envid
为0,所以在后续逻辑中一定会进入如下代码:
1 | if (e->env_status == ENV_FREE || e->env_id != envid) { |
并错误返回,导致测试程序无法正常结束,遂改正为:
1 | if(envid == 0){ |
看来,不要过分依赖注释,认真读代码,理解代码,还是十分重要的啊!(赞许)
心得体会
不要过分依赖于注释,认真阅读,理解代码的用意才是硬道理。
print法debug真的很好用!