ra
and sp
don't fit nicely into the classification of caller/callee (aka scratch/preserved). These are both parameters passed to the function, with special purpose, and, note that these values/concepts are not directly visible in C programming.
The ra
is a parameter — the place to return to — passed from the caller to the callee. The sp
is a parameter — the place below which stack memory can be allocated — passed from the caller to the callee.
ra
is restored in epilog, not for the caller's sake, but because it is immediately needed by the jr ra
instruction. The code would work perfectly fine1 if the saved ra
value was restored to t0
instead (still from 0(sp)
), the only adjustment needed would be that jr
then would do jr t0
).
The sp
also must be restored, but this one is for the caller, so that the caller's stack frame memory can be found where it was when it made the call — where the sp
points upon return should be the same as where it was upon initial call.
Should we actually treat them differently according to the calling convention?
Treat them as they need to be treated by the definition of what they do, not strictly as either caller or callee saves.
Some texts will put ra
into a caller saves classification, and others into a callee saves classification, but these are both over simplification as the real answer is that while ra
exhibits qualities of both classifications, neither/special is more accurate.
In prologue/epilog, ra
visually appears as callee saves, since it is saved and restored with other callee saves registers, but this is a red herring as the value being preserved is a parameter the callee will need to return to the caller, whereas the other callee saved register's initial values are meaningless to the callee and only saved so they can be restored to original. Once the return address value is saved though, ra
becomes a scratch register, meaning it can be used as a caller saves register, if an extra register is needed (it cannot be used as a callee saves register since it is, by definition, repurposed to make a call).
ra
is passed in order that the function can return to whoever called it. If the function invokes another function, the ra
register will necessarily be repurposed so saving its original value in prolog and restoring in epilog make sense as that is as efficient as we can get. The caller does not rely on ra
upon return.
(Saving ra
in prologue and restoring upon use effectively treats the return address as a memory variable that is initialized from the incoming ra
value. We have three ways to preserve variables that are live across a call (defined before and used after): callee saves, caller saves, and memory variables. We could treat ra
like a caller saves register saving and restoring around a call, but that would have only equal efficiency when in a function that has only one call not in a loop — otherwise the caller saves approach would be less efficient for non-leaf functions. This analysis is similar with usages of other caller saves registers.)
sp
is passed to communicate the boundary between free stack and in-use stack, and, if changed, this needs to be restored upon return. (In some sense, sp
is similar to an in & out parameter — though with specific purpose related restrictions: it is passed in for the callee to use for stack allocation, and passed back to the caller, restored to original.)
Parameters registers that are not used to pass parameters to the callee are caller saves (scratch) registers. However, those that are passed are inherently different than simple caller saves registers, since they are initialized already.
ra
is different than other passed arguments, in that this parameter is only used for only one purpose, namely by a final jr ra
, well past the body of the function. Because of this we can know without seeing the body of function the best way to preserve that value. Whereas the other passed arguments are used fully within the body of the function, in function-specific ways, so their best save/restore is custom.
1 modulo hardware return address prediction