C语言的内功一一函数栈帧的创建与销毁

在我们刚开始学习C语言的时候,我们可能还有很多困惑的地方。

比如:

局部变量是怎么创建的?

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

函数是怎么传参的,传参的顺序是怎么样的?

形参和实参是什么关系?

函数调用是怎么做的?

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

当看完今天这篇文章之后,一切都将豁然开朗。

目录

前言

预备知识

1.栈区的使用习惯

2.常见的几个寄存器

3.常用的汇编指令

2.变量的创建以及函数传参

3.Add函数栈帧的开辟

4.函数栈帧的销毁


前言

本人今天使用编译器的VS2013,我没有使用VS2019的原因是:越高级的编译器,越不容易去学习和观察,同时在不同的编译器下,函数调用过程中栈帧的创建是略有差异的,具体细节取决于编译器的实现。

预备知识

在正式开始之前还需要先了解以下几个小知识,以便于更好地去理解函数栈帧的创建与销毁。

1.栈区的使用习惯

栈区的使用习惯是会先使用高地址处的空间,再使用低地址处的空间。每个函数的调用,都需要在栈区中开辟一个空间,而这个空间的开辟,都会按照高地址向低地址的方向执行。也就是说在调用函数的时候,会先在栈区的高地址处开辟空间。

2.常见的几个寄存器

寄存器是CPU内部用来存放数据的一些小型存储区域,用来暂时存放参与运算的数据和运算的结果。我们常见的寄存器有:eax,ebx,ecx,edx,ebp以及esp等等。我们今天的两个主角便是ebpesp。ebp,esp这两个寄存器中存放的是地址,这两个地址是用来维护函数栈帧的。ebp通常指向栈底,存储栈底地址,因此又被称为栈底指针esp通常指向栈顶存储栈顶地址,因此又被称为栈顶指针

3.常用的汇编指令

1.add:加法指令,第一个是目标操作数,第二个是源操作数,格式为:目标操作数=目标操作数+源操作数;

2.sub:减法指令,格式同add一样;

3.call:调用函数,一般函数的参数放在寄存器中;

4.ret:跳转会调用函数的地方。对应于call,返回到对应的call调用的下一条指令,若有返回值,则放入eax中;

5.push:把一个32位的操作数压入堆栈中,这个操作在32位机中会使得esp被减去4个字节,esp通常是指向栈顶的(前面有说到),栈中顶部是地址小的区域,那么,压入堆栈的数据越多,esp也就越来越小。

6.pop:与push相反,每当有一个数据push(出栈),esp每次加4给四节。

7.mov:数据传送,第一个参数是目的操作数,第二个是源操作数,它的作用就是把第二个参数拷贝到第一个参数。

8.lea:取得第二个参数地址后放入到前面的寄存器(第一个参数)中。


下面我们通过这段代码和对应的汇编语言来详细的解释函数栈帧的创建与销毁

