C语言:那些不为人所知的函数栈帧的创建和销毁(底层知识)

前排提示:本文偏难!C语言作为一门偏底层的语言,了解到函数栈帧的创建和销毁还是非常有必要的。这对于我们理解C语言底层知识是有着非常大的帮助的,能够修炼我们的内功,不管你了不了解,本文都是强推!

本文受到

Code_Caoicon-default.png?t=LA92https://blog.csdn.net/qq2466200050  

原来45icon-default.png?t=LA92https://blog.csdn.net/weixin_62700590

两位大佬文章的启发

本文开篇我们提出5个问题:

1.局部变量是怎么创建的?
2.为什么局部变量的值是随机值?
3.函数是怎么传参的?传参的顺序是怎样的?形参和实参是什么关系?
4.函数调用是怎么做的?
5.函数调用是结束后怎么返回的?

认真看完这一篇文章你都能一一解答。

先看代码,这是我们今天研究的代码。

#include<stdio.h>

int ADD(int x, int y)
{
	int z = x + y;
	return z;
}

int main()
{
	int a = 10;
	int b = 20;

	int c = ADD(a, b);
	printf("%d", c);
}

 

目录

1.基础理解 

1.1 寄存器

1.2 函数

1.3 栈帧 

2.main函数栈帧的创建

3.main函数栈帧的初始化

4.函数的调用

5.函数的销毁

6.问题解答

3.函数是怎么传参的?传参的顺序是怎样的?形参和实参是什么关系?

4.函数调用是怎么做的?

5.函数调用是结束后怎么返回的?


1.基础理解 

1.1 寄存器

电脑中主要的本地存储方式有寄存器、内存、硬盘。硬盘最大,读取速度最慢。而寄存器的空间很小,但是速度是最快的,并且寄存器是集成在cpu上面的。它们可用来暂存指令、数据和地址。

通俗一点解释就是:

寄存器就是你的口袋。身上只有那么几个,只装最常用或者马上要用的东西。

内存就是你的背包。有时候拿点什么放到口袋里,有时候从口袋里拿出点东西放在背包里。

硬盘就是你家里的抽屉。可以放很多东西,但存取不方便。

 本文我们研究的寄存器主要是:ebp esp 

1.2 函数

C语言是由函数为单元模块构成的,每一次我们用C语言编写程序的时候,都需要写一个主函数main()。我们了解函数栈帧的创建和销毁,其实就在研究一个代码是如何实现的。

我们需要用到的是 汇编代码 。

1.3 栈帧 

C语言中,每个栈帧对应着一个未运行完的函数。栈帧中保存了该函数的返回地址和局部变量。栈帧也叫过程活动记录,是编译器用来实现过程/函数调用的一种数据结构。首先应该明白,栈帧是存放在内存中的栈区的。栈帧是栈区分配给进程的内存区

 

 2.main函数栈帧的创建

当我们创建了一个main函数,ebp和esp寄存器里面存的就是这个函数的首位地址,用来维护这一块空间。

我们在进入这个程序的时候,首先需要调用main函数,你没听错,就是调用它。那么是谁调用的main函数呢?它是被一个简称叫做CRT的函数所调用的。

以VS2019为例,我们转到反汇编

 可以看到这个seh函数调用了SRT函数(这里说法可能有误有错误请及时指出)

 然后SRT函数再调用了main函数(这里说法可能有误有错误请及时指出)

 

用图表示出来就是esp和ebp在维护函数CRT的空间。我们接下来再看了解主函数是如何创建函数栈帧的。

这里的push代表的是压栈,就是将ebp放到栈顶。并且esp的指针也要向低地址移动。此时变成了:

 接着我们看下一条指令:

mov是move的缩写,意思是将esp赋值给ebp,实质是将esp里面的地址放到ebp里面。(也就是两个指针指向了同一位置)

这里提一句:此时ebp和esp没有在维护CRT函数了,但他的函数栈帧没有销毁,因为栈空间的使用只能先销毁低地址,再销毁高地址。即图中的从上往下销毁。

 我们接下来看第三条指令:

 sub是减法的缩写,意思是将esp这个地址减去E4这个16进制数字。E4转化为十进制就是228,那么就是说将esp从高地址向低地址移动228以后存放起来。变成了如下图:

 

接下来看第4,5,6条指令:

 这就是把ebx、esi、edi放到栈顶。esp也会对应着变化。至此一个main函数的栈帧就创建成功了

 

3.main函数栈帧的初始化

我们往后面看四个指令,lea指的是load efficitve address,即加载有效地址,把后面的地址给edi。

mov就是move的意思,看到第三条和第四条指令,就是把ebp到edi之间的空间全部初始化成为

cc cc cc cc,这些 cc cc cc cc是随机值,被初始化的空间大小为24(16进制)。

dword即double word,就是4个字节。

 

 

 

 可以很明显的看到,ebp到edi这一部分被初始化成为了cc cc cc cc(随机值)。

main函数栈帧开辟完了,接下来就是执行代码了。

