为什么 swtch 只保存 callee-saved 寄存器?
[!CAUTION] swtch saves only callee-saved registers;the C compiler generates code in the caller to save caller-saved registers on the stack.
1. 背景:调用约定 (Calling Convention)
在 RISC-V(以及大多数现代 CPU 架构)里,函数调用遵循一个“君子协定”,把寄存器分成两类:
- 调用者保存寄存器 (Caller-Saved Registers)
- 被调用者保存寄存器 (Callee-Saved Registers)
- 被调用者保存寄存器 (Callee-Saved Registers)
- 寄存器代表:在 RISC-V 中主要是 s0 - s11 (Saved Registers)。
- 协定规则:如果你是一个被调用的函数(a callee, 比如 swtch),你就像一个被邀请到别人家的“客人”。s0-s11 就像是主人书房里摆放整齐的书。如果你作为客人,想用一下书桌(即使用 s0寄存器),你有义务在离开前,把书桌恢复成你来之前的样子。
- 具体做法:callee 函数在执行的开始,如果它需要用到某个 s 寄存器,它必须先把这个寄存器里的旧值存到栈上。在函数返回之前,再从栈上把旧值恢复到该寄存器中。
- 对调用者的好处:caller(主人)可以放心地把一些重要的、需要长期保存的变量(比如循环计数器)放在 s 寄存器里,然后去调用其他函数(邀请客人),完全不用担心函数返回后 s 寄存器里的值会被改变。
所以,swtch saves only callee-saved registers 这半句话的意思是:
swtch 作为一个被调用的函数 (callee),它严格遵守了这个协定。它需要“弄乱”s0-s11 这些寄存器(因为它要加载新进程的 s
寄存器值),所以它有义务保存当前的 s 寄存器。它通过将它们存入 old 上下文(struct context)来履行这个义务。
- 调用者保存寄存器 (Caller-Saved Registers)
- 寄存器代表:在 RISC-V 中主要是 t0 - t6 (Temporary Registers) 和 a0 - a7 (Argument Registers)。
- 协定规则:callee(客人)可以随意使用这些寄存器,而不需要为它们恢复原样。callee 默认这些寄存器里的值是临时的、无关紧要的。
- 调用者的责任:如果你是 caller(主人),你有一个很重要的临时变量放在了 t0 寄存器里。现在你要调用一个函数(邀请客人)。你知道这个“不讲礼貌”的客人可能会把 t0 弄得一团糟。所以,如果你希望在客人走后,还能找回 t0 的旧值,你自己必须在邀请客人之前,把 t0 的值备份好(通常是存到自己的栈上)。
- 对被调用者的好处:callee(客人)可以无所顾忌地使用这些临时寄存器,极大地提高了执行效率,因为它不需要为这些寄存器做额外的保存和恢复工作。
所以,the C compiler generates code in the caller to save caller-saved registers on the stack 这后半句话的意思是:
当 C 编译器编译一个函数(比如 yield)时,如果这个函数要调用 swtch,编译器会进行分析。
假如在调用 swtch 之前,yield函数把一个重要值放在了 t1 寄存器里,并且在swtch返回后还需要用到这个值,那么编译器会自动在 call swtch 指令之前,插入一条 sd t1, offset(sp) 这样的指令,把 t1 的值存到 yield 函数自己的栈帧里。
这样就保护了 t1 的值不会被 swtch 破坏。
这种分工的好处是:函数调用不必每次都保存所有寄存器,而是“各自负责”,性能更高。
区分caller-saved和callee-saved的责任
Caller-saved: 如果caller要调用一个函数,但它手里有重要数据放在 caller-saved 寄存器(比如 RISC-V 的 t0-t6, a0-a7), 那么调用者必须自己保存。因为约定里 callee不保证保留这些寄存器。
Callee-saved: 如果caller把数据放在 callee-saved 寄存器(比如 RISC-V 的 s0-s11),那么caller可以“理直气壮”地依赖这些数据保持不变。因为约定里 callee 有责任在返回时恢复这些寄存器。
2. 普通函数如何遵守约定
普通 C 函数作为 callee,会在自己的栈帧上保存/恢复 callee-saved 寄存器:
- 进入函数:栈指针
sp指向调用者的栈。 - 创建栈帧:向下移动
sp,预留空间。 - 保存寄存器:若需要用
s0,执行sd s0, offset(sp)保存旧值到栈。 - 执行计算。
- 恢复寄存器:返回前
ld s0, offset(sp)恢复。 - 销毁栈帧,返回。
关键点:普通函数始终在同一个栈上操作,保存到栈里是安全的。
3. swtch 的特殊性
swtch 不是普通函数,它的任务是切换进程上下文。
执行过程中,它会丢弃当前的栈,切换到另一个进程的栈。
如果它像普通函数一样把 s 寄存器保存到栈上,会出问题:
swtch开始时,sp指向旧进程的内核栈。- 如果此时执行
sd s0, offset(sp),保存的值就在旧栈里。 - 但很快
swtch会执行ld sp, 8(a1),切换到新进程的栈。 - 此时旧栈“失联”,之前保存的
s0值无法再访问。
因此,swtch 不能把寄存器保存到栈上。
4. swtch 的正确做法
swtch 依然遵守“callee 要保存 s 寄存器”的规则,但它的保存位置是 struct context,而不是栈。
- 参数
a0传入old,表示旧进程的struct context地址。 - 参数
a1传入new,表示新进程的struct context地址。
执行逻辑:
- 把旧进程的
s0-s11、sp保存到old->s*、old->sp。 - 再把新进程的
s0-s11、sp从new->s*、new->sp取出来。 - 完成寄存器和栈切换,CPU 进入新进程上下文。
5. 对比总结
| 特性 | 普通函数 (callee) | swtch (特殊 callee) |
|---|---|---|
| 保存目的地 | 栈帧 (stack frame) | struct context(proc 里的上下文区域) |
| 原因 | 栈持续有效,返回后可恢复 | 当前栈即将废弃,必须保存在独立于栈的、稳定的内存位置 |
| 保存内容 | callee-saved 寄存器(s0-s11 等) | callee-saved 寄存器 + 栈指针 sp |
| 调用者责任 | 自行保存 caller-saved 寄存器(t/a 寄存器) | 编译器自动在调用 swtch 前后插入保存/恢复 caller-saved 代码 |
6. 总结
那句话的真正含义是:
- “swtch 只保存 callee-saved 寄存器” :作为 callee,它履行了协定,但保存的位置是进程上下文,而不是栈。
- “编译器在 caller 保存 caller-saved 寄存器”:编译器会在调用 swtch 前后自动处理 caller-saved 寄存器,避免它们丢失。
通过这种分工合作,既保证了切换上下文的正确性,又避免了保存所有寄存器的低效做法。