【问题标题】:Is there any difference between pushing registers before stack frame creation or after?在创建堆栈帧之前或之后推送寄存器有什么区别吗?
【发布时间】:2020-03-26 12:32:04
【问题描述】:

假设我有一个名为 func 的函数:

PROC func:
    ;Bla bla
    ret
ENDP func

现在,假设我使用寄存器axbx 为例,为了保存它们的初始值,我将它们推送到函数内部的堆栈中。

现在的问题是:在创建堆栈帧之前推送寄存器是否有很大不同:

PROC func:
    push bp
    push ax
    push bx
    mov bp, sp
    ;Bla bla
    ret
ENDP func

还是之后?

PROC func:
    push bp
    mov bp, sp
    push ax
    push bx
    ;Bla bla
    ret
ENDP func

我应该在我的程序中使用什么?一种方法比另一种更好或更“正确”吗?因为我目前用的是第一种方法。

【问题讨论】:

  • 不,你会先 push bp 然后 push bp, sp 。好处是,如果你在 开始 这样做,那么在 16 位代码中,第一个参数将始终位于 [bp+4] ,第二个参数位于 [bp+6] 等,即使你做了更多的推送在做mov bp, sp 之后在堆栈上。这对人类来说更容易维护代码。从高级编译器的角度来看,它并没有太大的区别。优点:可维护性。
  • 不,从人类生成的汇编程序的可读性和可维护性的角度来看,您的朋友方法会更好恕我直言。事实上,编译器和人类生成的许多代码都遵循先推 bp,然后执行 mov bp,sp,然后在 ret 之前执行相反的模式。通常,开发人员会从 BP 的正偏移量和负偏移量的局部变量中找到读取参数。这是一个更常见的约定。
  • 我认为您的困惑是 push bp mov bp, sp 的情况在所有其他推送之前完成。所以它看起来像 push bp mov bp, sp push ax push bx 等等。我认识的大多数人都觉得这比 push ax push bx push bp mov bp, sp 更可取。
  • @Kidsm:为了简化您的 asm,您可以编写简单地保存它们使用的所有寄存器的函数。在 32 位调用约定中,允许函数销毁 EAX、ECX 和 EDX 而无需保存/恢复它们。拥有一些 call-clobbered aka volatile 暂存寄存器意味着对简单函数的推送/弹出更少。
  • 不,我的意思是你推送 ONLY BP,然后是 mov bp, sp,然后你执行所有其他需要保存的推送。在您修改 bp 之前,您需要将其保存,以便可以将其恢复为原始值。你的第二个代码 sn-p 是错误的,因为它甚至不推送 bp。

标签: assembly x86 callstack stack-frame


【解决方案1】:

第二种方式,push bpmov bp, sp 在推送更多寄存器之前,意味着您的第一个堆栈参数始终位于[bp+4],无论您进行多少次推送1如果您将所有 args 传递到寄存器而不是堆栈中,这并不重要,如果您只有几个,这在大多数情况下会更容易和更有效。

这有利于人类的可维护性;您可以更改保存/恢复的寄存器数量,而无需更改访问参数的方式。但是您仍然必须避开 BP 正下方的空间;保存更多 regs 意味着您可以将最高的本地 var 放在 [bp-6] 而不是 [bp-4]

脚注:“far proc”具有 32 位 CS:IP 返回地址,因此在这种情况下 args 从 [bp+6] 开始。请参阅 @MichaelPetch 的 cmets,了解如何让 MASM 等工具使用 args 和本地变量的符号名称为您解决这个问题。


另外,对于backtracing up the call stack,这意味着你的调用者的bp值指向你调用者的堆栈帧中保存的BP值,形成一个调试器可以跟踪的BP / ret-addr值的链表强>。在mov bp,sp 之前做更多的推送会让BP 指向别处。另请参阅 When do we create base pointer in a function - before or after local variables? 了解更多详情,关于 32 位模式的一个非常相似的问题。 (注意32位和64位代码可以使用[esp +- x]寻址方式,但是16位代码不行。16位代码基本上是强制设置BP为帧指针来访问自己的栈帧。)