#include<stdio.h>
int Add(int x, int y)
{
	int z = 0;
	z = x + y;
	return z;
}
int main()
{
	int a = 10;
	int b = 20;
	int c = 0;

	c = Add(a, b);
	printf("%d\n", c);
	return 0;
}
int main()
{
00E41410  push        ebp  
00E41411  mov         ebp,esp  
00E41413  sub         esp,0E4h  
00E41419  push        ebx  
00E4141A  push        esi  
00E4141B  push        edi  
00E4141C  lea         edi,[ebp+FFFFFF1Ch]  
00E41422  mov         ecx,39h  
00E41427  mov         eax,0CCCCCCCCh  
00E4142C  rep stos    dword ptr es:[edi]  
	int a = 10;
00E4142E  mov         dword ptr [ebp-8],0Ah  
	int b = 20;
00E41435  mov         dword ptr [ebp-14h],14h  
	int c = 0;
00E4143C  mov         dword ptr [ebp-20h],0  

	c = Add(a, b);
00E41443  mov         eax,dword ptr [ebp-14h]  

	c = Add(a, b);
00E41446  push        eax  
00E41447  mov         ecx,dword ptr [ebp-8]  
00E4144A  push        ecx  
00E4144B  call        00E410E1  
00E41450  add         esp,8  
00E41453  mov         dword ptr [ebp-20h],eax  
	printf("%d\n", c);
00E41456  mov         esi,esp  
00E41458  mov         eax,dword ptr [ebp-20h]  
00E4145B  push        eax  
00E4145C  push        0E45858h  
00E41461  call        dword ptr ds:[00E49114h]  
00E41467  add         esp,8  
00E4146A  cmp         esi,esp  
00E4146C  call        00E4113B  
	return 0;
00E41471  xor         eax,eax  
}
00E41473  pop         edi  
00E41474  pop         esi  
00E41475  pop         ebx  
00E41476  add         esp,0E4h  
00E4147C  cmp         ebp,esp  
00E4147E  call        00E4113B  
00E41483  mov         esp,ebp  
00E41485  pop         ebp  
00E41486  ret  

通过在VS2013上面对该代码调试与调用堆栈我们发现main是被调用的,那么是被谁调用的呢?下面就以这几张图片来回答(有图有真相嘛)

 

 知道了main函数被谁调用之后,我们再通过上面代码与汇编指令来详细讲一下main函数-函数栈帧的创建与销毁。

00E41410  push        ebp  
00E41411  mov         ebp,esp  
00E41413  sub         esp,0E4h  
00E41419  push        ebx  
00E4141A  push        esi  
00E4141B  push        edi
00E4141C  lea         edi,[ebp+FFFFFF1Ch]  
00E41422  mov         ecx,39h  
00E41427  mov         eax,0CCCCCCCCh  
00E4142C  rep stos    dword ptr es:[edi]  

 首先是push ebp意思就是将ebp这个参数压进去,因为esp一般是指向栈顶的所以当ebp压进去之后esp也会指向ebp的位置。这时esp会向上走,那么它的地址会变小(push一个参数,32位的,esp会减去4个字节,16位则减去两个),通过调用监视窗口我们也可以观察到esp的的值确是在变小

 第二步move ebp,esp 就是把esp的值给ebp,即把ebp的地址指向改为esp的地址。通过观察监视窗口我们也可以看到

再然后就是sub esp,0E4h 意思就是将esp减去0E4h,这时esp就会往上走。此时ebp与esp之间维护的这块空间就是为main函数开辟的栈帧。然后再push ebx,esi,edi则是把ebx,esi,edi压栈,每压一个参数esp的地址就会减去4个字节,此时esp会指向edi的位置上。

 通过调用监视窗口内存窗口我们可以发现ebx,esi,edi的三个值被压进去了。

然后我们再来看看接下来的四步(此时在VS2013里面打开了显示符号名)

00E4141C  lea         edi,[ebp-0E4h]  
00E41422  mov         ecx,39h  
00E41427  mov         eax,0CCCCCCCCh  
00E4142C  rep stos    dword ptr es:[edi]  

这段的作用是:(1)ebp-0E4h的地址放到edi里面去

                       (2)把39h放到ecx里面去

                       (3)把0CCCCCCCCh放到eax里面去

                       (4)从edi这个位置开始以下的39h个4字节都变成eax也就是CCCCCCCC

简而言之就是将ebp到edi-0E4h这段空间都初始化成CCCCCCCC,而CCCCCCCC就是烫对应的乱码。这也就解释了为什么局部变量没有初始化的时候是随机值的原因。

 

 此时为main函数栈帧的开辟就已经准备好了。下面就要来执行正式有效的代码啦。

2.变量的创建以及函数传参

int a = 10;
00E4142E  mov         dword ptr [ebp-8],0Ah  
	int b = 20;
00E41435  mov         dword ptr [ebp-14h],14h  
	int c = 0;
