思考题

Thinking 4.1

思考并回答下面的问题:

内核在保存现场的时候是如何避免破坏通用寄存器的?

内核通过在kern/entry.S中调用SAVE_ALL来保存现场,SAVE_ALL的实现在include/stackframe.h中,具体实现如下

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
.macro SAVE_ALL
.set noat
.set noreorder
mfc0 k0, CP0_STATUS
andi k0, STATUS_UM
beqz k0, 1f
move k0, sp //将用户栈指针存入k0
li sp, KSTACKTOP //sp指向内核处理栈
1:
subu sp, sp, TF_SIZE // 处理栈指针下压TF_SIZE
sw k0, TF_REG29(sp) // 将用户栈指针存入内核栈TF_REG29处
// 借助k0将CP0寄存器和HI、LO寄存器存入内核栈
mfc0 k0, CP0_STATUS
sw k0, TF_STATUS(sp)
mfc0 k0, CP0_CAUSE
sw k0, TF_CAUSE(sp)
mfc0 k0, CP0_EPC
sw k0, TF_EPC(sp)
mfc0 k0, CP0_BADVADDR
sw k0, TF_BADVADDR(sp)
mfhi k0
sw k0, TF_HI(sp)
mflo k0
sw k0, TF_LO(sp)
// 下面逐个把用户态的寄存器保存在内核栈中
sw $0, TF_REG0(sp)
sw $1, TF_REG1(sp)
sw $2, TF_REG2(sp)
…………
sw $28, TF_REG28(sp)
sw $30, TF_REG30(sp) //注:跳过29,因为前面已经保存过了
sw $31, TF_REG31(sp)
.set at
.set reorder
.endm

可以看到,内核在保存现场的过程中只更改了k0寄存器,用于暂存用户栈指针sp、CP0寄存器和HI、LO寄存器的值;而k0在MIPS中用于存储临时变量,一般不会在k0中保存需要留存的数值,因此k0可以随意更改。这样,内核就避免了破坏通用寄存器。

系统陷入内核调用后可以直接从当时的 $a0-$a3 参数寄存器中得到用户调用 msyscall 留下的信息吗?

可以,因为a1-a3寄存器的值都没有改变;对于a0寄存器,虽然在handle_sys中被修改为sys_*函数的地址,但是在sys_*中不再使用。

我们是怎么做到让 sys 开头的函数“认为”我们提供了和用户调用 msyscall 时同样的参数的?

在do_syscall中,从内核栈中保存的用户态现场获取了用户调用的参数,并传递给系统调用sys_*

内核处理系统调用的过程对 Trapframe 做了哪些更改?这种修改对应的用户态的变化是什么?

在do_syscall中

  • tf->cp0_epc += 4,修改了epc
  • tf->regs[2] = func(arg1, arg2, arg3, arg4, arg5)修改了v0寄存器

在用户态中,从系统调用返回之后可以获得正确的返回值(在v0中),并从系统调用的下一条指令继续执行

Thinking 4.2

思考 envid2env 函数: 为什么 envid2env 中需要判断 e->env_id != envid 的情况?如果没有这步判断会发生什么情况?

实际上考虑了这样一种情况,某一进程完成运行,资源被回收,这时其对应的进程控制块会插入回 env_free_list 中。当我们需要再次创建内存时,就可能重新取得该进程控制块,并为其赋予不同的 envid。这时,已销毁进程的 envid 和新创建进程的 envid 都能通过 ENVX 宏取得相同的值,对应了同一个进程控制块。可是已销毁进程的 envid 却不应当再次出现,e->env_id != envid 就处理了 envid 属于已销毁进程的情况。

Thinking 4.3

思考下面的问题,并对这个问题谈谈你的理解:请回顾 kern/env.c 文件 中 mkenvid() 函数的实现,该函数不会返回 0,请结合系统调用和 IPC 部分的实现与 envid2env() 函数的行为进行解释。

首先回顾宏定义:

1
#define LOG2NENV 10

mkenvid的实现

e的下标(十位)(e - envs)的最左侧添加一位设置为1,因此mkenvid得到的id不会是0

1
2
3
4
u_int mkenvid(struct Env *e) {
static u_int i = 0;
return ((++i) << (1 + LOG2NENV)) | (e - envs);
}

envid2env部分

