
🔥个人主页:艾莉丝努力练剑 ❄专栏传送门:《C语言》 🍉学习方向:C/C++方向 ⭐️人生格言:为天地立心,为生民立命,为往圣继绝学,为万世开太平

前言:今天我们介绍的知识和前面介绍的有所不同,学习函数栈帧的创建与销毁就像修炼内功,要反复学习,能对整体有更进一步的理解,不仅如此,搞定了函数栈帧的创建与销毁,也能搞懂后期更多的知识。我们将学习函数是怎样向内存申请空间以及在程序结束后又是怎样回收空间的。
博主碎碎念环节 ——
开篇之前,我们先要明确学习这一部分内容的目标——实际上可以反过来这样想——学习了这一部分内容之后,又解决了我们在前一阶段学习中的哪些困惑,这样反过来想一想就起到了正向反馈的作用,1+1>2的效果。
先前我们学习的时候,是不是对下面这些问题感到困惑?
1、局部变量是如何创建的? 2、为什么局部变量的值是随机值? 3、函数是怎么进行传参的?以及传参的顺序是怎么样的? 4、形参和实参的关系是怎么样的? 5、函数调用是怎么做到的? 6、函数调用结束后又是怎样返回的?
等等……这些困惑在博主介绍完今天的内容后都将迎刃而解!
我们在观察函数栈帧的创建与销毁的过程中最好不要使用太高级的编译器,比如VS2019、VS2022,越高级的编译器,越不容易我们学习和观察,而且不同编译器下,函数调用过程中栈帧的创建是略有差异的(大体逻辑是一致的),具体细节还要看编译器的实现,我们要接受这种差异,或者说不同。
用VC6.0来观察函数栈帧的创建与销毁的过程足够简单而且便于观察,但考虑到很多友友们电脑上可能没有装这个编译器,所以本文在讲解的过程中就使用VS2013的环境啦。
在C语言中,函数栈帧是指在函数调用过程中,在内存栈中为该函数分配的一块空间,用于存储函数的局部变量,参数,返回地址等信息。
栈帧的结构:
参数区:用于存放调用函数时传递给被调用函数的参数; 返回地址:记录函数调用结束后要返回的指令地址,以便函数执行完毕后能正确回到调用点继续执行; 局部变量区:存储函数内部定义的局部变量; ebp和esp相关区域:ebp指向当前栈帧的底部,esp指向当前栈帧的顶部,通过这两个指针来维护函数栈帧。
像eax、ebx、ecx、edx,我们在后面的介绍都会碰到它们的,还有ebp、esp,大家记住ebp、esp这两个寄存器,非常重要,这俩哥们就是我们今天的主角。
下面是几个寄存器的功能:
eax:通用寄存器,保留临时数据,常用于函数返回值; ebx:通用寄存器,保留临时数据; eip:指令寄存器,用于存储下一条要执行的指令的地址。
函数栈帧:
ebp、esp这两个寄存器中存放的是地址(由于本文发的比较晚,博主在本文更新前就已经把C语言的全部内容更新完了,大家要是对地址感兴趣,可以去看看博主更新的指针部分,一共分了六篇,详细讲述了指针的绝大部分内容),这两个地址是用来维护函数栈帧的。
每一次函数调用,都要在栈区创建一个空间。
注意:栈区的使用习惯是先使用高地址再使用低地址。
我们通过代码演示和图解来介绍这部分的知识点:
#define _CRT_SECURE_NO_WARNINGS 1
#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;
}比如上面这段代码,我们画出栈区的示意图:

esp指向栈顶,也叫栈顶指针;ebp指向栈底,也叫栈底指针。 因此,esp维护的是栈顶(顶部),ebp维护的是栈底(底部)。
栈区的使用就像搭房子,一块砖一块砖往上砌。因为栈区在使用过程中,从高地址指向低地址,空间在被消耗,如果再使用空间就相当于在上面使用空间,其实就是放数据的时候在顶上放数据。下面我们再要新空间的时候就可以观察到了。