00E4143C  mov         dword ptr [ebp-20h],0 

这段的作用是:(1)将0Ah(也就是10)放到ebp-8的位置上去

                              (2)将14h(也就是20)放到ebp-14h的位置上去

                              (3)将0放到ebp-20h的位置上去

这就是变量创建的过程。 

接下来我们就要调用Add函数以及传参了

c = Add(a, b);
00E41443  mov         eax,dword ptr [ebp-14h]  
00E41446  push        eax  
00E41447  mov         ecx,dword ptr [ebp-8]  
00E4144A  push        ecx  
00E4144B  call        00E410E1  
00E41450  add         esp,8  
00E41453  mov         dword ptr [ebp-20h],eax  
	printf("%d\n", c);

将ebp-14h(也就是20)放到eax里面去,然后将eax压入栈中。将ebp-8(也是就10)放到ecx里面去,接着将ecx压入栈中。其实这就是在传参!!!但是我们可能还感受不到,我们接着看后面,call 执行之后就会将它下一条指令的地址给压进去

3.Add函数栈帧的开辟

int Add(int x, int y)
{
00E413C0  push        ebp  
00E413C1  mov         ebp,esp  
00E413C3  sub         esp,0CCh  
00E413C9  push        ebx  
00E413CA  push        esi  
00E413CB  push        edi  
00E413CC  lea         edi,[ebp+FFFFFF34h]  
00E413D2  mov         ecx,33h  
00E413D7  mov         eax,0CCCCCCCCh  
00E413DC  rep stos    dword ptr es:[edi]  

接着我们再来看这段指令,是不是感觉有点熟悉呢?对,没错 这段指令就是在为Add函数开辟栈帧

这里就不再重复赘述了,这里我们直接看执行完后的图是怎样的。

	int z = 0;
00E413DE  mov         dword ptr [ebp-8],0  
	z = x + y;
00E413E5  mov         eax,dword ptr [ebp+8]  
00E413E8  add         eax,dword ptr [ebp+0Ch]  
00E413EB  mov         dword ptr [ebp-8],eax  
	return z;
00E413EE  mov         eax,dword ptr [ebp-8]  

这段指令的意思是把0的值放到ebp-8(z)的位置,再将ebp+8的值(也就是之前ecx=10这个值)放到eax里面,然后再将ebp+0ch的值(也就是之前eax=20这个值)加上eax现在的值,最后再将我们eax现在的值放到ebp-8(z)里面,此时我们z的值就是30了。

 再然后把ebp-8(也就是z的值)放到ebx里面去。

到这我们就真正清楚了函数是如何传参的以及传参的顺序。同时也明白了为什么说形参是实参的一份临时拷贝。 

4.函数栈帧的销毁

	return z;
00E413EE  mov         eax,dword ptr [ebp-8]  
}
00E413F1  pop         edi  
}
00E413F2  pop         esi  
00E413F3  pop         ebx  
00E413F4  mov         esp,ebp  
00E413F6  pop         ebp  
00E413F7  ret  

            接下来分别对edi,esi,ebx pop也就是将他们弹出栈,再将ebp值赋给esp,然后再把ebp弹到原main函数的最下面的地方,esp指向00E41450,此时又回到了main函数里头。 

  

 

 

 最后的ret指令,就是弹出栈顶(call指令下一条指令的地址),回到main含函数中call指令发出的地方,从而使得我们能够返回去,使得整个程序按流程继续下去。

00E41450  add         esp,8  
00E41453  mov         dword ptr [ebp-20h],eax  
	printf("%d\n", c);

紧接着esp往下加8个字节,再将eax存放的z值(30)给ebp-20h(c).此时我们的形参就销毁了,并且z值也返回了。

到这我想你们对于我刚开始所问的几个问题心里应该已经有了答案。

如果觉得对你有用的话可以点赞关注一波哦!

  • 9
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 11
    评论
评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值