为什么 GCC 在堆栈上压入一个额外的 return 地址?
Why is GCC pushing an extra return address on the stack?
我目前正在学习汇编的基础知识,在查看 GCC(6.1.1) 生成的指令时遇到了一些奇怪的事情。
这是来源:
#include <stdio.h>
int foo(int x, int y){
return x*y;
}
int main(){
int a = 5;
int b = foo(a, 0xF00D);
printf("0x%X\n", b);
return 0;
}
用于编译的命令:gcc -m32 -g test.c -o test
在检查 GDB 中的函数时,我得到:
(gdb) set disassembly-flavor intel
(gdb) disas main
Dump of assembler code for function main:
0x080483f7 <+0>: lea ecx,[esp+0x4]
0x080483fb <+4>: and esp,0xfffffff0
0x080483fe <+7>: push DWORD PTR [ecx-0x4]
0x08048401 <+10>: push ebp
0x08048402 <+11>: mov ebp,esp
0x08048404 <+13>: push ecx
0x08048405 <+14>: sub esp,0x14
0x08048408 <+17>: mov DWORD PTR [ebp-0xc],0x5
0x0804840f <+24>: push 0xf00d
0x08048414 <+29>: push DWORD PTR [ebp-0xc]
0x08048417 <+32>: call 0x80483eb <foo>
0x0804841c <+37>: add esp,0x8
0x0804841f <+40>: mov DWORD PTR [ebp-0x10],eax
0x08048422 <+43>: sub esp,0x8
0x08048425 <+46>: push DWORD PTR [ebp-0x10]
0x08048428 <+49>: push 0x80484d0
0x0804842d <+54>: call 0x80482c0 <printf@plt>
0x08048432 <+59>: add esp,0x10
0x08048435 <+62>: mov eax,0x0
0x0804843a <+67>: mov ecx,DWORD PTR [ebp-0x4]
0x0804843d <+70>: leave
0x0804843e <+71>: lea esp,[ecx-0x4]
0x08048441 <+74>: ret
End of assembler dump.
(gdb) disas foo
Dump of assembler code for function foo:
0x080483eb <+0>: push ebp
0x080483ec <+1>: mov ebp,esp
0x080483ee <+3>: mov eax,DWORD PTR [ebp+0x8]
0x080483f1 <+6>: imul eax,DWORD PTR [ebp+0xc]
0x080483f5 <+10>: pop ebp
0x080483f6 <+11>: ret
End of assembler dump.
让我困惑的部分是它试图对堆栈做什么。
据我了解,这就是它的作用:
- 它引用了堆栈中高 4 个字节的内存地址,据我所知,这应该是传递给 main 的变量,因为
esp
当前指向内存中的 return 地址。
- 出于性能原因,它将堆栈对齐到 0 边界。
- 它压入新的堆栈区域
ecx+4
,这应该转化为压入我们假设return进入堆栈的地址。
- 它将旧的帧指针压入堆栈并设置新的。
- 它将
ecx
(仍然指向应该是main
的参数)压入堆栈。
然后程序会做它应该做的事情并开始 returning:
- 它通过在
ebp
上使用 -0x4
偏移来恢复 ecx
,它应该访问第一个局部变量。
- 它执行离开指令,实际上只是将
esp
设置为 ebp
,然后从堆栈中弹出 ebp
。
所以现在堆栈上的下一个东西是 return 地址,esp 和 ebp 寄存器应该回到它们需要的 return 对吗?
显然不是,因为它接下来要做的是用 ecx-0x4
加载 esp
,因为 ecx
仍然指向传递给 main
的那个变量应该放在堆栈上 return 地址的地址。
这工作得很好,但提出了一个问题:为什么在第 3 步中将 return 地址放到堆栈上,因为它在最后 returned 堆栈到原始位置就在实际 return 从函数中调用之前?
更新:gcc8 至少针对正常用例简化了这一点(-fomit-frame-pointer
,并且没有 alloca
或需要可变大小分配的 C99 VLA)。可能是由于 AVX 使用的增加导致更多函数需要 32 字节对齐的本地或数组。
此外,可能是 What's up with gcc weird stack manipulation when it wants extra stack alignment?
的副本
这个复杂的序言如果只运行几次(例如在 32 位代码中 main
的开头)就没问题,但它出现的次数越多,优化它的价值就越大。 GCC 有时仍然会过度对齐函数中的堆栈,其中所有 >16 字节对齐的对象都被优化到寄存器中,这已经是一个错过的优化,但当堆栈对齐更便宜时就不那么糟糕了。
gcc 在函数内对齐堆栈时会产生一些笨拙的代码,即使启用了优化。我有一个 可能的理论(见下文) 为什么 gcc 可能将 return 地址复制到它保存 ebp
的位置之上以制作堆栈框架(和是的,我同意这就是 gcc 正在做的事情)。在这个函数中看起来没有必要,而且 clang 不会做那样的事情。
除此之外,ecx
的废话可能只是 gcc 没有优化其对齐堆栈样板中不需要的部分。 (需要 esp
的预对齐值来引用堆栈上的 args,因此将第一个可能的 arg 的地址放入寄存器是有意义的。
您在 32 位代码中看到 with 优化(其中 gcc 使 main
不假定 16B 堆栈对齐,即使当前ABI 的版本要求在进程启动时,调用 main
的 CRT 代码要么对齐堆栈本身,要么保留内核提供的初始对齐,我忘记了)。您还会在将堆栈对齐到超过 16B 的函数中看到这一点(例如,使用 __m256
类型的函数,有时即使它们从不将它们溢出到堆栈。或者使用 C++11 [= 声明的数组的函数22=],或任何其他请求对齐的方式。)在 64 位代码中,gcc 似乎总是为此使用 r10
,而不是 rcx
。
对于 ABI 合规性,gcc 的工作方式没有任何要求,因为 clang 做的事情要简单得多。
我添加了一个对齐变量(使用 volatile
作为强制编译器实际在堆栈上为其保留对齐 space 的简单方法,而不是将其优化掉)。我把你的代码 on the Godbolt compiler explorer,用 -O3
查看 asm。我在 gcc 4.9、5.3 和 6.1 中看到相同的行为,但在 clang 中看到不同的行为。
int main(){
__attribute__((aligned(32))) volatile int v = 1;
return 0;
}
Clang3.8 的 -O3 -m32
输出在功能上与其 -m64
输出相同。请注意 -O3
启用 -fomit-frame-pointer
,但某些函数仍然会生成堆栈帧。
push ebp
mov ebp, esp # make a stack frame *before* aligning, so ebp-relative addressing can only access stack args, not aligned locals.
and esp, -32
sub esp, 32 # esp is 32B aligned with 32 or 48B above esp reserved (depending on incoming alignment)
mov dword ptr [esp], 1 # store v
xor eax, eax # return 0
mov esp, ebp # leave
pop ebp
ret
gcc 的输出在 -m32
和 -m64
之间几乎相同,但是它将 v
放在 red-zone 和 -m64
中,所以 -m32
输出有两个额外的指令:
# gcc 6.1 -m32 -O3 -fverbose-asm. Most of gcc's comment lines are empty. I guess that means it has no idea why it's emitting those insns :P
lea ecx, [esp+4] #, get a pointer to where the first arg would be
and esp, -32 #, align
xor eax, eax # return 0
push DWORD PTR [ecx-4] # No clue WTF this is for; this looks batshit insane, but happens even in 64bit mode.
push ebp # make a stackframe, even though -fomit-frame-pointer is on by default and we can already restore the original esp from ecx (unlike clang)
mov ebp, esp #,
push ecx # save the old esp value (even though this function doesn't clobber ecx...)
sub esp, 52 #, reserve space for v (not present with -m64)
mov DWORD PTR [ebp-56], 1 # v,
add esp, 52 #, unreserve (not present with -m64)
pop ecx # restore ecx (even though nothing clobbered it)
pop ebp # at least it knows it can just pop instead of `leave`
lea esp, [ecx-4] #, restore pre-alignment esp
ret
gcc 似乎想让它的栈帧(push ebp
)在 对齐栈之后。我想这是有道理的,所以它可以引用相对于 ebp
的局部变量。否则它必须使用 esp
-相对寻址,如果它想要对齐的局部变量。
我对 gcc 为什么这样做的理论:
return地址在alignment后pushebp
前的额外副本,意思是return地址被复制到相对于保存的预期位置ebp
值(以及调用子函数时将在 ebp
中的值)。因此,这确实可能有助于通过跟踪堆栈帧的链表并查看 return-地址以找出所涉及的函数来展开堆栈的代码。
我不确定这是否与允许使用 -fomit-frame-pointer
进行堆栈展开(回溯/异常处理)的现代堆栈展开信息有关。 (它是 .eh_frame
部分中的元数据。这就是围绕 esp
的每次修改的 .cfi_*
指令的目的。)我应该看看当它必须对齐堆栈时 clang 做了什么一个非叶函数。
函数内部需要 esp
的原始值来引用堆栈上的函数参数。我认为 gcc 不知道如何优化其对齐堆栈方法中不需要的部分。 (例如 out main
不查看其参数(并声明不接受任何参数))
这种代码生成是您在需要对齐堆栈的函数中看到的典型代码;这并不奇怪,因为使用 volatile
和自动存储。
GCC 复制 return 地址以创建一个外观正常的堆栈帧,调试器可以通过链接保存的帧指针 (EBP) 值遍历该帧。虽然 GCC 生成这样的代码的部分原因是为了处理函数也具有可变长度堆栈分配的最坏情况,就像使用可变长度数组或 alloca()
时可能发生的那样。
通常,当代码在没有优化的情况下编译(或使用 -fno-omit-frame-pointer
选项)时,编译器会创建一个堆栈帧,其中包含一个 link 返回到使用保存的帧指针值的前一个堆栈帧呼叫者,召集者。通常,编译器将前一个帧指针值保存为堆栈中 return 地址之后的第一个内容,然后将帧指针设置为指向堆栈中的这个位置。当程序中的所有函数都执行此操作时,帧指针寄存器将成为指向堆栈帧的 linked 列表的指针,该列表可以一直追溯到程序的启动代码。每个帧中的return地址表示每个帧属于哪个函数。
然而,GCC 在需要对齐堆栈的函数中做的第一件事不是保存前一帧指针,而是执行对齐,在 return 地址后放置一个未知数的填充字节。因此,为了创建看起来像普通堆栈帧的内容,它会在这些填充字节之后复制 return 地址,然后保存前一个帧指针。的问题是,它并不是真的有必要像这样复制 return 地址,正如 Clang 所证明的那样,并显示在 Peter Cordes 的回答中。与 Clang 一样,GCC 可以立即保存前一个帧指针值 (EBP),然后对齐堆栈。
本质上,两个编译器所做的都是创建一个拆分堆栈帧,一个被创建的用于对齐堆栈的对齐填充一分为二。填充上方的顶部是存储语言环境变量的地方。填充下方的底部是可以找到传入参数的地方。 Clang 使用 ESP 访问顶部,使用 EBP 访问底部。 GCC 使用 EBP 访问底部,并使用堆栈中序言中保存的 ECX 值访问顶部。在这两种情况下,EBP 都指向看起来像普通堆栈帧的内容,尽管只有 GCC 的 EBP 可用于像普通帧一样访问函数的局部变量。
所以在正常情况下,Clang 的策略显然更好,不需要复制 return 地址,也不需要在堆栈上保存额外的值(ECX 值)。然而,在编译器需要对齐堆栈和分配可变大小的东西的情况下,额外的值确实需要存储在某处。由于变量分配意味着堆栈指针不再具有到局部变量的固定偏移量,因此不能再使用它来访问它们。需要在某处存储两个单独的值,一个指向拆分帧的顶部,一个指向底部。
如果您查看 Clang 在编译一个既需要对齐堆栈又具有可变长度分配的函数时生成的代码,您会发现它分配了一个有效地成为第二个帧指针的寄存器,该指针指向顶部拆分框架的一部分。 GCC 不需要这样做,因为它已经使用 EBP 指向顶部。 Clang 继续使用 EBP 指向底部,而 GCC 使用保存的 ECX 值。
虽然 Clang 在这里并不完美,因为它还分配了另一个寄存器以在超出范围时将堆栈恢复到可变长度分配之前的值。在许多情况下,这不是必需的,并且可以使用用作第二个帧指针的寄存器来代替恢复堆栈。
GCC 的策略似乎是基于希望拥有一组样板序言和结尾代码序列,可用于所有需要堆栈对齐的函数。它还避免在函数的生命周期内分配任何寄存器,尽管保存的 ECX 值可以直接从 ECX 使用,如果它还没有被破坏的话。考虑到 GCC 如何生成函数序言和结尾代码,我怀疑生成像 Clang 那样更灵活的代码会很困难。
(但是,当生成 64 位 x86 代码时,GCC 8 和更高版本确实对需要 over-align 堆栈的函数使用更简单的序言,如果它们不需要任何可变长度堆栈分配的话。这更像是 Clang 的策略。)
我目前正在学习汇编的基础知识,在查看 GCC(6.1.1) 生成的指令时遇到了一些奇怪的事情。
这是来源:
#include <stdio.h>
int foo(int x, int y){
return x*y;
}
int main(){
int a = 5;
int b = foo(a, 0xF00D);
printf("0x%X\n", b);
return 0;
}
用于编译的命令:gcc -m32 -g test.c -o test
在检查 GDB 中的函数时,我得到:
(gdb) set disassembly-flavor intel
(gdb) disas main
Dump of assembler code for function main:
0x080483f7 <+0>: lea ecx,[esp+0x4]
0x080483fb <+4>: and esp,0xfffffff0
0x080483fe <+7>: push DWORD PTR [ecx-0x4]
0x08048401 <+10>: push ebp
0x08048402 <+11>: mov ebp,esp
0x08048404 <+13>: push ecx
0x08048405 <+14>: sub esp,0x14
0x08048408 <+17>: mov DWORD PTR [ebp-0xc],0x5
0x0804840f <+24>: push 0xf00d
0x08048414 <+29>: push DWORD PTR [ebp-0xc]
0x08048417 <+32>: call 0x80483eb <foo>
0x0804841c <+37>: add esp,0x8
0x0804841f <+40>: mov DWORD PTR [ebp-0x10],eax
0x08048422 <+43>: sub esp,0x8
0x08048425 <+46>: push DWORD PTR [ebp-0x10]
0x08048428 <+49>: push 0x80484d0
0x0804842d <+54>: call 0x80482c0 <printf@plt>
0x08048432 <+59>: add esp,0x10
0x08048435 <+62>: mov eax,0x0
0x0804843a <+67>: mov ecx,DWORD PTR [ebp-0x4]
0x0804843d <+70>: leave
0x0804843e <+71>: lea esp,[ecx-0x4]
0x08048441 <+74>: ret
End of assembler dump.
(gdb) disas foo
Dump of assembler code for function foo:
0x080483eb <+0>: push ebp
0x080483ec <+1>: mov ebp,esp
0x080483ee <+3>: mov eax,DWORD PTR [ebp+0x8]
0x080483f1 <+6>: imul eax,DWORD PTR [ebp+0xc]
0x080483f5 <+10>: pop ebp
0x080483f6 <+11>: ret
End of assembler dump.
让我困惑的部分是它试图对堆栈做什么。 据我了解,这就是它的作用:
- 它引用了堆栈中高 4 个字节的内存地址,据我所知,这应该是传递给 main 的变量,因为
esp
当前指向内存中的 return 地址。 - 出于性能原因,它将堆栈对齐到 0 边界。
- 它压入新的堆栈区域
ecx+4
,这应该转化为压入我们假设return进入堆栈的地址。 - 它将旧的帧指针压入堆栈并设置新的。
- 它将
ecx
(仍然指向应该是main
的参数)压入堆栈。
然后程序会做它应该做的事情并开始 returning:
- 它通过在
ebp
上使用-0x4
偏移来恢复ecx
,它应该访问第一个局部变量。 - 它执行离开指令,实际上只是将
esp
设置为ebp
,然后从堆栈中弹出ebp
。
所以现在堆栈上的下一个东西是 return 地址,esp 和 ebp 寄存器应该回到它们需要的 return 对吗?
显然不是,因为它接下来要做的是用 ecx-0x4
加载 esp
,因为 ecx
仍然指向传递给 main
的那个变量应该放在堆栈上 return 地址的地址。
这工作得很好,但提出了一个问题:为什么在第 3 步中将 return 地址放到堆栈上,因为它在最后 returned 堆栈到原始位置就在实际 return 从函数中调用之前?
更新:gcc8 至少针对正常用例简化了这一点(-fomit-frame-pointer
,并且没有 alloca
或需要可变大小分配的 C99 VLA)。可能是由于 AVX 使用的增加导致更多函数需要 32 字节对齐的本地或数组。
此外,可能是 What's up with gcc weird stack manipulation when it wants extra stack alignment?
的副本这个复杂的序言如果只运行几次(例如在 32 位代码中 main
的开头)就没问题,但它出现的次数越多,优化它的价值就越大。 GCC 有时仍然会过度对齐函数中的堆栈,其中所有 >16 字节对齐的对象都被优化到寄存器中,这已经是一个错过的优化,但当堆栈对齐更便宜时就不那么糟糕了。
gcc 在函数内对齐堆栈时会产生一些笨拙的代码,即使启用了优化。我有一个 可能的理论(见下文) 为什么 gcc 可能将 return 地址复制到它保存 ebp
的位置之上以制作堆栈框架(和是的,我同意这就是 gcc 正在做的事情)。在这个函数中看起来没有必要,而且 clang 不会做那样的事情。
除此之外,ecx
的废话可能只是 gcc 没有优化其对齐堆栈样板中不需要的部分。 (需要 esp
的预对齐值来引用堆栈上的 args,因此将第一个可能的 arg 的地址放入寄存器是有意义的。
您在 32 位代码中看到 with 优化(其中 gcc 使 main
不假定 16B 堆栈对齐,即使当前ABI 的版本要求在进程启动时,调用 main
的 CRT 代码要么对齐堆栈本身,要么保留内核提供的初始对齐,我忘记了)。您还会在将堆栈对齐到超过 16B 的函数中看到这一点(例如,使用 __m256
类型的函数,有时即使它们从不将它们溢出到堆栈。或者使用 C++11 [= 声明的数组的函数22=],或任何其他请求对齐的方式。)在 64 位代码中,gcc 似乎总是为此使用 r10
,而不是 rcx
。
对于 ABI 合规性,gcc 的工作方式没有任何要求,因为 clang 做的事情要简单得多。
我添加了一个对齐变量(使用 volatile
作为强制编译器实际在堆栈上为其保留对齐 space 的简单方法,而不是将其优化掉)。我把你的代码 on the Godbolt compiler explorer,用 -O3
查看 asm。我在 gcc 4.9、5.3 和 6.1 中看到相同的行为,但在 clang 中看到不同的行为。
int main(){
__attribute__((aligned(32))) volatile int v = 1;
return 0;
}
Clang3.8 的 -O3 -m32
输出在功能上与其 -m64
输出相同。请注意 -O3
启用 -fomit-frame-pointer
,但某些函数仍然会生成堆栈帧。
push ebp
mov ebp, esp # make a stack frame *before* aligning, so ebp-relative addressing can only access stack args, not aligned locals.
and esp, -32
sub esp, 32 # esp is 32B aligned with 32 or 48B above esp reserved (depending on incoming alignment)
mov dword ptr [esp], 1 # store v
xor eax, eax # return 0
mov esp, ebp # leave
pop ebp
ret
gcc 的输出在 -m32
和 -m64
之间几乎相同,但是它将 v
放在 red-zone 和 -m64
中,所以 -m32
输出有两个额外的指令:
# gcc 6.1 -m32 -O3 -fverbose-asm. Most of gcc's comment lines are empty. I guess that means it has no idea why it's emitting those insns :P
lea ecx, [esp+4] #, get a pointer to where the first arg would be
and esp, -32 #, align
xor eax, eax # return 0
push DWORD PTR [ecx-4] # No clue WTF this is for; this looks batshit insane, but happens even in 64bit mode.
push ebp # make a stackframe, even though -fomit-frame-pointer is on by default and we can already restore the original esp from ecx (unlike clang)
mov ebp, esp #,
push ecx # save the old esp value (even though this function doesn't clobber ecx...)
sub esp, 52 #, reserve space for v (not present with -m64)
mov DWORD PTR [ebp-56], 1 # v,
add esp, 52 #, unreserve (not present with -m64)
pop ecx # restore ecx (even though nothing clobbered it)
pop ebp # at least it knows it can just pop instead of `leave`
lea esp, [ecx-4] #, restore pre-alignment esp
ret
gcc 似乎想让它的栈帧(push ebp
)在 对齐栈之后。我想这是有道理的,所以它可以引用相对于 ebp
的局部变量。否则它必须使用 esp
-相对寻址,如果它想要对齐的局部变量。
我对 gcc 为什么这样做的理论:
return地址在alignment后pushebp
前的额外副本,意思是return地址被复制到相对于保存的预期位置ebp
值(以及调用子函数时将在 ebp
中的值)。因此,这确实可能有助于通过跟踪堆栈帧的链表并查看 return-地址以找出所涉及的函数来展开堆栈的代码。
我不确定这是否与允许使用 -fomit-frame-pointer
进行堆栈展开(回溯/异常处理)的现代堆栈展开信息有关。 (它是 .eh_frame
部分中的元数据。这就是围绕 esp
的每次修改的 .cfi_*
指令的目的。)我应该看看当它必须对齐堆栈时 clang 做了什么一个非叶函数。
函数内部需要 esp
的原始值来引用堆栈上的函数参数。我认为 gcc 不知道如何优化其对齐堆栈方法中不需要的部分。 (例如 out main
不查看其参数(并声明不接受任何参数))
这种代码生成是您在需要对齐堆栈的函数中看到的典型代码;这并不奇怪,因为使用 volatile
和自动存储。
GCC 复制 return 地址以创建一个外观正常的堆栈帧,调试器可以通过链接保存的帧指针 (EBP) 值遍历该帧。虽然 GCC 生成这样的代码的部分原因是为了处理函数也具有可变长度堆栈分配的最坏情况,就像使用可变长度数组或 alloca()
时可能发生的那样。
通常,当代码在没有优化的情况下编译(或使用 -fno-omit-frame-pointer
选项)时,编译器会创建一个堆栈帧,其中包含一个 link 返回到使用保存的帧指针值的前一个堆栈帧呼叫者,召集者。通常,编译器将前一个帧指针值保存为堆栈中 return 地址之后的第一个内容,然后将帧指针设置为指向堆栈中的这个位置。当程序中的所有函数都执行此操作时,帧指针寄存器将成为指向堆栈帧的 linked 列表的指针,该列表可以一直追溯到程序的启动代码。每个帧中的return地址表示每个帧属于哪个函数。
然而,GCC 在需要对齐堆栈的函数中做的第一件事不是保存前一帧指针,而是执行对齐,在 return 地址后放置一个未知数的填充字节。因此,为了创建看起来像普通堆栈帧的内容,它会在这些填充字节之后复制 return 地址,然后保存前一个帧指针。的问题是,它并不是真的有必要像这样复制 return 地址,正如 Clang 所证明的那样,并显示在 Peter Cordes 的回答中。与 Clang 一样,GCC 可以立即保存前一个帧指针值 (EBP),然后对齐堆栈。
本质上,两个编译器所做的都是创建一个拆分堆栈帧,一个被创建的用于对齐堆栈的对齐填充一分为二。填充上方的顶部是存储语言环境变量的地方。填充下方的底部是可以找到传入参数的地方。 Clang 使用 ESP 访问顶部,使用 EBP 访问底部。 GCC 使用 EBP 访问底部,并使用堆栈中序言中保存的 ECX 值访问顶部。在这两种情况下,EBP 都指向看起来像普通堆栈帧的内容,尽管只有 GCC 的 EBP 可用于像普通帧一样访问函数的局部变量。
所以在正常情况下,Clang 的策略显然更好,不需要复制 return 地址,也不需要在堆栈上保存额外的值(ECX 值)。然而,在编译器需要对齐堆栈和分配可变大小的东西的情况下,额外的值确实需要存储在某处。由于变量分配意味着堆栈指针不再具有到局部变量的固定偏移量,因此不能再使用它来访问它们。需要在某处存储两个单独的值,一个指向拆分帧的顶部,一个指向底部。
如果您查看 Clang 在编译一个既需要对齐堆栈又具有可变长度分配的函数时生成的代码,您会发现它分配了一个有效地成为第二个帧指针的寄存器,该指针指向顶部拆分框架的一部分。 GCC 不需要这样做,因为它已经使用 EBP 指向顶部。 Clang 继续使用 EBP 指向底部,而 GCC 使用保存的 ECX 值。
虽然 Clang 在这里并不完美,因为它还分配了另一个寄存器以在超出范围时将堆栈恢复到可变长度分配之前的值。在许多情况下,这不是必需的,并且可以使用用作第二个帧指针的寄存器来代替恢复堆栈。
GCC 的策略似乎是基于希望拥有一组样板序言和结尾代码序列,可用于所有需要堆栈对齐的函数。它还避免在函数的生命周期内分配任何寄存器,尽管保存的 ECX 值可以直接从 ECX 使用,如果它还没有被破坏的话。考虑到 GCC 如何生成函数序言和结尾代码,我怀疑生成像 Clang 那样更灵活的代码会很困难。
(但是,当生成 64 位 x86 代码时,GCC 8 和更高版本确实对需要 over-align 堆栈的函数使用更简单的序言,如果它们不需要任何可变长度堆栈分配的话。这更像是 Clang 的策略。)