System V ABI - AMD64 - GCC生成的汇编中的堆栈对齐

4
以下是来自Compiler Explorer上GCC x86-64 10.2的C代码产生的汇编代码:
其中一个指令是subq $40, %rsp。问题是,从%rsp减去40个字节为什么不会使堆栈错位?
我的理解是:
- 在call foo之前,堆栈的大小为16字节对齐; - call foo在堆栈中放置了一个8字节的返回地址,因此堆栈的大小就不再是16字节对齐了; - 但是,在foo的开头执行pushq %rbp可以再次在堆栈上添加另外8个字节,以使其重新16字节对齐; - 因此,在subq $40, %rsp之前,堆栈已经恢复到16字节对齐状态;因此,将%rsp减少40个字节必须会破坏对齐性吧?
显然,GCC生成的汇编代码保持了堆栈对齐的有效性,所以我一定漏掉了什么。
(我尝试用CLANG替换GCC,CLANG生成的代码是subq $48, %rsp——正如我直觉所期望的那样。)
那么,在GCC生成的汇编代码中我漏掉了什么?它是如何保持堆栈16字节对齐的呢?
int bar(int i) { return i; }
int foo(int p0, int p1, int p2, int p3, int p4, int p5, int p6) {
    int sum = p0 + p1 + p2 + p3 + p4 + p5 + p6;
    return bar(sum);
}
int main() {
    return foo(0, 1, 2, 3, 4, 5, 6);
}

bar:
        pushq   %rbp
        movq    %rsp, %rbp
        movl    %edi, -4(%rbp)
        movl    -4(%rbp), %eax
        popq    %rbp
        ret
foo:
        pushq   %rbp
        movq    %rsp, %rbp
        subq    $40, %rsp
        movl    %edi, -20(%rbp)
        movl    %esi, -24(%rbp)
        movl    %edx, -28(%rbp)
        movl    %ecx, -32(%rbp)
        movl    %r8d, -36(%rbp)
        movl    %r9d, -40(%rbp)
        movl    -20(%rbp), %edx
        movl    -24(%rbp), %eax
        addl    %eax, %edx
        movl    -28(%rbp), %eax
        addl    %eax, %edx
        movl    -32(%rbp), %eax
        addl    %eax, %edx
        movl    -36(%rbp), %eax
        addl    %eax, %edx
        movl    -40(%rbp), %eax
        addl    %eax, %edx
        movl    16(%rbp), %eax
        addl    %edx, %eax
        movl    %eax, -4(%rbp)
        movl    -4(%rbp), %eax
        movl    %eax, %edi
        call    bar
        leave
        ret
main:
        pushq   %rbp
        movq    %rsp, %rbp
        pushq   $6
        movl    $5, %r9d
        movl    $4, %r8d
        movl    $3, %ecx
        movl    $2, %edx
        movl    $1, %esi
        movl    $0, %edi
        call    foo
        addq    $8, %rsp
        leave
        ret

2
有趣的发现。显然编译器认为bar不需要栈对齐,所以它没有费心。如果你将其改为extern int bar(int i);,那么栈将会被正确对齐。 - Jester
1
另外,如果您更改 bar 以便它需要对齐,例如因为它本身调用另一个函数,编译器也会注意到这一点。 - Jester
1
我对在-O0级别进行的这种优化很好奇。显然,这是GCC中默认的ipa堆栈对齐功能。您可以使用GCC版本>= 9.0中的-fipa-stack-alignment-fno-ipa-stack-alignment打开/关闭它。在GCC中启用/禁用选项的输出比较:https://godbolt.org/z/a1YdjG - Michael Petch
2
这里是否可以从外部(“上面”)调用函数并不是真正相关的。对齐要求保护当前函数下面的函数,由于gcc可以看到foo下面的所有函数都没有对齐要求,因此认为它是不必要的。 - paxdiablo
1个回答

7

16字节对齐的目的是为了确保在调用当前函数之下的任何级别的函数时,如果需要对齐栈本地变量,则不必担心对齐问题。

没有ABI(应用二进制接口)的保证,每个需要此功能的函数都必须将堆栈指针与某个值进行and运算以确保正确对齐,类似于以下操作:

and %rsp, $0xfffffffffffffff0

然而,在这种特殊情况下,没有必要这样做的原因 - bar()函数是叶子函数,意味着编译器在其级别或以下具有对任何对齐要求的完全了解(它没有本地变量,并且不调用任何函数,因此没有要求)。 foo()函数也没有以下要求,因为它调用的仅仅是 bar()。它还似乎决定了它自己的本地变量也不需要那个级别的对齐要求。
即使从立即转换单元外部调用 bar()foo() (因为它们没有标记为 static),这也不会改变他们不需要对齐的事实。
如果,例如,bar 在单独的转换单元中或者它调用其他函数,其中无法确定是否需要对齐,则情况将有所不同。
这意味着 gcc 将无法完全了解其对齐要求。实际上,如果您在godbolt中注释掉 bar 定义行(有效地隐藏定义),您将看到该行发生变化。
// int bar(int i) { return i; }
   --> subq $48, %rsp             ; no longer $40

顺便提一下,虽然在这种情况下16字节对齐不是技术上必需的,但我认为它可能会使gcc使用System V AMD64 ABI无效。该ABI中似乎没有任何允许此偏差的内容,《PDF》中指出(稍作改动,并加粗显示):

输入参数区域的结尾应以16字节边界(如果在堆栈上传递了__m256则为32字节边界)对齐。换句话说,当控制转移到函数入口点时,值%rsp + 8始终是16(或32)的倍数。堆栈指针%rsp始终指向最新分配的堆栈帧的结尾。

在任何方式下,似乎很难解释这种行为与其兼容,即使已知在这种情况下不会引起问题。
是否有人认为这很重要而值得担忧超出了本答案的范围,我不对此发表任何评论 :-)

1
这种过程间优化至少可以追溯到gcc4.1。https://godbolt.org/z/66TEne。`-fno-unit-at-a-time`无法禁用它。(我的Godbolt链接使用`register int参数和-fomit-frame-pointer-O0下获得*简单得多*的汇编,并且在使用-fno-inline-O1下也能正常工作。此外,在bar()内部需要一个16字节对齐的操作来使整个调用链遵循ABI,例如volatile __m128 v = _mm_setzero_ps();`) - Peter Cordes
1
https://godbolt.org/z/Y8ETTa 显示像 char arr[24] 这样的本地数组不会导致调用者对齐。x86-64 SysV ABI 指定全局和本地数组在其大小大于等于16或变量时将被对齐到16。但是这种情况似乎违反了这个规定。当然,ABI 真的应该不要干涉函数内部的细节,而且由于某些原因,数组最终被对齐,所以 asm 语句中的 movaps 没有问题... 我可以在 Arch Linux 上使用 gcc10.1.0 和 -fno-stack-protector 进行本地复现,否则它将使用 sub $8, %rsp 进行对齐。(我可能稍后会写一个答案。) - Peter Cordes
3
好的,是的。最终使用来自https://godbolt.org/z/3MPv7G的代码和选项,在本地使用“-O0”终于成功重现了一个故障,因为GCC选择的“-32(%rsp)”在直接从主函数调用时运行良好,但通过foo函数调用时却无法正常工作。不管怎样,这并不完全违反ABI;对于GCC忽略ABI中奇怪而具有侵入性的局部数组对齐点是非常合理的。我必须使用内联汇编将对齐要求隐藏起来才能让GCC正常工作。“_Alignas(16)”对数组进行对齐修复了问题。 - Peter Cordes
2
制作“私有”函数是编译器已经允许的事情,而过程间优化也是众所周知的。稍微优化一下调用约定是一个巧妙的技巧,重要的是来自另一个编译单元的对这些函数的任何调用都将完全遵守x86-64 SysV ABI。在这个意义上,GCC仍然“使用”ABI,例如不会将第一个参数传递到RAX中,因此bar: ret可以正常工作。这看起来像是as-if规则的应用:外部观察者无法(合法地)看到的东西不会伤害他们。 - Peter Cordes
1
嗨 @paxdiablo, 所以,差不多三年后,我只想花点时间感谢你的回答。说它是纯金简直是轻描淡写。 - Myk

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