在创建堆栈帧之前或之后推送寄存器之间有什么区别吗?

Is there any difference between pushing registers before stack frame creation or after?

假设我有一个名为 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

我应该在我的程序中使用什么?一种方法比另一种更好还是"more correct"?因为我目前使用的是第一种方法

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

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

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

这有利于人类的可维护性;您可以在不更改访问参数的方式的情况下更改 save/restore 的寄存器数量。但是你还是要避开BP正下方的space;保存更多 regs 意味着您可以将最高本地变量放在 [bp-6] 而不是 [bp-4].

脚注:"far proc" 有一个 32 位 CS:IP return 地址,所以在这种情况下 args 从 [bp+6] 开始。请参阅@MichaelPetch 关于让 MASM 等工具使用 args 和局部变量的符号名称为您解决这个问题的评论。


此外,for backtracing up the call stack, it means that your caller's bp value points a saved BP value in your caller's stack frame, forming a linked list of BP / ret-addr values a debugger can follow. Doing more pushes before mov bp,sp would leave BP pointing elsewhere. See also 有关此的更多详细信息,关于 32 位模式的一个非常相似的问题。 (注意32位和64位代码可以使用[esp +- x]寻址方式,16位代码不行,16位代码基本上是强制设置BP作为帧指针来访问自己的栈帧。 )

I 堆栈跟踪是 mov bp,sp 紧随 push bp 成为标准约定的主要原因之一。与其他一些同样有效的约定相反,比如做你所有的推动和 then 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 仅在创建堆栈帧但之后不会压入任何其他寄存器的函数中有用。但是确实保留了一些额外的 space 和 sub sp, 20,所以 sp 并不是仍然指向你想要 pop.

的东西

或者您可以使用类似这样的东西,这样堆栈参数和局部变量的偏移量与您 push/pop 除了 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 倾向于在 sub esp, imm 之前保存任何调用保留的 reg。例如

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。如果将函数调整为 return 一个值,GCC 会避免浪费 add esp,16.


顺便说一句,您可以 通过让函数在没有 saving/restoring 的情况下销毁至少 AX 来缩短您的代码。即,将它们视为 call-clobbered, aka volatile。普通的 32 位调用约定有 EAX、ECX 和 EDX volatile(就像上面示例中 GCC 编译的内容:Linux 的 i386 System V),但存在许多不同的 16 位约定。

让 SI、DI 或 BX 中的一个成为易失性函数可以让函数访问内存而无需 push/pop 其调用者的副本。

Agner Fog's calling convention guide includes some standard 16-bit calling conventions, see the table at the start of chapter 7 用于现有 C/C++ 编译器使用的 16 位约定。 @MichaelPetch 建议 Watcom 约定:AX 和 ES 总是被调用破坏,但 args 在 AX、BX、CX、DX 中传递。任何用于 arg-passing 的 reg 也会被 call-clobbered。当 SI 用于将指针传递到函数应存储大 return 值的位置时也是如此。

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

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

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

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”。

但是在设置框架之后总是压入寄存器更容易(第二种方法)。在这种情况下,我总是可以将 "type of frame" 指定为 near,这几乎完全等同于 2;之所以如此,是因为近 16 位 return 地址占用 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。同样,第二种方法可能有用,因为它将 return 地址保持在 word [bp + 2].