「BUAA OS Lab4」难点梳理与理解总结

Posted by saltyfishyjk on 2022-05-25
Words 2.5k and Reading Time 9 Minutes
Viewed Times

「BUAA OS Lab4」难点梳理与理解总结

为了节约页面,保证阅读的连贯性,本篇博客尝试不再提供函数的具体实现代码,而转将其在「BUAA OS Lab4」名词解释中专门介绍。读者若有需要,可以两篇博客对比阅读。

首先明确,硬件决定了我们在用户态时,用户进程不能访问系统的内核空间,即,不能存取内核使用的内存数据,不能调用内核函数。我们不允许内核态执行用户程序的代码,因此我们设计了内核空间中的函数,当用户进程需要相关操作的时候,引发特定的异常陷入内核态,获得更高的权限,由内核识别异常并调用对应的函数,安全地为用户提供系统级操作,这就是系统调用

我们在lab4的工作有:

  • 实现上述系统调用机制
  • 在系统调用的基础上实现进程间通信(IPC)机制
  • 实现进程创建机制fork(&写时复制(COW)特性&相关页写入异常处理)

CPU模式

  • 内核态:

    允许架构支持的任何操作、指令、IO行为、访问任何可达空间

  • 其他CPU状态:硬件决定CPU行为有特定的限制,典型的如一些可以改变机器全局状态的行为不被允许,访问某些特定空间不被允许。事实上这些状态下的CPU行为是内核态的子集

事实上,在我们的实验中,我们依靠的是SR寄存器中KUc位的值来判断当前CPU所处状态。

虚拟空间

  • 用户空间:kuseg
  • 内核空间:kseg0和kseg1

CPU在内核态可以访问任何内存区域,对物理内存有完整的控制权,用户态下则只能访问用户空间。

(用户)进程和内核

  • 进程是资源分配和调度的基本单位,对每个进程而言,其拥有独立的地址空间视图
  • 内核负责管理系统资源和调度,使进程得以并发运行。可以认为内核是存在于所有进程地址空间的一段代码

TOO LOW

KUc为1代表CPU处于用户态,此时如果进程试图访问内核空间,则会触发TLB异常。在Lab3中,我们的进程仍运行在内核态(可以通过我们设置的)。值得注意的是,在进程调度时,会首先调用rfe,因此,我们不能直接在env_alloc中设置KUc,而应当设置KUp,这样在调用rfe后会让我们的进程的CPU状态得到正确的设置。

系统调用

上图是指导书给出的流程,已经比较详细,我们以其为本更细致介绍具体操作及各个函数的声明与实现位置。

我们以writef为例梳理系统调用的一般流程:

  1. 调用writef这一用户函数
  2. writef中调用了用户空间的syscall_*的用户空间函数(在user/lib.h中声明,在user/syscall_lib.c中实现)
  3. syscall_*函数调用了msyscall函数,进入内核态
  4. 内核态中将异常分发到handle_sys函数,将系统调用所需要的信息传递到内核
  5. 内核获取信息,执行对应的内核空间的系统调用函数sys_*
  6. 从系统调用函数返回,回到用户程序writef调用处

handle_sys

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
NESTED(handle_sys,TF_SIZE, sp)
SAVE_ALL // Macro used to save trapframe
CLI // Clean Interrupt Mask
nop
.set at // Resume use of $at

// TODO: Fetch EPC from Trapframe, calculate a proper value and store it back to trapframe.
/* EPC += 4 */
lw t0, TF_EPC(sp)
addiu t0, t0, 4
sw t0, TF_EPC(sp)
// TODO: Copy the syscall number into $a0.
lw a0, TF_REG4(sp)

addiu a0, a0, -__SYSCALL_BASE // a0 <- relative syscall number
sll t0, a0, 2 // t0 <- relative syscall number times 4
la t1, sys_call_table // t1 <- syscall table base
addu t1, t1, t0 // t1 <- table entry of specific syscall
lw t2, 0(t1) // t2 <- function entry of specific syscall

lw t0, TF_REG29(sp) // t0 <- user's stack pointer
lw t3, 16(t0) // t3 <- the 5th argument of msyscall
lw t4, 20(t0) // t4 <- the 6th argument of msyscall

// TODO: Allocate a space of six arguments on current kernel stack and copy the six arguments to proper location
// a0, a1, a2, a3的寄存器编号分别是4,5,6,7,因此从栈的这些位置获取
lw a0, TF_REG4(sp)
lw a1, TF_REG5(sp)
lw a2, TF_REG6(sp)
lw a3, TF_REG7(sp)
addiu sp, sp, -24
sw t3, 16(sp)
sw t4, 20(sp)

jalr t2 // Invoke sys_* function
nop

// TODO: Resume current kernel stack
addiu sp, sp, 24 // recover stack
sw v0, TF_REG2(sp) // Store return value of function sys_* (in $v0) into trapframe

j ret_from_exception // Return from exeception
nop
END(handle_sys)

