22

  • Linux x86_64。
  • gcc 5.x

我正在研究两个代码的输出,有 -fomit-frame-pointer 和没有(默认情况下,“-O3”处的 gcc 启用该选项)。

pushq    %rbp
movq     %rsp, %rbp
...
popq     %rbp

我的问题是:

如果我全局禁用该选项,即使在极端情况下编译操作系统,是否有问题?

我知道中断使用该信息,那么该选项仅适用于用户空间吗?

4

1 回答 1

31

编译器总是生成自我一致的代码,因此只要您不使用对其进行一些假设的外部/手工制作的代码(例如,通过依赖例如的值rbp),禁用帧指针就可以了。

中断不使用帧指针信息,它们可能使用当前堆栈指针来保存最小上下文,但这取决于中断类型和操作系统(硬件中断可能使用 Ring 0 堆栈)。
您可以查看英特尔手册以获取更多信息。

关于帧指针的用处:
几年前,在编译了几个简单的例程并查看了生成的 64 位汇编代码之后,我遇到了同样的问题。
如果你不介意阅读我当时为自己写的大量笔记,它们就在这里。

注意:询问某事的有用性是相对的。为当前主要的 64 位 ABI 编写汇编代码我发现自己越来越少地使用堆栈帧。然而,这只是我的编码风格和观点。


我喜欢使用帧指针,编写函数的序言和结语,但我也喜欢直接不舒服的答案,所以这是我的看法:

是的,帧指针在 x86_64 中几乎没用。

当心它并不是完全没用的,尤其是对人类来说,但是编译器不再需要它了。为了更好地理解为什么我们首先有一个帧指针,最好回顾一些历史。

回到实模式(16位)天

当 Intel CPU 仅支持“16 位模式”时,对如何访问堆栈有一些限制,特别是这条指令是(并且仍然是)非法的

mov ax, WORD [sp+10h]

因为sp不能用作基址寄存器。只有少数指定的寄存器可用于此目的,例如bx或更著名的bp.
如今,这不是每个人都关注的细节,但bp与其他基址寄存器相比具有优势,默认情况下它隐含暗示ss用作段/选择器寄存器,就像sp(by push, pop, 等)的隐含用法esp一样后来的 32 位处理器。
即使您的程序分散在整个内存中,每个段寄存器都指向不同的区域,bp并且sp行为相同,但毕竟这是设计者的意图。

因此通常需要一个堆栈帧,因此需要一个帧指针。
bp有效地将堆栈划分为四个部分:参数区、返回地址旧 bp(只是一个 WORD)和局部变量区。每个区域由用于访问它的偏移量标识:参数和返回地址为正数, old 为零,bp局部变量为负数。

扩展有效地址

随着 Intel CPU 的发展,增加了更广泛的 32 位寻址模式。
特别是可以使用任何 32 位通用寄存器作为基址寄存器,这包括使用esp.
像这样的指示

mov eax, DWORD [esp+10h]

现在有效,堆栈帧和帧指针的使用似乎注定要结束了。
可能情况并非如此,至少在开始时是这样。
诚然,现在完全可以使用,esp但上述四个区域的堆栈分离仍然有用,尤其是对人类而言。

如果没有帧指针,push 或 pop 将更改相对于 的参数或局部变量偏移量esp,从而使代码看起来不直观。考虑如何使用 cdecl 调用约定实现以下 C 例程:

void my_routine(int a, int b)
{  
    return my_add(a, b); 
}

没有和有框架堆栈

my_routine:      
  push DWORD [esp+08h]
  push DWORD [esp+08h]
  call my_add
  ret

my_routine:
  push ebp
  mov ebp, esp

  push DWORD [ebp+0Ch]
  push DWORD [ebp+08h]
  call my_add
  
  pop ebp
  ret 

乍一看,第一个版本似乎两次推送相同的值。然而,它实际上推送了两个单独的参数,因为第一个推送降低esp了,因此相同的有效地址计算将第二个推送指向不同的参数。

