Thinking

Thinking 4.1

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

在陷入内核后PC指向了exc_gen_entry,在其中首先调用了SAVE_ALL宏,SAVE_ALL宏将所有寄存器保存在内核栈中。并在异常处理程序结束前调用RESTORE_ALL将保存的所有通用寄存器值恢复。

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

可以。msyscall函数被调用时候,$a0-$a3中存储了前四个参数,在执行syscall并陷入内核到执行sys_*过程中,参数寄存器并不会发生改变。(不过在SAVE_ALL中这几个寄存器的值也被保存了)

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

entry.S中调用SAVE_ALL,我们将通用寄存器等都保存在了内核栈空间中,且参数寄存器在该过程中没有发生改变。在将内核栈指针传递给sys_*函数中,其可以通过参数寄存器或者内核栈获得前四个参数,从内核栈获取用户栈指针,并利用用户栈指针得到后面两个参数。所以,sys开头的函数可以获取和调用msyscall时候一样的参数。

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

  • Trapframe中的epc += 4,使得异常处理程序返回后能从syscall下一条指令开始执行
  • Trapframe中的$v0寄存器赋予了sys_*的返回值,使得用户程序能够正常获得msyscall的返回值

Thinking 4.2

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

envid2env()是通过envid的后10位从envs数组中取出对应的进程控制块,但是如此获得的eenvid不一定与传入的参数相同,没有比对envid的高位是否相同。当envs中的某一进程控制块被替换,新生成的envid后10位不变,但是高位会变,所以需要进一步判断 e->env_id != envid

如果不判断,则最终获得的e可能并不是我们需要的进程控制块。并对该错误的进程控制块进行相应操作,可能会导致进程控制混乱。

Thinking 4.3

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

mkenvid()return ((++i) << (1 + LOG2NENV)) | (e - envs);确保了envid不会为0,而在envid2env中对于id为0的情况均返回了curenv。这使得某些系统调用函数需要访问当前进程的进程控制块时候可以直接通过向envid2env中传入0来获取,大大简化了这一操作。

Thinking 4.4

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

  • A、fork 在父进程中被调用两次,产生两个返回值
  • B、fork 在两个进程中分别被调用一次,产生两个不同的返回值
  • C、fork 只在父进程中被调用了一次,在两个进程中各产生一个返回值
  • D、fork 只在子进程中被调用了一次,在两个进程中各产生一个返回值

选择C,fork只在父进程中被调用一次,在父进程中返回子进程ID,在子进程中返回0

Thinking 4.5

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

从内存布局图来看,UTOP到ULIM之间存储的是与内核相关的页表信息,因为在env_alloc中早已经将这一步内容从base_pgdir拷贝到进程页表之中,所以不需要自行映射

USTACKTOP和UTOP之间是异常处理栈和无效内存,前者一般进行异常处理,后者通常不具有实际意义,所以这一部分也不需要共享映射

需要共享的只有UTEXT到USTACKTOP之间的页面

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得到页表的基地址,那么通过基地址+偏移量的方法就能存取页表了

UVPT是二级页表基地址,通过计算得到UVPT这一地址对应的页表项就是页目录所在的页面,且该地址也属于二级页表区域,这一过程体现了页目录自映射设计

不能。页表由内核维护,用户进程只能访问不可修改

Thinking 4.7

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

  • 这里实现了一个支持类似于“异常重入”的机制,而在什么时候会出现这种“异常重 入”?
  • 内核为什么需要将异常的现场 Trapframe复制到用户空间?

在处理一个页面写入异常时候(在异常处理程序中)发生另一个页面写入异常,防止第一次调用异常处理程序的栈空间内容丢失,不可再对栈指针改变

在实验完成的微内核架构中,对缺页异常引起的错误由用户进程进行处理。由于用户进程处理异常和恢复现场时候需要用到Trapframe(程序运行时情况)且内核栈无法共享至用户空间,所以需要将Trapframe复制到用户空间的异常栈。

Thinking 4.8

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

微内核架构下处理页写入异常时,用户进行新页面的分配映射更加灵活。可以降低内核压力,同时减少内核出现错误的可能性,提升程序稳定性。

Thinking 4.9

