在创建堆栈帧之前或之后推送寄存器之间有什么区别吗?
Is there any difference between pushing registers before stack frame creation or after?
假设我有一个名为 func 的函数:
PROC func:
;Bla bla
ret
ENDP func
现在,假设我使用寄存器 ax
和 bx
为例,为了保存它们的初始值,我将它们压入函数内部的堆栈。
现在的问题是:在创建堆栈帧之前压入寄存器之间是否有很大的不同:
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 bp
; mov 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 bp
last,你可以在尾声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]
.
假设我有一个名为 func 的函数:
PROC func:
;Bla bla
ret
ENDP func
现在,假设我使用寄存器 ax
和 bx
为例,为了保存它们的初始值,我将它们压入函数内部的堆栈。
现在的问题是:在创建堆栈帧之前压入寄存器之间是否有很大的不同:
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 bp
; mov 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 [esp +- x]
寻址方式,16位代码不行,16位代码基本上是强制设置BP作为帧指针来访问自己的栈帧。 )
I 堆栈跟踪是 mov bp,sp
紧随 push bp
成为标准约定的主要原因之一。与其他一些同样有效的约定相反,比如做你所有的推动和 then mov bp,sp
.
如果你push bp
last,你可以在尾声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]
.