4.函数的调用

 接着往下看,这是一个move指令,意思是把这个地址存放到ecx中去,就是将返回的地址存放起来,等会再回访这个地址执行下一条指令。

  接下来就是a和b的初始化。把0A(16进制数)存放到ebp-8的地址中间去,把14(16进制数)存放到epb-14的地址中去。这样子a和b的值就存放到内存中去了。

 这里a和b存放的位置取决于编译器,相隔多远也取决于编译器。

综上:局部变量a,b,c的创建就是先创建一个函数栈帧,然后在里面找到

一块空间,再把a,b,c放进去。

紧接着就要进行传参数的操作了。eax、ecx在此之前是没有出现过的寄存器,存放的是

ebp-14h的值,也就是b的值20,接着push一下。ecx也是同理,就变成了这样子(也可以看到函数传参的时候是先传递后面的参数)

 至此,我们把调试的f10按钮改成按f11按钮,进入ADD函数的反汇编代码中,我们仍然一步一步的进行解析。我们看到了一个call:

 

这个call指令是干什么的呢?call指令call了一个地址,这个地址是什么呢?

其实这个地址是执行完ADD函数以后返回来的一个地址,到时候ADD函数销毁以后直接找到这个地址就能继续完成代码。

 再次按下f11,反汇编代码就进入了ADD函数的内部,我们一步一步来看:

 这个ebp就是main函数的ebp,此时变成了这样子:

 经过之前的一系列操作以后,又出现了跟main函数相同的内容:把esp的值赋给ebp,esp向上移动0CCh,再push ebx,esi,edi

 这样子ADD函数就开辟好了空间,之后的操作也是跟main函数里面是一样的 

 接下来进入加法的部分

 这里很关键,ebp+8和ebp+0Ch,这两个地方并不是新开创的空间,而是之前a和b传递的值的拷贝,也就是

 也就是说传递的参数根本没有进入ADD函数里面去,而只是保存在寄存器里。

把两eax相加以后,再把eax的值放到ebp-8里面(也就是z)。这样子就完成了一次加法。

也可以证明,函数在调用的时候是自右向左传参的(ADD(a,b)先传b)。

同时再次证明了,形参是实参的一份临时拷贝!

5.函数的销毁

我们继续看反编译的代码:

这就是把30这个值放到eax这个全局的寄存器里面去,,然后接着的是三个pop(pop就是在栈顶弹出这个元素,可以理解为销毁): 

但是还有很大一块ADD开辟的空间还存在,怎么办呢?

 很简单,只要把ebp赋给esp就行了。这时候ebp和esp指向同一个地址,上面的ADD开辟的空间就被销毁了。计算出来的值已经存到eax中了。

 紧接着:

 pop了ebp,这个ebp是main函数的ebp。main函数的栈顶是很容易找到的,栈底并不好找。于是可以把ebp存在原来main函数里面,先push再pop后ebp回到得地方还是main函数栈底的位置。这样子ebp一下子就回到了栈底的位置。

 之后出现了ret指令:

为什么要在栈顶存上一个call指令的下一条指令地址?其实ret指令返回的时候就是返回的call指令的下一条指令地址,那么就直接返回到了这个地址里面。

 因为执行完call后,会直接进入到ADD函数中去,等到ADD函数销毁了,程序进行到这一步时,就会自然而然访问地址里面。

 再往后就是eax的值存到了c中去

 这样子,c的值就被打印出来了。

最后还要把形参销毁,这里销毁的方式比较简单,直接让esp加上8个字节,那么两个形参就不在esp和ebp的范围之内了。

 

6.问题解答

1.局部变量是怎么创建的?

首先为函数分配好栈帧空间,然后初始化一部分空间为cc cc cc cc,然后给局部变量在栈帧里面分配一点空间,把局部变量的值直接写入到内存中去,这就是局部变量的创建。


2.为什么局部变量的值是随机值?

因为不初始化的时候里面的随机值是我们放进去的。初始化成cc cc cc cc就是随机值。


3.函数是怎么传参的?传参的顺序是怎样的?形参和实参是什么关系?

当我们要调用函数的时候,其实在我们还没有调用函数的时候,我们就已经开始push从右向左开始压栈,压到了内从中,当我们真正进入函数内部的时候,通过指针的偏移量,找回了我们的形参,这就是函数的形参以及他的使用。

形参确实是我们在压栈的时候开辟的空间,他和我们的实参只是值是相同的,空间是独立的,所以形参是实参的一份临时拷贝,改变形参,不会影响实参。


4.函数调用是怎么做的?

开辟出新的内存空间。


5.函数调用是结束后怎么返回的?

在调用函数之前我们就把call函数的下一条指令地址记住了(压进去了),把ebp调用这个函数的上一个函数的栈帧edp存进去了,当我们函数调用完要返回的时候弹出edp就能找到原始上一个函数的edp,然后指针往下走的时候就能找到esp的地址,回到我们的栈帧空间,然后我们记住了call指令下一条指令的地址,当我们往回返的时候就可以跳转到call指令的下一条指令地址,让我们函数调用可以返回。返回值是通过寄存器的方式带回来的。

至此,函数的函数栈帧的创建和销毁就讲解到这里了,真是肝了整整的一天!觉得作者写的还不错的话留下赞和评论再走吧。要是本文对你有所帮助的话也可以动动手指收藏一下呀。博主会持续更新的!

评论 16
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值