请思考并回答以下几个问题:

  • 为什么需要将 syscall_set_tlb_mod_entry的调用放置在 syscall_exofork之前?
  • 如果放置在写时复制保护机制完成之后会有怎样的效果?
  • syscall_set_tlb_mod_entry放置在 syscall_exofork前后都没有影响,只要在进程开始运行前(可能产生TLB_mod异常前)完成mod_entry设置即可
  • 父进程运行时候在函数调用情况下会修改栈。在栈空间标记为写时复制之后,若父进程写入栈,则需要cow_entry去处理产生的TLB_Mod异常。所以在写时复制保护机制完成前需要syscall_set_tlb_mod_entry
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
int sys_ipc_broadcast(u_int val, void *srcva, u_int perm) {
u_int childs[20];
for (int i = 0; i < 20; i++) {
childs[i] = 0;
}
struct Env *e;
struct Page *p;

// printk("childs ready!\n");

if (srcva != 0 && is_illegal_va((u_int)srcva)) {
return -E_INVAL;
}

/* Step1: 找到直系的子进程 */
for (int i = 0; i < NENV; i++) {
if (envs[i].env_parent_id == curenv->env_id) {
for (int j = 0; j < 20; j++) {
if (childs[j] == 0) {
childs[j] = envs[i].env_id;
break;
}
}
}
}

/* Step2: 通过 bfs 找到所有子进程的子进程 */
for (int i = 0; childs[i] != 0; i++) {
for (int j = 0; j < NENV; j++) {
if (envs[j].env_parent_id == childs[i]) {
for (int k = 0; k < 20; k++) {
if (childs[k] == envs[j].env_id) {
break;
}
if (childs[k] == 0) {
childs[k] = envs[j].env_id;
break;
}
}
}
}
}

/* Step3: 对所有待发送的进程进行发送 */
for (int i = 0; childs[i] != 0; i++) {
// printk("%d: %x\n", i, childs[i]);
sys_ipc_try_send(childs[i], val, srcva, perm);
}

return 0;
}

实验难点

我觉的此次实验的难点可能是系统调用机制的实现,如何在用户进程中发起系统调用,并将相应参数传递到内核函数的对应实现处。我在此处将此流程加以总结:

  • 用户进程调用对应用程序封装好的编程接口,例如void ipc_send(u_int whom, u_int val, const void *srcva, u_int perm)
  • 在上层函数中调用用户态中最接近内核的函数(依旧处于用户态),通常是syscall_*,例如syscall_ipc_try_send
  • syscall_*调用msyscall()并按照mips调用约定放置相应参数
  • msyscall执行syscall指令引发异常陷入内核
  • PC 跳转到entry.S处,并执行SAVE_ALL指令,将运行时现场以struct Trapframe结构体形式保存
  • 异常分发程序将其分发至do_syscall()中,在其中正确设置epcsyscall下一条指令
  • do_syscall()根据传入的struct Trapframe获取相应参数,并根据第一个参数(系统调用号)调用内核级不同的系统调用具体实现函数
  • 将系统调用值放置在$v0中,退出异常处理程序,恢复程序运行现场

注意在相应的头文件中声明函数

实验体会

可能恰逢五一假期,在完成课下实验之后,有较为充裕的实验对实验内容进行思考。

我们此次主要完成了系统调用机制的实现,ipc进程通信以及fork机制实现,严格来说实验中实现的进程通信依旧是一种系统调用。fork机制创建进程不仅在父子进程间建立了某种关联,更是通过该关系使得父子进程通信、信息共享变得更为容易。通过系统调用时候checkperm位置的校验,使得父子进程间的交互更为安全与容易。可能在未来应用中,如果进程间功能有更多需求,可以相应创造更多的进程创建机制,使得对应功能更为实现。

系统调用机制是为了将某些用户进程极其可能会用到的功能实现(IO操作,内存申请)迁移到内核中实现。这可能是因为这些特殊操作会影响系统稳定性,若直接执行用户提供的代码并不安全,这一功能实现可以由内核来实现,对用户进程做好封装就好。不过,在微内核架构中,可能会将更多代码放在用户进程,例如在课下实验中写时复制机制处理函数cow_entry放在用户进程中,只不过可能会通过某些系统调用告诉内核相应的处理函数位置。这一样例不仅体现了微内核理念,更可以通过此机制实现不同用户的cow_entry实现,提升多样化服务。

此次实验,让我对用户进程与内核进程的交互有了更多的认识。

实验参考

在完成实验过程中,我参考了以下学长学姐的博客