OS - Lab4实验报告
归档于2025年7月8日。
感觉开始上强度了。
我需要从头理一些东西。
杂记
syscall的处理
以debugf()为例:
- 处理参数,随后调用
vdebugf() vdebugf()中调用熟悉的vprintfmt,用debug_output作为后端- 随后还调用了
debug_flush(),调用出syscall_print_cons syscall_print_cons()调用msyscall(),传入信号参数SYS_print_cons,指示之
好,到了msyscall,发现其只做了这么简单的事情:
1 | 1 #include <asm/asm.h> |
只调了个syscall,然后返回,没了?我参数呢?在调用msyscall的时候,参数就已经存在了栈上了,不用担心。
之后就是进入syscall的异常处理程序;自然我们想到往genex.s看:
1 | BUILD_HANDLER sys do_syscall |
好的,异常是由do_syscall()做的,继续看;存在syscall_all.c里的:
众所周知,我们的异常处理会保存Trapframe;Trapframe的保存,是在进入了内核态之后,在entry.S中进行;此时Trapframe保存了我们msyscall()的参数,故我们的参数这里才会用到;
首先就是第一个参数;当然是从$a0($4)里取了;这样,我们就知道要执行哪一个系统调用,从syscall_table[]里取函数了。
出问题的时候,就返回一个异常值;函数返回值(第一个)就是$v0($2)了;存了之后return就是。
整个系统调用流程大概就是这样。
sys_*()
需要注意的是,我们在系统调用中进行的系统操作(增删映射,设置进程控制块状态等),一般都是由sys_*()完成的,而非直接使用page_remove()等函数。
最主要的原因是,我们需要由envid2env(),围绕envid/pid进行一些处理,比如:
envid == 0,则指向当前进程- 判断进程是否已被释放(
status == ENV_FREE) - 进程ID是否出错:我们由
ENVX(envid)取出的进程块的envid,应该与我们现在这个envid一致 - 权限检查:检查操作是否是进程自己对自己,或者是进程对自己的 “直系子进程”。
我自己做Lab4的时候没怎么注意这个问题,然后就踩坑了。
进程间通信
我们这里是通过内存共享实现的。
为了实现IPC,我们需要在进程块中加这么几样东西:
- 起点;来自哪个进程
- 终点;被映射到了接收方的哪个虚拟地址
- 传递的信息
- 当前进程是否可接受信息
- 当前进程是否在等待要接收的信息
进行进程通信这个操作,还是通过系统调用(syscall) 实现的。因此,对应逻辑在sys_*()中。
我们在这里的问题是,这里对应的共享内存空间到底是哪里?
阅读源码,我们发现其利用is_illegal_va()检查地址是否合法:
1 | 112 static inline int is_illegal_va(u_long va) { |
然后,是时候好好看看内存布局了:
<UTEMP:无效内存对应空间>= UTOP:超出kuseg的一定范围;换言之,超出了用户栈的范围
也就是说,我们共享的信息,是在用户空间内共享的。
1 | 我们知道,所有的进程都共享同一个内核空间(主要为 kseg0)。因此,想要在不同空间之 间交换数据,我们就可以借助于内核空间来实现。 |
指导书的这句话,意思是借助内核空间,进行两个进程对同一内存块的映射。
看向sys_ipc_recv():
我们为何要((Trapframe *)KSTACKTOP - 1 )->regs[2] = 0;
此时,我们的当前进程的栈,自然在栈顶上;栈使用的空间正好就是Trapframe的大小,故我们这么做,就是在访问当前进程的上下文,将函数返回值设为0.
那么,为什么我们这里不直接return呢? 因为我们此时要做到一个阻塞进程的效果,若直接return 0,进程就会继续恢复进行,不符合我们的预期;将返回值暂存在上下文中,就可以做到阻塞解除后,进程被重新调度,恢复执行,得到返回值0的效果了。
阻塞的解除,是在sys_ipc_try_send()里,由修改接收方状态为ENV_RUNNABLE,并将接收方重新放回调度队列中实现的。
对了,还有个好玩的事情:
为何我们不需要checkperm?因为进程间通信不需要检查进程是否为父子关系;checkperm检查的是envid是否指向自己,或自己的直系子进程。
src/dstva == 0 ?
我认为指导书这里说的不是很清楚?
不难注意到,我们在try_send()时,在srcva == 0时,不会将发送方的某个页面,映射到接收方的某个页面上。
但是,我们还是会对接收方进程控制块的ipc域进行修改(e.g. 待接收位拉低,value位设为要传递的value值),并将接收方放回调度队列。 这一点很关键。
当然,这其中一个作用就如指导书所言,仅由进程块的value域传递小量信息,快捷方便;但我认为这跟进程的同步也有关系。
回顾前面的recv(),我们在待接收时阻塞,在发送时唤醒,这不就是多进程的同步吗?(笑
fork()
fork()怎么创建一个与父函数不同的新进程的?这个答案指导书已经告诉我们了,通过fork()-exec()的配合。
小实验
对了,在做实验的时候,我发现了一个有趣的现象。在使用管道重定向带fork()程序的输出的时候,输出有些奇怪:
1 | 1 |
1 | 1 Before: var = 1 |
Before句按理只会输出一次,为何这里输出了两次?由于我们的实验还没做到管道部分,这里我们暂时也不知道为什么…
自上而下分析
fork()干了这么几件事:
- 设置父进程的TLB Mod Handler
exofork()创建子进程,获得创建好的进程控制块的PID- 自此开始分割父进程和子进程;父进程在此完成所有操作,返回0
- 子进程:构建父进程所有页的映射
- 设置子进程的TLB Mod Handler,并让子进程进入RUNNABLE
还有一点:
1 | MOS 允许进程访问自身的进程控制块,而在 user/lib/libos.c 的实现中,用户程序在运 行时入口会将一个用户空间中的指针变量 struct Env *env 指向当前进程的控制块。对于 fork 后的子进程,它具有了一个与父亲不同的进程控制块,因此在子进程第一次被调度的时候(当然 这时还是在 fork 函数中)需要对 env 指针进行更新,使其仍指向当前进程的控制块。这一更新 过程与运行时入口对 env 指针的初始化过程相同。 |
这就是为什么:
继续。构建父进程映射部分的细节颇有意思:
结合上面的内存空间图,我们得知:我们将kuseg段,所有在USTACKTOP下的页面全部映射到子进程中(当然要做COW的处理);这样一来,循环条件就很明显了:找所有USTACKTOP下的页。
随后就是里面的条件了:
- 页号对应页目录项要有效
- 页表项要有效
非有效即可认为映射不准确,故选择不映射;到时候会产生缺页异常重新取的。
思考题
4.1
在我们上面回顾了一下syscall的流程后,这个问题就很好解答了。
- 将GPR的所有信息存入Trapframe,在处理完成后再从Trapframe恢复,这样GPR的信息在调用前后就不会发生变化。
- 理论而言是可以的,因为此时的
$a0-$a4按理没有发生改变,但这违反了我们保护现场的原则;这也是我们为何需要从Trapframe中读取的原因;此外,Trapframe自身能保证GPR就是调用前的状态,有更保险的方法为什么不用() do_syscall()从Trapframe中读出msyscall()传入的参数,从而将这些参数分发给sys_*()- 这部分的改变主要在
do_syscall()中发生;
此时,cp0_epc加4;在用户态,表示异常处理完后直接执行下一条指令;tf->regs[2]被设为了调用的处理函数的返回值;如果没有对应系统调用的处理函数,会返回一个错误值;对于用户态,我们此处就可以根据返回值确定系统调用的状态,对应进行处理。
4.2
为了检测envid对应的进程是否真的存在。
有这么一种可能:
- 传入
envid2env()的envid是一个当前不存在的进程 envid指向的envs[]存的是无效信息- 如果不判断,我们就会错误地认为这个进程存在…
4.3
首先,mkenvid()正如其名,创建一个新的进程ID,它的结果当然不应该为0。
envid2env()这一函数其实“名不副实”,因为除根据envid获取进程控制块外,他还整合了检查进程控制权限的功能。
何为“进程控制权限”?阅读源码,我们知道,“进程控制”只有在两种情况下合法:
- 查询的
envid,对应的正好就是调用envid2env()的进程自己; - 查询的
envid,对应的是调用进程的 “直系子进程”。
envid2env()能处理envid==0的情况,其实是为了给进程获取自身的进程控制块提供方便。毕竟,进程此时不获取自己的进程控制块,是不知道自己的PID的。
4.4
C。
父函数调用fork(),生成了子进程;随后,给父子两进程返回不同的返回值。
4.5
把内存空间图继续搬下来:
对于USTACKTOP以上的空间:
- ULIM-UVPT:父进程自己的页表,我们的
duppage会做重建页表的工作,故不用 pages/envs:被映射到系统的pages[]和envs[]两个内核数组;每个进程在env_init时就做了映射,故不用- exception_stack:仅在用户态处理异常时要用到,这里用不上,故不管
剩下的只要满足条件,就要映射。
4.6
首先,我们知道,页表是被映射到了一个确定的虚拟地址上的,且我们的MOS采用了页表自映射。
vpd,vpt分别获取页目录和页表的起始地址;lib.h中将二者分别定义为了Pde *与Pte *,故按照指针的用法使用之即可;比如说,可以vpd[PDE_ID],也可以vpd + PDE_ID。- 页表被映射到了一个确定的虚拟地址上;一个进程拥有一套自己的页表;在切换进程时,这个页表也会被切换。在得知基地址和偏移量后,能访问页表就很自然了。
1
2#define vpt ((const volatile Pte *)UVPT)
#define vpd ((const volatile Pde *)(UVPT + (PDX(UVPT) << PGSHIFT))) vpd处UVPT + (PDX(UVPT) << PGSHIFT)已经很明显了,不再赘述- 不能,权限不够
4.7
重入机制如下,注意
tf->regs[29] >= UXSTACKTOP1
2
3
4
5
680 void do_tlb_mod(struct Trapframe *tf) {
81 struct Trapframe tmp_tf = *tf;
82
83 if (tf->regs[29] < USTACKTOP || tf->regs[29] >= UXSTACKTOP) {
84 tf->regs[29] = UXSTACKTOP;
85 }考虑这么一种可能:
do_tlb_mod的处理函数,再次访问了一个需要COW的页。此处异常处理的一部分在用户态处理,这是有可能破坏上下文的;为了维护进程进入异常处理前后的上下文一致,我们自然需要保存进入异常前的上下文。此外,异常现场是在进入异常时保存的,对用户态透明,故我们需要将其复制到用户空间。
以防我忘了,多写一点:
do_tlb_mod()中,进行了上下文的复制;对于现在放在Trapframe的那一份上下文,我们修改其cp0.epc到处理函数的位置;随后,开始执行处理函数(如
cow_entry)。执行完成后,会调用syscall_set_trapframe,以复制的那一份上下文,恢复进程的上下文。恢复
$sp和上下文,是在sys_set_trapframe处完成的。
4.8
- 微内核的概念中,异常处理放在用户态处理,减少了因权限不足等问题引起系统崩溃的可能。
- 在用户态处理页写入异常时,我们可以调度其他进程继续执行,避免主机为了处理这一异常,让其他进程等待太久。
4.9
- 我认为二者相对位置没什么影响。因为第一个
set_entry是对于父进程的,子进程直到父进程为其设置tlb_mod_entry(第二个set_entry)前都不会执行,故没有影响。放的位置可能就看个人喜好了。 - 写时复制保护,保护的是USTACKTOP下的所有内容;这当然也包括了我们的函数参数;假设我们调用了一个函数,就可能触发COW;如果我们将
set_entry放在COW机制后,就有可能导致调用函数时产生COW异常,而没有对应处理函数的问题。
难点分析
- 各个过程耦合程度挺高的,需要真正重头过一遍才能看懂。具体见我上面的杂记吧。
- 在前几个单元的思维定势下,我常常忘记一些地方需要用
sys_*();比如duppage中页表的映射,我下意识就输入了page_insert()… - 细节很多。比如说
fork进行COW映射时,还需要关注页目录/页表项是否有效。没有注释的提醒,我一开始确实没有考虑这么多…
实验体会
我深刻感觉到本单元开始,任务的加重。
首先是注释细化程度进一步下降,这让我不得不更谨慎地考虑实现细节。最后确实发现不少细节漏掉了,比如上面提到的COW映射,与页目录/页表有效有关的问题。我自觉自己注意细节的能力在日趋下降,是时候想办法改变一下了。
说实话,本次Lab是我第一次大量参考往届学长/MOS开源仓库的代码。虽说能保证最后结果正确,但为了避免印象不深,而在第二天从头再看一遍的时间开销还是很大。怎么才能达到独立完成,和参考他人经验之间的平衡,是个值得探讨的问题。
原创声明
本人报告的绝大部分内容由我自己独立完成。但我在思考题4.5、4.7处遇到了一些困难,有参考两位学长(学姐)博客中的思路,谨此致谢:
OS - Lab4实验报告