从汇编代码看函数栈帧的创建和销毁【通俗版】

前言

读完本文,你将知道局部变量是怎么创建的,为什么局部变量的值是随机值,函数是怎么传参的,传参的顺序是怎样的,形参和实参是什么关系,函数调用是怎样的。需要注意的是,本文用的是vs2013,函数调用过程中栈帧的创建是略有差异的,具体细节取决于编译器的实现

铺垫

我们首先要知道寄存器的概念,寄存器是能高速存储的一块空间有限的存储部件。是为了配合cpu处理数据而存在的。

在汇编代码中寄存器的符号有eax ebx ecx edx等

而本文中最主要讲的寄存器符号为ebp和esp。这两个寄存器中存放的是地址。而这两个地址是用来维护函数栈帧的。

那么是怎么维护的呢?

需要注意的是,每一个函数的调用都要在栈区创建一个空间。

随便写一个足够简单的代码

此时main函数的创建就在栈区中开辟了一块空间

在反汇编层就是这样的,main函数会独自开辟一段空间

既然函数都是被调用的,那么main函数也是被调用的

当我们调试代码,结束运行出栈时,就会出现这个函数叫__tmainCRTStartup()

而我们经常写的main函数结尾return 0 就是给图中的mainret返回的

我们接着往上翻,即可找到这个函数

而main函数就是在这个函数的内部进行调用的

眼尖的朋友这时就会注意到了,在调用堆栈窗口,这个函数的下一行还有个函数。而这个函数就是调用__tmainCRTStartup()的函数 ,它叫做mainCRTStartup()

此时我们就可以对函数栈帧开辟有个大概的了解。

每个函数的调用都会开辟一段空间,可以知道,先是开辟了mainCRTStartup()函数空间,然后再调用并开辟__tmainCRTStartup()的空间,最后再开辟main函数的空间。然后在main函数中调用的Add函数,则为Add函数额外再开辟一段空间。

正文

main函数的空间开辟

那么具体过程和细节是怎样的呢?我们接着来看。

首先是 push ebp 就是压栈的意思。将ebp压入栈顶。而这个ebp是原本存放__tmainCRTStartup()的栈底地址的

这是因为在调用main函数前,会先给__tmainCRTStartup()创建空间,而ebp和esp两个寄存器会先存储着__tmainCRTStartup()的栈顶和栈底

这时此时的ebp和esp存放的地址

在push ebp将__tmainCRTStartup()的ebp压入栈顶后esp会自减4个字节,向上移动

esp变为0x003afcb0

接着是mov ebp,esp 意思是把esp的值赋给ebp

也就是说,原本由esp保存着的栈顶地址现在给了ebp。

也即ebp挪动到栈顶来了

我们可以看到此时ebp和esp存放的值都变成了0x003afcb0

然后是 sub esp,0E4H

sub是减,意思是esp - 0E4H

因为我们画的图往上是低地址,而函数也是向低地址方向开辟空间的。

所以esp - 0E4H意思是让指针往上移

其中0E4H是十六进制数字

此时esp变为0x003afbcc

在内存的监视窗口中,我们也可以看见esp在ebp上面

具体化来说就如下图所示

然后是三步压栈操作,这三个也是寄存器,且每一次压栈,esp的值都要减4,往上移动。

因为esp是维护栈顶的

lea 意思是load effective address加载有效地址

这里的ebp-0E4H就是我们上面sub的操作(因为那时的esp和ebp存放的地址是相同的)

也就是说这里拿到的是esp的地址

lea这一步是将esp的地址加载到edi这个寄存器中

mov ecx ,39H是将39H存到ecx这个寄存器中

mov eax,0CCCCCCCCH则是将0CCCCCCCCH存入eax中

这两个数据都是16进制形式,H作后缀只是编译器决定的。我们只要知道这两个数是十六进制数字就行

然后是最关键的一步

rep stos dword ptr es:[edi]

这一步是进行初始化空间的操作。

其中dword代表double word

word表示两个字节

dword就是四个字节(也就是32个bit位)

