为什么被呼叫者不首先使用呼叫者保存的寄存器?

时间:2020-07-30 01:51:40

标签: c assembly x86-64 compiler-optimization calling-convention

我们知道,根据x86-64约定,寄存器%rbx%rbp%r12%r15被归类为被保存者寄存器。 %r10%r111是保存呼叫者的寄存器。 但是在大多数情况下,当我使用C代码时,例如函数P调用Q,我看到以下函数Q的汇编代码:

Q:
   push %rbx
   movq %rdx, %rbx
   ...
   popq %rbx
   ret

我们知道,由于%rbx是已保存被调用方的寄存器,因此我们必须将其存储在堆栈中,并在以后将其恢复给调用方P

但不是更简洁,使用调用者保存的寄存器%r10来保存堆栈操作为:

Q:
   movq %rdx, %r10
   ...
   ret

那么被调用者不必担心保存和恢复调用者的寄存器,因为调用者在调用被调用者之前已经将其推入堆栈了?

1 个答案:

答案 0 :(得分:4)

您似乎对“保存呼叫者”的含义感到困惑。我认为这种错误的术语选择使您误以为编译器实际上将它们保存在函数调用周围的调用方中。通常,这会比较慢(Why do compilers insist on using a callee-saved register here?),尤其是在进行多个调用或循环调用的函数中。

更好的术语是call-clobbered vs. call-preserved,它反映了编译器实际上是如何使用它们的,以及人类应该如何看待它们:在函数调用中死亡的寄存器,或者在函数调用中死亡的寄存器。不要在每个call周围推送/弹出一个呼叫密集(又称保存呼叫者)的寄存器。

但是,如果要在单个函数调用周围推入/弹出一个值,则只需使用%rdx即可。将其复制到R10只会浪费指令。因此mov %r10是无用的。以后再进行推送,只会效率低下,没有错误。


之所以要复制到保留调用的寄存器,是因为函数arg将在以后进行的函数调用中保留下来。显然,您必须为此使用保留呼叫的寄存器;调用过多的寄存器无法在函数调用中生存。

当不需要调用保留寄存器时,是的,编译器会选择调用优先的寄存器。

如果将示例扩展为实际的MCVE而不是仅显示不带源的asm,则应该更清楚。如果您编写一个需要mov来计算表达式的叶子函数,或者编写了在第一次函数调用后不需要其任何args的非叶子函数,则不会看到它浪费指令的保存和使用呼叫保留的注册表。例如

int foo(int a) {
    return (a>>2) + (a>>3) + (a>>4);
}

https://godbolt.org/z/ceM4dP,带有GCC和叮当声-O3:

# gcc10.2
foo(int):
        mov     eax, edi
        mov     edx, edi      # using EDX, a call-clobbered register
        sar     edi, 4
        sar     eax, 2
        sar     edx, 3
        add     eax, edx
        add     eax, edi
        ret

使用LEA不能进行右移来复制和操作,并且以三种不同的方式移动相同的输入会说服GCC使用mov复制输入。 (而不是进行一系列的右移操作:编译器喜欢以增加更多的指令为代价来最大程度地减少延迟,因为这通常是最适合OoO执行人员的。)