内核在哪里

这其实是早在上上个 Lab 就该明白的问题,但由于本人的懒惰和得过且过当时想想算了就这样吧。

内核并非只有一个(指针指着)。虽然对我来说内核很像一群不靠谱的小朋友背后的大家长,一旦小朋友(用户进程)做了什么非法操作(访问不该访问的东西)或者打起来(都想拿 CPU)就要弹出来收拾残局,然后默默撤离。但实际上,内核和用户进程并非众星拱月的关系,而是进程包含内核的关系。**内核是存在于所有进程地址空间中的一段代码,每个进程的页表里,都包含对同一份内核空间的映射。**这样相比于 “众星拱月” 的好处在于陷入内核时不必费劲切换到另一个地址空间(经历了上个 Lab 的狼狈就知道这个操作挺麻烦的)。当然虽然内核映射一直存在于进程页表里,但平时是不能访问的,要陷入内核才能访问。

当然,这只是操作系统的一种设计,并非绝对,不过现在的主流操作系统一般都是这样设计的。

进程与内核间的关系并非对立:在内核处理进程发起的系统调用时,我们并没有切换地址空间(页目录地址),也不需要将进程上下文(Trapframe)保存到进程控制块中,只是切换到内核态下,执行了一些内核代码。可以说,处理系统调用时的内核仍然是代表当前进程的,这也是系统调用、TLB 缺失等同步异常与时钟中断等异步异常的本质区别

系统调用(System Call)

内核态是被信任的,内核态允许一切指令运行,对硬件设备有完整的控制权。而用户态是不被信任的,用户态能运行的指令是内核态的一个子集。以此类推,在用户态想运行差集中的指令时,就需要陷入内核,syscall 就是这一过程的包装。将一部分用户态不允许但会频繁使用的指令包装成函数,由用户态请求,切换到内核态执行,这就是系统调用。

所以说系统调用是用户向操作系统请求的接口。但用户程序是写在 API 上而非直接写在系统调用上的,API 是更加通用的协议,比如说 C 标准库,这样设计使得程序更便于移植。

写时复制

按道理来讲,子进程有着和父进程一摸一样的虚拟地址空间(继承来的),但这个虚拟空间会映射到不同的物理页,这意味着在 fork 时需要把父进程的所有物理页面复制一遍。

但事实上,子进程很可能根本不会去写这些页面中的绝大多数,既然如此,为什么不让父子进程共享一部分物理页呢?写时复制就是偷懒先不复制物理页,只是把他们标记为 COW(写时复制)、PTE_D = 0(不可写),等到父子进程中的任何一个想要写这个页面时再复制一份,把父子进程的物理页分开。

从 COW 状态到普通状态的过渡由 TLB Mod 异常处理实现(TLB Mod 异常由试图对 PTE_D = 0 的页面进行写入操作触发,现在分为普通的只读页面和 COW 页面)。

用户态异常处理栈 UXSTACK

作用:让用户态也能安全处理异常,尤其是 COW 写入异常。

普通用户栈 USTACKTOP:用于局部变量、普通函数调用

异常处理栈 UXSTACKTOP:专门放异常处理时的 Trapframe,专门为 cow_entry 这类异常处理函数做运行时栈

为什么普通用户栈不够用?因为普通用户栈也是页面,也可能被标记为 COW。触发 COW 写异常时,很可能普通用户栈正是触发异常的地方,这时候再让它处理该异常,就会再次触发异常,陷入无限递归。

所以专门搞一个不可能被标记为 COW 的 UXSTACKTOP 来处理这类 TLB Mod 异常,确保稳定安全。

TLB Mod 异常

内核允许用户进程在用户态处理 TLB Mod 异常,因此,当异常发生时,内核不直接处理 COW,而是把异常现场放在用户异常处理栈上,然后 EPC 给到用户态异常处理函数。以上操作由 kerntlbex.c 中的 do_tlb_mod 函数执行,该函数允许异常重入(在把 tf 压入 UXSTACKTOP 时会检测当前位置,如果已经在 UXSTACKTOP 里,说明正在发生异常重入,此时让 UXSTACKTOP 沿原方向继续生长即可),并根据用户是否注册了异常处理程序来决定是该跳到异常处理程序还是该 panic。

系统调用实现

下面三个函数都和写时映射有很强的关系。

1
2
3
sys_mem_alloc 新建映射
sys_mem_map 共享映射
sys_mem_unmap 解除映射

sys_mem_alloc

这个函数是用户进程申请内存的系统调用的实现。可以为自己或者自己的直接子进程申请一页内存。为什么要为子进程申请内存?很可能会在 fork 子进程刚创建还不能运行时使用。

