# lab-4 实验总结
# 实验思考题
# Thinking 4.1
内核在保存现场的时候是如何避免破坏通用寄存器的?
系统陷入内核调用后可以直接从当时的
$a0−$a3
参数寄存器中得到用户调用 msyscall 留下的信息吗?我们是怎么做到让 sys 开头的函数 “认为” 我们提供了和用户调用 msyscall 时同样的参数的?
内核处理系统调用的过程对 Trapframe 做了哪些更改?这种修改对应的用户态的变化是?
【答案】
保存现场时,k0 寄存器暂存了 sp 栈指针的值,k1 寄存器更新 sp 栈指针的值,除 k0、k1 之外所有的通用寄存器都在修改之前被保存了,k0 和 k1 是约定好保留供操作系统使用的寄存器,用户代码不会使用,因此修改也没关系
可以,a0-a3 寄存器没有被修改过
人工将参数加载到了 sys 开头函数认为的位置,事实上我们利用了 Calling Convention 中的对寄存器的描述直接把参数值存入相应寄存器内,或者压入栈中
对 Trapframe 结构体中的
cp0_epc
的值增加了 4,将 sys 开头函数的返回值存入 v0 寄存器,这样保证了系统调用结束后,从 syscall 的下一条开始执行;在发生异常时,存在 EPC 中的受害指令是当前指令,因此我们需要多加一步操作
# Thinking 4.2
思考下面的问题,并对这个问题谈谈你的理解: 请回顾 lib/env.c 文件中 mkenvid()
函数的实现,该函数不会返回 0,请结合系统调用和 IPC 部分的实现与 envid2env () 函数的行为进行解释。
【答案】
由以上 mkenvid()
函数可知,最终返回值的第 11 位始终为一,所以该函数不会返回零,因此不会存在 0 这个 envid,所以 envid2env 中如果是 0 则返回 curenv 功能才能够实现,IPC 部分才可以使用 envid2env 获取当前进程、根据进程 ID 获取进程结构体
# Thinking 4.3
思考下面的问题,并对这两个问题谈谈你的理解:
- 子进程完全按照 fork () 之后父进程的代码执行,说明了什么?
- 但是子进程却没有执行 fork () 之前父进程的代码,又说明了什么?
【答案】
说明了子进程和父进程具有相同的代码段,也说明了写时复制时确实共享了父进程包括代码在内的所有页面
创建子进程时,PC 值设置为了
fork()
的后一个指令,所以子进程没有执行fork()
之前父进程的代码,然后在程序中也会根据进程 ID 是 0 还是其它值来判断当前进程是父亲还是孩子,从而执行不同的代码段
# Thinking 4.4
关于 fork 函数的两个返回值,下面说法正确的是:
A、fork 在父进程中被调用两次,产生两个返回值
B、fork 在两个进程中分别被调用一次,产生两个不同的返回值
C、fork 只在父进程中被调用了一次,在两个进程中各产生一个返回值
D、fork 只在子进程中被调用了一次,在两个进程中各产生一个返回值
【答案】
C: fork
在父进程中调用一次,返回两次,在子进程中返回 0,在父进程中返回子进程 ID
# Thinking 4.5
我们并不应该对所有的用户空间页都使用 duppage 进行映射。那么究竟哪些用户空间页应该映射,哪些不应该呢?请结合本章的后续描述 mm/pmap.c 中 mips_vm_init 函数进行的页面映射以及 include/mmu.h 里的内存布局图进行思考。
【答案】
需要映射的是 0—USTACKTOP 范围内的空间。
因为其上的范围,USTACKTOP 到 UXSTACKTOP 之间为用户进程的异常栈和 Invalid memory,而异常栈是进行异常处理的地方,不应映射,不应该受到写时复制机制的保护;Invalid memory 不会用到为空闲区,不需要保护;
UTOP 以上的空间为内核相关的页表、进程控制块、物理页面管理等部分,对于所有的用户进程都相同,用户进程当然也无权进行更改,因此不需要用 duppage 进行保护。
综上,需要保护从 UTEXT 到 USTACKTOP 的页表。
# Thinking 4.6
在遍历地址空间存取页表项时你需要使用到 vpd 和 vpt 这两个 “指针的指针”,请参考 user/entry.S 和 include/mmu.h 中的相关实现,思考并回答这几个问题:
- vpt 和 vpd 的作用是什么?怎样使用它们?
- 从实现的角度谈一下为什么进程能够通过这种方式来存取自身的页表?
- 它们是如何体现自映射设计的?
- 进程能够通过这种方式来修改自己的页表项吗?
【答案】
vpd
存放的是页目录基地址(Virtual Page Directory),基地址加页目录项偏移数PDX(va)
即为va
对应的页目录项;vpt
为页表基地址(Virtual Page Table),指向页表区域第一个页表项,基地址加页表项偏移数即为va
对应的页表项。如果有效,
(*vpd)[va>>22(页目录的索引)]&(~0xfff)
表示二级页表的物理地址,如果有效,(*vpt)[va >> 12]&(~0xfff)
为va
对应的物理页面地址在用户空间入口函数
entry.S
中,我们可以发现下面的代码
这里的 globl 定义了全局符号,也就是对于用户代码来说,vpt 和 vpd 就如同全局变量一样,因此进程可以访问这两个地址去读取自身的页表
- vpd 指向
(UVPT+(UVPT>>12)*4)
,这直接就是自映射的公式,显然使用了自映射技术 - 不能,用户进程无权修改自己和内核的页表项,也就是说页表项的标记是没有 PTE_R 的,用户进程必须要通过系统调用陷入内核之后才能进行操作
# Thinking 4.7
page_fault_handler 函数中,你可能注意到了一个向异常处理栈复制 Trapframe 运行现场的过程,请思考并回答这几个问题:
- 这里实现了一个支持类似于 “中断重入” 的机制,而在什么时候会出现这种 “中断重入”?
- 内核为什么需要将异常的现场 Trapframe 复制到用户空间?
【答案】
用户发生写时复制导致缺页中断并处理这个缺页中断的过程中,有可能还会发生缺页,所以要 “中断重入”,类似于函数嵌套递归调用的方式,要重复处理,直到不再缺页异常。
因为我们的 MOS 系统使用微内核设计,将缺页中断的处理交给了用户进程,所以用户进程需要读取 Trapframe 的值获得哪一条指令触发了缺页,从而得到缺的页面是哪一页,并进行调页。用户进程处理完毕恢复现场的时候也要使用 Trapframe 的数据。
# Thinking 4.8
到这里我们大概知道了这是一个由用户程序处理并由用户程序自身来恢复运行现场的过程,请思考并回答以下几个问题:
- 在用户态处理页写入异常,相比于在内核态处理有什么优势?
- 从通用寄存器的用途角度讨论,在可能被中断的用户态下进行现场的恢复,要如何做到不破坏现场中的通用寄存器?
【答案】
减少内核代码的工作量,用户处理出现错误时还可以由内核进行处理,但是如果内核出现错误,就会导致系统崩溃
可以看到恢复过程代码如下:
// 恢复除了sp寄存器 .macro RESTORE_SOME .set mips1 mfc0 t0, CP0_STATUS ori t0, 0x3 xori t0, 0x3 mtc0 t0, CP0_STATUS //修改cp0_status lw v0, TF_STATUS(sp) li v1, 0xff00 and t0, v1 nor v1, $0, v1 and v0, v1 or v0, t0 mtc0 v0, CP0_STATUS lw v1, TF_LO(sp) mtlo v1 lw v0, TF_HI(sp) lw v1, TF_EPC(sp) mthi v0 mtc0 v1, CP0_EPC // 用v0 v1寄存器恢复非通用寄存器 lw $31, TF_REG31(sp) lw $30, TF_REG30(sp) lw $28, TF_REG28(sp) lw $25, TF_REG25(sp) lw $24, TF_REG24(sp) lw $23, TF_REG23(sp) lw $22, TF_REG22(sp) lw $21, TF_REG21(sp) lw $20, TF_REG20(sp) lw $19, TF_REG19(sp) lw $18, TF_REG18(sp) lw $17, TF_REG17(sp) lw $16, TF_REG16(sp) lw $15, TF_REG15(sp) lw $14, TF_REG14(sp) lw $13, TF_REG13(sp) lw $12, TF_REG12(sp) lw $11, TF_REG11(sp) lw $10, TF_REG10(sp) lw $9, TF_REG9(sp) lw $8, TF_REG8(sp) lw $7, TF_REG7(sp) lw $6, TF_REG6(sp) lw $5, TF_REG5(sp) lw $4, TF_REG4(sp) lw $3, TF_REG3(sp) lw $2, TF_REG2(sp) lw $1, TF_REG1(sp) // 通过sp寄存器恢复通用寄存器 .endm .macro RESTORE_ALL RESTORE_SOME lw sp, TF_REG29(sp) /* Deallocate stack sp已经到达高位,恢复sp,收回栈空间*/ .endm
首先不恢复 sp 寄存器,通过 sp 寄存器从栈中读取各个寄存器的值恢复;最后再恢复 sp 的值
# Thinking 4.9
请思考并回答以下几个问题:
- 为什么需要将 set_pgfault_handler 的调用放置在 syscall_env_alloc 之前?
- 如果放置在写时复制保护机制完成之后会有怎样的效果?
- 子进程是否需要对在 entry.S 定义的字__pgfault_handler 赋值?
【答案】
- 刚开始父子进程的虚拟空间实际上是共享的,在父进程调用
env_alloc
的过程中可能也会发生缺页中断,这时候就需要设置好的 set_pgfault_handler 处理缺页中断 - 这样的话发生缺页中断不能够被捕捉,也无法进入缺页中断异常,有可能会向未知页面写入数据导致内核崩溃
- 不需要,父子进程初始共享内存,因此子进程__pgfault_handler 的值应当和父进程一致 4
# 实验难点图示
# 难点一:系统调用过程中的参数传递
用户空间向内核传入的参数,syscall_all 中实现的系统调用是如何看到的呢?我觉得这个问题的答案是,其实内核是直接看不到的,但是我们可以在进行系统调用之前做一些操作,伪装成好像有某个函数调用了它并传给了它参数。这就需要用到调用约定,把相应的参数存入相应的寄存器里,这样 syscall_* 函数就可以通过参数直接访问这些数据了。
如上图所示,参数其实放在了 $a0-$a3
,其余的参数需要压栈,这也就是 syscall.S
中要填写的内容,刚开始没有提示需要看这方面的内容,因此我在理解代码上遇到了一些阻碍。尤其需要注意的是 arg0-arg4 实际上是存在寄存器中的,但是我们仍然在内存中给它们留下了空位,这在编写汇编代码访问参数时尤其需要注意
# 难点二:系统调用进入内核的全过程
系统调用是通过中断来进行的,syscall () 从用户态到内核态的陷入过程:
从用户态切换到内核态,上下文保存在 Trapframe 中,需要从用户栈切换到内核栈,最后返回时调用 env_pop_tf()
更有意思的应该是第一次进入用户态时,因为之前从未有过中断,所以系统需要假装好像曾就有过一次中断一样, env_pop_tf()
才能顺利进入用户态,这一点的操作在 lab3 就有做过
# 难点三:用户态 fork () 调用过程
graph TD | |
父进程得到ID --> 进程分配异常处理栈入口 --> 创建子进程 --> 保护页面保护位位置 --> 分配子进程内存空间 --> 设置子进程异常处理栈 --> 唤醒子进程 --> 返回子进程序号 | |
创建子进程 --> 子进程赋值给变量env --> 获得子进程ID --> 通过索引得到envs数组中的项 --> 设置parent_id --> 子进程返回0 |
# 难点四:vpt 和 vpd 两个指针的用法
这一点在 Lab4-2 中考到了
具体两个指针的位置在 entry.S
中,用汇编定义,而 entry.S
其实应该时用户空间的入口
总结来看用法就是 (*vpd)[va>>22(页目录的索引)]&(~0xfff)
表示二级页表的物理地址, (*vpt)[va >> 12]&(~0xfff)
为 va
对应的物理页面地址,使用前记得提前判断有效位
# 难点五:缺页中断
graph LR | |
异常分发 --> 普通缺页 --> 页表不缺失 --> 填入TLB,返回异常地址 | |
异常分发 --> 写时复制 | |
普通缺页 --> 页表中缺失 | |
页表中缺失 --> 出现缺页错误,跳转到分配页面 |
简单总结一下,page_fault_handle 的栈变化情况:
陷入:用户栈 -> 内核栈 -> 异常栈;返回:异常栈 -> 用户栈
<img src="https://s2.loli.net/2022/06/15/i1hSnAQf7zkPd6O.png" alt="img" style="zoom: 33%;" />
# 体会和感想
# 许多地方没有思考准确
对于 Trapframe、Timestack、用户栈、系统栈、用户异常栈还是有些糊涂
由于操作寄存器不可避免的需要使用汇编,仍然需要去啃一啃哪些进入离开内核的汇编代码
# C 语言省去了很多对寄存器值调用的步骤
函数调用时对寄存器的保存以及传值、恢复等,写 C 时候完全不可见。但与汇编相比较起来,就会感受到高级语言的美好。
而如果同时需要写 C 代码和汇编代码时,就不得不去考虑这些复杂的情况