在使用堆栈时,为什么我们需要一个基指针和一个栈指针?

6
就x86汇编代码而言,当我们调用函数时,需要使用基准/帧指针(EBP)和堆栈指针(ESP)。当前的EBP值将被放置在堆栈上,然后EBP获得当前ESP值。接下来,函数的返回值、函数参数和本地变量的占位符将被放置在堆栈上,并且堆栈指针ESP的值将减少(或增加),以指向堆栈上放置的最后一个占位符之后。现在,EBP指向当前堆栈帧的开头,ESP指向堆栈帧的结尾。
由于与EBP的恒定偏移量,EBP将用于访问函数的参数和本地变量。这非常好理解。我不理解的是,为什么不能使用ESP通过其偏移量来访问这些变量。EBP指向堆栈帧的开头,ESP指向堆栈帧的结尾。有什么区别吗?
一旦所有本地变量等的占位符已经存在,ESP就不应该再发生改变了,对吗?

3
通常情况下不需要它。对于许多架构来说,您只需使用堆栈指针而无需使用帧指针。一些人可能出于调试或其他原因想要一个帧指针,尽管它会占用一个寄存器(在某些架构中,帧指针不是通用寄存器),并且需要额外的指令。 - old_timer
@old_timer 有没有例子,可以展示在函数执行的过程中 ESP 会以不可预测的方式发生变化?我猜测应该事先定义好调用函数应该将哪些寄存器推送到堆栈上等等,这样就可以很容易地使用 ESP 偏移来访问局部变量和参数,对吗? - Engineer999
2
“ESP一旦为所有本地变量等设置了占位符,就不应该再更改。” - 为什么不呢?如果需要,编译器可以自由使用PUSH/POP指令,所以我不太理解你的这个说法...但正如@old_timer所说 - 可以在没有帧指针的情况下完成,只是对于编译器编写者、调试器等来说不太方便。 - tum_
4
请注意,许多现代编译器允许仅使用堆栈指针,并释放 EBP 来进行其他用途,例如 gcc 的“-fomit-frame-pointer”选项。 - ninjalj
1
@tum_: 通常你只会在编译器生成的代码中看到push来传递函数参数。但是,使用-Oz(即使牺牲速度也要优化代码大小)的clang将使用像push 2 / pop rcx(总共3个字节)这样的东西,而不是5个字节的mov ecx, 2。然而,使用push/pop代替mov reg,reg是疯狂的。它只为64位寄存器(不是r8..r15)节省1个字节,否则它是平衡或者是损失。(3个字节的mov r8, r9与2个字节的push r9 + 2个字节的pop r8相比)。在32位代码中,2个字节的mov始终比push/pop更快,并且在大小上保持平衡。 - Peter Cordes
显示剩余4条评论
2个回答

4

从技术上讲,跟踪存储在栈上的本地和临时变量的数量是可能的(但有时很难),因此可以在没有EBP的情况下访问函数输入和本地变量。

考虑以下“C”代码;

int func(int arg) {
   int result ;
   double x[arg+5] ;
   // Do something with x, calculate result
   return result ;
} ;

现在,存储在堆栈上的项目数量是变量(双倍的arg+5项)。从堆栈中计算'arg'的位置需要运行时计算,这可能会对性能产生重大负面影响。

额外的寄存器(EBP)使得arg的位置始终位于固定位置(EBP-2)。执行'return'始终很简单 - 将BP移动到SP并返回等。

总之,将EBP寄存器分配给单个函数(而不是将其用作通用寄存器)的决策是性能、简洁性、代码大小和其他因素之间的权衡。实践经验表明,好处大于成本。


1
是的,即使启用了优化,带有可变长度数组(VLAs)的函数也会设置帧指针。否则就不会。在GCC / clang中,默认情况下,在-O1及更高版本中启用-fomit-frame-pointer,即使是32位代码。您是否声称这是一个错误,并且-fno-omit-frame-pointer应该是默认值,即使在-O2-O3?在32位代码中,从6个寄存器到7个寄存器的转换是很重要的,这似乎是不太可能的。也许在64位代码中,代码大小优势与性能劣势之间的权衡会更好,但另一方面,许多x86-64函数可以将更多内容保留在寄存器中,减少堆栈访问。 - Peter Cordes
“-fomit-frame-pointer”的描述是“对于不需要帧指针的函数,不要将帧指针保留在寄存器中”,而“-O也会在不干扰调试的机器上打开-fomit-frame-pointer”。尝试使用没有帧指针的“-O”步进程序是非常不愉快的体验 - 因此,在我们公司,我们曾经在32位上禁用了这种优化。幸运的是,对于Intel 64位(-O -g),编译器甚至会使用EBP。” - dash-o
1
是的,带有VLAs或alloca的函数是“需要一个”的函数。现代调试格式不依赖于EBP / RBP作为帧指针;它们使用.eh_frame元数据进行堆栈展开,因此您仍然可以在优化构建中进行回溯,并且单步执行的效果不会比您对优化代码的期望差。 -fno-omit-frame-pointer的一个用例是与perf一起使用,当在事件上进行堆栈快照时。 EBP回溯更快,而且显然效果更好。 - Peter Cordes
@PeterCordes 我认为现代 EH 代码与基于表格和编译器为每个代码区域发出的指令有很强的相似之处。 - curiousguy
@curiousguy:是的,取消展开元数据是使-fomit-frame-pointer在C ++中成为默认值,即使启用异常也是如此。Linux ABI甚至要求对C进行取消展开元数据,这对于调试回溯至少是方便的。 - Peter Cordes
我很困惑为什么可变长度数组需要这样做。调用者知道哪个参数将用于VLA,并且调用者正在从堆栈指针中添加/减去...编译器是否仍然可以通过使用不变的变量来避免烧掉寄存器? - vitiral

2

关于调试器/运行时工具的注意事项:

使用 EBP 可以使调试器(和其他运行时工具)更容易“遍历堆栈”。这些工具可以在运行时检查堆栈,并且不需要知道当前程序堆栈的任何信息(例如,每个帧已经推入了多少项),它们可以一直遍历堆栈到“main”。

如果没有 EBP 指向“下一个”帧,运行时工具(包括调试器)将面临非常困难(甚至可能是不可能的)的任务,即如何从 ESP 移动到特定的局部变量。


2
如果二进制文件具有调试信息,则该调试信息可以包含有关堆栈帧的信息,以帮助调试器进行调试。否则,如果没有调试信息并且也没有基指针,则必须采用启发式方法,这通常会导致错误的结果:例如,假设堆栈上所有看起来像代码段中地址的内容都是返回地址。 - ninjalj
如果没有调试信息,你真正能够检查多少堆栈帧呢?这将非常困难,很难知道哪些变量是什么等等,对吧? - vitiral

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