我们在上面的代码中可以观察到Add函数被main函数给调用了,而我们使用【调用堆栈】观察函数时发现main函数起始也被调用了,那我们知道main函数是被谁给调用了吗?这个我们要在VS2013上观察,如果用VS2022版本来调试不容易看出来。

我们再按F10往下走,这里出现了一个叫__tmainCRTStartup()的函数:

这个函数往下翻我们看到了main()函数,这说明了什么问题?这说明main函数实际上也是被别人调用的,如下图:

而调用main函数的这个 __tmainCRTStartup()函数实际上也是被别的函数给调用的,就是这个mainCRTStartup(如下图),其实这个调用逻辑还是比较复杂的,但是大家还是要知道有这么一回事:

我们通过几张截图来简单看一下这个调用的过程:

这个 __tmainCRTStartup是在这儿被调用的,而它又是被谁调用的呢——

它又是被这个函数调用的,也就是说,这个mainCRTStartup调用了 __tmainCRTStartup, __tmainCRTStartup又调用了我们的main函数,main函数调用完有一个返回值(每一次我们调用完不是有一个返回值嘛),这个return 0 实际上就返回放到mainret里面去了:

如下图:

希望大家注意这个调用逻辑。
在VS2013中,main函数也是被其他函数调用的:
main --> __tmainCRTStartup --> mainCRTStartup

为什么要先讲这些内容哩?接下来我们来看——
我们每次调用函数不是要分配对应的空间嘛,调用main函数的时候我们为main函数分配了栈帧,程序往下走,走到Add函数这里,我们又要为Add函数分配栈帧,像这样:

我们先调用这两个函数,调用到main函数再为main函数分配栈帧,到Add函数再为Add函数分配栈帧空间,说到这里,希望友友们对栈帧分配的思路有了一个大致的了解,下面我就带大家看看具体是怎么做的,我们还是结合截图——这边鼠标右击给到反汇编:

这边我们就得到了C语言对应的汇编代码:

看到ebp、esp了吗?还记得它们是什么吗?没错,他们是寄存器,存放了地址,是用来维护函数栈帧的,一个是栈底指针(ebp),一个是栈顶指针(esp)。
图中的push、mov、sub、lea、rep stos dword(double word:双字,4个字节;word:2个字节)ptr es:[edi]等等都是我们接下来讲解的重点内容。

箭头指向这儿,就可以一行一行往下执行了。
这里我们还要进行一个操作:转到反汇编之后要记得把显示符号名关掉,便于观察——

因为我们一会儿要看这个地址,符号名在的话不方便,所以我们要把这个符号名去掉:

我们把这个【显示符号名 】去掉,如果友友们的没有这个【显示符号名 】勾选的话,我们可以选择右击鼠标,找到【显示符号名(S)】,不要勾选就可以了:

这里我们已经进到main函数(马上要调用main函数)了,那另外一个调用main函数的函数的栈帧是不是已经创建好了? 那我们就从这里开始看——

这个push就是压栈,刚才说了,栈区在使用过程中,从高地址指向低地址,空间在被消耗,如果再使用空间就相当于在上面使用空间,其实就是放数据的时候在顶上放数据,那么我们压栈也是压在栈顶上,如下图,此时栈顶的ebp存放的是栈底指针的值:

ebp挪到这儿去了,空间就变成了这样:

esp维护的是栈顶的指针,所以也要挪上去。看到这儿,我们打开监视,输入这两个寄存器(里面存放了地址),按F10,push完之后·地址会有什么变化哩?

我们对比一下按F10前后esp地址的变化,上图是按F10之前,下图就是压栈之后。

esp地址变小了!我们讲过:在栈区使用空间是由高地址指向低地址的,上面是低地址,下面是高地址,地址变小了,是不是就说明压栈是在栈顶进行的?是不是就说明压栈(ebp)已经压进去了?