堆栈跟踪是mov bp,sppush bp 成为标准约定之后的主要原因之一。与其他一些同样有效的约定相反,比如做所有的推送和然后mov bp,sp

如果你push bplast,你可以在结尾的 pop/pop/ret 之前使用leave 指令。 (这取决于 BP 指向保存的 BP 值)。

leave instruction 可以将代码大小保存为mov sp,bp 的紧凑版本; pop bp。 (这不是魔法,仅此而已。不使用它完全没问题。enter 在现代 x86 上非常慢,永远不要使用它。)如果你还有其他流行音乐要做,你就不能真正使用 leave第一的。在add sp, whatever 将 SP 指向您保存的 BX 值之后,您执行pop bx,然后您不妨只使用pop bp 而不是leave。所以leave 仅在生成堆栈帧但之后不推送任何其他寄存器的函数中有用。但是,例如,sub sp, 20 确实保留了一些额外的空间,所以sp 不是仍然指向你想要pop 的东西。

或者您可以使用类似这样的方法,因此堆栈 args 和本地变量的偏移量与您推送/弹出的 BP 数量无关。我认为这没有任何明显的缺点,但是也许有一些原因我错过了为什么它不是通常的约定。

func:
    push  bp
    mov   bp,sp
    sub   sp, 16   ; space for locals from [bp-16] to [bp-1]
    push  bx       ; save some call-preserved regs *below* that
    push  si

    ...  function body

    pop   si
    pop   bx
    leave         ; mov sp, bp;   pop bp
    ret

现代 GCC 倾向于保存所有调用保留的 regs之前 sub esp, imm。例如

void ext(int);  // non-inline function call to give GCC a reason to save/restore a reg

void foo(int arg1) {
    volatile int x = arg1;
    ext(1);
    ext(arg1);
    x = 2;
 //   return x;
}

gcc9.2 -m32 -O3 -fno-omit-frame-pointer -fverbose-asm on Godbolt

foo(int):
        push    ebp     #
        mov     ebp, esp  #,
        push    ebx                                       # save a call-preserved reg
        sub     esp, 32   #,
        mov     ebx, DWORD PTR [ebp+8]    # arg1, arg1    # load stack arg

        push    1       #
        mov     DWORD PTR [ebp-12], ebx   # x = arg1
        call    ext(int) #

        mov     DWORD PTR [esp], ebx      #, arg1
        call    ext(int) #

        mov     DWORD PTR [ebp-12], 2     # x,
        mov     ebx, DWORD PTR [ebp-4]    #,      ## restore EBX with mov instead of pop
        add     esp, 16   #,                      ## missed optimization, let leave do this
        leave   
        ret     

使用mov 而不是pop 恢复调用保留的寄存器让GCC 仍然使用leave。如果你调整函数以返回一个值,GCC 会避免浪费 add esp,16


顺便说一句,您可以通过让函数在不保存/恢复的情况下至少销毁 AX 来缩短代码。即将它们视为call-clobbered, aka volatile。正常的 32 位调用约定具有 EAX、ECX 和 EDX volatile(如上例中 GCC 的编译目标:Linux 的 i386 System V),但存在许多不同的 16 位约定。

具有 SI、DI 或 BX volatile 之一将使函数可以访问内存,而无需推送/弹出其调用者的副本。

Agner Fog's calling convention guide 包含一些标准的 16 位调用约定,有关现有 C/C++ 编译器使用的 16 位约定,请参见 table at the start of chapter 7。 @MichaelPetch 建议使用 Watcom 约定:AX 和 ES 总是被调用破坏,但 args 在 AX、BX、CX、DX 中传递。用于 arg 传递的任何 reg 也被调用破坏。 SI 在用于传递指向函数应该存储大返回值的位置的指针时也是如此。

