上下文切换

我们已经可以让用户程序运行在相互独立的虚拟地址空间上了, 我们只需要再加入上下文切换的机制, 就可以实现一个真正的分时多任务操作系统了! 所谓上下文, 其实可以看作是程序运行时候的状态. 聪明的你应该马上能想起来, 我们在 PA3 中遇到的陷阱帧, 不就正好保存了程序的状态了吗? 没错, 要实现上下文切换, 就是要实现在不同程序的陷阱帧之间的切换!

具体地, 假设程序 A 运行的过程中触发了系统调用, 陷入到内核. 根据 asm_trap() 的代码, A 的陷阱帧将会被保存到 A 的堆栈上. 本来系统调用处理完毕之后, asm_trap() 会根据 A 的陷阱帧恢复A的现场. 神奇的地方来了, 如果我们先不着急恢复 A 的现场, 而是先将栈顶指针切换到另一个程序 B 的堆栈上, 接下来的恢复现场操作将会恢复成 B 的现场: 恢复 B 的通用寄存器, 弹出 #irq 和错误码, 恢复 B 的 EIP, CS, EFLAGS. 从 asm_trap() 返回之后, 我们已经在运行程序 B 了!

那程序 A 到哪里去了呢? 别担心, 它只是被暂时"挂起"了而已. 在被挂起之前, 它已经把现场的信息保存在自己的堆栈上了, 如果将来的某一时刻栈顶指针被切换到 A 的堆栈上, 代码将会根据 A 的"陷阱帧"恢复 A 的现场, A 将得以唤醒并执行. 所以, 上下文切换其实就是不同程序之间的堆栈切换!

我们只要稍稍借助数学归纳法, 就可以让我们相信这个过程对于正在运行的程序来说总是正确的. 那么, 对于刚刚加载完的程序, 我们要怎么切换到它来让它运行起来呢? 答案很简单, 我们只需要在程序的堆栈上人工初始化一个陷阱帧, 使得将来切换的时候可以根据这个人工陷阱帧来正确地恢复现场即可.

在讨论具体如何初始化陷阱帧之前, 我们先来看一个关键的问题: 我们要如何找到别的程序的陷阱帧呢? 注意到陷阱帧是在堆栈上的形成的, 但堆栈那么大, 受到函数调用形成的栈帧的影响, 每次形成陷阱帧的位置并不是固定的. 自然地, 我们需要一个指针tf来记录陷阱帧的位置, 当想要找到别的程序的陷阱帧的时候, 只要寻找这个程序相关的tf指针即可.

事实上, 有不少信息都是进程相关的, 除了刚才提到的陷阱帧位置tf之外, 还有我们之前遇到的虚拟地址空间, 以及用户进程堆区的位置. 对于用户进程, 还需要有一个堆栈. 为了方便对进程进行管理, 操作系统使用一种叫进程控制块(PCB, process control block)的数据结构, 为每一个进程维护一个 PCB. Nanos-lite 的框架代码中已经定义了我们所需要使用的 PCB 结构(在 nanos-lite/include/proc.h 中定义):

typedef union {
  uint8_t stack[STACK_SIZE] PG_ALIGN;
  struct {
    _RegSet *tf;
    _Protect as;
    uintptr_t cur_brk;
    uintptr_t max_brk;
  };
} PCB;

Nanos-lite 使用一个联合体来把其它信息放置在进程堆栈的底部. 代码为每一个进程分配了一个 32KB 的堆栈, 已经足够使用了, 不会出现栈溢出导致 PCB 中的其它信息被覆盖的情况. 在进行上下文切换的时候, 只需要把 PCB 中的 tf 指针返回给 ASYE 的 irq_handle() 函数即可, 剩余部分的代码会根据上下文信息恢复现场. 在 GNU/Linux 中, 进程控制块是通过 task_struct 结构来定义的.

因此, 我们要做的事情, 就是在用户进程的堆栈上初始化一个陷阱帧. 具体来说, 就是如何初始化陷阱帧中的每一个域, 因此你需要仔细思考陷阱帧中的每一个域对一开始运行的用户进程有什么影响. 提醒一下, 为了保证 differential testing 的正确运行, 我们还是把陷阱帧中的 cs 设置为 8. 这件事情是通过 PTE 提供的 _umake() 函数(在 nexus-am/am/arch/x86-nemu/src/pte.c 中定义)来实现的, 它的原型是