1
2
3
4
if (envid == 0) {
*penv = curenv;
return 0;
}

根据特判可知,envid = 0对应的env控制块是指当前进程;从这个角度,envid = 0 用于获取当前进程的进程控制块,而envid又是唯一的,于是进程的envid不能是0

IPC部分

1
e->env_tf.regs[2] = 0;

envid为0是MOS修改了⼦进程的函数返回值,⽤于==区分⽗⼦进程==。MOS希望系统调⽤在内核态返回的 envid 只传递给⽗进 程,对于⼦进程则需要对它的保存的现场Trapframe进⾏⼀个修改,从⽽在恢复现场时⽤ 0 覆盖系统 调⽤原来的返回值。

指导书中提到:“……在我们的用户程序中,会大量使用 srcva 为 0 的调用来表示只传 value 值,而不需要传递物理页面,换句话说,当 srcva 不为 0 时,我们才建立两个进程的页面映射关系。”

从ipc的角度,envid也不能设置为0。

Thinking 4.4

关于 fork 函数的两个返回值,下面说法正确的是:

A、fork 在父进程中被调用两次,产生两个返回值

B、fork 在两个进程中分别被调用一次,产生两个不同的返回值

C、fork 只在父进程中被调用了一次,在两个进程中各产生一个返回值

D、fork 只在子进程中被调用了一次,在两个进程中各产生一个返回值

C,一次调用指的是只有父进程调用了 syscall_exofork,两次返回分别是父进程调用 syscall_exofork 得到的返回值和被创建的子进程中设定了 v0 寄存器的值为 0 作为返回值。这样当子进程开始运行时,就会拥有一个和父进程不同的返回值。

Thinking 4.5

我们并不应该对所有的用户空间页都使用 duppage 进行映射。那么究竟哪些用户空间页应该映射,哪些不应该呢?请结合 kern/env.c 中 env_init 函数进行的页面映射、include/mmu.h 里的内存布局图以及本章的后续描述进行思考。

![image-20240515212241913](C:\Users\86199\Documents\A blog\myblog\source_posts\image-20240515212241913.png)

在 0 ~ USTACKTOP 范围的内存需要使用 duppage 进行映射;

USTACKTOP 到 UTOP 之间的 user exception stack 是用来进行页写入异常的,不会在处理COW异常时调用 fork() ,所以 user exception stack 这一页不需要共享;

USTACKTOP 到 UTOP 之间的 invalid memory 是为处理页写入异常时做缓冲区用的,所以同理也不需要共享;

UTOP以上页面的内存与页表是所有进程共享的,且用户进程无权限访问,不需要做父子进程间的duppage;

其上范围的内存要么属于内核,要么是所有用户进程共享的空间,用户模式下只可以读取。除只读、共享的页面外都需要设置 PTE_COW 进行保护。

fork中进行的映射:

1
2
3
4
5
for (i = 0; i < VPN(USTACKTOP); i++) {
if ((vpd[i >> 10] & PTE_V) && (vpt[i] & PTE_V)) {
duppage(child, i);
}
}

tips:为什么不需要把父进程的页表dup给子进程?

答:在父进程向子进程duppage的过程中,每映射一个页面就会填写一个页表项,子进程的页表逐个完成了填写(在下文sys_mem_map函数的讲解中有实现细节),也就不需要在duppage的时候把父进程页表dup给子进程了

Thinking 4.6

在遍历地址空间存取页表项时你需要使用到 vpd 和 vpt 这两个指针,请参 考 user/include/lib.h 中的相关定义,思考并回答这几个问题:

vpt 和 vpd 的作用是什么?怎样使用它们?

1
2
#define vpt ((const volatile Pte *)UVPT)
#define vpd ((const volatile Pde *)(UVPT + (PDX(UVPT) << PGSHIFT)))

从定义可以看出,vpt和vpd指出了页表和页目录在虚拟内存中的首地址;在使用时,可以将vpt和vpd作为数组使用,认为vpt和vpd分别存储了页表和页目录,例如:

1
2
3
vpt[vpn] // 取出页号为vpn的页表项
perm = vpt[vpn] & 0xfff // 取页表项低12位权限位
vpd[vpn >> 10]

从实现的角度谈一下为什么进程能够通过这种方式来存取自身的页表?

