【问题标题】:When do we create base pointer in a function - before or after local variables?我们什么时候在函数中创建基指针——在局部变量之前还是之后?
【发布时间】:2018-05-24 03:53:47
【问题描述】:

我正在阅读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  $4, %esp       # Get room for our local storage

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

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

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

感兴趣的相关链接很少:

Function Prologue

【问题讨论】:

  • 那个start函数不为局部变量预留空间,文件描述符是静态CRT状态,如stdin和stdout。所以你必须忽略它。第二个 sn-p 是正常的方式。这样做,ebp 既适合访问传递给函数的参数(在 [ebp+x] 处),也适合访问局部变量(在 [ebp-y] 处)。

标签: function assembly x86 calling-convention


【解决方案1】:

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

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

为了编码的完整性,编译器作者的理智等,如果您需要使用堆栈有一个基地址,以便在函数的持续时间内偏移到您的堆栈部分,这是可取的。或者至少在设置之后和清理之前。这可以是堆栈指针(sp)本身,也可以是帧指针,有时从指令集中很明显。有些有一个向下增长的堆栈(在地址空间中趋向于零),堆栈指针只能在基于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

更多的工作,但可以使程序(编译器)保持跟踪并且比人工更不容易出错,但是在调试编译器输出(人类)时,更难跟踪和跟踪。所以一般我们烧栈空间,只有一个参考点。帧指针可用于分隔传入参数和局部变量,例如使用基指针 (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

不要将您在教科书、白板(或 StackOverflow 中的答案)中看到的内容视为信条。仔细考虑问题,并考虑替代方案。

  • 替代品的功能是否损坏?
  • 它们在功能上是否正确?
  • 是否存在可读性等缺点?
  • 性能?
  • 性能是普遍的还是取决于如何 内存是慢还是快?
  • 替代方案是否会生成更多代码,这会影响性能,但 也许该代码是流水线与随机内存访问?
  • 如果我不使用帧指针,架构是否让我重新获得 该寄存器用于通用用途?

在第一个示例中,bp 被丢弃,这通常很糟糕,但这是程序的入口点,没有必要保留 bp(除非操作系统规定)。

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

如果你先修改sp 然后推bp,你基本上会有两个指针彼此推开宽度,这有意义吗?无论如何,拥有两个帧指针是否有意义,如果是,让它们几乎相同的地址是否有意义?

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

【讨论】:

  • 处理器架构/指令集、调用约定和操作系统在这里扮演着重要的角色,你可以做什么或不能做什么(或者可能或不希望做什么)。诚然,约定和操作系统规则已针对架构进行了很大调整。
  • 这是一个很好的答案,它为程序员提供了一个很好的视角。我正在切换到另一个,因为它涵盖了更多的理由。希望这很好,然后在正确的意义上。
【解决方案2】:

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

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

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

C 在创建机器码时是否有特定的约定?

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

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

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

如果你想在当前的 gdb 中使用传统的 frame-walking,你必须自己定义一个 GDB 函数,如 gdb backtrace by walking frame pointersForce GDB to use frame-pointer based unwinding。或者显然,如果您的可执行文件根本没有 .eh_frame 部分,gdb will use the EBP-based stack-walking method

如果您使用 gcc -fno-omit-frame-pointer 进行编译,您的调用堆栈将具有此链表属性,因为当 C 编译器 生成正确的堆栈帧时,它们会推送 @987654344先@@。

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


即使我将它们混合在程序的不同功能中,两者都不会起作用吗?一个函数在之前执行,另一个在之后执行,等等。

是的,它可以正常工作。事实上setting up ebp at all is optional,但是当手写时,更容易有一个固定的底座(不像esp,它会在你推/弹出时移动)。

出于同样的原因,在一次推送后(旧的%ebp)更容易坚持mov %esp, %ebp 的约定,因此第一个函数arg 始终位于ebp+8。通常的约定见What is stack frame in assembly?

但是您可以通过将ebp 指向您保留的一些空间的中间来节省代码大小,因此可以使用ebp + disp8 寻址模式寻址的所有内存。 (disp8 是有符号的 8 位位移:如果我们限制为 4 字节对齐的位置,则为 -128 到 +124)。这节省了代码字节,而不是需要 disp32 才能到达更远的地方。所以你可能会这样做

bigfunc:
    push   %ebp
    lea    -112(%esp), %ebp   # first arg at ebp+8+112 = 120(%ebp)
    sub    $236, %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.

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

bigfunc2:                     # first arg at 4(%esp)
    sub    $252, %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不正确。使用“正常”堆栈帧序列,你可能会恢复其他推送的寄存器(从在保存的 EBP 附近)使用mov,然后使用leave。或恢复esp 指向最后一次推送(使用add),并使用pop 指令。)

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

