OS-Lab4实验报告
思考题
Thinking 4.1
思考并回答下面的问题:
• 内核在保存现场的时候是如何避免破坏通用寄存器的?
• 系统陷入内核调用后可以直接从当时的$a0-$a3 参数寄存器中得到用户调用msyscall留下的信息吗?
• 我们是怎么做到让sys 开头的函数“认为”我们提供了和用户调用msyscall 时同样的参数的?
• 内核处理系统调用的过程对Trapframe 做了哪些更改?这种修改对应的用户态的变化是什么?
- 使用SAVE_ALL函数保存现场,该函数位于include/stackframe.h,将各个寄存器的值保存到栈上。
- 不能直接使用,而是从传入的tf中使用。
- 在SAVE_ALL中,将参数全部保存到栈上,进入内核态后,通过异常码进入exception_handlers进行分发,而在genex.S进行实际调用do_syscall函数之前,先将sp保存到a0,即将现场值全部传给了do_syscall,并在do_syscall中恢复了5个参数(最多的参数是5个)并实际传给sys_*函数。
- 实际的系统调用会根据功能不同对Trapframe做出不同的更改,但是不会对用户态产生影响,因为返回现场时会RESTORE_ALL将现场的寄存器状态全部恢复。
Thinking 4.2
思考 envid2env 函数: 为什么 envid2env 中需要判断 e->env_id != envid的情况?如果没有这步判断会发生什么情况?
因为索引过程中只使用的envid的低位(即envs数组索引),但同一索引可能会重新分配,如果不检查的话,可能出现使用已经弃用的envid检索到了另外一个现在正在使用的env的情况。
Thinking 4.3
思考下面的问题,并对这个问题谈谈你的理解:请回顾kern/env.c 文件中mkenvid() 函数的实现,该函数不会返回0,请结合系统调用和IPC 部分的实现与envid2env() 函数的行为进行解释。
envid2env()中,特殊处理了envid==0的情况,返回了当前进程,也就是说envid的值为0时被认为是当前进程,这样在系统调用,特别是IPC部分的实现中,可以直接传入0表示当前进程。
Thinking 4.4
关于fork 函数的两个返回值,下面说法正确的是:
A、fork 在父进程中被调用两次,产生两个返回值
B、fork 在两个进程中分别被调用一次,产生两个不同的返回值
C、fork 只在父进程中被调用了一次,在两个进程中各产生一个返回值
D、fork 只在子进程中被调用了一次,在两个进程中各产生一个返回值
C(程序里只有一处 fork 调用:它由父进程执行一次,但返回后父子进程都会从 fork 返回点继续执行,因此会在两个进程中得到两个不同返回值:父进程得到子进程 pid,子进程得到 0。)
Thinking 4.5
上述“最小版”策略要求你在遍历时跳过UXSTACK 与UVPT/VPT/VPD等特殊区域。请结合kern/env.c 中env_init 的映射行为、include/mmu.h 的内存布局图,解释:
• 为什么子进程的UXSTACK 必须重新分配,而不能共享或设为COW?
• 为什么不应对UVPT/VPT/VPD 等自映射区域调用duppage?如果误处理,可能引发哪些不一致或错误行为?
- UXSTACK 必须重新分配:若共享,则并发触发可能出现相互覆盖等问题;若COW,则可能出现处理页故障需要写入UXSTACK时再次出发页故障,导致递归。
- 不能对 UVPT/VPT/VPD 调用
duppage:自映射区域是每一个进程只读的、用来定位内存的区域,如果变成了写时复制,可能会导致自映射区域出现错误,进而导致用户在访问地址空间时访问到错误位置。
Thinking 4.6
在遍历地址空间存取页表项时你需要使用到 vpd 和 vpt 这两个指针,请参考user/include/lib.h 中的相关定义,思考并回答这几个问题:
• vpt 和 vpd 的作用是什么?怎样使用它们?
• 从实现的角度谈一下为什么进程能够通过这种方式来存取自身的页表?
• 它们是如何体现自映射设计的?
• 进程能够通过这种方式来修改自己的页表项吗?
vpt是“页表的虚拟映射”,可按虚拟页号索引到对应Pte;vpd是“页目录的虚拟映射”,可按目录号索引到对应Pde。使用时通常先看vpd[PDX(va)]是否有效,再看vpt[VPN(va)]的权限位与有效位来判断某个va是否映射、权限是否可读写。- 进程之所以能这样读到自己的页表,是因为内核在建立地址空间时把“当前页目录自身”映射进了用户可见的固定高地址区(自映射窗口),于是页表结构本身也被放进了同一套虚拟地址体系里,用户态就能像读普通内存一样只读访问这些表项。
- 这正是自映射:把页目录的一个目录项指回页目录物理页本身。这样通过这一段固定虚拟区,既能看到页目录(
vpd),也能展开看到所有页表(vpt),实现“用虚拟地址访问管理虚拟地址的元数据”。 - 不能直接修改。
UVPT/VPT/VPD区域是用户态只读映射,用户只能查询;若要修改页表,必须通过系统调用进入内核,由内核检查权限后更新映射。
Thinking 4.7
在do_tlb_mod 函数中,你可能注意到了一个向异常处理栈复制 Trapframe运行现场的过程,请思考并回答这几个问题:
• 这里实现了一个支持类似于“异常重入”的机制,而在什么时候会出现这种“异常重入”?
• 内核为什么需要将异常的现场 Trapframe 复制到用户空间?
- 在出现其他异常,比如写入异常栈时权限不足,就需要异常重入。
- 因为微内核的设计理念,实际处理异常过程在用户空间进行,因此需要将异常现场上下文复制到用户空间。
Thinking 4.8
在用户态处理页写入异常,相比于在内核态处理有什么优势?
- 灵活性更高:不同进程可以实现自己的缺页/写时复制策略(如 COW、懒分配),不必把所有策略写死在内核里。
- 内核更小更安全:把策略下放到用户态,内核只保留机制,减少内核复杂度与 bug 面。
- 故障隔离更好:用户态处理逻辑出错通常只影响该进程,不会直接破坏内核。
- 可扩展性更强:新增内存管理策略主要改用户库或用户态处理函数,无需频繁修改内核。
Thinking 4.9
为什么在设置写时复制保护机制之前,需通过 syscall_set_tlb_mod_entry设置父进程页写入异常处理函数?若在写时复制保护机制完成之后再设置会怎样?
若写时复制保护机制完成后再设置异常处理函数,可能会出现已经发生写时复制异常但是没有函数处理的情况。
难点分析
系统调用
系统调用作为一种特殊的异常,依赖Lab3中的异常处理机制。用户态下的系统调用函数syscall_*调用msyscall,而msyscall只调用syscall指令陷入内核。陷入内核后,在入口entry.S中设置内核态标志、保存现场并跳转至genex.S分发异常,分发至do_syscall函数,在此函数中恢复前文保存的现场中a0-a3,以及保存到栈上的参数,并传递给真正的系统调用实现函数。
fork
fork函数的流程为:先设置TLB mod异常处理函数,随后调用syscall_exofork系统调用创建了一个新的进程,并在其中将trapframe复制给新进程,同时将v0设置为0,这样新进程被调度时,从该函数返回的返回值为0,而父进程返回值则为父进程的id。随后在fork中判断,如果返回值为0,说明当前被调度的是子进程,直接将0值返回;否则说明是父进程,进行COW标记、设置子进程的异常处理函数,并将子进程设置为RUNNABLE。
实验体会
Lab4让我理解了系统调用是如何实现的,补全了CO-P7的“最后一公里”,并理解了进程间通信与创建新进程的流程,对用户态和内核态有了更加深刻的理解。
原创说明
本文均为原创,部分问题参考AI回答。