这里根据大小端字节序,VS是小端,要倒着读,是不是就是008ffbf4 ?正是esp的地址,是不是就说明我们确实把这个值给压进去了?
接下来我们再往下看,这个mov是什么意思?

mov ebp,esp//把后面的值赋给前面的值
我们按F10看看,如下图,是不是确实把esp的值赋给ebp了:

接下来是sub,我们观察一下:

esp减了个什么哩——0E4h——这是多少?

我们在监视输入0E4h,发现是一个十六进制的数,如果你想知道这个值是多少,我们右击关闭十六进制显示,发现这个值的十进制是228:

sub减完之后,esp地址变小了,上面是低地址,esp要往上挪:

这时候我们终于为main函数做点事情了,我们为main还是创建了栈帧空间:

再往下看,又压了三个值进去:

先压进去一个ebx:

再压进去一个esi:

再压进去一个edi:

这时候我们在顶上压了三个元素,此时esp已经挪到上面去了,这里我们可以得出一个小结论:
push完了esp也要往上压,以维护栈顶空间。

再往下看,有个lea,lea即
load effective address:加载有效地址。
汇编指令总结:
push:将操作数压入栈中,栈顶指针esp也会相应调整; mov:数据传送指令,用于在寄存器之间,寄存器与内存之间传送数据; lea:加载有效地址,将操作数的地址加载到指定的寄存器中; sub:减法指令,用于将两个操作数相减,结果存放于指定的寄存器中; add:加法指令,用于将两个操作数相加,结果存放于指定的寄存器中; call:调用——过程调用,压入返回地址或转入调用函数; pop:弹出一个元素,从栈中弹出数据到指定的位置,栈顶指针esp也会相应调整; ret:返回地址指令,回到调用位置。
push(压栈)的意义是什么?
压栈是在栈顶上一直往上增加元素,是在当前已有元素的上面。
压栈就是在给栈里面放了一个元素——从顶上往前放一个元素进去——这个过程就叫压栈; pop(出栈):那么反之,从栈顶拿走一个元素这个过程就叫出栈。

加载有效地址就相当于在edi里面放了一个地址进去,我们这里要是感觉不好观察的话,把【符号名显示】再打开就看见了:

刚才说了,esp-0E4h到ebp之间维护的空间就是main函数的函数栈帧。

这里真正正儿八经起到作用的是最后一句,前文也说了,一个word是两个字节,一个dword(double word)就是四个字节。
mov ecx,39h——ecx里面放的是39h次是一会儿在这进行复制的时候准确的一个次数,ecx,39h就是把39h的值放到ecx这个寄存器里面。
从edi(ebp-0E4h)往下39h次这么多个dword(四个字节)的数据全部初始化成这样的数据:


这四句话的意思其实就是把从edi开始向下的ecx的值39h这么多个dword(四个字节)的数据,每次都初始化四个字节,总共初始化ecx次,把它们全部初始化成cc cc cc cc,初始化eax里面的内容。
接下来正式进入函数内部:

我们这里准备好了三个变量,然后才是函数调用——

接下来我们就要调用Add函数了。

call是调用,我们这里就不能按F10了,要按F11,而且:

call指令调用又把下一个地址压进去了。
下面这里和前面同理了:

完整示意图:

这边执行完我们要返回(我们这里还没有返回),我们怎么回去?

这里说把ebp-8的值放到eax里面去,这个eax可是个寄存器,不会因为程序退出而销毁。这个ebp-8是谁啊,根据画出的示意图,ebp-8就是z的值,为什么放在eax里面?因为程序退出,z就销毁了,放在寄存器里面就安全了,相当于用一个全局的寄存器先把值保存起来,等回到主函数再把值用起来就可以了。接下来:

我们之前为什么要存call指令的下一条指令的地址,就是为了能在回来之后,接着从call指令的下一条指令的地址开始执行,就是要走得出去又回得来。
我们再pop一下,就把x、y的空间也还给操作系统了,这就是形参空间的销毁,形参是什么时候销毁的、怎么把空间还给操作系统的,我们就介绍完了:

这个就是Add函数的销毁,理解Add函数的销毁,那么想必main函数的销毁大家也能理解了。

先是调用堆栈:

然后是函数栈帧的创建:
#include<stdio.h>
int main()
{
//函数栈帧的创建
005518D0 push ebp
005518D1 mov ebp, esp
005518D3 sub esp, 0E4h
005518D9 push ebx
005518DA push esi
005518DB push edi
005518DC lea edi, [ebp - 24h]
005518DF mov ecx, 9
005518E4 mov eax, 0CCCCCCCCh
005518E9 rep stos dword ptr es : [edi]
005518EB mov ecx, 55C008h
005518F0 call 0055132F
005518F5 nop
//main函数中的主要代码
int a = 10;
005518F6 mov dword ptr[ebp - 8], 0Ah
int b = 20;
005518FD mov dword ptr[ebp - 14h], 14h
int c = 0;
00551904 mov dword ptr[ebp - 20h], 0
c = Add(a, b);
0055190B mov eax, dword ptr[ebp - 14h]
0055190E push eax
0055190F mov ecx, dword ptr[ebp - 8]
00551912 push ecx
00551913 call 005510B9
00551918 add esp, 8
0055191B mov dword ptr[ebp - 20h], eax
------------------------------------------------------------
printf("%d\n", c);
0055191E mov eax, dword ptr[ebp - 20h]
00551921 push eax
00551922 push 557B30h
00551927 call 005510D7
0055192C add esp, 8
return 0;
0055192F xor eax, eax
}我们把main函数栈帧创建过程主要部分单独拆解,如下:
005518D0 push ebp
//把ebp寄存器中的值进行压栈,到了esp-4的位置,此时的ebp中存放的是invoke_main函数栈帧的ebp
005518D1 mov ebp, esp
//将esp的值存放到ebp中,相当于ebp来到了invoke_main函数栈帧的esp位置,产生了main函数的ebp
005518D3 sub esp, 0E4h
//将esp中的地址减去一个16进制数字0E4h,esp向上移动,产生了新的esp,也就是main函数的esp
//结合上面产生的ebp之后,ebp和esp之间就维护了一块为main函数开辟的栈帧空间
005518D9 push ebx
//将寄存器ebx中的值压栈,esp-4,esp向上移动
005518DA push esi
//将寄存器epi中的值压栈,esp-4,esp继续向上移动
005518DB push edi
//将寄存器edi中的值压栈,esp-4,esp接着向上移动
005518DC lea edi, [ebp - 24h]
005518DF mov ecx, 9
005518E4 mov eax, 0CCCCCCCCh
005518E9 rep stos dword ptr es : [edi]
//以上这四串代码是在初始化main函数的栈帧空间
//1.先将ebp-24h的地址加载到edi中
//2.将9放入ecx中
//3.将0xCCCCCCCC放入eax中
//4.将从ebp-24h到ebp之间ecx个4个字节的数字初始化为0xCCCCCCCC再回顾一下main函数的主要代码:
//main函数的主要代码
int a = 10;
005518F6 mov dword ptr[ebp - 8], 0Ah
//将0Ah存储到ebp-8这个地址中,ebp-8的位置其实就是变量a
int b = 20;
005518FD mov dword ptr[ebp - 14h], 14h
//将14h存储到ebp-14h这个地址中,ebp-14h的位置其实就是变量b
int c = 0;
00551904 mov dword ptr[ebp - 20h], 0
//将0存储到ebp-20h这个地址中,ebp-20h的位置其实就是变量c
//以上就是局部变量在其所在函数的栈帧空间中创建和初始化的过程
c = Add(a, b);
0055190B mov eax, dword ptr[ebp - 14h]
// 先传参b,将ebp-14h位置中b的值存储到eax中
0055190E push eax
//将eax的值压栈,esp-4,向上移动
0055190F mov ecx, dword ptr[ebp - 8]
//再传参a,将ebp-8位置中a的值存储到ecx中
00551912 push ecx
//将ecx的值压栈,esp-4,继续向上移动
//跳转调用函数
00551913 call 005510B9
//call指令会将call指令的下一条指令的地址进行压栈操作
//这样做可以让函数调用结束后回到call的下一条指令地址后继续执行
00551918 add esp, 8
0055191B mov dword ptr[ebp - 20h], eax我们再看看Add函数的反汇编——
//Add函数的反汇编
int Add(int x, int y)
{
00551790 push ebp
//将ebp压栈,到了esp-4 的位置,此时的ebp是main函数中的ebp
00551791 mov ebp, esp
//将esp的值给了ebp,ebp来到了原来main函数esp的位置,形成了Add函数的ebp
00551793 sub esp, 0CCh
//将esp-00ch,形成了Add函数的esp,结合前面的ebp,ebp和esp维护了一块为Add函数开辟的栈帧空间
00551799 push ebx
//将ebx压栈,esp-4,向上移动
0055179A push esi
//将esi压栈,esp-4,向上移动
0055179B push edi
//将edi压栈,esp-4,向上移动
0055179C lea edi, [ebp - 0Ch]
//将ebp-0ch的地址加载到edi中
0055179F mov ecx, 3
//将3放入exc中
005517A4 mov eax, 0CCCCCCCCh
//将0CCCCCCCCh放入eax中
005517A9 rep stos dword ptr es : [edi]
//将ebp-0ch到ebp之间ecx个4个字节的数字初始化为0CCCCCCCCh
005517AB mov ecx, 55C008h
005517B0 call 0055132F
005517B5 nop
int z = 0;
005517B6 mov dword ptr[ebp - 8], 0
//将0的值给到ebp-8中
z = x + y;
005517BD mov eax, dword ptr[ebp + 8]
005517C0 add eax, dword ptr[ebp + 0Ch]
005517C3 mov dword ptr[ebp - 8], eax
return z;
005517C6 mov eax, dword ptr[ebp - 8]
}
005517C9 pop edi
005517CA pop esi
005517CB pop ebx
005517CC add esp, 0CCh
005517D2 cmp ebp, esp
005517D4 call 00551253
005517D9 mov esp, ebp
005517DB pop ebp
005517DC ret这里很好地说明了形参是实参的一份临时拷贝——
/很好地说明了形参是实参的一份临时拷贝
z = x + y;
005517BD mov eax, dword ptr[ebp + 8]
// 将ebp+8地址处的数字存储到eax中
005517C0 add eax, dword ptr[ebp + 0Ch]
// 将ebp+12地址处的数字加到eax寄存中
005517C3 mov dword ptr[ebp - 8], eax
//将eax的结果保存到ebp-8的地址处,其实就是放到z中
return z;
005517C6 mov eax, dword ptr[ebp - 8]
//将ebp-8地址处的值放在eax中,其实就是把z的值存储到eax寄存器中,通过eax寄存器带回计算的结果最后就是函数栈帧的销毁——
//函数栈帧的销毁
005517C9 pop edi //在栈顶弹出一个值,存放到edi中,esp+4,向下移
005517CA pop esi //在栈顶弹出一个值,存放到esi中,esp+4,向下移
005517CB pop ebx //在栈顶弹出一个值,存放到ebx中,esp+4,向下移
005517CC add esp, 0CCh //esp+0cch,向下移
005517D2 cmp ebp, esp //esp的值给ebp,ebp来到了esp的位置
005517D4 call 00551253
005517D9 mov esp, ebp //再将ebp的值给了esp,回收了Add函数的栈帧空间
005517DB pop ebp
//弹出栈顶的值存放到ebp,栈顶此时的值恰好就是main函数的ebp,esp+4,恢复了main函数栈帧空间的维护
005517DC ret
//ret指令的执行,首先是从栈顶弹出一个值,此时栈顶的值就是call指令下一条指令的地址,此时esp+4,向下移动,然后直接跳转到call指令下一条指令的地址处,继续往下执行函数执行完毕后,栈帧被销毁,通过恢复ebp和esp的值,释放栈帧空间,将控制权返回给调用函数,继续执行调用函数中调用之后的代码,然后我们就回到了call指令的下一条指令的地址, 最终将空间一一回收到操作系统。
还记得我们开篇的这几个问题吗?现在我们可以一一回答了:
1、局部变量是如何创建的? (1)首先为函数分配好栈帧空间; (2)栈帧空间里面我们初始化完一部分空间之后,再给局部变量在栈帧里面分配空间。 2、为什么局部变量的值是随机值? 因为随机值是我们放进去的,局部变量如果不初始化,因为局部变量来自这里:

这里的值是我们随机放进去的,如果我们初始化,相当于我们把局部变量的随机值覆盖了。 3、函数是怎么进行传参的?以及传参的顺序是怎么样的? 其实在我们要去调用函数之前,我们就已经把这两个参数push从右向左压栈压进去了,当我们真正进入形参函数的时候,其实在Add函数的栈帧里面,通过指针的偏移量找回了形参。 4、形参和实参的关系是怎么样的? 形参确实是在压栈过程中开辟的空间,它和实参只是值相同,空间是独立的,所以形参是实参的一份临时拷贝,改变形参不会影响实参。 5、函数调用是怎么做到的? 上文已经介绍得很详细了。 6、函数调用结束后又是怎样返回的? 我们在调用之前就记住了call指令下一条指令的地址,把调用的函数的上一个函数的栈帧的ebp就已经存进去了,当函数调用完要返回时当我们pop弹出ebp就能找到上一个函数的ebp,然后指针往下走的时候就能回到esp的顶,回到我们的栈帧空间,因为我们记住了call指令下一条指令的地址,当我们往回返回的时候我们就可以直接跳转到call指令的下一条指令的地址,让我们函数调用之后可以返回,返回值是通过寄存器的方式带回的。
补充:
(1)只要函数调用完了,esp、ebp回收了,这些空间就全都还给操作系统了,即使主函数里面指针指向形参的那片空间,也是白指向的,因为空间已经还给操作系统了,我们这里只讨论局部变量(函数内部如果创建了静态变量就是在全局开辟的);
(2)ebp是个寄存器,汇编指令中pop了ebp的意思是把栈顶上那个元素弹出来,放到ebp里面去,见下图: 当我们的指针指向这里的时候,这里ebp存的是main函数的地址:

这里存的是下图中的地址——

pop一下弹出去就放到ebp里面去,图一中ebp的值就更新成图二中ebp的地址了,ebp就相当于指向图二中的地址了。
(3)不用考虑函数预开辟空间不够这种问题,编译器会主动计算好空间,给每个函数预开辟的空间是不一定一样的,由编译器决定;
(4)寄存器不在内存上,寄存器是集成到CPU上的,跟main函数没关系; ——硬盘、内存、寄存器都是相互独立的
(5)如上图中a、b的位置不是相邻的,中间隔了一块空间,具体实现方式、中间开辟多少空间完全取决于编译器,编译器不同可能会有所差异;
(6)如果传输地址的话也会在子函数那里开辟空间,不为指针开辟,也要为其他变量开辟,一个代码可能是比较复杂的;
(7)计算机删除数据是直接允许后续操作直接覆盖;
(8) ebp、esp的地址跟函数的地址没什么关系;
(9)函数的形参不在函数的栈帧里面吗?可以这样理解: 刚刚的函数栈帧拓展了——

因为这些空间的增长、开辟都是在main里完成的,可以这样理解。Add函数确实是在上面。
本文就没有往期回顾啦!
结语:本篇文章到这里就结束了,本文和大家分享了函数栈帧的创建与销毁相关的一些重要知识点,本文的内容可以说是起到了一个承上启下的作用,友友们一定要自己去尝试、自己去动动手指,亲自去尝试观察一下,这样学习的效果会更好。如果友友们有补充的话欢迎在评论区留言,在这里感谢友友们的关注与支持!