从内存分布图中,我们可以看到UVPT是进程页表的起始地址,那么作为类型为Pte *的vpt,实际上可以表示整个进程页表的第一个二级页表的第一个页表项,也就是页表的起始地址;在c语言中,数组名称其实就是数组首地址,指向数组的第一个元素,因此可以通过数组的形式存取页表

并且对于Pte *指针(也就是u_int *指针),指针 + 1 实际上步长是32,因此虽然页号vpn是以1为单位增长,而页表项地址是以 32bits为单位增长,实际上的 vpt[vpn]也能取到对应的页表项

它们是如何体现自映射设计的?

在vpd的定义中体现了自映射设计

#define vpd ((const volatile Pde *)(UVPT + (PDX(UVPT) << PGSHIFT)))

实际上就是PD_base = PT_base + (PT_base >> 22) << 12 = PT_base + PT_base << 10

进程能够通过这种方式来修改自己的页表项吗?

可以,因为页表项的权限是可写

Thinking 4.7

在 do_tlb_mod 函数中,你可能注意到了一个向异常处理栈复制 Trapframe 运行现场的过程,请思考并回答这几个问题:

这里实现了一个支持类似于“异常重入”的机制,而在什么时候会出现这种“异常重入”?

在⽤⼾发⽣写时复制引发的缺⻚中断并进⾏处理时,可能会再次发⽣缺⻚中断,从⽽“中断重⼊”。

内核为什么需要将异常的现场 Trapframe 复制到用户空间?

我们在⽤⼾进程处理此缺⻚中断,因此⽤⼾进程需要读取Trapframe中的值;同时⽤⼾进程在中断结束恢复现 场时也需要⽤到Trapframe中数据,因此存到⽤⼾空间。

Thinking 4.8

在用户态处理页写入异常,相比于在内核态处理有什么优势?

符合微内核的设计理念,能够精简系统

Thinking 4.9

为什么需要将 syscall_set_tlb_mod_entry 的调用放置在 syscall_exofork 之前?

避免子进程在被设置为runnable之后重复执行syscall_set_tlb_mod_entry。实际上,syscall_exofork中并不涉及对cow页面的写入,也不会触发页写入异常,如果交换syscall_set_tlb_mod_entry 与syscall_exofork两者的位置,运行也不会出现问题。

以下是助教在讨论区的回答:

“第一次 syscall_set_tlb_mod_entry 是针对父进程设置的。子进程 RUNNABLE 后会从 syscall_exo_fork 逐级调用的 syscall 指令之后开始执行。如果在 syscall_exo_fork 之后再 syscall_set_tlb_mod_entry,那么子进程也会执行这个系统调用。之所以这样也能正确工作,是因为在子进程 RUNNABLE 之前父进程已经设置了子进程的 tlb_mod_entry。”

如果放置在写时复制保护机制完成之后会有怎样的效果?

父进程运行时在函数调用等情形下会修改栈。在栈空间的页面标记为写时复制之后,父进程继续运行并修改栈,就会触发 TLB Mod 异常。所以在写时复制保护机制完成之前就需要 syscall_set_tlb_mod_entry

难点分析

异常处理栈? Trapframe?

Trapframe在KSTACKTOP下,用于保存进程上下文

异常处理栈在UXSTACKTOP

系统调用机制

![image-20240508134439019](C:\Users\86199\Documents\A blog\myblog\source_posts\image-20240508134439019.png)

系统调用流程

  • 用户空间:

    • 调用syscall_*
    • syscall_* 调用msyscall
    • msyscall调用syscall陷入内核并返回
  • 内核空间:

    • 处理器跳转到异常分发代码处
    • 根据 cause 寄存器值判断异常类型为系统调用,跳转到handle_sys
    • handle_sys调用对应的异常处理函数do_syscall
    • do_syscall
      • 获取用户空间传递的参数
      • 通过第一个参数——系统调用号,找到对应的系统调用 sys_*
      • 执行 sys_* 并返回

msyscall 时发生了什么

  • 参数是什么

msyscall 一共有 6 个参数,第一个参数都是一个与调用名相似的宏(如 SYS_print_cons),称为==系统调用号==

  • 参数如何传递:栈帧

MIPS 寄存器使用规范中指出,寄存器 $a0-$a3 用于存放函数调用的前四个参数 (但在栈中仍然需要为其预留空间),剩余的参数仅存放在栈中

