OS_Lab4_实验报告
一、思考题
Thinking 4.1 思考-系统调用的实现
- 问:内核在保存现场的时候是如何避免破坏通用寄存器的?系统陷入内核调用后可以直接从当时的
$a0-$a3参数寄存器中得到用户调用 msyscall 留下的信息吗?我们是怎么做到让 sys 开头的函数“认为”我们提供了和用户调用 msyscall 时同样的参数的? - 解答:
- 避免破坏通用寄存器:内核在保存现场时,利用了 MIPS 约定的两个内核保留寄存器
$k0和$k1。陷入异常时,硬件自动将返回地址保存在$epc中,汇编代码先利用$k0暂存当前的栈指针$sp的值,接着将$sp切换为异常栈顶指针。因为$k0和$k1在正常的用户态代码中是被严格保留不用的,所以这个中转过程完美避免了破坏任何用户通用的寄存器环境,随后利用保存好的栈依次将所有寄存器压栈。 - 不能直接读取:系统陷入内核后,不能直接从当时的
$a0-$a3获取信息。因为在陷入内核态执行底层汇编跳转到系统调用分发函数(如handle_sys)的过程中,可能会发生进一步的 C 语言函数调用,由于 MIPS 的传参规约,这些操作极有可能覆盖覆写$a0-$a3。 - “欺骗” sys_ 函数:系统会将异常发生瞬间的用户上下文封装成
Trapframe并保存在栈中。在系统调用分发代码中,我们直接从栈上的Trapframe里提取出$a0-$a3(即tf->regs[4]到tf->regs[7]),然后再显式地将这些值作为真正的实参去调用内核中sys_*对应的 C 函数。通过这种机制,参数被安全传递并伪装成了调用时的原样。
Thinking 4.2 思考-envid2env 的实现
- 问:思考
envid2env函数的细节:返回值有什么意义?对 envid 进行了怎样的处理?是否进行了合法性检查? - 解答:
- 返回值意义:返回
0代表成功在进程块数组中找到了对应的进程并成功返回指针;若返回< 0(如-E_BAD_ENV)则说明未能找到合法进程。 - 参数处理:如果传入的
envid等于 0,函数会将其自动视为对当前正在运行的进程(curenv)的调用;若非 0,则通过解析envid的低位特征(例如通过宏ENVX)获取其在envs进程数组中的偏移量,取出该进程控制块。 - 合法性检查:存在严格的合法性检查。首先会比对通过偏移取出的进程控制块自带的
env_id是否与传入的envid一致;其次判断该进程状态是否处于未分配阶段(ENV_FREE)。并且函数还带有一个checkperm参数,如果为1,系统会强制检查当前操作者是否有权限修改该目标进程(即目标进程必须是当前进程本身或其直接的子进程),防止越权操作。
Thinking 4.3 思考-mkenvid 函数细节
- 问:
mkenvid函数是如何生成 envid 的? - 解答:
mkenvid函数在创建进程时生成的env_id不是简单的数组索引。该 ID 的低位是进程控制块在envs数组中的索引偏移,而高位部分则存储了一个系统中不断累加的序列号。由于系统中进程会不断生成与销毁,同一个数组位点可能被反复回收再分配。引入高位序列号后,即使由于索引复用导致低位相同,新进程的全局env_id依然与已经销毁的旧进程不同。这防止了别的进程使用旧env_id发送 IPC 通信给复用该控制块的新进程,起到了强有力的安全性保障。
Thinking 4.4 思考-fork 的返回结果
- 问:为什么 fork 在父进程和子进程中的返回值不同?内核是如何实现的?
- 解答:
在父进程中,系统调用sys_exofork会在内核中完整分配出一个子进程控制块,并将父进程当前的上下文原封不动地复制给子进程,随后向父进程返回这个新子进程的envid作为函数返回值。
对于子进程,虽然它的代码段与执行现场(Trapframe)与父进程完全相同,但是内核在创建它时,刻意将子进程结构体Trapframe里保存返回值的寄存器$v0修改为了0。于是当子进程被操作系统的进程调度器首次挂载到 CPU 上并恢复现场执行时,硬件从它的Trapframe中恢复所有寄存器,此时$v0会变成0。这就实现了同一个上下文分叉,却因为被内核做了不同的定制,使得父进程看到子进程 ID,子进程却看到返回值 0。
Thinking 4.5 思考-用户空间的保护
- 问:在实现系统调用和页写入等异常时,内核是如何保护用户空间的?
- 解答:
由于 MOS 系统对物理与虚拟内存做了隔离,在用户进程触发类似于sys_mem_map或者内存申请等系统调用时,内核会严格审查传入的虚拟地址参数,保证其不允许超出UTOP边界,这就断绝了用户程序跨界修改系统内核区(如kseg0等)的可能。此外,无论是申请内存还是父子进程地址映射,系统都会强制过滤标志权限(例如只能设置只读、可写、COW 等),任何不合理、不属于系统允许配置的硬件标志位都会在转换时予以抛弃或报错,彻底保护了用户态环境的纯洁。
Thinking 4.6 思考-vpt 的使用
- 问:页目录自映射带来了什么好处?用户进程如何使用
vpt数组? - 解答:
MOS 操作系统使用了“自映射页目录”机制,通过在初始化时将页目录的某一项指向自身的物理页,巧妙地将完整的二级页表结构映射到了用户地址空间中的一段虚拟地址(如UVPT)。
这样带来的巨大好处是,用户进程无需依赖高开销的系统调用(即不用陷入内核态),即可将vpt和vpd作为两个线性大数组直接查阅自身的页面映射与权限状态。这一特性在实现写时复制时对于父进程遍历UTOP之下的整个有效内存页极其方便;同时,因为该地址区段受内存权限的写保护,用户只能看不能写,兼顾了高效和安全。
Thinking 4.7 思考-页写入异常-内核处理
- 问:当触发 TLB Mod 异常时,内核具体是怎么处理的?
- 解答:
一旦用户对通过 Fork 设为写时复制(带PTE_COW标志且被限制为只读)的页面发起写操作,硬件将拒绝并触发页写入异常(TLB Mod),使得执行流落入内核。
由于写时复制的分配属于用户进程自身行为,出于微内核设计,内核不对该页进行实际分配,只负责“反射异常”。内核会在当前出错的进程专有的用户异常栈(UXSTACKTOP区域)上开拓空间,并手动伪造压入一个Trapframe,记录发生错误一瞬间 CPU 的所有寄存器环境及中断处的异常地址(原epc)。随后内核修改保存现场中的执行指针$epc为该进程之前通过sys_set_tlb_mod_entry注册好的用户异常处理程序的入口地址。最后退出异常分发并恢复环境,强行使 CPU 退回用户态,从那个指定的入口程序开始执行补救逻辑。
Thinking 4.8 思考-页写入异常-用户处理-1
- 问:用户态的写时复制入口函数做了什么工作?
- 解答:
在用户态异常函数cow_entry被唤醒后,它:
- 通过读取压在其异常栈上的
Trapframe信息,拿到了引发页写入异常的真实虚拟内存地址badvaddr。 - 使用
sys_mem_alloc的系统调用,为自己分配出新的一页物理页面,但暂时挂载到一个不冲突的临时地址(如UCOW)上。 - 通过内存拷贝,将由于异常没能成功写下去的那个“旧页面”上的原有数据全部“深拷贝”到
UCOW映射出的那块新页里。 - 利用
sys_mem_map将带有全新物理页并带有可写标志的虚拟内存覆盖重映射到刚刚的报错地址badvaddr。这使得那个位置正式解除了 COW 限制,变成了进程私享的可写页,最后解除临时的UCOW映射。
Thinking 4.9 思考-页写入异常-用户处理-2
- 问:如何在完成写时拷贝后安全地退回原先的执行流?
- 解答:
既然在复制与映射已经做完,就应当去继续执行导致异常的那条写指令。因为之前并非处于普通的函数跳转,无法使用jr $ra返回,所以程序必须在此时重新充当“调度器”的角色:通过特殊设计的汇编,将堆栈指针sp指向内核之前在异常栈中精心存入的那个Trapframe。然后依照与系统调用相仿的逻辑,逐一重置全局寄存器,接着取出中断时报错的那条指令地址(epc),最后平滑地跳回该地址继续运行代码。此刻目标页面已被换成了可读写的新页面,指令就能成功写下数据了。
二、难点分析
本次 Lab4 的难度相较于前三次实验有极大的跃升,不仅要求对寄存器、内存架构有细致入微的了解,还涉及大量的用户态/内核态之间的横跳调度。分析得出本次实验的三大难点:
- 进程间通信(IPC)机制与挂起状态的同步管理
在实现sys_ipc_recv和sys_ipc_try_send时,需要深刻理解系统阻塞一个进程的真谛。接收函数要求我们手动修改进程状态至ENV_NOT_RUNNABLE并放弃 CPU 进行调度重分配;发送进程除了判定被发送对象的recving标志位之外,还需要注意把目标进程状态及时拉回ENV_RUNNABLE,并跨进程将页面映射关系写到对方控制块的特定指针区里。由于在测试用例如 pingpong 里收发操作高度异步,如果进程间状态的转换哪怕慢了一步或者遗漏了对调度队列的处理,就会导致死锁。 - 写时复制(COW)实现中的内存判断
利用fork时实现完整的 COW 机制极其考验对于操作系统物理页管理和虚拟页保护机制的掌握。在使用duppage遍查vpt中低于UTOP的每一页虚拟内存进行拷贝时,不仅需要绕过无效页(未分配),还需要区分共享页面(PTE_LIBRARY标志)与数据页。必须慎之又慎地为这两种页面制定不同的保护与分配标志(如读写变只读+COW),并将修改映射反映给原进程和子进程两者,一不留神就会导致父子互相伤害。 - 用户态异常栈(UXSTACK)的现场保存及汇编倒带
内核无法包办 COW 异常恢复的核心原因是进程本身更清楚怎么处理自己的内存。在这个微内核思想中,如何手写一段汇编代码让其通过指针去强行还原 CPU 的寄存器快照是个非常大的门槛。在内核里构造一个位于用户栈指针底部的Trapframe并实现跳转,计算字节偏移量错了一次就会引发整个 MIPS 架构的执行流乱跑乃至 TLB 重填死循环。调试这部分汇编是整个实验中时间投入最大的难点。
三、实验体会
- 在以前操作系统的理论课堂上,虽然也学习到了诸如“写时复制”、“虚拟内存页表”、“自映射”等概念,但它们就像是云端的海市蜃楼。直到在这次系统调用的实验中,一步一步地编写寄存器的恢复与跳转,手动分配
badvaddr的新物理页,我才真正理解到操作系统是如何通过一次次的软硬件协同和“障眼法”来维系一个高效安全的计算机底层环境的。 - 完成此次实验后,我不禁对操作系统研发先驱们设计的精巧程度感到敬佩。无论是页目录使用自映射,从而省去了每次探查内存分配情况都要通过系统陷入的开销,还是在中断保存时使用专用不透明异常栈的做法,每行底层机制的设计都洋溢着微内核思想中的极简与克制,将安全底线与效率提升拿捏得极其精准。
- 本次实验可以说是“写代码半小时,修 Bug 三整天”。由于错误的隐蔽性和发作的滞后性——也许是子进程返回寄存器少了一句修改,也许是页异常恢复的时候少弹出了四个字节,这些错误通常要在代码历经成千上万次 CPU 轮转后才会引发 panic 或断言失败。这段排错经历极大地提升了我对于利用 GDB 的异常向量追踪能力,也让我意识到了 C 语言指针和地址解引在底层系统开发中严格检查边界的极端重要性。整体来看,当跑通测试样例的一瞬间,所有繁复劳动都被巨大的获得感彻底覆盖,非常充实。