如果添加局部变量(尤其是很多),那么情况很快就会变得难以阅读:mov eax, [esp+0CAh]是引用局部变量还是参数?使用堆栈帧,我们为参数和局部变量提供了固定的偏移量。

甚至最初的编译器仍然更喜欢使用帧基指针给出的固定偏移量。我看到这种行为首先在 gcc 中发生了变化。
在调试构建中,堆栈框架有效地增加了代码的清晰度,并使(熟练的)程序员可以轻松地跟踪正在发生的事情,并且正如评论中指出的那样,让他们更容易恢复堆栈框架。
然而,现代编译器擅长数学,可以轻松地计算堆栈指针的移动并从中生成适当的偏移量esp,省略堆栈帧以加快执行速度。

当 CISC 需要数据对齐时

在引入 SSE 指令之前,与它们的 RISC 兄弟相比,英特尔处理器从未向程序员提出太多要求。
特别是他们从不要求数据对齐,我们可以访问一个地址上的 32 位数据,而不是 4 的倍数,而没有重大抱怨(取决于 DRAM 数据宽度,这可能会导致延迟增加)。
SSE 使用需要在 16 字节边界上访问的 16 字节操作数,因为 SIMD 范式在硬件中变得有效实现并且变得越来越流行,16 字节边界上的对齐变得很重要。

主要的 64 位 ABI 现在需要它,堆栈必须按段落对齐(即 16 字节)。
现在,我们通常被称为在序言之后堆栈对齐,但假设我们没有得到这个保证,我们需要做一个

push rbp                   push rbp
mov rbp, rsp               mov rbp, rsp             

and spl, 0f0h              sub rsp, xxx
sub rsp, 10h*k             and spl, 0f0h

在这些序言之后堆栈以一种或另一种方式对齐,但是我们不能再使用负偏移量rbp来访问需要对齐的本地变量,因为帧指针本身未对齐。
我们需要使用rsp,我们可以安排一个序言,它rbp指向局部变量的对齐区域的顶部,但参数将位于未知偏移量处。
我们可以安排一个复杂的堆栈帧(可能有多个指针),但老式帧基指针的关键是它的简单性。

因此,我们可以使用帧指针访问堆栈上的参数,并使用堆栈指针访问局部变量,这很公平。
唉,堆栈在参数传递中的作用已经减少,对于少数参数(目前是四个),它甚至没有被使用,并且在未来它可能会被使用得更少。

所以我们不将帧指针用于局部变量(大部分),也不用于参数(大部分),我们用它做什么?

  1. 它保存了原始的副本rsp,因此要在函数退出时恢复堆栈指针,amov就足够了。如果堆栈与and不可逆的 对齐,则需要原始副本。

  2. 实际上,一些 ABI 保证在标准序言之后堆栈是对齐的,从而允许我们像往常一样使用帧指针。

  3. 有些变量不需要对齐,可以使用未对齐的帧指针访问,这通常适用于手工编写的代码。

  4. 有些函数需要四个以上的参数。

概括

帧指针是 16 位程序的残留范式,已证明它在 32 位机器上仍然有用,因为它在访问局部变量和参数时简单明了。
然而,在 64 位机器上,严格的要求消除了大部分的简单性和清晰性,但帧指针仍然在调试模式下使用。


事实上,帧指针可以用来制作有趣的东西:我想这是真的,我从未见过这样的代码,但我可以想象它是如何工作的。
然而,我专注于帧指针的管家角色,因为这是我一直看到的方式。
所有疯狂的事情都可以通过将任何指针设置为与帧指针相同的值来完成,我赋予后者一个更“特殊”的角色。
例如 VS2013 有时rdi用作“帧指针”,但如果它不使用rbp/ebp/bp.
对我来说,使用rdi意味着帧指针省略优化:)

于 2015-07-15T09:04:57.223 回答