嗨(^_−)☆,我们又见面啦,今天我们要讲的和前面有所不同,要深入到程序运行的“幕后舞台”——函数栈帧的创建与销毁。这是理解函数调用机制、内存管理的核心环节,每个函数执行时都会搭建专属的“临时内存舞台”,用完后再有序拆除。掌握它,你就能看透函数调用的底层逻辑,对排查栈溢出、理解递归执行等问题也会豁然开朗。可能有点难度,但是我们学会它对我们后面学习会有很大的帮助,那么接下来,我们就一起拆解这个“舞台”的搭建与谢幕全过程~
我把它放在数据结构的栈后面,方便大家更好的理解 也可以在C语言中学习,但是我感觉在栈后面学更好
我们在学这篇之前,我们是不是想要了解它能对我们有什么帮助,能帮我们解决什么问题呢,虽然我已经在前言中提到了,但是可能不够形象,所以我们在这里再具体说一下 在我们之前的学习中,是不是有这样的疑问:
1、局部变量是如何创建的? 2、为什么局部变量的值是随机值? 3、函数是怎么进行传参的?以及传参的顺序是怎么样的? 4、形参和实参的关系是怎么样的? 5、函数调用是怎么做到的? 6、函数调用结束后又是怎样返回的?
这些问题都与函数栈帧有关,只要理解了函数栈帧的创建和销毁,这些问题就能够很好地理解了,让我们一起走进函数栈帧的创建和销毁的过程中吧!
概念:函数栈帧(stack frame)就是函数调用过程中在程序的调用栈(call stack)所开辟的空间,这些空间是用来存放:函数参数和函数返回值,临时变量,保存上下文信息等等; 栈帧的结构 栈帧是函数调用时在栈内存里的专属“小隔间”,核心结构可简单拆为4部分:
int a、数组),内存连续分配。栈是从高地址往低地址生长的,这些部分在栈里按顺序排布,函数执行完就会销毁这个“小隔间”~
这里的栈和我们数据结构的栈不同,但核心十分相似,所以我把它放在数据结构的栈后面进行讲解
这里的栈为程序运行中的栈(栈内存) 栈是现代计算机程序的核心概念之一,可从逻辑定义和系统实现两个维度总结:
esp寄存器定位栈顶。
这里的“栈”需从两个维度理解,二者概念不同但核心逻辑(先进后出)高度关联:
维度 | 数据结构的“栈”(抽象逻辑) | 程序运行的“栈”(物理内存) |
|---|---|---|
本质 | 一种“先进后出”的逻辑数据模型 | 计算机内存中一块连续的物理区域 |
存在形式 | 仅在算法/代码设计中存在(如数组模拟栈) | 真实占用物理内存,由操作系统管理分配 |
操作主体 | 开发者手动实现 push/pop 等接口 | 编译器+CPU 自动执行(函数调用、局部变量存储时触发) |
核心规则 | 严格遵循“先进后出” | 同样遵循“先进后出”,是函数栈帧创建/销毁的底层支撑 |
简言之,数据结构的“栈”是逻辑规则,程序中的“栈”是该规则的物理实现。函数栈帧的创建(压栈)、销毁(出栈),就是借助“栈内存”的先进后出特性来完成的——这也是两者的核心关联。 今天我们讲的就是程序运行的栈(物理内存)
注意:

esp指向栈顶,也叫栈顶指针;ebp指向栈底,也叫栈底指针。 所以,esp维护的是栈顶(顶部),ebp维护的是栈底(底部)。
下面我们来实现函数的调用堆栈,我们先来一个简单的代码来观察 代码如下:
#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;
}这段代码对应的栈区的图如下

我们进行调试窗口中的调用堆栈来观察

按F10进行调试,我们知道Add是被main调用的但我们发现main也是被调用的,是被谁调用的呢

我们继续按F10,我们会发现我们到这个页面

我们可以从这个页面向上翻,可以看出main是被 __tmainCRTStartup()函数调用的,再按F10可以看出 __tmainCRTStartup()函数也是被mainCRTStartup调用的,如下图

在这里我们只需要理解它的调用逻辑就行啦, 总的来说可以用箭头表示,箭头表示被调用,表示main被调用过程 main --> __tmainCRTStartup --> mainCRTStartup 我们每次调用函数都要分配相应的空间,而程序的运行是从高地址到底地址,所以函数分配的空间也是由高地址到低地址,所以函数的栈帧分配如下图:

我们现在已经有了大概的轮廓啦,接下来我们来看一下它的具体调用过程
可能大家还有所疑惑,那么接下来我们来看一下具体的调用过程 这里还是F10调试,直接右键点反汇编

这里屏幕右边就出现了C语言的汇编代码,可以看清每一步的操作啦

