为什么IA-32有一个不直观的调用者和被调用者寄存器保存惯例?

6
IA-32的常见调用规约如下所示:
• Callee-save registers
%ebx, %esi, %edi, %ebp, %esp
Callee must not change these.  (Or restore the caller's values before returning.)

• Caller-save registers
%eax, %edx, %ecx, condition flags
Caller saves these if it wants to preserve them.  Callee can freely clobber.

为什么存在这种奇怪的惯例?为什么不在调用另一个函数之前保存所有寄存器?或者让被调用者使用 pusha/popa 来保存和恢复所有内容呢?


2
哪一部分是“非直观的”?是关于哪些寄存器应该被调用者保存还是调用者保存的具体选择,还是有区分的调用者保存和被调用者保存寄存器的事实? - Gian
4个回答

10
为什么要在可能不需要的每个函数中编写保存寄存器的代码?这将在每个函数调用中增加额外的代码和内存写入。现在可能看起来不重要,但在80年代创建这个约定时可能确实很重要。
而且请注意,ia-32没有固定的调用约定 - 你列出的只是一个外部约定 - ia-32不强制执行它。如果你正在编写自己的代码,可以随意使用寄存器。
另请参阅Old New Thing博客上的Calling Conventions的历史讨论。
在决定哪些寄存器应该由调用约定保留时,你需要权衡调用者和被调用者的需求。调用者希望保留所有寄存器,因为这样就不需要担心跨调用保存/恢复值的问题。被调用者希望不保留任何寄存器,因为这样就不需要在进入和退出时保存和恢复值。
如果要求保留的寄存器太少,则调用者会充满寄存器保存/恢复代码。但如果要求保留的寄存器太多,则被调用者必须保存和恢复调用者可能并不真正关心的寄存器。这对于叶子函数(不调用任何其他函数的函数)尤其重要。

我很想知道这个答案是否同样适用于具有大量尾调用的函数式代码。我的想法是,caller-preserve-all 并且在尾调用的情况下无需保留任何内容。 - Rich Remer
@RichRemer:函数式代码是否往往没有太多的非尾调用?这些仍然受益于一些保留调用寄存器。而且它不会破坏尾调用优化,而是跳转而不是调用/返回:只需在跳转到尾调用函数之前恢复被调用者保存的寄存器即可。进入您跳转到的函数时,被调用者保存的寄存器将具有它们在最终返回时应该具有的值。如果大多数调用都是尾调用,那么在每个函数中保存/恢复寄存器基本上是浪费的工作,您是正确的。 - Peter Cordes

5

一个猜测:

如果调用方在函数调用后仍然需要保存所有寄存器,当被调用的函数不修改这些寄存器时,它会浪费时间。

如果被调用方保存所有它使用的寄存器,当调用方不再需要这些寄存器中的值时,它也会浪费时间。

如果一些寄存器由调用方保存,而另一些寄存器由被调用方保存,编译器(或汇编程序员)可以根据下一个函数调用后是否需要该值来选择使用哪种方式。


1
这是正确的。请注意,有时候调用者只需要恢复,因为寄存器中的值已经在内存中了,他们只是将其缓存在寄存器中。也许再多一个被调用破坏的寄存器会导致更好的代码,至少对于那些内联非常小的现代编译器来说是这样。(不幸的是,ABI是在现代编译器之前设计的。)另一方面,许多库函数很小,但不会被内联。保存/恢复代码在每个调用点都是必需的,并且许多函数有多个调用者,因此在被调用者中只保存/恢复代码一次是一个胜利。 - Peter Cordes

5
如果你深入了解所使用的寄存器,就会明白为什么不会被调用者保留:
  • EAX: 用于函数返回值,因此显然无法保留。
  • EDX:EAX: 用于64位函数返回值,与EAX相同。
  • ECX: 这是计数寄存器,在x86早期时代,当LOOPcc很酷时,这个寄存器会被疯狂地破坏,即使今天,仍有不少指令使用ECX作为计数器(如带前缀的REP指令)。但是,由于引入了__thiscall__fastcall,它被用于传递参数,这意味着它很可能会改变,因此几乎没有必要保留它。
  • ESP: 这是一个小例外,因为它实际上并没有被保留,而是根据栈的变化而改变。虽然可以通过堆栈帧来保留它,以防止堆栈指针损坏/安全问题或不平衡。
现在,它变得更加直观了 :)


为什么被调用者必须保留%ebx、%esi和%edi寄存器?有没有直观的解释? - Bruce
@Bruce:这样它可以被任何东西调用,即:调用者不需要了解被调用者的内部情况,因为调用者的数据将被保留(ESIEDI是数据源/目标寄存器,因此这符合将调用者数据与被调用者分离的原则)。 - Necrolis
1
由于这些是您的基本和偏移寄存器,它们用于在字符串等上进行循环。通常,这些被调用者保留,以便可以在循环内部进行调用,而无需每次都推送/弹出它们。 - rich remer

-1
简而言之,调用者保存是因为参数传递。其他所有内容都是被调用者保存。

这是不正确的。即使像32位Linux使用的SysV约定这样的纯堆栈参数调用约定,也将eax、ecx和edx视为调用破坏。edx:eax用于返回64位整数,但ecx是一个临时寄存器,因为拥有足够数量的寄存器很有用。拥有一个或两个更多的调用破坏寄存器可以自由地用作临时寄存器,可能会导致更好的代码,至少对于内联非常小的函数的现代编译器来说是这样的。(不幸的是,ABI是在现代编译器之前设计的。) - Peter Cordes
实际上,即使使用内联,许多代码仍然在循环内从函数调用具有大量“活动”状态的情况下进行函数调用,因此在32位x86中只使用三个临时寄存器可以减少调用者中的溢出/重载。 pushpop是便宜而小的(每个字节一次)。但这个答案仍然是错误的 >.< - Peter Cordes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接