C语言函数栈帧深度剖析
本篇文章将从以下几个方面带领大家深度学习函数栈帧的创建和销毁:
- 局部变量是怎么创建的?
- 为什么局部变量的值是随机值?
- 函数是怎么传参的?传参的顺序怎么样?
- 形参和实参是什么关系?
- 函数调用是怎么做的?
- 函数调用结束后是怎么返回的?
首先大家得明确一点:在不同的编译器下,函数调用过程中栈帧的创建是略有差异的,具体细节取决于编译器的实现,本篇文章是在vs2013的基础上进行演示的
一:学习函数栈帧前的铺垫:
在学习函数栈帧前先要进行一点知识铺垫,这样会有助于我们后面对函数栈帧的理解
1:何为函数栈帧?
函数栈帧是在内存中的栈区为被调函数开辟的一块空间,里面用来存放该函数中定义的变量等东西(下文会详细讲到),当函数运行完毕栈帧将被销毁。再向大家介绍一下**“栈”这个概念,“栈”实际上时一种数据结构**,它是一种先进后出的数据表,何为先进后出?举个简单的例子:就像洗盘子,最先吃完饭的人把盘子放在水池的最低端,比他后吃完饭的人会把盘子落在他盘子上面,当洗碗的时候,会从最上面的盘子开始洗,这也就意味着,虽然你第一个吃完饭,但你的盘子却是最后一个被洗的。这就对应栈的先进后出。对栈常见的操作有两种:
- Push(入栈):为栈增加一个元素,就相当于往水池里放盘子
- Pop (出栈): 从栈中取出一个元素,相当于洗完一个盘子把这个洗过的盘子从水池中拿出来
2:寄存器
- eax:是"累加器"(accumulator), 用来存放函数的返回值。
- ebx:是"基地址"(base)寄存器,可作为储存器指针来使用, 在内存寻址时存放基地址。
- ecx: 是计数器(counter), 在循环和指针操作时,要用它来控制循环次数。
- edx:是"数据寄存器’,在进行乘、除法运算时,可作为默认的操作数参数参与运算。
- ebp和esp:他俩都是指针寄存器它最经常被用作高级语言函数调用的"框架指针"(frame pointer),简单来说这两个寄存器中存放的是地址,这两个地址是用来维护函数栈帧的。
ebp:存放栈底的地址(指向栈底)
esp:存放栈顶的地址(指向栈顶)- edi和esi:它俩都是变址寄存器,常用来配合使用完成数据的赋值操作
3:汇编指令
- move:move A,B (将数据B移到数据A)
- push:压栈(入栈)
- pop:出栈
- call:调用函数
- add:加法
- sub:减法
- rep: 重复
- lea:加载有效地址
4:每一个函数调用都要创建函数栈帧
所有的函数调用都会在内存里面的栈区创建函数栈帧,包括main函数。通过上面对函数栈帧的介绍我们知道,函数栈帧是为被调函数在内存的栈区中开辟的一块空间,所以这里间接证明了,main函数也是被调函数。可能很多小伙伴的认知都停留在,main函数是主函数,可以在main函数中调用其他函数,从来没有想过main函数其实也是被调用的。
二:函数栈帧的创建与销毁详解:
我们以下面这段代码为例,向大家讲解函数栈帧的创建和销毁
#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;
}
2.1:main函数也是被调用的
首先点击F10进行调试,在窗口界面找到“调用堆栈”,点击,调出此窗口,此时我们就能很直观的看出main函数是被调用的。
接下来一直点击F10直到程序调试结束
调试结束后我们发现main 函数被 __tmainCRTStartup 这个函数调用。
2.2:main函数的函数栈帧的创建与销毁
由于main函数是被其他函数所调用,所以在 __tmainCRTStartup 这个函数调用main函数的时候会为main函数在内存的栈区中开辟空间:
这里大家需要注意的是:栈区的使用习惯是先使用高地址再使用低地址,在顶上往进放数据
接下来我们调到反汇编进行调试,深入了解函数栈帧,
第一步:push ebp
通过上图可以看出第一步是push ebp,这是因为mian函数是被__tmainCRTStartup 这个函数调用的,在调用main函数之前,esp和ebp分别指向__tmainCRTStartup 函数的栈顶和栈底,当调用main函数的时候,就要为main函数开辟相应的函数栈帧,此时esp和ebp就需要移动去指向main函数的函数栈帧。那这里的第一步就是push ebp,具体过程如下图:
push ebp就是把__mainCRTStartup 函数栈底的地址压栈,ebp的值压入后,esp指针会上移一位
如上图,再push ebp没有执行的时候,esp里面存的地址是0x0037fdb8,当执行完push ebp后,esp存的地址变成了0x0037fdb4,可见地址减小了4,这就意味着esp指针往上走了4个字节。,通过内存窗口我们也可以很容易看出:
此时esp所指向的地址里面存的数据就是ebp所指向的地址0037fe04,说明此时我们已经成功地把ebp所指向的地址压入栈中。
第二步:move ebp,esp
move ebp,esp的意思是:把esp的值给ebp。
esp当前的值是0x0037fdb4,也就是说esp此时指向0x0037fdb4这个地址,把esp的值给ebp后,ebp就也指向0x0037fdb4这个地址
通过下面的调试我们可以看出执行完第二步后ebp和esp指向同一块地址。
动画演示:
第三步:sub esp,0E4h
sub esp,0ECh,就是给esp减去一个0E4h。这里的0E4h是一个十六进制的数字(h表示是十六进制),0E4对应的10进制数字就是228。这也就意味着esp指向的地址会减小228,对应图示就是esp指针会上移228个字节
如上图,第三步执行后esp的值变成了0x0037FCD0,也就是说此时esp指向0x0037FCD0这个地址
如上图所示,紫色这一块空间就是为main函数申请的空间
第四步:3个push
如上图,此时有三个push操作,也就是分别把ebx、esi、edi压入栈中。
如上图:执行完push ebx后,esp指向的地址减小了4个字节(从0037FCD0变到00F37CCC,前者比后者大4)。此时esp所指向的地址里面存储的就是ebx的值005c200,这说明ebx成功压栈。剩下的两个push esi和push edi也是这样入栈,这里就不一一讲解了,可以通过下面的动图进行深入理解。
第五步:lea加载有效地址
lea是load effective address(加载有效地址)的缩写。而 lea edi,[ebp-0E4h]的意思就是把ebp-0E4h这个地址放到edi里面。还记得第二步move ebp,esp嘛?。执行完第二步后ebp和esp指向了同一个地址,然后第三步sub esp,0E4h,让esp指向的地址减了0E4h(228),,此后ebp指向的地址没有发生任何变化,第四步的3个push操作让esp指向的地址又减小了12(一次push减小4,3次push就减小12)。而当前的第五步中的地址ebp-0E4h也就是在执行完第三步后esp所指向的地址,就是要把这个地址放到edi里面(其实就是让edi指向这个地址,因为edi是一个变址寄存器,用存储地址的)如下图:
通过上面两张图可以看出,确实如我们上面分析的那样:执行完lea指令后edi指向的地址就是在第三步执行结束后esp所指向的地址(0x0037fcd0)。
图示如下:
第六步:把main函数里面的空间全初始化
执行完lea后,我们来看下面这三行汇编指令,这三行汇编指令放在一起只为了执行一件事情,所以把这三条指令放在一起看。(分别标上序号1 , 2 ,3如下图所示:)
- 序号1指令中的ecx是一个计数器,该指令完成的操作是把39h(39h是十六进制数,对应十进制数57)存到ecx里。
- 序号2指令完成的操作是把CCCCCCCC存到eax里。
- 序号3指令中rep的目的是重复其上面的指令,而重复的次数就是ecx存的值,stos的目的是把eax中的值拷贝到es:[edi]所指向的地址里面。一个word代表两个字节,dword其实就是double word的缩写,所以dword代表4个字节。因此,一次拷贝4个字节,重复57次,那最后就一共拷贝了4×57=228个字节
也就是说,在执行完这三条指令后edi所指向的地址(0x0037fcd0)和其后面的228个地址里面所存储的内容全被赋值为CCCCCCCC,其实这里0x0037fcd0后面的第228个地址就是ebp所指向的地址。也就是说从edi所指向的地址一直到ebp所指向的地址中间这一部分全被赋值成CCCCCCCC。
值得注意的是:在执行完这三条指令后,edi所指向的地址已经发生改变,此时的edi和edp指向同一地址。
第七步:main函数中变量的创建
1:变量a的创建
接着就来到了红框圈起来的指令了,mov dword ptr [ebp-8],0Ah的意思是:把0Ah(对应十进制的10)放在edp-8(8是8个字节的意思)这个地址里面。
可见执行完这条指令后,原本ebp-8这个地址所指向空间中的CCCCCCCC被替换成了0Ah(10),说明变量a已经创建完毕。
动图演示:
这里也说明了一个问题,如果只是对变量进行声明而不进行初始化赋值,那内存里面存的就是CCCCCCCC(随机值,只是vs2013放的是CCCCCCCC,其他的编译器可能是其他的值),这也就是为什么我们有时打印变量会出现“烫烫烫烫”的原因
2:变量b的创建
和上面一样,这里的mov dword ptr [ebp-14h],14h指令执行的操作就是把14h(对应十进制的20)存到ebp-14h这个地址所指向的空间里。 注意,这里出现的两个14h没有任何关联,如果让b等于其他任何一个整数,始终都是把这个整数存到ebp-14h所指向的这个空间里面。
可以看出在vs2013这个编译器上,变量a和b在内存中的存储地址相差了8个字节,至于相差几个字节这以取决于编译器,不同的编译器会有不同的效果。
3:变量c的创建
三:在main函数中调用Add函数:
1:传参
接下来就到了调用Add函数的时候了。
此时出现了两组mov和push。第一组中的mov eax,dword ptr [ebp-14h]意思是,把ebp-14h这个地址里面存放的值(也就是20)赋值给eax,然后push(压栈)eax。同理,第二组先把ebp-8这个地址里面存的值(也就是10)赋值给ecx,然后push(压栈)ecx。
经过这两次压栈后,esp指针指向的地址减小8个字节(一次减小4个字节,也就是1个整型)
动图演示:
以上就是函数调用时的传参过程
2:进入Add函数
在执行call指令之前,先看一下call指令下一条指令的地址,也就是add指令的地址003F1450
执行call指令时我们需要点击F11才能进入到Add函数的内部去一探究竟。
点击F11执行了call指令后,我们不但进入了Add函数的内部,还让esp指针上移了4个字节,为什么会上移呢?那一定是又元素压栈,因为只有这样才能让栈顶指针上移,那我们再来看一下压入的元素是什么呢?不难发现,压入的这个值就是call指令下一条指令add的地址003F1450。
那为什么要把call指令下一条指令的地址存起来呢?是因为在Add函数调用结束的时候,需要返回继续执行call指令的下一条指令,所以在执行call指令的时候要把call指令的下一条指令的地址存下来,在Add函数调用结束的时候,就能根据存的这个地址找到call指令的下一条指令,进而让程序继续进行。
接着再点击一次F11,就真真正正的进入到Add函数的内部了
蓝色的部分是不是特别眼熟,这部分和main函数前面那部分是一样的,就是为Add函数创建函数栈帧。
实际结果:
2.1:Add函数中变量z的创建
这里变量z的创建过程和main函数中变量a、b、c的创建过程一模一样,详细过程就不再进行赘述,忘了的下伙伴可以往上翻翻看看前面的介绍。这里就简单的用动画为大家演示一下:
2.2:Add函数中的求x+y的和
接着往下,终于来到了 z = x + y,要求两数和了:
求和分3条指令来完成
- 首先第一条指令mov eax,dword ptr [ebp+8]执行的结果是:把ebp+8这个地址里面存储的值放到eax里
不难发现ebp+8这个地址里面存的其实就是10(实参a和形参x的值)- 接着第二条指令add eax,dword ptr [ebp+0Ch]的执行结果就是:把ebp+0Ch这个地址里面存储的值加到eax里面去,通过上图可以看出ebp+0Ch这个地址里面存的其实就是20(实参b和形参y的值),第二条指令执行完后eax里面存的值就变成了10和20的和,也就是30。
- 最后第三条指令mov dword ptr [ebp-8],eax执行的结果就是:把eax里面的值存到ebp-8这个地址里面(变量z的地址),也就是;z=30
通过上面的分析可以看出,在调用Add函数的过程中,并没有创建形参x和y,而是在执行call指令调用Add函数之前就已经进行了传参,当时先让eax等于20(也就是实参b的值)先压栈,再让ecx等于10(也就是实参a的值)进行压栈,实参是按照从右到左的顺序进行传递的。当在Add函数中需要用到形参x的时候,是返回去找到ecx取出ecx里面存的值,这个值就是形参x的值。而当Add函数中需要用到形参y的时候,是返回去找到eax取出eax里面存的值,这个值就是形参y的值。
通过这里我们还可以看出,形参实际上只是实参的一份拷贝,改变形参的值不会影响实参
3:return z
接下来,最重要的一步来了:就是把z=30返回到main函数里面,分成以下6条指令来完成:
- 第一条指令mov eax,dword ptr [ebp-8]的执行结果是:把ebp-8里面存的值(也就是30)存到eax里面,这里的eax是一个寄存器,它不会随着函数调用的结束而销毁掉。如果没有这条指令,在Add函数代用调用结束后的时候,ebp-8(变量z的地址)这个地址所指向的空间会被释放掉,这样的话30x+y的和就无处可寻。(上图序号1和2之间的 } 的右边表明Add函数调用结束)
- 接着标号2、3、4的这3条指令都执行的是pop(出栈)操作,比如pop edi的意思就是把栈顶数据弹出至edi这个寄存器里,然后栈顶指针下移,后面两个同理,动画演示如下:
通过实际的调试也可以看出没每执行一次pop指令,esp指针的值就加4. 这和每执行一次push指令,esp的值就减4形成呼应。- 接着执行标号5的指令 mov esp,ebp,这条指令执行的结果就是把ebp存的地址给esp,也就是说,执行完这条指令后,esp和edp指向同一个地址空间。动画演示如下:
- 接着执行标号6的指令:pop ebp。这条指令的执行结果是:把栈顶数据弹出至edp里面进行储存。此时的栈顶数据是什么呢?通过动画不拿发现:此时的栈顶数据其实就是main函数栈底的地址呀,这样以来,执行完这条指令后edp就又指向main函数的栈底了,而此时栈顶存储的数据也变成了call指令下一条子陵的地址。动画演示如下:
- 最后执行ret指令**,ret指令会从栈顶弹出元素给IP,也就是下一条要执行的指令的地址**。此时的栈顶存储的就是call指令下一条指令的地址,这就是为什们当时要存call指令下一条指令的地址。是为了在Add函数调用结束后这个黄色的小箭头能够回到正确的地方继续执行接下来的指令
通过上面的调试动图可以看出:在执行完ret指令之后esp的值从个位上的8变成了个位上的c(c对应十进制下的12),二者相差4。说明确实把栈顶数据弹出了。
动画演示如下:
到这里return z的操作就顺利结束了
四:回到main函数:
此时黄色的小箭头成功地来到了main函数中call指令的下一条指令add,这完全归功于最初我们压栈的add指令的地址。
- 首先执行add esp,8这条指令,该指令的执行结果是让esp指针指向的地址加8,动画展示如下:
这条指令执行完就相当于释放了形参变量的存储空间- 接着执行:mov dword ptr [ebp-20h],eax指令。执行结果是:把eax里面存的值(30)赋值给ebp-20h所指向的这块空间,其实就是变量c的存储空间
通过调试可以看出:在执行这条指令之前ebp-20h所指向的这块空间存的值是0,在执行完这条指令之后,ebp-20h所指向的这块空间里面存的就是1e(十进制下的30)。并且可以看出ebp-20h和&c的值相同,说明他们指向同一块空间——变量c的存储空间.动画演示如下:
现在我们就清楚函数的返回值是怎么带回来的了——返回值首先会放到寄存器里,当我们真的回到主调函数时,需要用到这个返回值的时候,去访问寄存器,从寄存器里面取值。
相信看到这里,你的心中一定有了最开始那几个问题的答案了!!!
到这里,函数栈帧的有关分享就结束啦,喜欢的话可以点赞、评论和收藏哟!
- 点赞
- 收藏
- 关注作者
评论(0)