Lab4
用户程序启动的流程
user.lds
用户程序的链接脚本:加载 .text
, .data
, .bss
至 0x00400000,设定用户程序的入口为 _start
。
entry.S
: user\lib
,用户程序main函数运行时需要两个参数,跳转至libmain
。
libmain
: libos.c
,设置 env
,使得用户可以访问到当前进程的进程控制块;跳转至 main
主函数;exit
系统调用结束当前进程的运行。
系统调用 - 8号异常
系统调用过程
1. 用户程序使用 syscall_*
接口
- 用户程序调用
syscall_*
函数接口。 syscall_*
将实现系统调用所需的参数作为msyscall
的传入参数,在调用msyscall
时,会自动将参数(第一个参数为系统调用号)存入寄存器和用户栈(内核后续可以访问这些参数)。此处的自动理解为存储过程编译器会自动编译为汇编代码,我们只需要在内核中显示地从保存的用户上下文中获取函数的参数msyscall
中 执行syscall
指令使 CPU 陷入内核态完成系统调用;在返回用户态继续运行此函数时,执行jr ra
指令返回到syscall_*
函数。
LEAF(msyscall) |
2. 异常入口与分发
用户态下执行 syscall
指令会触发异常,CPU 陷入内核态,从 0x80000080 (即 .exc_gen_entry
所在位置) 开始取指令。
.section .text.exc_gen_entry |
3. 内核处理系统调用请求
handle_sys
核心逻辑位于 do_syscall
函数。当内核处理完系统调用请求后,调用 ret_from_exception
从内核态返回用户程序。
在 kern/genex.S
中用 BUILD_HANDLER
宏封装了 handle_sys
函数。
.macro BUILD_HANDLER exception handler |
C预处理器中的宏替换语法
在这个代码片段中,BUILD_HANDLER
宏具有两个参数,exception
和 handler
。它会将 \handler
替换为传递给宏的第二个参数,即对应的异常处理程序的名称。在代码中使用宏时,只需在宏名称后面加上括号,并在括号中传递参数即可。\
被用作宏替换文本中的转义符。当宏替换文本中需要使用 \
字符时,需要在它前面加上 \
转义符,以确保在宏替换时 \
能够正确地被处理。
内核获取用户传递的参数
do_syscall
函数在处理系统调用请求时需要获得由用户进程传递的参数。
用户进程在调用 msyscall
时,已将这些参数存入 a0
, a1
, a2
, a3
寄存器和用户栈中。内核处理系统调用请求时,CPU处于内核态,通用寄存器等现场环境发生了变化,堆栈指针也已指向了内核栈。借助内核栈中保存的用户进程上下文环境。观察 SAVE_ALL
,在保护用户态现场时 sp
减去了一个 Trapframe
结构体的空间大小,此时我们将用户进程现场保存在内核栈中范围为 [sp, sp + sizeof(TrapFrame))
的这一空间范围内。
void do_syscall(struct Trapframe *tf) { |
在所有内核的syscall实现中,如果想修改用户进程从 syscall 返回后的用户现场,都需要修改内核栈中的 Trapframe 实现,而不是直接修改进程控制块的tf。
系统调用功能实现
在内核处理进程发起的系统调用时,我们并没有切换地址空间(页目录地址),也不需要将进程上下文(Trapframe)保存到进程控制块中,只是切换到内核态下,执行了一些内核代码。
envid2env
函数
int envid2env(u_int envid, struct Env **penv, int checkperm) { |
在 envid2env
函数中,如果 checkperm
不为零,那么函数需要检查调用者是否有足够的权限来操作指定的进程(e
)。具体来说,如果 e
不是当前进程(curenv
)或当前进程的直接子进程,那么就认为权限不足,返回 -E_BAD_ENV
错误。
进程间通信(IPC)
由于进程的地址空间之间是相互独立的,要想传递数据,我们就需要想办法把一个地址空间中的东西传给 另一个地址空间。因为所有的进程共享内核空间(主要为 kseg0),我们可以借助内核空间来实现不同用户地址空间的数据交换。发送方进程使用系统调用将传递的数据存放在进程控制块中,接收方进程同样使用 系统调用在进程控制块中找到对应的数据,读取并返回。
进程创建FORK
写时复制机制
在 fork 时,我们只需将地址空间中的所有可写页标记为写时复制页面(PTE_COW = 1 && PTE_D = 0
),使得在父进程或子进程对写时复制页面进行写入时,会触发 TLB Mod 页写入异常。操作系统在异常处理时,为当前进程试图写入的虚拟地址分配新的物理页面,并复制原页面的内容,最后再返回用户程序,对新分配的物理页面进行写入。
页写入异常流程:
当用户程序写入一个在 TLB 中被标记为不可写入(无 PTE_D)的页面时,MIPS 会陷入页写入异常(TLB Mod)。
- 用户进程触发页写入异常,陷入到内核中的 handle_mod,再跳转到 do_tlb_mod 函数。
- do_tlb_mod 函数负责将当前现场保存在异常处理栈中,并设置 a0 和 EPC 寄存器的值,使得从异常恢复后能够以异常处理栈中保存的现场(Trapframe)为参数,跳转到env_user_tlb_mod_entry 域存储的用户异常处理函数的地址。
- 从异常恢复到用户态,跳转到用户异常处理函数
cow_entry
中,由用户程序完成写时复制等自定义处理。
void do_tlb_mod(struct Trapframe *tf) { |
// 用户态下 |
int fork(void) { |
int sys_set_trapframe(u_int envid, struct Trapframe *tf) { |
int sys_set_tlb_mod_entry(u_int envid, u_int func) { |
int sys_exofork(void) { |
static void duppage(u_int envid, u_int vpn) { |
int sys_set_env_status(u_int envid, u_int status) { |
思考题
1. 系统调用的实现
-
内核在保存现场的时候是如何避免破坏通用寄存器的?
stackframe.h
定义了SAVE_ALL
和RESTORE_SOME
宏用于保存和恢复CPU寄存器。在SAVE_ALL
宏中,首先用k0
暂存栈指针(k0
,k1
仅内核态下使用,允许异常处理程序使用k0
且无需保存或恢复其值),然后对sp
操作确定保存用户上下文环境的内核栈空间。先将之前暂存的k0
存入,再依次将 CP0 寄存器、HI/LO寄存器和通用寄存器存入。
-
系统陷入内核调用后可以直接从当时的 a0-a3 参数寄存器中得到用户调用 msyscall留下的信息吗?
- 不可以,系统陷入内核态后,进行上下文环境切换,上述值应该从内核栈中获取。
-
我们是怎么做到让 sys 开头的函数“认为”我们提供了和用户调用 msyscall 时同样的参数的?
- 根据 MIPS 调用规范,用户进程再调用 msyscall 时,已将这些参数存入 a0 - a3 寄存器和用户栈中。由于内核处理系统调用请求时,CPU处于内核态,需要从内核栈中保存的用户进程上下文环境获取这些参数。
-
内核处理系统调用的过程对 Trapframe 做了哪些更改?这种修改对应的用户态的变化是什么?
- cp0_epc + 4, epc 修改前存放 msyscall 函数中 syscall 指令地址,修改后指向 msyscall 函数中 jr ra 指令。将func函数返回值写入 v0 寄存器,使得在恢复用户进程上下文环境后,用户程序可以从该寄存器读取 msyscall 的返回值。
2. envid2env 的实现
思考 envid2env 函数: 为什么 envid2env 中需要判断 e->env_id != envid的情况?如果没有这步判断会发生什么情况?
观察生成新进程的 env_id
的函数:
|
当 envid != 0
时,通过 e = &envs[ENVX(envid)]
获取当前进程,ENVX
宏取 envid 的低10位,没有对高位进行判断。当查询的 envid 为错误的进程 id,比如 envid = 0xc00
,而 envs[0].env_id = 0x400
,如果没有这个判断,*penv = &envs[0]
,这显然是不正确的。
3. mkenvid 函数细节
请回顾 kern/env.c 文件中 mkenvid() 函数的实现,该函数不会返回 0,请结合系统调用和 IPC 部分的实现与envid2env() 函数的行为进行解释。
u_int mkenvid(struct Env *e) { |
在系统调用和 IPC部分的实现中,常常需要使用 envid2env
函数来查找进程标识符,而在 envid2env
函数中我们将 envid==0
的含义定义为返回当前进程。观察函数即可发现其通过 ((++i) << (1 + LOG2NENV))
保证该函数不会返回0,避免冲突。
4. fork的返回结果
关于 fork 函数的两个返回值,下面说法正确的是:
A、fork 在父进程中被调用两次,产生两个返回值
B、fork 在两个进程中分别被调用一次,产生两个不同的返回值
C、fork 只在父进程中被调用了一次,在两个进程中各产生一个返回值
D、fork 只在子进程中被调用了一次,在两个进程中各产生一个返回值
C
5. 用户空间的保护
我们并不应该对所有的用户空间页都使用 duppage 进行映射。那么究竟哪些用户空间页应该映射,哪些不应该呢?请结合 kern/env.c 中 env_init 函数进行的页面映射、include/mmu.h 里的内存布局图以及本章的后续描述进行思考。
不同权限位的页使用情况处理:
- 只读页面:对于不具有 PTE_D 权限位的页面。按照相同权限(只读)映射给子进程即可。
- 写时复制页面:即具有 PTE_COW 权限位的页面。这类页面是之前的 fork 时 duppage 的结果,且在本次 fork 前必然未被写入过。按照相同权限(写时复制)映射给子进程即可。
- 共享页面:即具有 PTE_LIBRARY 权限位的页面。这类页面需要保持共享可写的状态,即在父子进程中映射到相同的物理页,使对其进行修改的结果相互可见。按照相同权限(共享)映射给子进程即可。
- 可写页面:即具有 PTE_D 权限位,且不符合以上特殊情况的页面。这类页面需要在父进程和子进程的页表项中都使用 PTE_COW 权限位进行保护。
6. vpt 和 vpd 的使用
-
vpt 和 vpd 的作用是什么?怎样使用它们?
//volatile 提醒编译器它后面所定义的变量随时都有可能改变,因此编译后的程序每次需要存储或读取这个变量的时候,都会直接从变量地址中读取数据
Pde pde = vpd[va >> PDSHIFT];
Pte pte = vpt[va >> PGSHIFT];
// 通过上述操作访问相应的页目录项和页表项 -
从实现的角度谈一下为什么进程能够通过这种方式来存取自身的页表?
它们是如何体现自映射设计的?
观察
env.c
中的env_setup_vm
函数:static int env_setup_vm(struct Env *e) {
struct Page *p;
try(page_alloc(&p)); // 页目录
p->pp_ref ++;
e->env_pgdir = (Pde *)page2kva(p);
// 将 base_pgdir页目录表中从UTOP 到 UVPT 的页目录项拷贝到进程页目录表中的相同位置,那么用户程序就可以读取到内核的 pages 和 envs 数据。
memcpy(e->env_pgdir + PDX(UTOP), base_pgdir + PDX(UTOP),
sizeof(Pde) * (PDX(UVPT) - PDX(UTOP)));
// 将进程页目录表中 UVPT 所对应的页目录项设置成一个自映射的页表,将自身映射到UVPT所在的虚拟地址上。(其实我还是不理解这个自映射)
e->env_pgdir[PDX(UVPT)] = PADDR(e->env_pgdir) | PTE_V;
return 0;
} -
进程能够通过这种方式来修改自己的页表项吗?
不可以。观察 env.c/env_setup_vm
用户空间页表页发现只有可读权限。并且实践也说明不行。
7. 页写入异常 - 内核处理
在 do_tlb_mod 函数中,你可能注意到了一个向异常处理栈复制 Trapframe运行现场的过程,请思考并回答这几个问题:
• 这里实现了一个支持类似于“异常重入”的机制,而在什么时候会出现这种“异常重入”?
• 内核为什么需要将异常的现场 Trapframe 复制到用户空间?
- 异常重入是指一个异常在处理过程中,又触发了新的异常。此处,在处理写时复制的页写入异常时,又发生了缺页,就需要异常重入。
- 因为页写入异常的处理在用户态下进行。用户进程需要读取 Trapframe 的值获得哪一条指令触发了异常,并且需要 Trapframe 恢复现场。
8. 页写入异常 - 内核处理1
在用户态处理页写入异常,相比于在内核态处理有什么优势?
- 提高系统的可靠性和稳定性。在用户态处理页写入异常时,如果出现问题,只会导致当前进程崩溃;而如果交给内核态处理,出现问题会导致整个系统崩溃,另外内核态能访问到关键数据,容易产生安全问题。
- 提高系统的性能。由于内核态的系统调用涉及上下文切换,会消耗一定的时间和资源。
9. 页写入异常 - 内核处理2
为什么需要将 syscall_set_tlb_mod_entry 的调用放置在 syscall_exofork 之前?
我认为放在 syscall_exofork 可以,不会有影响。
如果放置在写时复制保护机制完成之后会有怎样的效果?
duppage
函数会调用 syscall_mem_map
实现系统调用,其中调用的 msyscall
会将参数存入寄存器和用户栈。而用户栈可能也会被设置 PTE_COW
位,此时若未设置页写入异常处理函数,会无法处理页写入异常。
debug
make objdump |
课上
lab4-2 的 extra 没通过,感觉用惯了vscode,在vim上写有点慢,在vim上也不方便直接复制,下次想直接在gitlab修改,感觉会更方便,lab4-2的码量还是有的,再加上不断切换文件,速度有点慢,然后有点急,就容易读题不仔细,其中一个bug就是 sem_init 返回分配的 id,一开始没看到。另外当前进程使用某个信号量是否合法,需要判断该进程是否为创建该信号量的进程或其后代进程,由于父进程可能会丢失,直接dfs会有问题,建议并查集做。