msyscall 前 4 个参数会被 syscall_* 的函数分别存入 $a0-$a3 寄存器 (寄存器传参的部分)同时栈帧底部保留 16 字节的空间(不要求存入参数的值)

后 2 个参数只会被存入在预留空间之上的 8 字节空间内(没有寄存器传参),于是总共 24 字节的空间用于参数传递

需要注意的是,陷入内核态的操作并不是从一个函数跳转到了另一个函数,代码使用的栈指 针 $sp 是内核空间中的栈指针。

系统从用户态切换到内核态后:

  • 内核首先需要将原用户进程的运行现场保存到内核空间(在 kern/entry.S 中通过 SAVE_ALL 宏完成)
  • 随后的栈指针则指向保存的 Trapframe,因此我们正是借助这个保存的结构体来获取用户态中传递过来的值
  • sp是什么

栈底(?)指针,值存储在29号寄存器中。在regdef中有:

1
#define sp $29 /* stack pointer */

syscall 之后发生了什么

执行自陷指令 syscall 陷入内核态后,处理器将 PC 寄存器指向一个内核中固定的异常处理入口;在异常向量表中,系统调用这一异常类型的处理入口为 handle_sys 函数,它是在 kern/genex.S 中定义的对 do_syscall 函数的包装

kern/genex.S中,BUILD_HANDLER函数实现了对一系列异常处理的包装:

1
2
3
4
5
.macro BUILD_HANDLER exception handler
NESTED(handle_\exception, TF_SIZE + 8, zero)
…… //执行\handler函数并返回
END(handle_\exception)
.endm

具体实现了handle_tlb、handle_mod、handle_sys、handle_reserved函数:

1
2
3
4
BUILD_HANDLER tlb do_tlb_refill
BUILD_HANDLER mod do_tlb_mod
BUILD_HANDLER sys do_syscall
BUILD_HANDLER reserved do_reserved

因此,syscall之后发生了:syscall -> handle_sys -> do_syscall -> sys_*

进程通信机制IPC

借助进程之间共享的内核空间 kseg0来传递信息:以系统调用的形式在进程控制块中存取数据

在我们的用户程序中,会大量使用 srcva 为 0 的调用来表示只传 value 值,而不需要传递物理页面,换句话说,当 srcva 不为 0 时,我们才建立两个进程的 页面映射关系

文件整理

用户态 user

user/lib/syscall_wrap.S

实现msyscall:调用syscall并返回

1
2
3
4
LEAF(msyscall)
syscall
jr ra
END(msyscall)

user/lib/syscall_lib.c

  • syscall_getenvid
    • return msyscall (SYS_getenvid)
  • syscall_yield
    • msyscall (SYS_yield)
  • syscall_mem_map
    • return msyscall (SYS_mem_map, srcid, srcva, dstid, dstva, perm)
  • ……

user/lib.ipc.c

  • ipc_recv
    • syscall_ipcrecv
  • ipc_send
    • syscall_ipc_try_send
    • syscall_yield

user/lib/fork.c

  • cow_entry
    • syscall_mem_alloc
    • memcpy
    • syscall_mem_map
    • syscall_mem_enmap
    • syscall_set_trapframe 恢复进程现场
  • duppage
  • fork

内核 kern

kern/tlbex.c

  • do_tlb_mod
    • page_lookup

函数整理

SAVE_ALL

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
.macro SAVE_ALL
.set noat
.set noreorder
mfc0 k0, CP0_STATUS
andi k0, STATUS_UM
beqz k0, 1f
move k0, sp //将用户栈指针存入k0
li sp, KSTACKTOP //sp指向内核处理栈
1:
subu sp, sp, TF_SIZE // 处理栈指针下压TF_SIZE
sw k0, TF_REG29(sp) // 将用户栈指针存入内核栈TF_REG29处
// 借助k0将CP0寄存器和HI、LO寄存器存入内核栈
mfc0 k0, CP0_STATUS
sw k0, TF_STATUS(sp)
mfc0 k0, CP0_CAUSE
sw k0, TF_CAUSE(sp)
mfc0 k0, CP0_EPC
sw k0, TF_EPC(sp)
mfc0 k0, CP0_BADVADDR
sw k0, TF_BADVADDR(sp)
mfhi k0
sw k0, TF_HI(sp)
mflo k0
sw k0, TF_LO(sp)
// 下面逐个把用户态的通用寄存器保存在内核栈中
sw $0, TF_REG0(sp)
sw $1, TF_REG1(sp)
sw $2, TF_REG2(sp)
…………
sw $28, TF_REG28(sp)
sw $30, TF_REG30(sp) //注:跳过29,因为前面已经保存过了
sw $31, TF_REG31(sp)
.set at
.set reorder
.endm