1
2
3
4
5
6
7
8
9
10
11
int sys_mem_alloc(u_int envid, u_int va, u_int perm) {
struct Env *env;
struct Page *pp;

if (is_illegal_va(va)) {
return -E_INVAL;
}
try(envid2env(envid, &env, 1));
try(page_alloc(&pp));
return page_insert(env->env_pgdir, env->env_asid, pp, va, perm);
}

sys_mem_map

是用户进程共享页面的系统调用的实现,src 进程把 srcva 映射到的物理页共享给 dst 进程的 dstva。这样一来两个进程都可以操作该物理页面,十分的危险,所以经常被用做 “写时复制”,权限设置为 COW 而非 PTE_D。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int sys_mem_map(u_int srcid, u_int srcva, u_int dstid, u_int dstva, u_int perm) {
struct Env *srcenv;
struct Env *dstenv;
struct Page *pp;

if (is_illegal_va(srcva) || is_illegal_va(dstva)) {
return -E_INVAL;
}
try(envid2env(srcid, &srcenv, 1));
try(envid2env(dstid, &dstenv, 1));
// 找到物理页
pp = page_lookup(srcenv -> env_pgdir, srcva, NULL);
if (pp == NULL) {
return -E_INVAL;
}
return page_insert(dstenv->env_pgdir, dstenv->env_asid, pp, dstva, perm);
}

sys_mem_unmap

为自己或者自己的子进程解除映射,很可能也是写时复制的一部分,因为 COW 异常之后 COW 的地址就没用了。

1
2
3
4
5
6
7
8
9
10
int sys_mem_unmap(u_int envid, u_int va) {
struct Env *e;

if (is_illegal_va(va)) {
return -E_INVAL;
}
try(envid2env(envid, &e, 1));
page_remove(e -> env_pgdir, e -> env_asid, va);
return 0;
}

sys_exofork

复制出一个还不能运行的半成品子进程,fork 调用的两个返回值就是在这里实现的。

这里需要注意的是:

1
e->env_tf = *((struct Trapframe *)KSTACKTOP - 1);

它的作用是把父进程(当前所在进程)陷入系统调用之前的运行现场复制给子进程。

因为栈是向低地址增长的,所以 KSTACKTOP - sizeof(struct Trapframe) 的位置,正好放着当前进程的 Trapframe

1
2
3
4
5
6
7
8
9
int sys_exofork(void) {
struct Env *e;
try(env_alloc(&e, curenv -> env_id));
e -> env_tf = *((struct Trapframe *)KSTACKTOP - 1);
e -> env_tf.regs[2] = 0;
e -> env_status = ENV_NOT_RUNNABLE;
e -> env_pri = curenv -> env_pri;
return e->env_id;
}

然后,父与子就能够分别各自从 syscall_exofork 中返回。

duppage

duppage 是 fork 里用来把父进程的一页复制给子进程的函数。

不但要给子进程设置为 COW 权限,还要覆盖自己原有的写权限。

思考题

Thinking 4.1

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

通过 SAVE_ALL 宏把通用寄存器都收到内核栈的 TrapFrame,恢复现场的时候在一一放出来,期间内核可以自由使用寄存器而不用担心破坏用户进程。注意这里是保存到内核栈而非用户进程 PCB,PCB -> tf 是为较长时间(等待 CPU 或阻塞)存储准备的,而系统调用通常不会切换进程,如果频繁地写入 PCB 会很麻烦,而存储到内核栈帧是最合适的。

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

不能。如上文所述,系统调用开始时 $a0 ~ $a3 的值也会被保存到 TrapFrame 里,而 $a0 ~ $a3 本身供内核程序自由使用,可能被覆盖,因此必须从 TrapFrame 里读取用户调用 msyscall留下的信息。

准确来说:

1
2
3
4
sysno = tf->regs[4]; // $a0
arg1 = tf->regs[5]; // $a1
arg2 = tf->regs[6]; // $a2
arg3 = tf->regs[7]; // $a3

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

1
2
u_int arg4 = *(u_int *)(tf->regs[29] + 16);
u_int arg5 = *(u_int *)(tf->regs[29] + 20);

do_syscall 手动模拟了一次普通的 C 函数调用

1
tf->regs[2] = func(arg1, arg2, arg3, arg4, arg5);

所以 sys_* 以为自己收到了和用户调用 msyscall 时相同的参数。

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

  • 返回到下一条指令处:

    1
    tf -> cp0_epc += 4;
  • 返回值:

    1
    tf -> regs[2] = res;

