void f ( int i, int j /* in $a1 */ ) {
// save $a1 to stack allocated memory here in prologue.
g(i); // this function call kills scratch registers, e.g. $a0 and $a1
h(j); // load $a0 with from the stack memory where j is
}
这里我们要注意,i在调用中不是实时的-它是在输入时定义的(它是一个参数),并且在进行第一次调用时在此函数的主体中使用,但之后不会使用-因此它在任何调用中都不是活动的。它在$a0中出现,并且需要在g调用时出现,所以对于i没有任何操作。 类似地,j是在输入时定义的,但是相比之下,j不仅在整个调用中是活动的(到g,它不参与),但它在错误的位置供下次使用-因此应将其保存到堆栈内存位置(通常这会在序言中完成,但在g工作之前的任何时间,因为这是它被清除的地方)。由于需要调用h(在g调用之后),它将被发送到需要它的地方,这里是$a0(即,从堆栈存储器直接加载到$a0)。 $s寄存器还将用于确保j的值在g函数调用之后仍然存在;然而,在这种情况下,就指令而言,效率会稍低:用于在序言中分配寄存器(比如$s0)和保存在那里的寄存器的空间;将$a1复制到$s0中,然后调用g,并通过将$s0复制到$a0来设置对h的调用-比直接保存到内存的方法多两条指令。但是,在其他情况下,$s寄存器的使用占优势。 我知道当我们调用函数时,我们会保存返回地址$ra。 有:返回地址是函数的一个参数,告诉我们返回到哪里,函数体中的函数调用必须重新使用$ra,以便被调用的函数返回给我们,但是我们仍然需要返回给调用者,所以涉及到两个$ra值,我们的值进入内存,以便我们可以重新使用$ra,以便我们将要进行的调用,即jal function将把新值设置为$ra。 我还知道,当参数在代码中稍后要更改时,例如在执行factorial int factorial(n-1)时,我们会保存参数。 我们保存了函数稍后需要的参数,像$ra一样,我们在创建一个新函数调用时清除了一些参数寄存器,但是即使我们不清除所有暂存寄存器,新调用的函数也可以使用它们,所以我们必须假设它们在返回时已经被清除了。
1条答案
按热度按时间83qze16e1#
关于这一点有很多文字,但也许再多一个解释会有所帮助。
你要明白
所有调用保留寄存器(也称为(稍差一些)术语被调用方保存,必须遵守将其原始值返回给调用方的规则,句号(句号)。如果你正在写一个函数,并且它没有使用一些调用保留寄存器,那么就没有什么可做的,我们只是假设所有的都遵循规则,因此,如果您的函数不使用它们,则不需要保存和恢复它们。
然而,如果你想在你写的函数中使用一个,你必须在序言中保存并在尾声中恢复这个集合中任何你改变了值的寄存器(你不必担心你调用的任何其他函数以及它们使用了什么调用保留寄存器--只需要保留这个函数实际修改的寄存器)。
调用保留寄存器的优点是寄存器中保存的值在函数调用后仍然存在,但这一优点也带来了一些开销,即在开始时保存这些寄存器,在结束时恢复它们--这里没有神奇的硬件来处理这些寄存器,这些规则是如何通过调用来保留这些寄存器的。
当你写的函数有大量使用的变量时,使用这些寄存器是一个性能优势,例如,多次使用,并且在不同的使用之间有函数调用。一个很好的例子是,一个函数有一个循环,在循环中有一个函数调用。对于循环的活变量,调用保留寄存器是有效的存储。一个循环确实可以说,
printf
将至少需要循环控制变量以使对printf
的调用继续存在。调用保留寄存器的使用模式为:假设对于每个调用保留寄存器,一些调用者在调用链的更上游(因此,这发生在函数调用之前),在寄存器中保存了一个值,因此,编写新函数的工作是向调用者保持这些寄存器的值不会改变的假象,通过保存新函数启动时的内容并在返回时恢复(假设您希望使用这些寄存器中的一个)-保存/恢复您不知道的值,以便某些调用者使用(谁调用了您的新函数)您不知道。
在MIPS上,调用保留寄存器为
$s0
-$s7
。我还要补充一点,堆栈指针本身必须在返回时恢复到它的原始值,但这通常是通过仅仅释放任何和所有分配的堆栈空间来完成的,而不是涉及内存存储的保存和恢复。
其它寄存器的使用模式不同:它们的保存和恢复是为了你自己的利益--为了你正在编写的新函数的利益,而不是为了任何调用者的利益。
这些寄存器被称为暂存寄存器,也被称为call-clobbed,也被称为caller-saves,这些寄存器应该被假定为被函数调用所清除(除非你实际上知道被调用函数的内部实现细节,并且还想偏离调用约定)。
因此,这些寄存器中保存的任何值,如果在调用后需要,必须保存在某个地方,以便它们在调用后继续存在,但同样,只有在您编写的函数内需要时,以及在使用标准调用约定的某个函数调用后才需要。
调用方保存模型的名字暗示我们在调用前清空这些寄存器,并在调用后恢复它们(如果我们知道调用后可能需要它们),然而,在实际使用中,它们很少完全以这种方式使用-相反,编译器或汇编程序员将这些值移到其他地方,只在函数写入时才恢复它们,也不一定回到同一寄存器。
因为这里要考虑的是保存和恢复你自己的新函数使用的值,我们需要引入活变量分析的概念,这是一种你可以对你的函数变量执行的局部分析,这种分析是由编译器自动完成的。
为了调用者的利益,scratch(call-clobbed)寄存器在结尾处永远不会恢复--这是没有意义的;标准的调用约定调用者不会期望它们返回其原始值。
这包括变元寄存器
$a0
-$a3
以及临时寄存器$t0
-$t9
。还包括返回地址寄存器
$ra
,它是函数的一个参数(无论谁调用了新函数都要返回的地址),它在C和其他语言中是隐藏的。按照约定,我们将返回地址恢复到$ra
寄存器,但这不是为了调用者的利益,相反,我们可以自己使用它(通过jr $ra
)返回到特定的调用者(事实证明,在使用影子返回堆栈时,硬件可以从这些模式中获益)。一种情况是,根据调用约定,形参在寄存器中传递,而这些寄存器位于call-clobbed aka scratch set中,如果活变量分析显示形参变量在函数调用中不是活的,那么您就成功了,只需将其留在该寄存器中以便从那里使用即可。
但是,如果对该变量的分析表明它在某个函数调用中是活动的,则必须采取措施以某种方式保留变量的值,因为它位于一个被调用破坏的变量中。
(Note这适用于跨某个调用而存活的可能性,即使该调用被有条件地执行/有条件地跳过)。
除了形式参数变量之外,类似的分析也必须针对局部变量--以及C代码中看不到的临时变量。
然后,对于通过分析发现在呼叫中是活的这样的变量,有两个基本选择要做:将其放置在堆栈本地存储器中或放置在调用保留寄存器中,因为这两个存储位置都能在函数调用后继续存在。
例如,假设您有第二个参数,它在
$a1
中传递,需要它来生存函数调用,但在函数调用后,需要它在$a0
中-只需将它从堆栈本地内存位置直接加载到$a0
中。这里我们要注意,
i
在调用中不是实时的-它是在输入时定义的(它是一个参数),并且在进行第一次调用时在此函数的主体中使用,但之后不会使用-因此它在任何调用中都不是活动的。它在$a0
中出现,并且需要在g
调用时出现,所以对于i
没有任何操作。类似地,
j
是在输入时定义的,但是相比之下,j
不仅在整个调用中是活动的(到g
,它不参与),但它在错误的位置供下次使用-因此应将其保存到堆栈内存位置(通常这会在序言中完成,但在g
工作之前的任何时间,因为这是它被清除的地方)。由于需要调用h
(在g
调用之后),它将被发送到需要它的地方,这里是$a0
(即,从堆栈存储器直接加载到$a0
)。$s
寄存器还将用于确保j
的值在g
函数调用之后仍然存在;然而,在这种情况下,就指令而言,效率会稍低:用于在序言中分配寄存器(比如$s0
)和保存在那里的寄存器的空间;将$a1
复制到$s0
中,然后调用g
,并通过将$s0
复制到$a0
来设置对h
的调用-比直接保存到内存的方法多两条指令。但是,在其他情况下,$s
寄存器的使用占优势。我知道当我们调用函数时,我们会保存返回地址$ra。
有:返回地址是函数的一个参数,告诉我们返回到哪里,函数体中的函数调用必须重新使用
$ra
,以便被调用的函数返回给我们,但是我们仍然需要返回给调用者,所以涉及到两个$ra
值,我们的值进入内存,以便我们可以重新使用$ra
,以便我们将要进行的调用,即jal function
将把新值设置为$ra
。我还知道,当参数在代码中稍后要更改时,例如在执行factorial int factorial(n-1)时,我们会保存参数。
我们保存了函数稍后需要的参数,像
$ra
一样,我们在创建一个新函数调用时清除了一些参数寄存器,但是即使我们不清除所有暂存寄存器,新调用的函数也可以使用它们,所以我们必须假设它们在返回时已经被清除了。在将代码从C翻译成MIPS时,我一直在努力理解我们何时将a0-a3和s 0-s7这样的寄存器存储在$sp中。
我们保存
$a0
-$a3
是为了函数自身的利益,当形参变量在我们自己的函数中的调用时,这些变量在调用之前定义,但是在调用之后我们自己的函数体需要。在尾声中没有必要恢复这些寄存器(除非调用者和被调用者选择使用非标准的调用约定)。我们保存和恢复
$s0
-$s7
是为了调用者的利益,这样我们就可以在我们自己的函数中使用这些寄存器,如果我们选择这样做是为了在我们的函数体中使用它们更简单的使用模型,代价是在序言和尾声中进行额外的处理。我们还要补充一点,只要系统使用动态调用堆栈,那么递归就不会比普通函数调用增加任何额外的要求--普通函数调用的所有要求和规则都已经存在,所以递归不会增加任何新规则。(不过,这可能会增加使用非标准调用约定的机会,就像您知道调用者和被调用者代码的内部细节一样,因为对于(直接)递归,调用者和被调用者是同一个。