我们这里还要把符号名关掉,便于我们观察


因为main是由_tmainCRTStartup调用的,所以刚开始内存布局如图:

这里相当于压栈,加个元素进去,将_tmainCRTStartup的ebp元素压栈


由于esp维护栈顶元素。所以esp的地址也要向上移动,如图

我们也可以通过看一下esp的地址变化

F10

esp的地址-4,所以向上走了
我们还可以通过内存块来观察

地址搜索esp可看出 这里根据大小端字节序,由于VS是小端,所以要倒着读,地址就是与008ffbf4相同,说明我们把这个元素给压进去啦

接下来是mov,这里就把图形和地址观察放在一起啦 mov 就是把后面的值赋给前面去·


sub就是将前一个元素的值-去一个值 这里0E4h是16进制数字,我们可以通过监视来显示


我们发现它是228 下面进行操作 esp-228,esp向上移动

这里就相当于esp和ebp预开辟出main函数的栈帧,如图:

这里再压3个元素这里ebx,esi,edi不用管他们是什么

lea加载有效地址 这里我们把显示符号名再加上就好啦

(word为2个字节,dword(double word)4个字节)
我们可以从内存中看到的确初始化成了,这里只展示一部分

我们还用图来表示一下

这里我们还是把显示符号名去掉

其中0Ah=10,14h=20,0=0, 所以这三步是对a,b,c进行赋值 其中dword ptr 表示是指明操作数类型为指向 4 字节数据的指针 ebp为栈低指针,其中ebp-8,-14h,-20h表示对应a,b,c三个变量的地址 这里用图来表示,main中一个小格子表示4个字节

先讲传参过程

这里Add函数传参数,从右向左读取

这里先把b元素的数据传到eax这个寄存器中,再进行压栈 a的同理

进行完后

接下来我们就要正式进入Add函数中

call函数为调用函数,这里我们就不要按F10啦,要按F11 按之前

按之后,我们可以发现call在调用时call又把下一个地址压进去了

就形成了这样,这是为了在执行完调用函数时,方便继续执行代码

再按F11,就真正进到调用函数里面了


我们发现下面绿色框起来的,和我们之前执行过的一样,这里我就不再解释了

执行后

接下来,我们来执行Add函数中的指令

先初始化z=0


再将ebp+8所对应的值传给eax,而ebp+8对应的就是当时我们第7步是进行的传参过程对应的a的值,下一个add将eax(a)的值与ebp+0ch(b)相加,相当于eax+=b;

最后把eax得到的值传给对应的z,但是程序结束要销毁,但还得返回z的值,所以我们再把z的值传给寄存器eax 所以我们发现 传参数时参数从左向右读取,调用时参数从左向右调用
完整示意图


这里就不做解释了,其中mov操作就把调用函数给销毁啦 pop ebp 这个操作就是把ebp栈底指针,回到main的栈底 ret 这个就是从栈顶弹出call的下一条指令 再按F10我们发现,回来main了


add esp,8就是将esp向下移动8个字节,也就是把传参的临时变量销毁

这个操作将eax的值给ebp-20h对应的,也就是将返回值给C 函数栈帧的创建和创建的具体过程就讲的差不多了,后面的操作就不进行讲解啦
现在我们来简单回顾一下函数的栈帧的创建和销毁

我们来回顾先前的疑问

首先为函数分配好栈帧空间,栈帧空间里面我们初始化完一部分空间之后,再给局部变量在栈帧里面分配空间。
如果局部变量不初始化,那么它就会得到随机值,我们设置变量自动赋的随机值,如下:

如果初始化就会把随机值覆盖掉
函数调用时,参数通常按约定顺序(如从右到左)压入栈中,被调用函数通过栈地址偏移读取参数。
形参是函数定义时用于接收数据的临时变量,实参是调用时传入的实际数据,调用时实参的值传递给形参,形参仅为实参的副本,对形参的修改(值传递下)不影响实参。
如上文
函数调用结束后,会先将返回值存入指定寄存器(如x86的eax),再销毁当前栈帧(mov esp, ebp释放局部变量、pop ebp恢复调用者栈底),最后执行ret指令弹出返回地址,跳回调用者的下一条指令继续执行。
传参数时参数从左向右读取,调用时参数从左向右调用 我们创建的函数栈帧理论上可以被占满,但是我们的编译器会解决这个问题不会让他慢的
嗨|ू・ω・` ),本文到这里就结束啦,我把它放在数据结构和C语言中,但是我觉得学完数据结构的栈再来学更容易理解,两个栈的核心十分相同。本篇有点难度,不用全部掌握,只需要理解就行啦!本篇如果博主有写的不好的地方,欢迎大家在评论区建议或讨论!感谢大家的支持啦!