sys_call_table: // Syscall Table
.align 2
.word sys_putchar
.word sys_getenvid
.word sys_yield
.word sys_env_destroy
.word sys_set_pgfault_handler
.word sys_mem_alloc
.word sys_mem_map
.word sys_mem_unmap
.word sys_env_alloc
.word sys_set_env_status
.word sys_set_trapframe
.word sys_panic
.word sys_ipc_can_send
.word sys_ipc_recv
.word sys_cgetc

进程间通信(IPC)

fork

实现流程

父进程分支中,在正常执行之前,是fork在父进程中执行的流程,在正常执行之下是写时复制。同理,在子进程分支中,正常执行之上是fork在子进程中执行的流程,正常执行之下是写时复制。

为了保证逻辑的连贯性,我们接下来分别从宏观视角的函数效果,微观实现的正常执行写时复制进行介绍。

函数效果

产生一个和原进程几乎一样的子进程,但二者互相独立。

fork在父进程和子进程中返回值不同,在父进程中返回值不为0(事实上为子进程的id),在子进程中返回值为0,我们以此来区分代码在调用fork后是处在父进程还是子进程。

正常执行

父子进程公共部分

事实上,此时还没有产生子进程,但是子进程返回时又依赖于此,因此可以类似看作公共部分。

首先,设置缺页异常处理函数为pgfault

接着,fork调用了syscall_env_alloc,获得了新的进程控制块(编号)。实际上,我们也是在这里根据返回值区分父进程和子进程的:返回值为0则为子进程,返回值不为0(实际上是子进程的envid)则为父进程。

父进程

父进程正常执行独有的部分从duppage开始。

duppage

父进程对子进程页面空间进行映射,并设置相应权限。这里需要着重注意的是权限问题。

上图为指导书介绍,我们根据其首先进行实现,然后分析原因。

对于该介绍,本质上其实是if-else块,我们首先处理第四类可写页面。若perm & PTE_R && !(perm & PTE_LIBRARY),则perm |= PTE_COW,进行保护。可以注意到,其实这还包含了第二类共享页面的情况,但这样做结果一样,因此没有问题。

为子进程申请异常处理栈

调用syscall_mem_alloc,在UXSTACKTOP - BY2PG为子进程alloc一块异常处理栈。

为子进程设置缺页异常处理函数

调用syscall_set_pgfault_handler,设置缺页异常处理函数为__asm_pgfault_handler

设置子进程可运行

调用syscall_set_env_status,设置子进程的状态为ENV_RUNNABLE

子进程

在用户进程中,我们用声明在user/libos.c中的变量env指向当前进程的控制块。自然地,我们需要在fork出的子进程中修改env,使其指向自身。由于在子进程中,syscall_env_alloc返回值为0,因此,我们调用syscall_getenvid获取当前envid,从而计算出其相对envs的偏移量并最终使其指向正确的进程块。

设置完env后,子进程就可以返回了。

写时复制

在fork刚刚结束的时候,父子进程事实上在共享物理内存。对于不被任一进程修改的物理内存,这种共享是没有问题的。而有一些内存将被修改,因此引入写时复制机制(Copy On Write, COW)。

写时复制机制保证,在fork后的父子进程有修改内存的行为的时候,产生页写入异常,接着在异常处理中为修改内存的进程的地址空间中的相应地址分配新的物理页面。

对于如代码段等,父子进程一般仍会共享相同的物理空间。

回顾常规缺页异常

内核捕获缺页中断(page fault)时,会进入lib/traps.c/trap_init中注册好的异常处理函数handle_tlb

这一汇编函数的实现在lib/genex.S中,这里,化名为do_refill的函数。

do_refill中,若物理页面在页表中存在,填入TLB并返回异常地址再次执行内存存取的指令;如果物理页面不存在,触发一个一般意义的缺页错误,跳转到mm/pmap.c中的pageout函数,在这一函数中,若存取地址合法,内核为相应地址分配并映射一个物理页面来解决缺页问题。

流程

上面我们首先回顾了常规的缺页异常。对于写时复制来说,同样依赖异常处理。

为了实现写时复制功能,我们在lib/traps.c/trap_init中注册了handle_mod,并使其跳转到lib/traps.c/page_fault_handler这一内核函数来处理

上图为指导书给出的页写入异常处理流程,接下来我们结合流程分析具体过程。

page_fault_handler

进行写时复制前的一些处理,并在最后将cp0_epc指向env_pgfault_handler函数的入口。

pgfault

事实上,env_pgfault_handler指向的函数就是pgfault(这个指向是在fork中实现的)。

首先判断权限位是否有PTE_COW,如果没有,说明发生了错误,即,对于没有PTE_COW的页,不应当触发pgfault

将权限位的PTE_COW置零,表征此页面为独立的页面,不需要与其他进程共享。

将新的页映射到临时地址。

复制页内容到临时地址。

va映射到临时地址。

取消临时地址的映射。


This is copyright.