图解->函数栈帧的创建与销毁

 序言:

每一个函数运行时都需要为函数中的局部变量函数参数返回值等开辟一块独立的空间,这个空间是在栈内存上开辟的,和局部变量一样也会随着栈的销毁而销毁,但是我们不清楚具体过程是怎么样的比如:

  • 局部变量是怎么创建的?
  • 为什么局部变量不初始化是随机值
  • 函数是怎么传参的?传参顺序是怎么样的?
  • 形参和实参的关系?
  • 函数调用是怎么做的?
  • 函数调用结束后是怎么返回的?
  • 我们通过这篇文章来了解栈帧的具体创建与销毁过程

补充知识

寄存器

寄存器是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的值增大 


  • mov 把一个字节、字或双字的操作数从源位置传送到目的位置,源操作数的内容不变属于复制性质,不属于搬家性质

 解释:将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的下一条语句,此时被调函数的栈帧空间已经完全销毁,接下来销毁形参空间,最后将返回值通过寄存器赋给需要接受的变量

猜你喜欢

转载自blog.csdn.net/m0_74278159/article/details/129795037