序言:
每一个函数运行时都需要为函数中的局部变量,函数参数,返回值等开辟一块独立的空间,这个空间是在栈内存上开辟的,和局部变量一样也会随着栈的销毁而销毁,但是我们不清楚具体过程是怎么样的比如:
- 局部变量是怎么创建的?
- 为什么局部变量不初始化是随机值
- 函数是怎么传参的?传参顺序是怎么样的?
- 形参和实参的关系?
- 函数调用是怎么做的?
- 函数调用结束后是怎么返回的?
- 我们通过这篇文章来了解栈帧的具体创建与销毁过程
补充知识
寄存器
寄存器是CPU内部用来存放数据的一些小型存储区域,用来暂时存放参与运算的数据和运算结果
寄存器前缀为e的是32位机器上的寄存器,寄存器前缀位r的是64位机器上的寄存器
常见寄存器种类
通用寄存器名称 功能 AX 累加器。有些指令约定以AX(或AL)为源或目的寄存器 BX 基址寄存器。BX可用作间接寻址的地址寄存器和基地址寄存器 CX 计数寄存器。CX在循环和串操作中充当计数器,指令执行后CX内容自动修改,每次执行一次值-1 DX 数据寄存器。用来存放数据
指针和变址存储器名称 功能 BP 基址指针寄存器。存放指向栈底的指针 SP 堆栈指针寄存器。存放指向栈顶的指针 SI 源变址寄存器。存放地址 DI 目的变址寄存器,存放地址 常见汇编指令
- push 实现压入操作的指令
具体操作:先修改栈顶指针使栈顶指针的值减少一个字节(32位机器),将push的操作数压入新的栈顶
解释:将栈顶指针esp的值减4,并且将ebp的值压入栈顶
- pop 实现弹出栈中一个数据的指令
具体操作:将sp所指向的数据复制给pop的操作数,sp的值变成sp+s(s是弹出数据所对应的字节数)
解释:将esp所指向的值赋给edi中,esp的值增大
解释:将esp的值给ebp,esp的值保持不变
解释:将0cccccccch这个值给eax
- add 不带进位的加法指令
解释:将esp的值+8再赋给esp
- sub 不带借位的减法指令。
解释:将esp-0E4h的值赋给esp
- call CALL指令用于调用其他函数
具体过程:将call指令的下一条指令入栈,调用call操作数所对应的函数
解释:调用call指令时会将add指令的地址压栈 ,并且进入call指令处的代码所指向的子程序
- ret 程序返回栈顶所指向的地址(记录函数调用的入口,从出口出来),通常是call指令的下一条指令,并且弹出栈顶
解释:将栈顶弹出,返回到之前call的下一条指令
- lea(load effective address) 通常和mov rep stos指令一起使用,lea把源操作数的地址偏移量传送目的操作数
- rep rep指令是重复执行该指令后面的汇编代码,执行次数由寄存器ecx控制。
stos(Store String Data) 将寄存器ax里的内容(一个字节或一个字)存储到内存单元Di,同时CPU自动修改Di,使其指向下一个元素((DI))←(AX或AL),(DI)←(DI)±1或2
解释:将ebp-024h这个地址值赋给edi,将ecx的值置为9,将eax的值置为0cccccccch
最后一步中,dword为双字类型(4字节) ,ptr是指针的缩写es:[edi]表示edi的值(是一个地址),所以最后一句应该理解为将eax的值赋给地址值为edi的数据对象中,这个数据对象是双字类型的(4字节),每完成这样一个动作后edi+-1使edi指向下一个元素,这个动作重复ecx(9)次
main函数栈帧的创建
main函数是被系统调用的函数,具体来说,main函数是被一个名字为__tmaniCRTStartup的函数调用,而__tmainCRTStartup函数又是被一个名为mianCRTStartup的函数调用,这个函数才是真正被系统调用的函数
从这张图我们可以看出来__tmainCRTStartup是被mainCRTStartup函数调用的d
本章直接从main函数的栈帧开始研究,不研究__tmainCRTStartup和mainCRTStartup函数的栈帧
函数代码
为了更清楚的观察变量的创建,我们需要将步骤拆分非常细
#include <stdio.h> int Add(int a, int b) { int z = 0; z = a + b; return z; } int main() { int a = 10; int b = 20; int c = 0; c = Add(a, b); printf("%d", c); return 0; }
转到对应的反汇编代码(不同版本的编译器反汇编的结果不完全相同,越高级的编译器反汇编获得的信息越少,下面反汇编代码使用的是VS2013)
为main函数准备栈帧空间
注:栈区的使用习惯是先使用高地址后使用低地址,所以这里空间是从下往上使用
2. 为初始化main栈帧做准备
3. 初始化main栈帧
注意
:看到这里我们就应该知道了为什么不给局部变量初始化会是随机值,因为在给main函数开辟栈帧时已经main的栈帧已经被系统初始化了(不同的编译器初始化的值不同),所以如果没给变量初始化则打印的就是系统初始化的值(VS2013中是cccccccc)
4. 初始化变量
5. 函数形参压栈
- 注意:我们注意到eax的值就是形参b的值,ecx的值就是形参a的值,而在这里我们可以看出来压栈顺序是先eax后ebx,所以参数压栈的顺序是从右至左
形参压栈后就是call指令了
Add函数栈帧的创建
注意:下一步才是正式进入Add函数内部
1. Add函数栈帧准备
2. 执行Add函数函数体
所以返回值是通过寄存器变量带回来的(这里是eax)
- 注意:调用Add函数可以观察到函数的形参不是在调用函数时才传递,而是先传递形参,传递好了之后再调用该函数
- 函数的返回值是通过寄存器变量带回来的,所以即使局部变量销毁作为全局存在的寄存1器可以成功将返回值带出来
栈帧的销毁
Add函数已经结束了,返回值此时被eax寄存器存起来了,接下来是Add函数局部变量的销毁
执行三条pop语句
回收Add函数栈帧空间
- mov语句
- pop语句
注意:此时pop语句esp指向的值是main函数中的ebp ,即main函数的栈底,所以pop后ebp重新指向main函数的栈底
此时esp和ebp维护的就是现在存在的空间
所以可以不考虑esp与ebp之外的空间
回收形参
- 执行ret语句
ret语句是根据当前esp所指向的值返回,因为当前esp存放的是call指令下一条指令的地址,通过执行ret语句回到main函数
注意:此时已经回到主函数了,该执行call下面这条语句
注意:执行ret语句栈顶还要弹出来一次,所以此时esp+8是指向edi的值
从这里可以看出我们形参的销毁是再函数栈帧销毁后才销毁
主函数赋值
执行mov语句,将eax的值赋值给地址为ebp-20h的数据对象
主函数中创建的变量c所在的地址就是ebp-20h,所以成功将主函数中的c赋值为30,这个值是通过寄存器eax带回来的
至此,我们终于弄明白了函数栈帧的创建与销毁的具体过程~
总结
我们最后回顾一下函数调用具体执行的步骤
每一个函数调用都会产生对应的函数栈帧,函数形参的空间是在函数栈帧开辟前就已经存在的了,并且函数的形参是从右到左依次压栈,在调用函数时会通过call指令将函数需要返回的位置记录到当前栈顶,当函数即将执行完时,函数的返回值会通过寄存器变量带出来(如果有的话),并且此时栈顶指针指向栈底指针所指向的位置,栈底指针弹出,栈底指针指向main函数的栈底,栈顶指针指向call指令的下一条指令,此时执行ret语句,栈顶指针就会弹出,程序回到主函数call的下一条语句,此时被调函数的栈帧空间已经完全销毁,接下来销毁形参空间,最后将返回值通过寄存器赋给需要接受的变量