或者在极端情况下,根据对该函数及其调用者最有效的方式,为每个函数选择自定义调用约定。但这很快就会成为维护的噩梦;如果您想要这种优化,只需使用编译器并让它内联短函数并将它们优化到调用者中,或者根据函数实际使用的寄存器进行过程间优化。

【讨论】:

  • DOS 的调用约定(不包括你自己的)与现代的调用约定有很大的不同和变化,并且因编译器而异。应该注意的是,通过远调用到达的 16 位代码中的函数将在 bp+6 处具有第一个参数。所以它不一定总是正确的,它取决于你正在创建的函数的性质。与具有基于堆栈的调用约定的 MASM 一样,您可以在 PROC 上和之后使用 MASM 指令来说明参数和局部变量是什么(按名称)&让汇编器处理计算 BP 偏移量的苦差事
  • 在.COM 程序的情况下,默认模型很小(类似于small),所以它是一个近距离调用。在其他模型中,默认值可能是远调用(段:偏移)地址。使用 PROC 和 MASM 本地指令来定义函数可以减少这些令人头疼的问题,因为它从模型默认值(或 PROC 覆盖)中知道,如果某物是近还是远,并将 ret 更改为 retnretf。编写可以在不同模型中组装的代码要容易得多。
  • @MichaelPetch:好点。我想编辑开头的段落以提及远程序,但决定不把它弄乱,只谈谈最简单的情况。也许是一个脚注。你有什么好的 16 位调用约定和一组精心挑选的调用破坏规则的建议吗?
  • 我使用的约定是 Watcom C 16 位。他们创建了第一个通过寄存器传递约定的编译器(微软后来用他们自己的版本模仿了它)。我相信 Agner Fog 调用约定包括该约定的细节。您总是可以通过调用约定将人们引导至 Agner 的文档。无论您决定选择哪种语言,只要保持一致就更容易,如果与具有特定约定的语言交互,则必须选择合适的语言。
  • 一个问题的答案与本次讨论模糊相关,但确实提供了有关如何使用 MASM 指令来简化参数传递的想法,可以在此处找到:stackoverflow.com/questions/36293714/…。需要注意的是,一些旧的 MASM 版本不支持所有指令,并且 EMU8086 也受到限制。我不知道这个人在用什么。
【解决方案2】:

在我的程序中我一般使用第二种方法,即先创建栈帧。这是使用push bp \ mov bp, sp 完成的,然后可选地push ax 一次或两次或lea sp, [bp - x] 为未初始化的变量保留空间。 (我让我的堆栈帧宏创建这些指令。)然后您可以进一步选择压入堆栈以保留空间并同时初始化更多变量。在变量之后,可以推送要在函数执行期间保留的寄存器。

您没有在问题中作为示例列出第三种方式。它看起来像这样:

PROC func:
    push ax
    push bx
    push bp
    mov bp, sp
    ;Bla bla
    ret
ENDP func

对于我的使用,第二种和第三种方式很容易实现。如果我先推送内容,我可以使用第三种方式,然后在创建堆栈框架时在我的 lframe 宏调用中指定我称之为“how large the return address and other things between bp and the last parameter are”的内容。

但是在设置帧之后总是推送寄存器更容易(第二种方法)。在这种情况下,我总是可以将“框架类型”指定为near,这几乎完全等同于2;之所以如此,是因为接近 16 位的返回地址占用了 2 个字节。

这里是an example of a stack frame,通过推送它们来保存寄存器:

        lframe near, nested
        lpar word,      inp_index_out_segment
        lpar word,      out_offset
        lpar_return
        lenter
        lvar dword,     start_pointer
         push word [sym_storage.str.start + 2]
         push word [sym_storage.str.start]
        lvar word,      orig_cx
         push cx
        mov cx, SYMSTR_index_size

        ldup

        lleave ctx
        lleave ctx

                ; INP:  ?inp_index_out_segment = index
                ;       ?start_pointer = start far pointer of this area
                ;       ?orig_cx = what to return cx to
                ;       cx = index size
