我们什么时候在函数中创建基指针——在局部变量之前还是之后?

When do we create base pointer in a function - before or after local variables?

我正在阅读 Programming From Ground Up 这本书。我看到两个不同的示例,说明如何从当前堆栈位置 %esp.

创建 base pointer %ebp

在一种情况下,它在局部变量之前完成。

_start:
        # INITIALIZE PROGRAM
        subl  $ST_SIZE_RESERVE, %esp       # Allocate space for pointers on the
                                           # stack (file descriptors in this
                                           # case)
        movl  %esp, %ebp

然而_start与其他函数不同,它是程序的入口点。

另一种情况是在之后完成。

power:
        pushl %ebp           # Save old base pointer
        movl  %esp, %ebp     # Make stack pointer the base pointer
        subl  , %esp       # Get room for our local storage

所以我的问题是,我们是先在堆栈中为 local variables 保留 space 然后创建 base pointer 还是先创建 base pointer 然后保留 space 对于 local variables?

即使我在一个程序的不同函数中将它们混合起来,两者不会都起作用吗?一个函数在之前执行,另一个在之后执行等等。C 在创建机器代码时是否有特定的约定?

我的推理是函数中的所有代码都与 base pointer 相关,因此只要该函数遵循创建堆栈引用所依据的约定,它就可以正常工作吗?

感兴趣的相关链接很少:

Function Prologue

在第一种情况下,您不关心保存 - 这是入口点。当您退出程序时,您正在丢弃 %ebp - 谁在乎寄存器的状态?因为您的应用程序已经结束,所以不再重要。但是在一个函数中,当你从那个函数中 return 时,调用者肯定不希望 %ebp 被丢弃。现在可以先修改%esp然后保存%ebp然后使用%ebp吗?当然,只要您在函数的另一端以相同的方式展开,您可能根本不需要帧指针,通常这只是个人选择。

你只需要一幅相对的世界图景。帧指针通常只是为了让编译器作者的工作更轻松,实际上它通常只是为了为许多指令集浪费一个寄存器。可能是因为某些老师或教科书是这样教的,没有人问为什么。

为了编码的完整性、编译器作者的完整性等,如果您需要使用堆栈来获得一个基地址,在函数的持续时间内从该地址偏移到您的堆栈部分是可取的。或者至少在设置之后和清理之前。这可以是堆栈指针 (sp) 本身,也可以是帧指针,有时从指令集中很明显。有些有一个向下增长的堆栈(在地址 space 中朝向零)并且堆栈指针只能在基于 sp 的地址中具有正偏移量(正常)或仅具有负偏移量(疯狂)(不太可能但可以说在那)。所以你可能需要一个通用寄存器。也许有一些你根本不能在寻址中使用 sp,你必须使用通用寄存器。

最重要的是,为了理智,您需要一个参考点来偏移堆栈中的项目,更痛苦但使用更少内存的方法是在您进行时添加和删除东西:

x is at sp+4
push a
push b
do stuff
x is at sp+12
pop b
x is at sp+8
call something
pop a
x is at sp+4
do stuff

更多的工作,但可以使程序(编译器)保持跟踪并且比人工更不容易出错,但是在调试编译器输出(人类)时更难跟踪和跟踪。所以一般我们烧栈space,有一个参考点。帧指针可用于分隔传入参数和局部变量,使用基指针 (bp) 例如作为函数内的静态基地址,sp 作为局部变量的基地址(尽管 sp 如果指令集提供那么大的偏移量,则可以用于所有内容)。因此,通过推送 bp 然后修改 sp 你正在创建这两个基地址的情况,sp 可能会为本地内容移动(尽管通常不正常)并且 bp 可以如果这是一个调用约定,规定所有参数都在堆栈上(通常当您没有很多通用寄存器时),则用作获取参数的静态位置有时您会看到参数被复制到堆栈上的本地分配以供以后使用使用,但如果你有足够的寄存器,你可能会看到一个寄存器被保存在堆栈上并在函数中使用,而不是需要使用基地址和偏移量访问堆栈。

