任务切换

本节导读

在上一节实现的二叠纪“锯齿螈”操作系统还是比较原始,一个应用会独占 CPU 直到它出错或主动退出。操作系统还是以程序的一次执行过程(从开始到结束)作为处理器切换程序的时间段。为了提高效率,我们需要引入新的操作系统概念 任务任务切换任务上下文

如果把应用程序执行的整个过程进行进一步分析,可以看到,如果程序访问 IO 或睡眠等待时,其实是不需要占用处理器的,于是我们可以把应用程序的不同时间段的执行过程分为两类,占用处理器执行有效任务的计算阶段和不必占用处理器的等待阶段。这些按时间流连接在一起的不同类型的多个阶段形成了一个我们熟悉的“暂停-继续…”组合的 执行流或执行历史 。从开始到结束的整个执行流就是应用程序的整个执行过程。

本节的重点是操作系统的核心机制—— 任务切换 。 任务切换支持的场景是:一个应用在运行途中便会主动交出 CPU 的使用权,此时它只能暂停执行,等到内核重新给它分配处理器资源之后才能恢复并继续执行。

任务的概念形成

如果操作系统能够在某个应用程序处于等待阶段的时候,把处理器转给另外一个处于计算阶段的应用程序,那么只要转换的开销不大,那么处理器的执行效率就会大大提高。当然,这需要应用程序在运行途中能主动交出 CPU 的使用权,此时它处于等待阶段,等到操作系统让它再次执行后,那它就可以继续执行了。

到这里,我们就把应用程序的一个计算阶段的执行过程(也是一段执行流)称为一个 任务 ,所有的任务都完成后,应用程序也就完成了。从一个程序的任务切换到另外一个程序的任务称为 任务切换 。为了确保切换后的任务能够正确继续执行,操作系统需要支持让任务的执行“暂停”和“继续”。

我们又看到了熟悉的“暂停-继续”组合。一旦一条执行流需要支持“暂停-继续”,就需要提供一种执行流切换的机制,而且需要保证执行流被切换出去之前和切换回来之后,它的状态,也就是在执行过程中同步变化的资源(如寄存器、栈等)需要保持不变,或者变化在它的预期之内。而不是所有的资源都需要被保存,事实上只有那些对于执行流接下来的进行仍然有用,且在它被切换出去的时候有被覆盖的风险的那些资源才有被保存的价值。这些物理资源被称为 任务上下文 (Task Context)

这里,大家开始在具体的操作系统中接触到了一些抽象的概念,其实这些概念都是具体代码的结构和代码动态执行过程的文字表述而已。

不同类型的上下文与切换

在执行流切换过程中,我们需要结合硬件机制和软件实现来保存和恢复任务上下文。任务的一次切换涉及到被换出和即将被换入的两条执行流(分属两个任务),通常它们都需要共同遵循某些约定来合作完成这一过程。在前两章,我们已经看到了两种上下文保存/恢复的实例。让我们再来回顾一下它们:

  • 第一章《RV64 裸机应用》中,我们介绍了 函数调用与栈 。当时提到过,为了支持嵌套函数调用,不仅需要硬件平台提供特殊的跳转指令,还需要保存和恢复 函数调用上下文 。注意在 我们 的定义中,函数调用包含在普通控制流(与异常控制流相对)之内,且始终用一个固定的栈来保存执行的历史记录,因此函数调用并不涉及执行流的切换。但是我们依然可以将其看成调用者和被调用者两个执行过程的“切换”,二者的协作体现在它们都遵循调用规范,分别保存一部分通用寄存器,这样的好处是编译器能够有足够的信息来尽可能减少需要保存的寄存器的数目。虽然当时用了很大的篇幅来说明,但其实整个过程都是编译器负责完成的,我们只需设置好栈就行了。

  • 第二章《批处理系统》中第一次涉及到了某种异常(Trap)控制流,即两条执行流的切换,需要保存和恢复 系统调用(Trap)上下文 。当时,为了让内核能够 完全掌控 应用的执行,且不会被应用破坏整个系统,我们必须利用硬件 提供的特权级机制,让应用和内核运行在不同的特权级。应用运行在 U 特权级,它所被允许的操作进一步受限,处处被内核监督管理;而内核运行在 S 特权级,有能力处理应用执行过程中提出的请求或遇到的状况。

    应用程序与操作系统打交道的核心在于硬件提供的 Trap 机制,也就是在 U 特权级运行的应用执行流和在 S 特权级运行的 Trap 执行流(操作系统的陷入处理部分)之间的切换。Trap 执行流是在 Trap 触发的一瞬间生成的,它和原应用执行流有着很密切的联系,因为它唯一的目标就是处理 Trap 并恢复到原应用执行流。而且,由于 Trap 机制对于应用来说几乎是透明的,所以基本上都是 Trap 执行流在“负重前行”。Trap 执行流需要把 Trap 上下文 保存在自己的 内核栈上,里面包含几乎所有的通用寄存器,因为在 Trap 处理过程中它们都可能被用到。如果有需要的话,可以回看 Trap 上下文保存与恢复 小节。