do_syscall

  • epc + 4

  • 从栈帧取用户态传递到内核的六个参数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    int sysno = tf->regs[4]; //a0 系统调用号
    ……
    u_int arg1 = tf->regs[5]; //a1
    u_int arg2 = tf->regs[6];
    u_int arg3 = tf->regs[7];
    ……
    arg4 = *(u_int *)(tf->regs[29] + 16);
    arg5 = *(u_int *)(tf->regs[29] + 20);
    // tf->regs[29]是用户态栈指针
  • 用第一个参数确定要执行的函数名 sys_*

  • 调用sys_*

回顾Lab3:env_tf.cp0_epc 字段指示了进程恢复运行时 PC 应恢复到的位置

fork

  • 设置父进程cow异常处理函数入口 syscall_set_tlb_mod_entry(0, cow_entry)
  • syscall_exofork创建child
  • duppage完成向子进程的内存映射
  • 设置子进程cow异常处理函数入口
  • 设置子进程status为runnable

sys_mem_alloc

作用:分配内存。用户程序可以通过这个系统调用,给该程序所允许的虚拟内存空间,显式地分配实际的物理内存。

envid2env

  • 声明:int envid2env(u_int envid, struct Env **penv, int checkperm)
  • 作用:利用envid找到对应的控制块env
  • 调用:
  • 解读:

首先回顾几个宏定义:

1
2
#define LOG2NENV 10
#define NENV (1 << LOG2NENV) // 1024

mkenvid:e的下标(十位)(e - envs)的最左侧添加一位设置为1

1
2
3
4
u_int mkenvid(struct Env *e) {
static u_int i = 0;
return ((++i) << (1 + LOG2NENV)) | (e - envs);
}

因此:ENVX实际上是取envid的低10位

#define ENVX(envid) ((envid) & (NENV - 1)) 用于寻找该envid在envs数组中的下标

duppage

作用:父进程对子进程页面空间进行映射并标记 COW

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static void duppage(u_int envid, u_int vpn) {
int r;
u_int addr;
u_int perm;

addr = vpn << PGSHIFT; // 获取该页框的虚拟地址
perm = vpt[vpn] & 0xfff; // 获取权限位

int flag = 0;
if ((perm & PTE_D) && !(perm & PTE_LIBRARY)) {
perm = (perm & ~ PTE_D) | PTE_COW; // 取消可写,设置COW
flag = 1;
}
// 映射到子进程
syscall_mem_map(0, addr, envid, addr, perm);

// 如果COW,再映射到父进程
if (flag) {
syscall_mem_map(0, addr, 0,addr, perm);
}
}

sys_mem_map

  • 检查地址合法性

  • 取env块

  • page_lookup取srcva的物理页pp

  • page_insert完成pp到子进程dstva的映射

    注:回顾page_insert的执行逻辑,会利用pgdir_walk(pgdir, va, 0, &pte);先查找srcva是否已经有映射,如果没有则tlb_incalidate后再进行一次pgdir_walk,不过这次create位为1,在此时会使用page_alloc函数分配一页物理内存用于存放二级页表,pp_ref++后重新填写页表项*pgdir_entryp = page2pa(pp) | PTE_C_CACHEABLE | PTE_V;

    因此,在父进程向子进程duppage的过程中,每映射一个页面就会填写一个页表项,子进程的页表逐个完成了填写,也就不需要在duppage的时候把父进程页表dup给子进程了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>
#include <unistd.h>

int main() {
int var= 1;
long pid;
printf("Beforefork,var=%d.\n", var);
pid = fork();
printf("Afterfork, var=%d.\n",var);
if(pid ==0){
var =2;
sleep(3);
printf("childgot%ld,var=%d", pid, var);
} else {
sleep(2);
printf("parentgot%ld,var= %d", pid,var);
}
printf(",pid:%ld\n",(long) getpid());
return 0;
}