线程切换
我们要用这个函数完成线程切换:
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
3. #![feature(naked_functions)]
5. // src/context.rs
7. impl Context {
8. #[naked]
9. #[inline(never)]
10. pub unsafe extern "C" fn switch(&mut self, target: &mut Context) {
11. asm!(include_str!("process/switch.asm") :::: "volatile");
12. }
13. }
这里需要对两个宏进行一下说明:
#[naked],告诉 rust 编译器不要给这个函数插入任何开场白 (prologue) 以及结语 (epilogue) 。 我们知道,一般情况下根据 函数调用约定(calling convention) ,编译器会自动在函数开头为我们插入设置寄存器、栈(比如保存 callee-save 寄存器,分配局部变量等工作)的代码作为开场白,结语则是将开场白造成的影响恢复。#[inline(never)],告诉 rust 编译器永远不要将该函数内联。
内联 (inline) 是指编译器对于一个函数调用,直接将函数体内的代码复制到调用函数的位置。而非像经典的函数调用那样,先跳转到函数入口,函数体结束后再返回。这样做的优点在于避免了跳转;但却加大了代码容量。
有时编译器在优化中会将未显式声明为内联的函数优化为内联的。但是我们这里要用到调用-返回机制,因此告诉编译器不能将这个函数内联。
这个函数我们用汇编代码 src/process/switch.asm 实现。
由于函数调用约定(calling convention) ,我们知道的是寄存器

分别保存“当前线程栈顶地址”所在的地址,以及“要切换到的线程栈顶地址”所在的地址。
RISC-V 函数调用约定(Calling Convention)
寄存器 ABI 名称 描述 Saver x0 zero Hard-wired zero <!—-<!—- x1 ra Return address Caller x2 sp Stack pointer Callee x3 gp Global pointer <!—-<!—- x4 tp Thread pointer <!—-<!—- x5-7 t0-2 Temporaries Caller x8 s0/fp Saved register/frame pointer Callee x9 s1 Saved register Callee x10-11 a0-1 Function arguments/return values Caller x12-17 a2-7 Function arguments Caller x18-27 s2-11 Saved registers Callee x28-31 t3-6 Temporaries Caller 我们切换进程时需要保存 Callee-saved registers 以及
ra。
所以要做的事情是:
- 将当前的 CPU 状态保存到当前栈上,并更新“当前线程栈顶地址”,通过写入寄存器

值所指向的内存; 2. 读取寄存器

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

发生了变化:在切换回来之后我们需要从 switch_to 返回之后的第一条指令继续执行!
因此可以较为巧妙地利用函数调用及返回机制:在调用 switch_to 函数之前编译器会帮我们将

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

,再调用

,这样会跳转到返回之后的第一条指令。 2. 为何不必保存全部寄存器
因此这是一个函数调用,由于函数调用约定(calling convention) ,编译器会自动生成代码在调用前后帮我们保存、恢复所有的 caller-saved 寄存器。于是乎我们需要手动保存所有的 callee-saved 寄存器

。这样所有的寄存器都被保存了。
下面一节我们来研究如何进行线程初始化。