任务切换的设计与实现

本节的任务切换的执行过程是第二章的 Trap 之后的另一种异常控制流,都是描述两条执行流之间的切换,如果将它和 Trap 切换进行比较,会有如下异同:

  • 与 Trap 切换不同,它不涉及特权级切换;

  • 与 Trap 切换不同,它的一部分是由编译器帮忙完成的;

  • 与 Trap 切换相同,它对应用是透明的。

事实上,它是来自两个不同应用的 Trap 执行流之间的切换。当一个应用 Trap 到 S 模式的操作系统中进行进一步处理(即进入了操作系统的Trap执行流)的时候,其 Trap 执行流可以调用一个特殊的 __switch 函数。这个函数表面上就是一个普通的函数调用:在 __switch 返回之后,将继续从调用该函数的位置继续向下执行。但是其间却隐藏着复杂的执行流切换过程。具体来说,调用 __switch 之后直到它返回前的这段时间,原 Trap 执行流会先被暂停并被切换出去, CPU 转而运行另一个应用的 Trap 执行流。之后在时机合适的时候,原 Trap 执行流才会从某一条 Trap 执行流(很有可能不是它之前切换到的那一条)切换回来继续执行并最终返回。不过,从实现的角度讲, __switch 和一个普通的函数之间的差别仅仅是它会换栈。

../_images/task_context.png

当 Trap 执行流准备调用 __switch 函数并进入暂停状态的时候,让我们考察一下它内核栈上的情况。如上图所示,在准备调用 __switch 函数之前,内核栈上从栈底到栈顶分别是保存了应用执行状态的 Trap 上下文以及内核在对 Trap 处理的过程中留下的调用栈信息。由于之后还要恢复回来执行,我们必须保存 CPU 当前的某些寄存器,我们称它们为 任务上下文 (Task Context)。我们会在稍后介绍里面需要包含哪些寄存器。至于保存的位置,我们将任务上下文直接压入内核栈的栈顶,从这一点上来说它和函数调用一样。

这样需要保存的信息就已经确实的保存在内核栈上了,而恢复的时候我们要从任务上下文的位置——也就是这一时刻内核栈栈顶的位置找到被保存的寄存器快照进行恢复,这个位置也需要被保存下来。对于每一条被暂停的 Trap 执行流,我们都用一个名为 task_cx_ptr 的变量来保存它栈顶的任务上下文的地址。利用 C 语言的引用来描述的话就是:

  1. TaskContext *task_cx_ptr = &task_cx;

由于我们要用 task_cx_ptr 这个变量来进行保存任务上下文的地址,自然也要对任务上下文的地址进行修改。于是我们还需要指向 task_cx_ptr 这个变量的指针 task_cx_ptr2

  1. TaskContext **task_cx_ptr2 = &task_cx_ptr;

接下来我们同样从栈上内容的角度来看 __switch 的整体流程:

../_images/switch-1.png ../_images/switch-2.png

Trap 执行流在调用 __switch 之前就需要明确知道即将切换到哪一条目前正处于暂停状态的 Trap 执行流,因此 __switch 有两个参数,第一个参数代表它自己,第二个参数则代表即将切换到的那条 Trap 执行流。这里我们用上面提到过的 task_cx_ptr2 作为代表。在上图中我们假设某次 __switch 调用要从 Trap 执行流 A 切换到 B,一共可以分为五个阶段,在每个阶段中我们都给出了 A 和 B 内核栈上的内容。

  • 阶段 [1]:在 Trap 执行流 A 调用 __switch 之前,A 的内核栈上只有 Trap 上下文和 Trap 处理的调用栈信息,而 B 是之前被切换出去的,它的栈顶还有额外的一个任务上下文;

  • 阶段 [2]:A 在自身的内核栈上分配一块任务上下文的空间在里面保存 CPU 当前的寄存器快照。随后,我们更新 A 的 task_cx_ptr,只需写入指向它的指针 task_cx_ptr2 指向的内存即可;

  • 阶段 [3]:这一步极为关键。这里读取 B 的 task_cx_ptr 或者说 task_cx_ptr2 指向的那块内存获取到 B 的内核栈栈顶位置,并复制给 sp 寄存器来换到 B 的内核栈。由于内核栈保存着它迄今为止的执行历史记录,可以说 换栈也就实现了执行流的切换 。正是因为这一步, __switch 才能做到一个函数跨两条执行流执行。

  • 阶段 [4]:CPU 从 B 的内核栈栈顶取出任务上下文并恢复寄存器状态,在这之后还要进行退栈操作。

  • 阶段 [5]:对于 B 而言, __switch 函数返回,可以从调用 __switch 的位置继续向下执行。

