为什么 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 架构)里,函数调用遵循一个“君子协定”,把寄存器分成两类:

  1. 调用者保存寄存器 (Caller-Saved Registers)
  2. 被调用者保存寄存器 (Callee-Saved Registers)

  1. 被调用者保存寄存器 (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)来履行这个义务。


  1. 调用者保存寄存器 (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-savedcallee-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 寄存器:

  1. 进入函数:栈指针 sp 指向调用者的栈。
  2. 创建栈帧:向下移动 sp,预留空间。
  3. 保存寄存器:若需要用 s0,执行 sd s0, offset(sp) 保存旧值到栈。
  4. 执行计算。
  5. 恢复寄存器:返回前 ld s0, offset(sp) 恢复。
  6. 销毁栈帧,返回。

关键点:普通函数始终在同一个栈上操作,保存到栈里是安全的。


3. swtch 的特殊性

swtch 不是普通函数,它的任务是切换进程上下文

执行过程中,它会丢弃当前的栈,切换到另一个进程的栈。

如果它像普通函数一样把 s 寄存器保存到栈上,会出问题:

  1. swtch 开始时,sp 指向旧进程的内核栈。
  2. 如果此时执行 sd s0, offset(sp),保存的值就在旧栈里。
  3. 但很快 swtch 会执行 ld sp, 8(a1),切换到新进程的栈。
  4. 此时旧栈“失联”,之前保存的 s0 值无法再访问。

因此,swtch 不能把寄存器保存到栈上


4. swtch 的正确做法

swtch 依然遵守“callee 要保存 s 寄存器”的规则,但它的保存位置是 struct context,而不是栈。

  • 参数 a0 传入 old,表示旧进程的 struct context 地址。
  • 参数 a1 传入 new,表示新进程的 struct context 地址。

执行逻辑:

  1. 把旧进程的 s0-s11sp 保存到 old->s*old->sp
  2. 再把新进程的 s0-s11spnew->s*new->sp 取出来。
  3. 完成寄存器和栈切换,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 寄存器,避免它们丢失。

通过这种分工合作,既保证了切换上下文的正确性,又避免了保存所有寄存器的低效做法。