调用约定
什么是调用约定?
根据调用约定,当我们调用一个函数时,该函数承诺保持部分(而非全部)寄存器的值不变。
(提醒:调用者(caller)是发起调用的函数,被调用者(callee)是被调用的函数。所有函数都可以是调用者和被调用者;作为程序员,我们永远不应假设一个函数不是某种角色,因为我们不知道函数可能从哪里被调用。调用其他函数的函数始终是调用者,即使它们默认也是被调用者。)
函数承诺保持不变的寄存器是被调用者保存寄存器(保留寄存器)。s0到s11(保存寄存器)和sp是保留寄存器。
函数不承诺保持不变的寄存器是调用者保存寄存器(非保留寄存器)。a0到a7(参数寄存器)、t0到t6(临时寄存器)和ra(返回地址)是非保留寄存器。
调用者视角
当我们调用一个函数时,从调用者的角度来看,该函数承诺不修改任何保留寄存器。这意味着当函数返回时,我们可以确定保留寄存器没有改变。然而,这并不保证被调用的函数在函数调用过程中不会修改保留寄存器的值;它只保证从调用者的角度来看,保留寄存器的值看起来没有改变。
addi s0, x0, 5 # s0包含5
jal ra, func # 调用函数
addi s0, s0, 0 # s0仍然包含5!
然而,该函数可以自由修改任何非保留寄存器。这意味着一旦您调用一个函数且函数返回,每个非保留寄存器现在都包含垃圾值。您不再知道非保留寄存器中的值是什么。
注意:垃圾值指的是未知值;即使非保留寄存器中的值在函数调用后没有改变。这是因为我们的保证并没有说非保留寄存器不会被修改,所以即使它们没有被改变,我们也必须假设它们已被修改。
addi t0, x0, 5 # t0包含5
jal ra, func # 调用函数
addi t0, t0, 0 # t0包含垃圾值!
这是一个常见的调用约定错误:当函数返回时,您不能依赖任何非保留寄存器中的值。
避免此错误的一种方法是在调用函数之前将非保留寄存器中的值保存到栈上,然后在函数返回后恢复这些值。例如:
addi t0, x0, 5 # t0包含5
addi a0, t0, 10 # a0(func的参数)包含15
# 序言
addi sp, sp, -8 # 递减栈指针
sw t0, 0(sp) # 将t0的值存入栈
sw a0, 4(sp) # 将a0的值存入栈
# 函数调用
jal ra, func # 调用函数
mv s0, a0 # 保存返回值1,在恢复a0的旧值之前,以避免覆盖返回值
mv s1, a1 # 保存返回值2,在继续之前,以防将来使用a1,避免可能覆盖返回值
# 尾声
lw t0, 0(sp) # 从栈中恢复t0的值
lw a0, 4(sp) # 从栈中恢复a0的值
addi sp, sp, 8 # 递增栈指针
addi t0, t0, 0 # t0在这里包含5,因为您在函数调用之前保存了它!
xori t1, a0, t0 # 在这里安全地使用t0^a0
这就是为什么非保留寄存器通常被称为调用者保存寄存器,因为调用者在调用函数时必须保存它们。
被调用者视角
当我们编写一个函数时,我们可以自由修改任何非保留寄存器。然而,从调用者的角度来看,我们必须承诺不修改任何保留寄存器。
这是另一个常见的调用约定错误:函数不能明显地改变任何保留寄存器。换句话说,被调用者可以随意修改,只要确保在返回调用者之前恢复保留寄存器的值,不用担心非保留寄存器。
如果我们想在函数执行期间使用某个保留寄存器,我们需要在函数开始时将寄存器的值保存到栈上,然后在函数结束时恢复这些值。例如:
# 序言
addi sp, sp, -12 # 递减栈指针
sw ra, 0(sp) # 将ra的值存入栈
sw s0, 4(sp) # 将s0的值存入栈
sw s1, 8(sp) # 将s1的值存入栈
# 在函数中执行操作
# 尾声
lw ra, 0(sp) # 从栈中恢复ra的值
lw s0, 4(sp) # 从栈中恢复s0的值
lw s1, 8(sp) # 从栈中恢复s1的值
addi sp, sp, 12 # 递增栈指针
# 完成收尾工作
ret # 从函数返回
请注意,我们也把ra的值保存在栈上。请记住,ra寄存器存储的是此函数执行完毕后要跳转回去的地址。如果我们决定在此函数内部调用另一个函数,则必须将ra的值保存在栈上。如果我们在"do stuff"注释处进行函数调用,那么该函数调用将覆盖ra,我们将丢失应该跳转回去的地址。
编码建议
在遵循调用约定时,宁可安全也不要后悔!多保存一个不需要保存的寄存器不会有什么坏处,但忘记保存应该保存的寄存器就很糟糕了。话虽如此,也不要保存所有寄存器,因为这会占用不必要的栈空间,也会使代码变得混乱,因为最终不清楚哪些寄存器正在被使用,哪些只是闲置的。
- 始终在函数开始时保存
ra的值,在函数结束时恢复它。虽然看起来它是在被调用者序言中保存、在被调用者尾声中恢复的,像是调用者保存寄存器,但实际上它是预先保存ra,以防任何时候调用函数,并保证我们要返回的返回地址在任何其他操作之前被保留。 - 在函数开始时保存所有保留寄存器的值,在函数结束时在序言和尾声中恢复它们。如果您选择不保存某个保存寄存器中的值,请绝对确定您没有在函数中使用该寄存器。
- 保存在调用函数后依赖的非保留寄存器的值;换句话说,只有在函数调用之间依赖的非保留寄存器值才需要保存。否则,可以丢弃。为此,在调用函数之前保存它们,在调用函数之后恢复它们。您可以通过将临时值移动到空闲的保存寄存器(如果有的话)或保存到栈上来实现。如果您选择不保存某个非保留寄存器中的值,请绝对确定在函数返回后不依赖该寄存器中的值。
调试建议
调用约定可能很难调试!如果您不遵循调用约定,代码行为是未定义的;有时能正常工作,有时则不行。
要检查您是否遵循了保留寄存器的调用约定:
- 识别您在函数中使用的每个保留寄存器。对于您修改的每个保留寄存器,确保其值在函数开始时被保存,在函数结束时被恢复。
- 检查您的序言和尾声是否有拼写错误。寄存器或数字很容易打错!
要检查您是否遵循了非保留寄存器的调用约定:
- 找到代码中所有的函数调用。对于每个函数调用,识别在该函数返回后您使用的每个非保留寄存器,并确保其值在调用前被保存,在调用后被恢复。
随机化非保留寄存器
请记住,在调用函数之后,您必须假设非保留寄存器包含垃圾值。(这是因为函数可能已经修改了任何非保留寄存器。)
有时您的代码违反了调用约定,因为在调用函数后使用了非保留寄存器中的垃圾值。然而,您的代码可能碰巧能正常工作,因为垃圾值恰好是正确的。
全面检查代码是否遵循调用约定的一种方法是在非保留寄存器中放入完全随机的垃圾值,然后检查代码是否仍然能正常工作(不依赖那些垃圾值)。
在utils.s中,我们提供了辅助函数randomizeCallerSavedRegs和randomizeCallerSavedRegsBesidesA0,它们会为所有非保留寄存器放入随机垃圾值。
一个使用示例是在函数返回后向所有非保留寄存器中放入垃圾值:
addi t0, x0, 5 # t0包含5
jal ra, func # 调用函数
jal ra, randomizeCallerSavedRegs # 在非保留寄存器中放入垃圾值
# t0此时不再等于5,即使'func'没有修改它。
# 之后的所有代码应该仍然正常工作!
如果函数在a0中存储了返回值,而您不想在a0中放入垃圾值,可以改用randomizeCallerSavedRegsBesidesA0。
另一个使用示例是在调用函数并设置其参数之前,向所有非保留寄存器中放入垃圾值:
addi t0, x0, 5 # t0包含5
jal ra, randomizeCallerSavedRegs # 在非保留寄存器中放入垃圾值
# t0此时不再等于5。'func'不应依赖t0等于5。
# 如果需要,在此处使用a*寄存器设置函数参数
jal ra, func # 此函数调用应该仍然正常工作!
函数不应依赖非保留寄存器中的任何值,除了与函数本身相关的参数寄存器。因此,即使非保留寄存器中有垃圾值,您的代码也应该能正常工作。
随机化保留寄存器
我们也可以在寄存器中放入随机值来检查保留寄存器是否保持不变。在函数开始时,您可以在保留寄存器中放入一组已知的常量值。在函数结束时,您可以检查这些值是否因您的函数而改变。
在utils.s中,我们提供了辅助函数:randomizeCalleeSavedRegs在保留寄存器中放入随机值。checkCalleeSavedRegs检查随机值是否仍在保留寄存器中,如果任何保存寄存器被修改,则返回错误代码100。
在函数开始和结束时使用此功能的示例:
jal ra, randomizeCalleeSavedRegs # 在保存寄存器中放入值
# 您的函数调用
jal ra, checkCalleeSavedRegs # 检查保存寄存器的值没有改变