这些例子是相当人为的;除非它是一个数组,否则您通常不需要太多的本地空间空间,然后您将使用索引寻址模式或指针,而不仅仅是相对于ebp 的 disp8。但也许您需要一些 32 字节 AVX 向量的空间。在只有 8 个向量寄存器的 32 位代码中,这是有道理的。

不过,AVX512 压缩的 disp8 在很大程度上击败了 64 字节 AVX512 向量的这个论点。 (但 32 位模式下的 AVX512 仍然只能使用 8 个向量寄存器,zmm0-zmm7,因此您很容易需要溢出一些。您只能在 64 位模式下获得 x/ymm8-15 和 zmm8-31。)

【讨论】:

  • @Nishant:很高兴你发现它很有用。我写它是因为@old_timer 没有包含有关 i386 Linux 的任何具体细节(特别是省略了有关由保存的 EBP 值形成的链表的部分,如果所有函数都首先推送 EBP,则它也会为您提供返回地址)。他是 ARM 开发人员,所以他的回答非常通用,并且使用 ARM 的 register-args 调用约定(而不是像硬壳的旧 i386 System V 调用约定那样的堆栈 args。)顺便说一句,如果你可以将你的接受投票切换到这个答案认为这是对这个问题的更好回答。
  • gdbeh_frame 的情况并不像上面那样可怕。我发现如果相关编译单元(例如,当前函数来自的.o)不提供任何调试信息,它仍然会使用基于rbp 的展开。因此,您可以将使用 -g 编译的 C/C++ 代码与编译时没有任何调试信息的 asm 代码链接起来,它将正确地通过汇编代码(使用帧指针)和 C/C++ 代码(可能使用调试信息)展开。一旦你在 asm 对象中包含 any DWARF 调试信息,例如,-F dwarf,那么 gdb 将无法展开汇编代码。
  • 即使 nasm(例如)实际上并没有生成任何 eh_framedebug_frame 信息,只会生成其他类型的调试信息,例如指令源代码行映射,也会发生这种情况。即使在这种情况下,某些进程(例如 valgrind)仍然可以很好地展开堆栈。您可能会想象 gdb 将具有一些最强大的堆栈遍历启发式算法,或者至少允许您在您了解得更好时覆盖算法,但显然不是。
  • @Nishant:是的,完全正确。除非您将esp 修改为可变数量(例如,对于allocaint C99_variable_length_array[N]),否则您(或编译器)始终知道从esp 到任何其他堆栈位置的偏移量。查看编译器输出; -fomit-frame-pointer 默认使用 gcc -O1 或更高版本,即使在 32 位代码中已经有几年了。在 x86-64 中,它的默认值甚至更长。
  • @Nishant:注意[sp] 不是a valid 16-bit addressing mode,所以历史上x86 实际上需要 一个帧指针来访问堆栈(或者只使用push/pop无需随机访问您的堆栈帧,例如保存/恢复某些内容。)
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2021-11-26
  • 2014-02-17
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2019-05-31
相关资源
最近更新 更多