unsigned int more_fun ( unsigned int x );
unsigned int fun ( unsigned int x )
{
    unsigned int y;
    y = x;
    return(more_fun(x+1)+y);
}

00000000 <fun>:
   0:   e92d4010    push    {r4, lr}
   4:   e1a04000    mov r4, r0
   8:   e2800001    add r0, r0, #1
   c:   ebfffffe    bl  0 <more_fun>
  10:   e0800004    add r0, r0, r4
  14:   e8bd4010    pop {r4, lr}
  18:   e12fff1e    bx  lr

不要把你在教科书、白板上(或 Whosebug 上的答案)上看到的当成福音。仔细考虑问题和备选方案。

  • 备选方案在功能上是否损坏?
  • 它们在功能上是否正确?
  • 是否存在可读性等缺点?
  • 性能?
  • 性能受到普遍影响还是取决于如何 slow/fast记忆是?
  • 替代方案是否会生成更多代码,这会影响性能但 也许该代码是流水线而不是随机内存访问?
  • 如果我不使用帧指针,架构是否让我重新获得 注册为一般用途?

在第一个例子中 bp 被丢弃了,这通常是不好的,但这是程序的入口点,没有必要保留 bp(除非操作系统要求).

虽然在一个函数中,根据调用约定,我们假设 bp 被调用者使用并且必须被保留,所以你必须将它保存在堆栈上才能使用它。在这种情况下,它似乎希望用于访问调用者在堆栈上传入的参数,然后移动 sp 以腾出空间(并且可能访问但不一定需要,如果 bp 可以使用)局部变量。

如果你先修改sp然后推送bp,你基本上会有两个指针彼此相距一个推送宽度,这有意义吗?不管怎样,有两个帧指针有意义吗?如果有的话,让它们几乎相同的地址有意义吗?

通过先压入 bp,如果调用约定最后压入第一个参数,那么作为编译器作者,您可以使 bp+N 始终或理想情况下始终指向固定值 N 的第一个参数同样 bp+M 总是指向第二个。对我来说有点懒,但是如果寄存器在那里要被烧毁就烧掉它...

In one case, it is done before the local variables.

_start 不是函数。这是你的切入点。没有 return 地址,也没有要保存的 %ebp 调用者值。

i386 System V ABI doc 建议(在 2.3.1 初始堆栈和寄存器状态 部分)您可能希望将 %ebp 归零以标记最深的堆栈帧。 (即在你的第一个 call 指令之前,所以当第一个函数压入归零的 ebp 时,保存的 ebp 值的链表有一个 NULL 终止符。见下文)。

Does C have a specific convention when it creates the machine code?

不,与其他一些 x86 系统不同,i386 System V ABI 不需要太多关于堆栈框架布局的信息。 (Linux 使用 System V ABI / 调用约定,而您正在使用的书 (PGU) 适用于 Linux。)

在一些调用约定中,设置ebp不是可选的,函数入口序列必须将ebp推到return地址的正下方。这将创建一个 链接列表 堆栈帧,它允许异常处理程序(或调试器)回溯堆栈。 (How to generate the backtrace by looking at the stack values?)。我认为这在 SEH(结构化异常处理)的 32 位 Windows 代码中是必需的,至少在某些情况下是这样,但我不知道细节。

i386 SysV ABI 定义了一种堆栈展开的替代机制,它使帧指针成为可选,使用另一部分中的元数据(.eh_frame and .eh_frame_hdr,其中包含由 .cfi_... 汇编器指令创建的元数据,理论上你如果你想通过你的函数进行堆栈展开,你可以自己写。也就是说,如果你正在调用任何期望 throw 工作的 C++ 代码。)

如果你想在当前的 gdb 中使用传统的 frame-walking,你必须自己定义一个 GDB 函数,比如 gdb backtrace by walking frame pointers or Force GDB to use frame-pointer based unwinding. Or apparently if your executable has no .eh_frame section at all, gdb will use the EBP-based stack-walking method.

