线程切换

我们要用这个函数完成线程切换:

  1. // src/process/structs.rs
  2. impl Thread {
  3. pub fn switch_to(&mut self, target: &mut Thread) {
  4. unsafe { self.context.switch(&mut target.context); }
  5. }
  6. }

通过调用 switch_to 函数将当前正在执行的线程切换为另一个线程。实现方法是两个 Context 的切换。

  1. // src/lib.rs
  2. #![feature(naked_functions)]
  3. // src/context.rs
  4. impl Context {
  5. #[naked]
  6. #[inline(never)]
  7. pub unsafe extern "C" fn switch(&mut self, target: &mut Context) {
  8. asm!(include_str!("process/switch.asm") :::: "volatile");
  9. }
  10. }

这里需要对两个宏进行一下说明:

  • #[naked] ,告诉 rust 编译器不要给这个函数插入任何开场白 (prologue) 以及结语 (epilogue) 。 我们知道,一般情况下根据 函数调用约定(calling convention) ,编译器会自动在函数开头为我们插入设置寄存器、栈(比如保存 callee-save 寄存器,分配局部变量等工作)的代码作为开场白,结语则是将开场白造成的影响恢复。

  • #[inline(never)] ,告诉 rust 编译器永远不要将该函数内联

    内联 (inline) 是指编译器对于一个函数调用,直接将函数体内的代码复制到调用函数的位置。而非像经典的函数调用那样,先跳转到函数入口,函数体结束后再返回。这样做的优点在于避免了跳转;但却加大了代码容量。

    有时编译器在优化中会将未显式声明为内联的函数优化为内联的。但是我们这里要用到调用-返回机制,因此告诉编译器不能将这个函数内联。

这个函数我们用汇编代码 src/process/switch.asm 实现。

由于函数调用约定(calling convention) ,我们知道的是寄存器

线程切换 - 图1

分别保存“当前线程栈顶地址”所在的地址,以及“要切换到的线程栈顶地址”所在的地址。

RISC-V 函数调用约定(Calling Convention)

寄存器ABI 名称描述Saver
x0zeroHard-wired zero<!—-<!—-
x1raReturn addressCaller
x2spStack pointerCallee
x3gpGlobal pointer<!—-<!—-
x4tpThread pointer<!—-<!—-
x5-7t0-2TemporariesCaller
x8s0/fpSaved register/frame pointerCallee
x9s1Saved registerCallee
x10-11a0-1Function arguments/return valuesCaller
x12-17a2-7Function argumentsCaller
x18-27s2-11Saved registersCallee
x28-31t3-6TemporariesCaller

我们切换进程时需要保存 Callee-saved registers 以及ra

所以要做的事情是:

  1. 将当前的 CPU 状态保存到当前栈上,并更新“当前线程栈顶地址”,通过写入寄存器

    线程切换 - 图2

    值所指向的内存;

  2. 读取寄存器

    线程切换 - 图3

    值所指向的内存获取“要切换到的线程栈顶地址”,切换栈,并从栈上恢复 CPU 状态

  1. # src/process/switch.asm
  2. .equ XLENB, 8
  3. .macro Load a1, a2
  4. ld \a1, \a2*XLENB(sp)
  5. .endm
  6. .macro Store a1, a2
  7. sd \a1, \a2*XLENB(sp)
  8. .endm
  9. # 入栈,即在当前栈上分配空间保存当前 CPU 状态
  10. addi sp, sp, -14*XLENB
  11. # 更新“当前线程栈顶地址”
  12. sd sp, 0(a0)
  13. # 依次保存各寄存器的值
  14. Store ra, 0
  15. Store s0, 2
  16. ......
  17. Store s11, 13
  18. csrr s11, satp
  19. Store s11, 1
  20. # 当前线程状态保存完毕
  21. # 准备恢复到“要切换到的线程”
  22. # 读取“要切换到的线程栈顶地址”,并直接换栈
  23. ld sp, 0(a1)
  24. # 依序恢复各寄存器
  25. Load s11, 1
  26. # 恢复页表寄存器 satp,别忘了使用屏障指令 sfence.vma 刷新 TLB
  27. csrw satp, s11
  28. sfence.vma
  29. Load ra, 0
  30. Load s0, 2
  31. ......
  32. Load s11, 13
  33. # 各寄存器均被恢复,恢复过程结束
  34. # “要切换到的线程” 变成了 “当前线程”
  35. # 出栈,即在当前栈上回收用来保存线程状态的内存
  36. addi sp, sp, 14*XLENB
  37. # 将“当前线程的栈顶地址”修改为 0
  38. # 这并不会修改当前的栈
  39. # 事实上这个值只有当对应的线程暂停(sleep)时才有效
  40. # 防止别人企图 switch 到它,把它的栈进行修改
  41. sd zero, 0(a1)
  42. ret

这里需要说明的是:

  1. 我们是如何利用函数调用及返回机制的

    我们说为了线程能够切换回来,我们要保证切换前后线程状态不变。这并不完全正确,事实上程序计数器

    线程切换 - 图4

    发生了变化:在切换回来之后我们需要从 switch_to 返回之后的第一条指令继续执行!

    因此可以较为巧妙地利用函数调用及返回机制:在调用 switch_to 函数之前编译器会帮我们将

    线程切换 - 图5

    寄存器的值改为 switch_to 返回后第一条指令的地址。所以我们恢复

    线程切换 - 图6

    ,再调用

    线程切换 - 图7

    ,这样会跳转到返回之后的第一条指令。

  2. 为何不必保存全部寄存器

    因此这是一个函数调用,由于函数调用约定(calling convention) ,编译器会自动生成代码在调用前后帮我们保存、恢复所有的 caller-saved 寄存器。于是乎我们需要手动保存所有的 callee-saved 寄存器

    线程切换 - 图8

    。这样所有的寄存器都被保存了。

下面一节我们来研究如何进行线程初始化。