而这一步的意思是将edi以下的空间进行初始化,一次初始化操作4个字节

将4个字节的空间初始化为eax中存储的数据0CCCCCCCCH

一共操作ecx次 ,这里是39H

此时我们可以看见一堆空间的值被初始化为CCCCCCCC

到这一步,main函数的栈帧才创建完毕

函数的调用

(这里意思意思画点cccccccc)

之后正式进入到main函数内部

变量的创建和初始化

mov dword ptr [a],0AH 就是将10赋值给变量a的地址上

b和c的操作类似

其实从这里我们也可以看出ebp和esp是怎么来维护main函数的栈帧的。

在main函数中开辟的变量都是基于ebp的位置得出的

因为之前初始化空间为CCCCCCCC的原因,如果不赋值访问变量的话

就会显示烫烫烫……

可以看到,这里编译器每个变量的创建都隔了8个字节

至于为什么这么分配变量的空间取决于编译器。

形参的创建

然后是函数的调用,这里我们将知道函数是如何传参的。

首先是mov 和push的指令

将在ebp-14h处的值给eax 也就是b变量的值(mov)

然后再将eax压栈(push)

同样的操作对a变量进行

从这一步就可以看出,形参其实就是实参的一份拷贝

在函数调用前,就创建好并压入栈中

调用

然后到call操作

call就是调用函数的意思

执行call时,会压入call指令的下一条指令的地址到栈顶

这是为了在函数调用完毕出栈时,能接着从call的下一条指令继续执行

我们可以从内存监视窗口看到这些一一对应起来

调用函数的栈帧创建

接下来跳到Add函数栈帧的创建,其过程是和main函数栈帧创建是一样的

所以不再赘述

这里对z局部变量的空间分配和初始化也是和main函数中的类似

调用函数中的代码执行

接着就是加和的操作

此时有朋友会问,x,y我们什么时候创建的?

其实早在创建Add函数栈帧之前,x,y的值就已经创建好了。

我们看汇编代码

mov eax,dword ptr [ebp+8]

ebp+8是将指针下移

因为高地址在下,低地址在上,对指针++是将指针往下移动

从内存函数监视窗口可以看到ebp+8就指向了我们之前压入的ecx 即a变量传参的值(临时拷贝的,并不是在main函数内部的真实的a的值)

所以这里先是将a参数的值放入eax,然后将b参数的值与eax中的a加和(add)

最后mov dword ptr [ebp-8] ,eax

就是将eax(即a和b加和后的值30)赋值给ebp-8地址处的空间

可以看到这里的值变为1e (十六进制)转换为十进制就是30

最后return z

将刚得到的30放入eax中

所以我们调用完函数的返回值并不是直接返回去的

而是先寄存在寄存器中

调用函数的栈帧销毁

最后pop出栈

依次pop掉在Add栈帧上创建的edi esi ebx

然后将Add的ebp赋值给esp

也就是让管理栈顶的指针下移

然后pop掉ebp

最后ret返回

可以看到我们直接返回到了call指令的下一条指令。这就是之前调用开始的时候

先在栈顶压入call指令下一个指令地址的意义

是为了调用完函数时仍能继续执行主函数

此时我们在地址监视窗口输入esp可以发现esp指向了

我们先前创建的a、b变量的临时拷贝(也就是a,b变量传参的值)

此时esp+8 相当于pop掉了这两个参数

所以我们之前经常听到的

传值调用的参数(形式参数)只是实参的临时拷贝就是这么来的

形式参数的调用并不会改变实际参数的值,且在函数调用完成后就会销毁

然后mov ptr [ebp-20h],eax

就是将eax(之前存放着加和后的30)赋值到ebp-20h这块空间

而从上面可知 ebp-20h就是c变量在main函数中的地址

到这里我们整个函数栈帧的创建和销毁的过程就已经讲解完成了。了解函数栈帧的创建和销毁对后续递归的理解意义重大,看不懂的可以直接在评论区提问,非常乐意为你解答。

  • 25
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值