打印
[疑难问答]

函数栈帧的创建和销毁

[复制链接]
1852|53
手机看帖
扫描二维码
随时随地手机跟帖
跳转到指定楼层
楼主
pixhw|  楼主 | 2024-7-18 17:15 | 只看该作者 回帖奖励 |倒序浏览 |阅读模式
前言:
为了深入学习C语言,也为了方便理解,我学习了函数栈帧。函数栈帧的创建和销毁能够让我更加深刻的了解编程逻辑和语法。我们学习语法和编程逻辑都是基于封装好的知识上得。因此,我们有必要对函数栈帧的创建和销毁进行学习。本篇博客将用来介绍函数栈帧的创建和销毁的过程,希望大家一起学习。如有不足之处,请大家多多指出,谢谢!
注意:
这里我使用的是vs2022和大家展示。不同编译器上展示的结果会有差异,但大体逻辑一样(也能起到参考的作用)。版本越高的编译器越不好观察,不容易观看函数栈帧创建和销毁的过程,封装过程也会复杂一下。
一、认识相关寄存器和汇编指令1.寄存器(寄存器是集成在cpu上的)
eax:累加寄存器,相对于其他寄存器,在运算方面比较常用
ebx:基地址寄存器,在内存寻址时存放基地址。
ecx:计数寄存器,用于循环操作,如重复的字符存储操作或者数字统计。
edx:作为EAX的溢出寄存器,总是被用来放整数除法产生的余数。
esi:源变址寄存器,主要用于存放存储单元在段内的偏移量。通常在内存操作指令中作为“源地址指针”使用。
edi:目的变址寄存器,主要用于存放存储单元在段内的偏移量。
ebp:栈底指针
esp:栈顶指针
esp和ebp这两个寄存器中存放的是地址,这两个地址是用来维护函数栈帧得;esp和ebp用来维护函数栈帧时,正在调用什么函数,就会维护那个函数。
rbp,rsp(64位编译,对于32位编译是ebp,esp寄存器)这2个寄存器中存放的是地址,这2个地址是用来维护函数栈帧的。
2.汇编指令
push:
压栈,给栈顶放一个元素。(数据入栈,同时esp栈顶寄存器也要发生改变)
pop:
出栈,给栈顶删除一个元素。(数据弹出至指定位置,同时esp栈顶寄存器也要发生改变)
mov:数据转移指令。(后面的指针指向前面)
sub:减法命令。(前面的值减后面的值)
add:加法命令。
call:函数调用,1. 压入返回地址 2. 转入目标函数
jump:通过修改eip,转入目标函数,进行调用。
lea:加载,把后面的有效地址加载到前面。
补充:
栈区的使用是从高地址到低地址
栈区的使用遵循先进后出,后进先出
栈区的放置是从高地址往低地址放置:push 是压栈
删除是从低往高删除:pop 是出栈
如图:
二、函数栈帧创建和销毁的过程
本次演示以vs2022为例
演示代码:
#include <stdio.h>int ADD(int x,int y){    int z = x + y;    return z;}int main(){    int a = 3,b=6,c=0;    c = ADD(a,b);    printf("%d\n", c);    return 0;}
准备工作:
1)按F10进入函数调用模式:

2)打开调用堆栈,出现调用堆栈窗口:


3)在调用模式下右击鼠标后,单击转到反汇编,进入反汇编界面:

1.main函数的调用
main函数也可以被其他函数调用:
1)为了阅读方便,我们把“显示符号名”取消勾选。
2)按F10,从调用堆栈,我们可以看到main函数被别的函数调用:
main()函数被invoke_main()函数调用;
invoke_main()函数被__scrt_common_main_seh() 函数调用;
__scrt_common_main_seh()函数被__scrt_common_main() 函数调用;
__scrt_common_main() 函数被mainCRTStartup(void * __formal) 函数调用。
注意:
编译器版本越高,反汇编越不容易观察,编译器版本过高,会优化。
2.函数栈帧的创建
1)汇编代码如下:
int main(){00CD18B0  push        ebp  00CD18B1  mov         ebp,esp  00CD18B3  sub         esp,0E4h  00CD18B9  push        ebx  00CD18BA  push        esi  00CD18BB  push        edi  00CD18BC  lea         edi,[ebp-24h]  00CD18BF  mov         ecx,9  00CD18C4  mov         eax,0CCCCCCCCh  00CD18C9  rep stos    dword ptr es:[edi]  00CD18CB  mov         ecx,0CDC008h  00CD18D0  call        00CD131B      int a = 3, b = 6,c = 0;00CD18D5  mov         dword ptr [ebp-8],3  00CD18DC  mov         dword ptr [ebp-14h],6  00CD18E3  mov         dword ptr [ebp-20h],0      c = ADD(a,b);00CD18EA  mov         eax,dword ptr [ebp-14h]  00CD18ED  push        eax  00CD18EE  mov         ecx,dword ptr [ebp-8]  00CD18F1  push        ecx  00CD18F2  call        00CD1217  00CD18F7  add         esp,8  00CD18FA  mov         dword ptr [ebp-20h],eax      printf("%d\n", c);00CD18FD  mov         eax,dword ptr [ebp-20h]  00CD1900  push        eax  00CD1901  push        0CD7B30h  00CD1906  call        00CD10CD  00CD190B  add         esp,8      return 0;00CD190E  xor         eax,eax  }00CD1910  pop         edi  00CD1911  pop         esi  00CD1912  pop         ebx  00CD1913  add         esp,0E4h  00CD1919  cmp         ebp,esp  00CD191B  call        00CD1244  00CD1920  mov         esp,ebp  00CD1922  pop         ebp  00CD1923  ret  
2)给main函数开辟空间
00CD18B0  push        ebp  /*压栈,栈顶放一个元素,把ebp寄存器中的值进行压栈,此时的ebp中存放的是invoke_main函数栈帧的ebp,esp-4*/00CD18B1  mov         ebp,esp  /*把esp的值存放到ebp中,相当于产生了main函数的ebp,这个值就是invoke_main函数栈帧的esp*/00CD18B3  sub         esp,0E4h  /*sub会让esp中的地址减去一个16进制数字0xe4,产生新的esp,此时的esp是main函数栈帧的esp,此时结合上一条指令的ebp和当前的esp,ebp和esp之间维护了一个块栈空间,这块栈空间就是为main函数开辟的,就是main函数的栈帧空间,这一段空间中将存储main函数中的局部变量,临时数据已经调试信息等。*/00CD18B9  push        ebx  //将寄存器ebx的值压栈,esp-400CD18BA  push        esi  //将寄存器esi的值压栈,esp-400CD18BB  push        edi  //将寄存器edi的值压栈,esp-4/*上面3条指令保存了3个寄存器的值在栈区,这3个寄存器的在函数随后执行中可能会被修改,所以先保存寄存器原来的值,以便在退出函数时恢复。*///下面的代码是在初始化main函数的栈帧空间。//1. 先把ebp-24h的地址,放在edi中//2. 把9放在ecx中//3. 把0xCCCCCCCC放在eax中//4. 将从ebp-0x24h到ebp这一段的内存的每个字节都初始化为CCCCCCCCh00CD18BC  lea         edi,[ebp-24h]  //把后面有效的地址加载到前面空间里00CD18BF  mov         ecx,9  00CD18C4  mov         eax,0CCCCCCCCh /*每一次四个字节,总共出了*/ 00CD18C9  rep stos    dword ptr es:[edi]  //word是一个字两个字节;dword是两个字,四个字节。00CD18CB  mov         ecx,0CDC008h  //把0CDC008h放在ecx里00CD18D0  call        00CD131B  //执行 call指令之前先会把call 指令的下一条指令的地址进行压栈操作

图示:

3)核心代码
int a = 3, b = 6,c = 0;//变量a,b,c的创建和初始化,这就是局部的变量的创建和初始化00CD18D5  mov         dword ptr [ebp-8],3  00CD18DC  mov         dword ptr [ebp-14h],6  00CD18E3  mov         dword ptr [ebp-20h],0      c = ADD(a,b);00CD18EA  mov         eax,dword ptr [ebp-14h]  00CD18ED  push        eax  00CD18EE  mov         ecx,dword ptr [ebp-8]  00CD18F1  push        ecx  00CD18F2  call        00CD1217  00CD18F7  add         esp,8  00CD18FA  mov         dword ptr [ebp-20h],eax  
1).给变量a、b、c创建初始化
int a = 3, b = 6,c = 0;//变量a,b,c的创建和初始化,这就是局部的变量的创建和初始化00CD18D5  mov         dword ptr [ebp-8],3  //把3放到ebp-8地址里00CD18DC  mov         dword ptr [ebp-14h],6  //把6放到ebp-14h里00CD18E3  mov         dword ptr [ebp-20h],0  //把0放到ebp-20h里

图示:

2).调用Add函数
c = ADD(a,b);00CD18EA  mov         eax,dword ptr [ebp-14h]  //把ebp-14h里的值给eax00CD18ED  push        eax  //压栈,压一个元素,寄存器eax里压入ebp-14h里面的值00CD18EE  mov         ecx,dword ptr [ebp-8] //把ebp-8里的值给ecx 00CD18F1  push        ecx  //压栈,压一个元素,寄存器exc里压入ebp-8里面的值00CD18F2  call        00CD1217  /*这条指令是去调用ADD函数,把地址00CD18F7存放到地址00CD18F2里(call指令的下一条指令的地址),按一下F11,进入被调函数ADD里(地址00CD1217),调用结束后,来到了下一条指令的地址处*/00CD18F7  add         esp,8  00CD18FA  mov         dword ptr [ebp-20h],eax  
图示:

3).进入ADD函数(在call指令处按F11,然后再按一次F11)
这里我重新进入调试模式,所以地址的位置也就发生了变化,意思还是不变的。
int main(){00C518B0  push        ebp  00C518B1  mov         ebp,esp  00C518B3  sub         esp,0E4h  00C518B9  push        ebx  00C518BA  push        esi  00C518BB  push        edi  00C518BC  lea         edi,[ebp-24h]  00C518BF  mov         ecx,9  00C518C4  mov         eax,0CCCCCCCCh  00C518C9  rep stos    dword ptr es:[edi]  00C518CB  mov         ecx,0C5C008h  00C518D0  call        00C5131B      int a = 3, b = 6,c = 0;00C518D5  mov         dword ptr [ebp-8],3  00C518DC  mov         dword ptr [ebp-14h],6  00C518E3  mov         dword ptr [ebp-20h],0      c = ADD(a,b);00C518EA  mov         eax,dword ptr [ebp-14h]  00C518ED  push        eax  00C518EE  mov         ecx,dword ptr [ebp-8]  00C518F1  push        ecx  00C518F2  call        00C51217  00C518F7  add         esp,8  00C518FA  mov         dword ptr [ebp-20h],eax  


在按一下F11,进入ADD函数里

4).创建ADD函数栈帧

5).ADD函数的执行过程
int z = x + y;00C51795  mov         eax,dword ptr [ebp+8]  //把ebp+8里面的值给eax00C51798  add         eax,dword ptr [ebp+0Ch]  //eax里面的值加上ebp+0Ch地址里的值00C5179B  mov         dword ptr [ebp-8],eax //eax的值放到ebp-8地址里    return z;00C5179E  mov         eax,dword ptr [ebp-8]  //eax相当于全局的寄存器,ebp-8的值放到寄存器里。
如图:

6),函数栈帧创建的视图:
3.函数栈帧的销毁
1)ADD函数栈帧的销毁
00C517A1  pop         edi  //在栈顶弹出一个值,存放到edi中,esp+400C517A2  pop         esi  //在栈顶弹出一个值,存放到esi中,esp+400C517A3  pop         ebx   //在栈顶弹出一个值,存放到ebx中,esp+400C517A4  add         esp,0CCh  /*将esp的地址加上0cch,相当于回收了ADD函数的栈帧空间*/ 00C517AA  cmp         ebp,esp  //判断有没有溢出00C517AC  call        00C51244  //call指令里放的是下一个指令的地址00C517B1  mov         esp,ebp  //ebp里面的值放到esp里00C517B3  pop         ebp  //出栈,弹出一个元素,dsp+400C517B4  ret /*call指令可以实现调用一个子程序,在子程序里使用ret指令,结束子程序的执行并返回主函数,让主函数继续往下执行*/
图示:

2).ADD函数栈帧销毁后,回到主函数:

调用完ADD函数,回到main函数的时候,继续往下执行,可以看到:
00C518F7  add         esp,8  //esp直接+8,相当于跳过了main函数中压栈的00C518FA  mov         dword ptr [ebp-20h],eax  /*将eax中值,存档到ebp-20h的地址处,其实就是存储到main函数中c变量中,而此时eax中就是ADD函数中计算的x和y的和,可以看出来,本次函数的返回值是由eax寄存器带回来的。程序是在函数调用返回之后,在eax中去读取返回值的。*/    printf("%d\n", c);
注意:

总结:
1为什么局部变量不初始化内容是随机的或者是"烫"?
因为在创建函数栈帧的时候,中间的地址的值都是不确定的,而如果访问一个未初始化的变量,指向这些不确定的值,就是随机值。而初始化为0CCCCCCCCh时,遇到0xCCCC(两个连续排列的0xCC)的汉字编码就是“烫”,所以0xCCCC被当作文本就是“烫”。
2.函数调用时参数时如何传递的?传参的顺序是怎样的?
从创建局部变量的函数(比如main函数)栈帧中通过内存访问,储存在eax和ecx中再入栈(相当于临时拷贝)。
3.函数的形参和实参分别是怎样实例化的?
实参是在函数栈帧里通过ebp内存访问储存的值。形参是由ebp内存访问将栈中储存的临时变量。
4.函数调用结束后怎么返回值?
ADD函数中通过将在寄存器(eax)中相加得到的9,在移入ADD函数栈帧中c的地址位置,再将这个地址位置的值传给eax,在销毁ADD函数栈帧后,将eax中的值传给main函数栈帧中创建的c地址位置。

使用特权

评论回复
沙发
guijial511| | 2024-7-24 08:38 | 只看该作者
笙泉单片机主要是以8位机为主吗?

使用特权

评论回复
板凳
robertesth| | 2024-8-8 22:13 | 只看该作者
函数栈帧(Stack Frame)是函数调用过程中在程序的调用栈(Call Stack)上开辟的一块内存空间,用于存储函数的局部变量、参数、返回值以及保存上下文信息(如寄存器状态)。

使用特权

评论回复
地板
bestwell| | 2024-8-8 22:25 | 只看该作者
函数栈帧的创建和销毁是函数调用机制的核心部分,它们确保了函数能够正确地接收参数、执行代码、返回结果,并恢复到调用前的状态。

使用特权

评论回复
5
caigang13| | 2024-8-9 08:24 | 只看该作者
学一下汇编语言,能够增加对底层的理解。

使用特权

评论回复
6
hearstnorman323| | 2024-8-10 22:09 | 只看该作者
当一个函数被调用时,CPU会自动将下一条指令的地址(即返回地址)压入栈中。这个地址用于函数执行完毕后返回到调用它的地方。

使用特权

评论回复
7
ccook11| | 2024-8-10 22:50 | 只看该作者
根据栈帧中的返回地址,跳转到函数调用后的下一条指令继续执行。

使用特权

评论回复
8
mnynt121| | 2024-8-11 21:27 | 只看该作者
函数栈帧的创建和销毁是程序执行过程中的重要环节,它们通过维护栈顶指针和栈底指针来实现对函数局部变量的管理和函数调用的控制。

