我试图理解一些基本的汇编代码概念,并且陷入了汇编代码如何确定将事物放在堆栈上的位置以及提供多少空间的问题。
为了开始玩它,我在godbolt.org的编译器浏览器中输入了这个简单的代码。
int main(int argc, char** argv) {
int num = 1;
num++;
return num;
}
并获得此汇编代码
main:
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-20], edi
mov QWORD PTR [rbp-32], rsi
mov DWORD PTR [rbp-4], 1
add DWORD PTR [rbp-4], 1
mov eax, DWORD PTR [rbp-4]
pop rbp
ret
这里有几个问题:
sub rsp, 4
那样为本地int腾出空间吗?然后,如果我修改它以添加更多的本地化:
int main(int argc, char** argv) {
int num = 1;
char *str1 = {0};
char *str2 = "some string";
num++;
return num;
}
然后我们得到
main:
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-36], edi
mov QWORD PTR [rbp-48], rsi
mov DWORD PTR [rbp-4], 1
mov QWORD PTR [rbp-16], 0
mov QWORD PTR [rbp-24], OFFSET FLAT:.LC0
add DWORD PTR [rbp-4], 1
mov eax, DWORD PTR [rbp-4]
pop rbp
ret
所以现在主要论点从基指针进一步推下来了。为什么前两个本地之间的空间是12个字节,而后两个本地之间的空间是8个字节?这是因为类型的大小?
我只想回答这部分问题:
在通话之前,参数是否不应放在堆栈上?为什么argc和argv位于当前堆栈帧的基址指针的偏移量20和32处?
main
的参数确实由调用main
的代码设置。
这似乎是根据64-bit ELF psABI for x86编译的代码,其中任何函数的前几个参数都在寄存器中传递,而不是在堆栈中传递。当控制到达main:
标签时,argc
将在edi
,argv
将在rsi
,第三个参数通常称为envp
将在rdx
。 (你没有声明那个参数,所以你不能使用它,但是调用main
的代码是通用的并且总是设置它。)
我相信你指的是指示
mov DWORD PTR [rbp-20], edi
mov QWORD PTR [rbp-32], rsi
是编译器书呆子调用溢出指令的原因:它们将argc
和argv
参数的初始值从其原始寄存器复制到堆栈,以防其他东西需要这些寄存器。正如其他几位人士指出的那样,这是未经优化的代码;这些说明是不必要的,如果您已启用优化,则不会发出这些指令。当然,如果您已经进行了优化,那么您已经获得了根本不接触堆栈的代码:
main:
mov eax, 2
ret
在此ABI中,允许编译器将保存寄存器值的“溢出槽”放在堆栈帧中的任何位置。它们的位置没有意义,可能因编译器和编译器,从同一编译器的补丁级别到补丁级别,或者源代码的明显未连接的更改而异。
(有些ABI确实详细说明了堆栈帧布局,例如IIRC 32位Windows ABI这样做,以便于“展开”,但现在这并不重要。)
(为了强调main
的参数在寄存器中,这是我从-O1
得到的汇编
int main(int argc) { return argc + 1; }
:
main:
lea eax, [rdi+1]
ret
仍然没有对堆栈做任何事情! (除了ret
。))
这是“编译器101”,你想要研究的是“调用约定”和“堆栈框架”。细节依赖于编译器/ OS /优化。简而言之,传入参数可以在寄存器中或堆栈上。输入函数时,它可能会创建一个堆栈帧来保存一些寄存器。然后它可以定义一个“帧指针”来引用堆栈本地和堆栈参数的帧指针。有时堆栈指针也用作帧指针。
对于寄存器,通常有人(公司)会定义一个调用约定并指定哪些寄存器是“易失性的”,这意味着它们可以被例程使用而没有问题,并且“保留”,这意味着如果例程使用它们,它们将会必须在功能进入和退出时保存和恢复。调用约定还指定哪些寄存器(如果有)用于参数传递和函数返回。