_RegSet *_umake(_Protect *p, _Area ustack, _Area kstack,
  void *entry, char *const argv[], char *const envp[]);

_umake() 是专门用来创建用户进程的现场的, 但由于 NEMU 并没有实现 ring 3, Nanos-lite 也对用户进程作了一些简化, 因此目前 _umake() 只需要实现以下功能: 在 ustack 的底部初始化一个以 entry 为返回地址的陷阱帧. p是用户进程的虚拟地址空间, 在简化之后, _umake() 不需要使用它. argvenvp 分别是用户进程的 main() 函数参数和环境变量, 目前 Nanos-lite 暂不支持, 因此我们可以忽略它们. 但是, Navy-apps 中程序的入口函数是 navy-apps/libs/libc/src/start.c 中的 _start() 函数, _start() 函数认为它是有参数的, 因此我们还需要在陷阱帧之前设置好 _start() 函数的栈帧, 这是为了 _start() 开始执行的时候, 可以访问到正确的栈帧. 我们只需要把这一栈帧中的参数设置为 0NULL 即可, 至于返回地址, 我们永远不会从 _start() 返回, 因此可以不设置它.

因此, _umake() 函数需要在栈上初始化如下内容, 然后返回陷阱帧的指针, 由 Nanos-lite 把这一指针记录到用户进程 PCB 的 tf 中:

|               |
+---------------+ <---- ustack.end
|  stack frame  |
|   of _start() |
+---------------+
|               |
|   trap frame  |
|               |
+---------------+ <--+
|               |    |
|               |    |
|               |    |
|               |    |
+---------------+    |
|       tf      | ---+
+---------------+ <---- ustack.start
|               |

我们之前让 Nanos-lite 在加载用户程序后通过函数调用跳转到用户程序中执行. 事实上, 这并不是一个合理的方式, 从安全的角度来说, 高特权级的代码是不能直接跳转到低特权级的代码中执行的, 真实硬件的保护机制甚至会抛出异常来阻止这种情况的发生. 合理的做法是, 当操作系统初始化工作结束之后, 就会通过自陷指令触发一次上下文切换, 切换到第一个用户程序中来执行. 真实的操作系统就是这样做的.

为了测试 _umake() 的正确性, 我们也先通过自陷的方式触发第一次上下文切换. 内核自陷的功能与 ISA 相关, 是由 ASYE 的 _trap() 函数提供的. 在 x86-nemu 的 AM 中, 我们约定内核自陷通过指令 int $0x81 触发. ASYE 的 irq_handle() 函数发现触发了内核自陷之后, 会包装成一个 _EVENT_TRAP 事件. Nanos-lite 收到这个事件之后, 就可以返回第一个用户程序的现场了.

任务1:实现内核自陷

修改 Nanos-lite 的如下代码:

--- nanos-lite/src/main.c
+++ nanos-lite/src/main.c
@@ -33,3 +33,5 @@
   load_prog("/bin/pal");

+  _trap();
+
   panic("Should not reach here");
--- nanos-lite/src/proc.c
+++ nanos-lite/src/proc.c
@@ -17,4 +17,4 @@
   // TODO: remove the following three lines after you have implemented _umake()
-  _switch(&pcb[i].as);
-  current = &pcb[i];
-  ((void (*)(void))entry)();
+  // _switch(&pcb[i].as);
+  // current = &pcb[i];
+  // ((void (*)(void))entry)();

并在 ASYE 添加相应的代码, 使得 irq_handle() 可以识别内核自陷并包装成 _EVENT_TRAP 事件, Nanos-lite 接收到 _EVENT_TRAP 之后可以输出一句话, 然后直接返回即可, 因为真正的上下文切换还需要正确实现 _umake() 之后才能实现. 实现正确之后, 你会看到 Nanos-lite 触发了 main() 函数中最后的 panic. 如果你不知道应该怎么做, 请参考你对 PA3 必答题中关于系统调用部分的回答.