使用特权

评论回复
9
robincotton| | 2024-8-12 17:10 | 只看该作者
栈帧中的局部变量在函数返回后不再需要,因此不需要显式清理,但栈指针需要移动以释放分配的栈空间。

使用特权

评论回复
10
usysm| | 2024-8-13 05:00 | 只看该作者
将当前指令的下一条指令地址(即函数调用后的下一条指令)保存在栈帧中,确保函数能够正确返回调用处。

使用特权

评论回复
11
pixhw|  楼主 | 2024-8-13 12:08 | 只看该作者
诸如push、pop、mov、sub、add等汇编指令在函数栈帧的创建和销毁过程中发挥着重要作用。例如,push指令用于将数据压入栈中并更新esp的值,pop指令用于从栈中取出数据并更新esp的值,mov指令用于传送数据,sub和add指令则分别用于减少和增加esp的值,以分配和释放栈帧空间。

使用特权

评论回复
12
claretttt| | 2024-8-13 12:59 | 只看该作者
理解函数栈帧的创建和销毁过程有助于深入理解程序的执行流程和内存管理机制。

使用特权

评论回复
13
mattlincoln| | 2024-8-13 14:25 | 只看该作者
对于需要初始化的局部变量,会在栈帧中为其赋初值。

使用特权

评论回复
14
hearstnorman323| | 2024-8-13 15:43 | 只看该作者
函数栈帧的创建和销毁是由编译器和操作系统协同完成的,对于开发者来说,理解这个过程有助于更好地优化程序、处理异常情况以及避免一些常见的内存错误,比如栈溢出。

使用特权

评论回复
15
abotomson| | 2024-8-14 21:20 | 只看该作者
当函数被调用时,调用者的下一条指令的地址(返回地址)会被压入栈中。

使用特权

评论回复
16
nomomy| | 2024-8-17 12:27 | 只看该作者
在大多数现代编程语言中,函数调用时会在栈上创建一个新的栈帧,用于存储局部变量、函数参数以及返回地址等信息。

使用特权

评论回复
17
sheflynn| | 2024-8-17 18:07 | 只看该作者
当一个函数被调用时,以下步骤通常会发生来创建函数栈帧:
保存调用者的上下文:包括程序计数器(PC)的值,以便函数执行完毕后能正确返回调用点继续执行。
为函数的参数分配空间:参数按照从右到左的顺序被压入栈中。
为函数的局部变量分配空间:根据局部变量的类型和数量,在栈帧中预留相应的内存。
例如,假设有一个函数 void func(int a, int b) ,调用时参数 a 和 b 会被依次压入栈。

使用特权

评论回复
18
loutin| | 2024-8-18 14:41 | 只看该作者
当函数完成其任务并准备返回时,它会执行以下步骤来销毁栈帧:

恢复寄存器状态:
如果函数保存了某些寄存器的值,现在需要从栈上恢复这些值。
清理栈帧:
清理栈帧中的局部变量和临时数据。这通常意味着不需要显式地释放内存,因为栈帧将在函数退出时自动释放。
恢复旧的栈指针:
将栈指针恢复到函数调用之前的值。这意味着将SP向上移动,即加上先前分配的栈帧大小。
返回到调用者:
函数将返回值(如果有)存放在适当的位置(如寄存器中),然后通过跳转到保存的返回地址来回到调用者。
示例

使用特权

评论回复
19
chenci2013| | 2024-8-18 16:57 | 只看该作者
栈帧保存了函数调用前的寄存器状态,使得函数返回后可以恢复到调用前的状态。

使用特权

评论回复
20
gygp| | 2024-8-18 18:54 | 只看该作者
在函数执行完毕之前,需要恢复之前保存的寄存器状态。这些寄存器的值会从栈中弹出。

使用特权

评论回复
发新帖 我要提问
您需要登录后才可以回帖 登录 | 注册

本版积分规则

34

主题

4536

帖子

1

粉丝