0%

计算机组成-寄存器保存

一、糟糕的名字

函数被调用的过程中会使用寄存器,这就可能给函数的调用者造成困扰,原本在调用前的寄存器可能会被调用的函数污染。如果希望寄存器不被污染,可以将寄存器保存在栈上,在调用结束后再将栈上的值重新拷贝回对应的寄存器。

我们将寄存器分为两组,其中一组由调用者保存,被称为 caller-saved ,这是说调用者可以按照自己的需要来保存这些寄存器,如果自己使用了,那么就保存,如果没有使用,就不需要保存。另一组由被调用者保存,被称为 callee-saved ,被调用者在执行正式的功能前前需要将这些寄存器压栈,在返回前需要将这些寄存器弹栈。

不过这种标准的定义其实并不容易让人理解其中的原因。我们可以换一个角度去看这件事。当一个函数调用发生时,只有 caller-saved 的寄存器有可能被污染(在 caller 并不保存的情况下),这是因为所有的 callee-saved 寄存器都会本着“谁污染,谁治理”的策略被保护起来。所以 caller-saved register 其实核心特性是 unsafe ,也就是“调用前后并能保证寄存器中的值不发生变化”,所以这种寄存器也被称为临时寄存器。而 callee-saved register 则正好相反,是 safe 的,调用者不用担心调用一个函数后,自己的寄存器值被改写。

所以我建议使用更好的名字:

  • call-preserved: 不严格对应 callee-saved ,也就是在 call 后寄存器的值不会改变。
  • call-clobbered: 不严格对应 caller-saved ,也就是在 call 后寄存器的值可能会发生改变。

二、解耦的代价

有没有可能所有寄存器都是 caller-saved 或者都是 callee-saved ?如果所有寄存器都是 caller-saved 的,那么 caller 就要保存自己所有正在使用的寄存器,这种工作是存在冗余的,因为可能有些寄存器 caller 使用了,而 callee 没有使用,如果 caller 一股脑保存,那么就降低了效率。如果所有寄存器都是 callee-saved ,那么 callee 就要保存自己所有使用过的寄存器,这种工作同样存在一定的冗余性,如果 callee 使用了,而 caller 没有使用,那么同样降低了效率。

如果在全局视角下分析,在调用过程中,我们真正需要保存的寄存器,具有两个特性:

  1. 被 caller 使用且生命周期必须跨越调用过程。
  2. 被 callee 使用。

那无论是 callee 还是 caller 都没有办法很好达到这个理想状态,因为作为 caller 虽然可以正确的分析出具有第 1 点特性的寄存器,但是对于第 2 点无能为力。同样的 callee 可以清楚地知道第 2 点,但是对于第 1 点一无所知。这种困境是由于“函数”这个概念本就是为了代码段间的解耦和隔离,如果想要一个个解耦的函数,就必须承担无法全局优化的代价。

如果这样看,那么将寄存器分成差不多大小的两组的行为是有一定道理的,我们虽然不能做到全局优化,但是可以做到基于 caller 和 callee 各自一定的权力,使得双方的关于寄存器使用的“洞见”都可以得到利用。总好过只利用一方的信息。

从使用上来说,如果寄存器中是一个生命周期很长的变量,那么应当使用 callee-saved register ,它可以保证在其生命周期的多次函数调用中,值始终不发生变化;而如果寄存器中是一个用完就扔的临时变量,那么应当使用 caller-saved-register ,使用它并不需要保存,心理负担小。

二、特殊寄存器

没有承载特殊意义的通用寄存器被选为 callee-saved 还是 caller-saved 其实没啥要考虑的,不过有特殊意义的寄存器往往只能是 callee-saved 或者 caller-saved 中的一种。

参数寄存器只能是 caller-saved 。为了传递参数,caller 必须将参数寄存器中原本的值覆盖成传给 callee 的参数,如果参数寄存器内之前有值,那么就需要 caller 进行保存。这个工作没法由 callee 进行,因为提交给 callee 的参数寄存器已经被污染了。虽然参数寄存器不是 callee 污染的,但是却是在 call 的过程中污染的。

返回值寄存器只能是 caller-saved 。如果让 callee 保存 caller 的返回值,那么 callee 的自己的返回值该怎么传递给 caller 呢?

返回地址寄存器只能是 caller-saved 。这是因为 callee 需要使用自己 ra 寄存器,如果 callee 在进入函数时保存了 caller 的 ra ,而退出函数前恢复了 caller 的 ra 那么 return 就会根据 ra 跳转到错误的地方(调到了 caller 被调用的地方了)。

栈指针寄存器是 callee-saved 。但是其实怎么都说的过去,前面三种是因为设计而不得已,栈指针更像是一种宽松的 call-preserved ,也就是必须在调用前后值不发生变化,至于是 callee-saved 还是 caller-saved 不过是实现需求的手段。