如果你用 gcc -fno-omit-frame-pointer 编译,你的调用堆栈将有这个链表 属性,因为当 C 编译器 制作适当的堆栈帧,他们先推送 ebp

IIRC,perf 有一种在分析时使用帧指针链获取回溯的模式,显然这比默认的 .eh_frame 更可靠,可以正确计算哪些函数是负责使用最多 CPU 时间。 (或者导致最多的缓存未命中、分支预测错误或您使用性能计数器计算的任何其他内容。)


Wouldn't both just work even if I mix them up in different functions of a program? One function does it before, the other does it after etc.

是的,它会很好用。事实上 ,但是手写时更容易有一个固定的基数(不像 esp,当你 push/pop 时它会四处移动)。

出于同样的原因,一次推送后更容易坚持mov %esp, %ebp的约定(旧的%ebp),所以第一个函数arg总是在ebp+8 .请参阅 What is stack frame in assembly? 以了解通常的约定。

但是您可以通过在您保留的某些 space 中间设置 ebp 点来节省代码大小,因此所有可使用 ebp + disp8 寻址模式寻址的内存都可用。 (disp8 是一个带符号的 8 位位移:-128 到 +124,如果我们限制为 4 字节对齐的位置)。与需要 disp32 以达到更远的距离相比,这节省了代码字节。所以你可能会

bigfunc:
    push   %ebp
    lea    -112(%esp), %ebp   # first arg at ebp+8+112 = 120(%ebp)
    sub    6, %esp         # locals from -124(%ebp) ... 108(%ebp)
                              # saved EBP at 112(%ebp), ret addr at 116(%ebp)
                              # 236 was chosen to leave %esp 16-byte aligned.

或者延迟保存任何寄存器,直到为本地人保留 space 之后,这样我们就不会用完任何我们不想处理的已保存值的位置(ret addr 除外)。

bigfunc2:                     # first arg at 4(%esp)
    sub    2, %esp         # first arg at 252+4(%esp)
    push   %ebp               # first arg at 252+4+4(%esp)
    lea    140(%esp), %ebp    # first arg at 260-140 = 120(%ebp)

    push   %edi              # save the other call-preserved regs
    push   %esi
    push   %ebx
             # %esp is 16-byte aligned after these pushes, in case that matters

(记住要小心如何恢复寄存器和清理。你不能使用 leave 因为 esp = ebp 是不正确的。使用 "normal" 堆栈帧序列,您可以使用 mov 恢复其他推送的寄存器(从保存的 EBP 附近),然后使用 leave。或者恢复 esp 指向最后一次推送(使用 add),并使用 pop 指令。)

但是如果您要这样做,使用 ebp 而不是 ebx 之类的方法没有任何优势。事实上,使用 ebp 有一个缺点:0(%ebp) 寻址模式需要 disp8 为 0,而不是没有位移,但 %ebx 不会。所以使用 %ebp 作为非指针暂存器。或者至少是一个你不会在没有位移的情况下取消引用的。 (这个怪癖与真正的帧指针无关:(%ebp) 是保存的 EBP 值。顺便说一句,意味着 (%ebp) 没有位移的编码是 ModRM 字节如何编码没有基址寄存器的 disp32 , 如 (12345)my_label)

这些例子很人为;你通常不需要那么多 space 给本地人,除非它是一个数组,然后你会使用索引寻址模式或指针,而不仅仅是相对于 ebp 的 disp8。但也许您需要 space 来获取一些 32 字节的 AVX 向量。在只有 8 个向量寄存器的 32 位代码中,这是合理的。

AVX512 compressed disp8 主要驳斥了 64 字节 AVX512 向量的这个论点。 (但是 32 位模式下的 AVX512 仍然只能使用 8 个向量寄存器,zmm0-zmm7,因此您很容易需要溢出一些。在 64 位模式下您只能得到 x/ymm8-15 和 zmm8-31。)