上下文切换只是 AM 的工作, 而具体切换到哪个进程的上下文, 是由操作系统来决定的, 这项任务叫做进程调度. 进程调度是由 schedule() 函数(在 nanos-lite/src/proc.c 中定义)来完成的, 它用于返回将要调度的进程的上下文. 因此, 我们需要一种方式来记录当前正在运行哪一个进程, 这样我们才能在 schedule() 中返回另一个进程的现场, 以实现多任务的效果. 这一工作是通过 current 指针(在 nanos-lite/src/proc.c 中定义)实现的, 它用于指向当前运行进程的 PCB. 这样, 我们就可以在 schedule() 中通过 current 来决定接下来要调度哪一个进程了. 不过在调度之前, 我们还需要把当前进程的上下文信息的位置保存在 PCB 当中:

// save the context pointer
current->tf = prev;

// always select pcb[0] as the new process
current = &pcb[0];

// TODO: switch to the new address space,
// then return the new context

目前 schedule() 只需要总是切换到第一个用户进程即可, 即 pcb[0]. 注意它的上下文是在加载程序的时候通过 _umake() 创建的, 在 schedule() 中才决定要切换到它, 然后在 ASYE 的 asm_trap()中才真正地恢复这一上下文. 在 schedule() 返回之前, 还需要切换到新进程的虚拟地址空间. 这样, 等到从异常处理的代码返回之后, 我们就已经正确地在仙剑奇侠传的虚拟地址空间中运行仙剑奇侠传的代码了!

任务2:实现上下文切换

根据讲义的上述内容, 实现以下功能:

  • PTE 的 _umake() 函数
  • Nanos-lite 的 schedule() 函数
  • Nanos-lite 收到 _EVENT_TRAP 事件后, 调用 schedule() 并返回其现场
  • 修改 ASYE 中 asm_trap() 的实现, 使得从 irq_handle() 返回后, 先将栈顶指针切换到新进程的陷阱帧, 然后才根据陷阱帧的内容恢复现场, 从而完成上下文切换的本质操作

实现成功后, Nanos-lite 就可以通过内核自陷触发上下文切换的方式运行仙剑奇侠传了.

分时多任务

我们已经实现了虚拟内存和上下文切换机制, Nanos-lite已经能支持分时多任务了! 这时候, 我们就可以加载第二个用户程序了:

--- nanos-lite/src/main.c
+++ nanos-lite/src/main.c
@@ -33,3 +33,4 @@
   load_prog("/bin/pal");
+  load_prog("/bin/hello");

   _trap();

我们让仙剑奇侠传和hello程序分时运行. 需要注意的是, 我们目前只允许最多一个需要更新画面的进程参与调度, 这是因为多个这样的进程分时运行会导致画面被相互覆盖, 影响画面输出的效果. 在真正的图形界面操作系统中, 通常由一个窗口管理进程来统一管理画面的显示, 需要显示画面的进程与这一管理进程进行通信, 来实现更新画面的目的. 但这需要操作系统支持进程间通信的机制, 这已经超出了ICS的范围, 而且Nanos-lite作为一个裁剪版的操作系统, 也不提供进程间通信的服务. 因此我们进行了简化, 最多只允许一个需要更新画面的进程参与调度即可.

为此, 我们还需要修改调度的代码, 让 schedule() 轮流返回仙剑奇侠传和 hello 的现场:

current = (current == &pcb[0] ? &pcb[1] : &pcb[0]);

最后, 我们还需要选择一个时机来触发进程调度. 目前比较合适的时机就是处理系统调用之后: 修改 do_event() 的代码, 在处理完系统调用之后, 调用 schedule() 函数并返回其现场.

任务3:分时运行仙剑奇侠传和hello程序

根据讲义的上述内容, 添加相应的代码来实现仙剑奇侠传和 hello 程序之间的分时运行.

实现正确后, 你会看到仙剑奇侠传一边运行的同时, hello 程序也会一边输出.

但我们会发现, 和hello程序分时运行之后, 仙剑奇侠传的运行速度有了明显的下降. 这其实再次向我们展现了"分时"的本质: 程序之间只是轮流使用处理器, 它们并不是真正意义上的"同时"运行. 为了让仙剑奇侠传尽量保持原来的性能, 我们可以在调度的时候进行一些修改.

任务4:优先级调度

我们可以修改 schedule() 的代码, 来调整仙剑奇侠传和 hello 程序调度的频率比例, 使得仙剑奇侠传调度若干次(如200), 才让hello程序调度 1 次. 这是因为 hello 程序做的事情只是不断地输出字符串, 我们只需要让 hello 程序偶尔进行输出, 以确认它还在运行就可以了.


以上是 PA4.1 的所有内容。

results matching ""

    No results matching ""