x86_64 : 堆栈帧指针几乎没用了吗?

x86_64 : is stack frame pointer almost useless?



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

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

我的问题是:

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

我知道中断使用该信息,那么该选项仅对用户 space 有用吗?

编译器总是生成自我一致的代码,因此只要您不使用 external/hand 精心设计的代码对其进行一些假设(例如依赖于 rbp 例如)。

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

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

:问一个东西的用处,有点相对。为当前主要的 64 位 ABI 编写汇编代码我发现自己越来越少地使用堆栈框架。然而,这只是我的编码风格和观点。


我喜欢用框架指针,写一个函数的序言和结语,但我也喜欢直接的不舒服的答案,所以我是这样看的:

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

注意它并非完全无用,尤其是对人类而言,但编译器不再需要它了。 为了更好地理解为什么我们首先有一个帧指针,最好回顾一下历史。

回到实模式(16位)天

当 Intel CPU 仅支持“16 位模式”时,对如何访问堆栈有一些限制,尤其是这条指令曾经(现在仍然)是非法的

mov ax, WORD [sp+10h]

因为sp不能作为基址寄存器使用。只有少数指定的寄存器可以用于此目的,例如 bx 或更著名的 bp.
现在这不是每个人都关注的细节,但 bp 比其他基址寄存器有优势,默认情况下它隐式暗示 ss 用作 segment/selector 寄存器,就像隐式用法一样sp(通过 pushpop 等),就像 esp 在后来的 32 位处理器上所做的那样。
即使你的程序分散在内存中,每个段寄存器指向不同的区域,bpsp 的行为是一样的,毕竟那是设计者的意图。

因此通常需要堆栈帧,因此需要帧指针。
bp 有效地将堆栈分为四个部分:arguments 区域,return 地址old bp(只是一个 WORD)和 局部变量 区域。每个区域都由用于访问它的偏移量标识:参数和 return 地址为正,旧 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 兄弟相比,英特尔处理器从未对程序员提出太多要求。
特别是他们从未要求数据对齐,我们可以在不是 4 的倍数的地址上访问 32 位数据而没有大的抱怨(取决于 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,所以要恢复函数退出时的栈指针,一个mov就够了。如果堆栈与不可逆的 and 对齐,则需要原始副本。

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

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

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

总结

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


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