.common:
        push es
        push di
        push dx
        push bx
        push ax
%if _BUFFER_86MM_SLICE
        push si
        push ds
%endif

这里使用第二种方式有一点优势:初始栈帧实际上是由不同的函数入口点创建的多次。这些通过在.common 处理中推送寄存器来轻松共享保存。如果在推送寄存器以保留它们的值之后,每个入口点的不同介绍都将随之而来,那么这将无法轻松实现。


除此之外,没有太大的区别,没有。但是,将先前的 bp 值保持在word [bp](第二种或第三种方式)可能对调试器或其他软件遵循the chain of stack frames 有帮助甚至需要。同样,第二种方式可能很有用,因为它将返回地址保持在word [bp + 2]

【讨论】:

  • 为什么是lea sp, [bp - x] 而不是sub sp, x - 2*n_pushes?我认为无论哪种方式,它的代码大小都是相同的,除非sub 可以使用imm8,而lea 需要disp16,因为一些推动会有所不同。对于具有堆栈引擎(Pentium M 及更高版本)的现代 Intel 的性能,我认为两者都需要a "stack sync" uop,即使在后端只写使用 SP 也是如此。但是,在 PPro / PIII 上,读取 BP 而不是 SP-after-more-push 会缩短依赖链。是这个原因吗?
  • @Peter Cordes:如果有的话,我会针对尺寸进行优化。我决定使用lea,因为它同样短,但不修改标志。很少,我将标志输入到函数中,或者在入口点设置标志然后使用lreserve 宏,或者在lframe x, innerlleave 中返回标志。所有这些都是使用lea 完成的,除了提到的第一个可能使用push ax(它也不会修改标志)。顺便说一句,我的堆栈通常在 512 字节到 1 KiB 范围内,因此不建议创建超过 255 字节的堆栈帧。
  • 啊,保留标志是一个很好的理由,我没有考虑过这种差异。考虑编译器生成的代码很容易忘记所有其他可能性,即使我试图记住它们。 (标准的 C 调用约定非常有限,例如只返回 1 个值导致 API 设计失败,如 memcmp 丢弃差异的位置。或者可能是因为它们是为像 C 这样的语言设计的。)
  • @Peter Cordes:标志保留和使用lea 的另一个功能是documented for the lenter macro,另一个是lenter early 之后的lenter 实现“使用lea sp, [bp - x] 所以它有多少变量已经通过推入它们被初始化并不重要。” (lreserve 也是如此。)如果您想改用 sub sp, x - y,则必须跟踪有多少变量已保留堆栈空间以确定 y。
  • @Peter Cordes:有趣的是,我一开始是actually did use sub for the normal lenter usage。这是在添加 lenter earlylreserve 之前。
【解决方案3】:

更常见的是先设置堆栈帧。这是因为您的函数的参数通常可以在堆栈中找到。您可以使用相对于 bp 的固定(正)偏移量来访问它们。 如果先压入其他寄存器,则参数在栈帧中的位置会发生变化。

如果您需要在堆栈上分配本地存储,您可以从 sp 中减去一个常量以创建一个空白空间,然后压入其他寄存器。这样,您的本地存储相对于 bp 的(负)偏移量在您将更多或更少的寄存器压入堆栈时不会改变。

【讨论】:

  • 函数参数高于返回地址,距离 BP 为 个偏移量。此外,您使用sub sp, constant 为本地人保留空间,而不是sub bp, const,因此他们低于 BP,高于 SP。
  • 绝对正确。我一直在研究一个向上增长堆栈的 PIC 微 ;)
猜你喜欢
  • 2011-10-03
  • 1970-01-01
  • 2020-03-04
  • 1970-01-01
  • 2014-01-09
  • 1970-01-01
  • 2013-01-22
  • 2015-01-15
  • 2012-01-28
相关资源
最近更新 更多