Thinking 4.2

为什么 envid2env 中需要判断 e->env_id != envid?如果没有这步判断会发生什么?

1
2
3
4
5
6
7
8
9
10
int envid2env(u_int envid, struct Env **penv, int checkperm) {
struct Env *e;
...
e = &envs[ENVX(envid)];
if (e->env_status == ENV_FREE || e->env_id != envid) {
return -E_BAD_ENV;
}
...
return 0;
}

e->env_id != envid 本质上是 env_status == ENV_FREE 的一种特殊情况。

因为 ENVX(envid) 得到的是其在 envs[] 中的下标,而 envs[] 中的槽位可以被反复使用。假设进程 A 已被销毁,A 原本在 envs[] 中的位置被进程 B 获取,这时候如果对 A 的 env_id 执行 envid2env 就会获得进程 B 的进程控制块,而 B 的 env_status 不一定是 ENV_FREE,如果不检查 env_status == ENV_FREE 就会命中错误地进程,导致严重的权限错误。

Thinking 4.3

请回顾 kern/env.c 文件中 mkenvid() 函数的实现,该函数不会返回 0,请结合系统调用和 IPC 部分的实现与

envid2env() 函数的行为进行解释。

envid = 0 在 MOS 中有特殊的含义:0 表示当前进程。如果真正的进程 ID 也能为 0,那就无从区分当前进程和 ID = 0 的进程(或许也可以区分,但麻烦)。

Thinking 4.4

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

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

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

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

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

C。

fork 确实只在父进程中被调用,但因为子进程会复制父进程的运行现场,使得它看起来也像是刚从 fork 返回,所以会有两个返回值。其实只是子进程继承了父进程的 fork 调用现场。

Thinking 4.5

上述 “最小版” 策略要求你在遍历时跳过 UXSTACK 与 UVPT/VPT/VPD 等特殊区域。请结合 kern/env.c 中 env_init 的映射行为、include/mmu.h 的内存布局图,解释:

为什么子进程的 UXSTACK 必须重新分配,而不能共享或设为 COW?

直接共享肯定不行,因为父子进程同时发生异常的话,压进去的 TrapFrame 会互相覆盖。

设成 COW 也不行,因为 COW 本身就是依赖于异常栈来处理,如果异常栈也是 COW 的,那么处理 COW 异常时又会再次触发 COW,造成无限递归。所以 UXSTACK 必须稳定可写。

为什么不应对 UVPT/VPT/VPD 等自映射区域调用 duppage?如果误处理,可能引发哪些不一致或错误行为?

UVPT/VPT/VPD 是页表自映射区域,用来让用户只读查看自己的页表和页目录。

所以为什么呢?不知道啊

Thinking 4.6

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

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

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

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

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

  • vpt 指向页表窗口的起始处,用 vpt[vpn] 获取页表项。

    vpd 指向页表窗口中页目录起始处,用 vpd[pdx] 获取页目录项。

    1
    2
    3
    4
    5
    for (i = 0; i < PDX(UXSTACKTOP); i ++) {
    if (vpd[i] & PTE_V) {
    ......
    }
    }
  • 实现了自映射。

  • 正常情况下,页表是内核管理的数据结构,用户程序不能直接访问。

    自映射的思想是:

    把页目录本身也映射进虚拟地址空间,于是页表结构也能通过虚拟地址访问。

    vpd 对应页目录视图,vpt 对应页表视图。它们本质上都是通过 UVPT 这一类特殊虚拟地址窗口访问页表结构。

  • 不能,一般是只读映射。

Thinking 4.7

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

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

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

  • 用户进程正在用户异常处理程序中运行时,又一次触发写入异常
  • 因为 COW 的具体处理在用户态完成

Thinking 4.8

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

内核只负责异常转发,不负责异常处理

这符合微内核的思想:

  • (如果可以的话),便于移植

  • 用户态出问题只影响该进程,不会破坏整个内核

  • 内核只提供异常分发和系统调用支持,复杂策略放在用户态实现

Thinking 4.9

为什么在设置写时复制保护机制之前,需通过 syscall_set_tlb_mod_entry 设置父进程页写入异常处理函数?若在写时复制保护机制完成之后再设置会怎样?

因为把共享页面设置为 COW 后,父进程自己也被禁止写入(!PTE_D)。如果在写时复制保护机制完成之后再设置,则可能导致在保护机制设置过程中,父进程试图写入该页面,由于此时写时复制保护机制还未完全建立,该事件可能会触发非法写入 panic 而非 COW 异常。