在 32 位和 16 位时代,一个常见的序言,即例程开始时的指令序列是
push ebp
mov ebp, esp
sub esp, <local_var_size>
push <clobbered_reg1>
push <clobbered_reg2>
...
这里没有什么是随意的,指令的顺序很重要,我们最终得到
|parN | <-- EBP + 04 + n*4 par1..parN = Routine parameters
... ... ra = Return address
|par2 | <-- EBP + 0ch o ebp = Original (caller) EBP
|par1 | <-- EBP + 08h lvar1..lavarM = Local variables
|ra | <-- EBP + 04h creg1..cregK = Clobbered registers
|o ebp| <-- EBP
|lvar1| <-- EBP - 04h
|lvar2| <-- EBP - 08h
... ...
|lvarM| <-- EBP - m*4
|creg1|
|creg2|
...
|cregK| <-- ESP
看看如何使用来自ebp 的合适指针轻松访问所有数据(参数为大于或等于 8 的连续正偏移量,局部变量为小于或等于 4 的负偏移量)以及该模型在更多的本地变量或参数。
因此,ebp 被称为帧指针。
结语必须撤消所有这一切。
一种可能的变体是
pop <clobbered_regK>
...
pop <clobbered_reg1>
add esp, <local_var_size>
pop ebp
ret n*4
但是,这涉及到重复 <local_var_size> - 很容易忘记让两个版本保持同步。
我们可以利用ebp 是分配本地变量之前esp 的值这一事实,因此通过恢复该值我们可以有效地释放它们。
pop <clobbered_regK>
...
pop <clobbered_reg1>
mov esp, ebp
pop ebp
ret n*4
但是从末尾算起的第三条和第二条指令就是leave 指令的作用。所以:
pop <clobbered_regK>
...
pop <clobbered_reg1>
leave
ret n*4
是等效的序幕。
enter 是一条非常慢的指令 (https://agner.org/optimize),因此编译器从不使用它,但 leave 可用于优化代码空间,而对性能的影响很小(可能会被代码平衡-尺寸节省)。当 pop ebp 本身不够用时,GCC 使用 leave,大多数 -mtune= 设置。
在当前的 Intel CPU(例如 Skylake)上,leave 总共需要 3 个 uop,而 mov esp, ebp / pop ebp 需要 2 个。在一个真实的测试用例中,通过调用(从重复循环中)一个实际的小函数来解释堆栈同步 uops 的可能差异,该函数将 EBP 设置为帧指针并分配一些堆栈空间,然后将其拆除,测量硬件性能计数器leave 函数每次调用比 mov/pop 函数多占用 1 个前端 uop。但是由于某种未知原因,leave 函数运行得稍微快了一点,即使两者都对齐了 32。(@petercordes 运行了这个测试。)