从结果来看,我们看到 A 执行流 和 B 执行流的状态发生了互换, A 在保存任务上下文之后进入暂停状态,而 B 则恢复了上下文并在 CPU 上执行。

下面我们给出 __switch 的实现:

  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
  19. 19
  20. 20
  21. 21
  22. 22
  23. 23
  24. 24
  25. 25
  26. 26
  27. 27
  28. 28
  29. 29
  30. 30
  31. 31
  32. 32
  33. 33
  34. 34
  35. 35
  36. 36
  37. 37
  38. 38
  1. # os/src/task/switch.S
  2. .altmacro
  3. .macro SAVE_SN n
  4. sd s\n, (\n+1)8(sp)
  5. .endm
  6. .macro LOAD_SN n
  7. ld s\n, (\n+1)8(sp)
  8. .endm
  9. .section .text
  10. .globl switch
  11. switch:
  12. # __switch(
  13. # current_task_cx_ptr2: &const TaskContext,
  14. # next_task_cx_ptr2: &const TaskContext
  15. # )
  16. # push TaskContext to current sp and save its address to where a0 points to
  17. addi sp, sp, -138
  18. sd sp, 0(a0)
  19. # fill TaskContext with ra & s0-s11
  20. sd ra, 0(sp)
  21. .set n, 0
  22. .rept 12
  23. SAVE_SN %n
  24. .set n, n + 1
  25. .endr
  26. # ready for loading TaskContext a1 points to
  27. ld sp, 0(a1)
  28. # load registers in the TaskContext
  29. ld ra, 0(sp)
  30. .set n, 0
  31. .rept 12
  32. LOAD_SN %n
  33. .set n, n + 1
  34. .endr
  35. # pop TaskContext
  36. addi sp, sp, 138
  37. ret

我们手写汇编代码来实现 __switch 。可以看到它的函数原型中的两个参数分别是当前 Trap 执行流和即将被切换到的 Trap 执行流的 task_cx_ptr2 ,从 RISC-V 调用规范 可以知道它们分别通过寄存器 a0/a1 传入。

阶段 [2] 体现在第 18~26 行。第 18 行在 A 的内核栈上预留任务上下文的空间,然后将当前的栈顶位置保存下来。接下来就是逐个对寄存器进行保存,从中我们也能够看出 TaskContext 里面究竟包含哪些寄存器:

  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  1. // os/src/task/context.rs
  2. #[repr(C)]
  3. pub struct TaskContext {
  4. ra: usize,
  5. s: [usize; 12],
  6. }

这里面只保存了 ra 和被调用者保存的 s0~s11ra 的保存很重要,它记录了 __switch 返回之后应该到哪里继续执行,从而在切换回来并 ret 之后能到正确的位置。而保存调用者保存的寄存器是因为,调用者保存的寄存器可以由编译器帮我们自动保存。我们会将这段汇编代码中的全局符号 __switch 解释为一个 Rust 函数:

  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  1. // os/src/task/switch.rs
  2. global_asm!(include_str!(“switch.S”));
  3. extern C {
  4. pub fn __switch(
  5. current_task_cx_ptr2: const usize,
  6. next_task_cx_ptr2: const usize
  7. );
  8. }

我们会调用该函数来完成切换功能而不是直接跳转到符号 __switch 的地址。因此在调用前后 Rust 编译器会自动帮助我们插入保存/恢复调用者保存寄存器的汇编代码。

仔细观察的话可以发现 TaskContext 很像一个普通函数栈帧中的内容。正如之前所说, __switch 的实现除了换栈之外几乎就是一个普通函数,也能在这里得到体现。尽管如此,二者的内涵却有着很大的不同。

剩下的汇编代码就比较简单了。读者可以自行对照注释看看图示中的后面几个阶段各是如何实现的。另外,后面会出现传给 __switch 的两个参数相同,也就是某个 Trap 执行流自己切换到自己的情形,请读者对照图示思考目前的实现能否对它进行正确处理。