• c语言:深度刨析函数栈帧


    ✨作者介绍:大家好,我是摸鱼王胖嘟嘟,可以叫我小嘟💕
    ✨作者主页:摸鱼王胖嘟嘟的个人博客主页.🎉
    🎈作者的gitee: 小比特_嘟嘟的个人gitee
    🎈系列专栏: 【从0到1,漫游c语言的世界】
    ✨小嘟和大家一起学习,一起进步!尽己所能,写好每一篇博客,沉醉在自己进步的喜悦当中🤭。如果文章有错误,欢迎大家在评论区✏️指正。让我们开始今天的学习吧!😊请添加图片描述

    💻前言

    🍁本篇博客会带大家深度刨析函数栈帧,理解函数栈帧的创建与销毁的过程,能够从底层上理解一些知识点,做到知其然,知其所以然。
    🍁我们知道,一个代码必定有函数,那么必然要用内存来存储,而这储存就涉及到了函数栈帧的创建和销毁,理解好函数栈帧可以让我们所学的知识不只是停留在表面,同时可以更好的理解现在以及以后所学到的知识,那么就开始我们的学习吧!


    🎈一、函数栈帧的定义

    我们在写C语言代码的时候,经常会把一个独立的功能抽象为函数,所以C程序是以函数为基本单位的。
    那函数是如何调用的?函数的返回值又是如何返回的?函数参数是如何传递的?这些问题都和函数栈帧有关系。
    C语言中,每个栈帧对应着一个未运行完的函数。栈帧中保存了该函数的返回地址和局部变量。
    函数栈帧(stack frame)就是函数调用过程中在程序调用栈(call stack)所开辟的空间,这些空间是用来存放:

    1. 函数参数和函数返回值
    2. 临时变量(包括函数的非静态的局部变量以及编译器自动生产的其他临时变量)
    3. 保存上下文信息(包括在函数调用前后需要保持不变的寄存器

    而调用栈是:

    调用栈是经常被用于存放子程序的返回地址。 在调用任何子程序时,主程序都必须暂存子程序运行完毕后应该返回到的地址。因此,如果被调用的子程序还要调用其他的子程序,其自身的返回地址就必须存入调用栈,在其自身运行完毕后再行取回。在递归程序中,每一层次递归都必须在调用栈上增加一条地址,因此如果程序出现无限递归(或仅仅是过多的递归层次),调用栈就会产生栈溢出。
    调用栈的主要功能是存放返回地址。
    除此之外,调用栈还用于存放:
    1.本地变量:子程序的变量可以存入调用栈,这样可以达到不同子程序间变量分离开的作用。
    2.参数传递:如果寄存器不足以容纳子程序的参数,可以在调用栈上存入参数。
    3.环境传递:有些语言(如Pascal与Ada)支持“多层子程序”,即子程序中可以利用主程序的本地变量。这些变量可以通过调用栈传入子程序。


    🎈一、理解函数栈帧能解决什么问题?

    理解函数栈帧有什么用?
    只要理解了函数栈帧的创建和销毁,以下问题就能够很好的理解了:

    1. 局部变量是如何创建的? 2.为什么局部变量不初始化内容是随机的? 3.函数调用时参数是如何传递的?传参的顺序是怎么样的? 4.函数的形参和实参分别是如何实例化的? 5.函数的返回值是如何带回的?

    ✏️三、函数栈帧的创建和销毁

    ✏️什么是栈?

    栈(stack)是现代计算机程序里最为重要的概念之一,几乎每一个程序都使用了栈,没有栈就没有函数,没有局部变量,也就没有我们如今看到的所有的计算机语言。

    在经典的计算机科学中,栈被定义为一种特殊的容器,用户可以将数据压入栈中(入栈,push),也可以将已经压入栈中的数据弹出(出栈,pop),但是栈这个容器必须遵守一条规则:先入栈的数据后出栈(First In Last Out, FIFO)。就像叠成一叠的书,先叠上去的书在最下面,因此要最后才能取出。在计算机系统中,栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据从栈顶弹出。压栈操作使得栈增大,而弹出操作使得栈减小。

    在经典的操作系统中,栈总是向下增长(由高地址向低地址)的。

    在我们常见的i386或者x86-64下,栈顶由esp寄存器进行定位。
    在这里插入图片描述

    在这里插入图片描述


    ✏️认识相关寄存器和汇编指令

    相关寄存器

    eax:通用寄存器,保留临时数据,常用于返回值
    ebx:通用寄存器,保留临时数据
    ebp:栈底寄存器
    esp:栈顶寄存器
    eip:指令寄存器,保存当前指令的下一条指令的地址

    相关汇编命名

    mov:数据转移指令
    push:数据入栈,同时esp栈顶寄存器也要发生改变
    pop:数据弹出至指定位置,同时esp栈顶寄存器也要发生改变
    sub:减法命令
    add:加法命令
    call:函数调用,1.压入返回地址 2.转入目标函数
    jump:通过修改eip,转入目标函数,进行调用
    ret:恢复返回地址,压入eip,类似pop eip命令

    ✏️解析函数栈帧的创建和销毁

    🎉预备知识

    1.每一次函数调用,都要为本次函数调用开辟空间,就是函数栈帧的空间。
    2.这块空间的维护是使用了2个寄存器:esp和ebp,ebp记录的是栈底的地址,esp记录的是栈顶的地址

    如图所示:
    在这里插入图片描述

    3.函数栈帧的创建和销毁过程,在不同的编译环境上实现的方法大同小异,这里示范以VS2019为例

    🎉函数的调用堆栈

    演示代码:

    #include
    
    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", c);
    
    	return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    在这里插入图片描述
    这段代码,如果我们在VS2019编译器上调试,调试进入Add函数后,我们就可以观察到函数的调用堆栈
    (右击勾选【显示外部代码】),如下图:
    在这里插入图片描述
    函数调用堆栈是反馈函数调用逻辑的,那么我们可以清晰的观察到,main函数调用之前,是由invoke_main函数来调用main函数。在invoke_main函数之前的函数调用我们就暂时不考虑了。
    那我们可以确定,invoke_main函数应该会有自己的栈帧,main函数和Add函数也会维护自己的栈帧,每个函数都有自己的ebp和esp来维护栈帧空间。

    🎉转到反汇编

    我们将鼠标指向main函数,右击鼠标转到反汇编。
    在这里插入图片描述

    int main()
    {
    //函数栈帧的创建
    009818A0  push        ebp  
    009818A1  mov         ebp,esp  
    009818A3  sub         esp,0E4h  
    009818A9  push        ebx  
    009818AA  push        esi  
    009818AB  push        edi  
    009818AC  lea         edi,[ebp-24h]  
    009818AF  mov         ecx,9  
    009818B4  mov         eax,0CCCCCCCCh  
    009818B9  rep stos    dword ptr es:[edi]  
    009818BB  mov         ecx,98C003h  
    009818C0  call        0098131B  
    //main函数中的核心代码
     	int a = 10;
    009818C5  mov         dword ptr [ebp-8],0Ah  
    	int b = 20;
    009818CC  mov         dword ptr [ebp-14h],14h  
    	int c = 0;
    009818D3  mov         dword ptr [ebp-20h],0  
    	c = Add(a, b);
    009818DA  mov         eax,dword ptr [ebp-14h]  
    009818DD  push        eax  
    009818DE  mov         ecx,dword ptr [ebp-8]  
    009818E1  push        ecx  
    009818E2  call        009813B6  
    009818E7  add         esp,8  
    009818EA  mov         dword ptr [ebp-20h],eax  
    	printf("%d", c);
    009818ED  mov         eax,dword ptr [ebp-20h]  
    009818F0  push        eax  
    009818F1  push        987B30h  
    009818F6  call        009810CD  
    009818FB  add         esp,8  
    
    	return 0;
    009818FE  xor         eax,eax  
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40

    🎉函数栈帧的创建

    我们这里看到main函数转化来的汇编代码如上所示。
    接下来我们就一行一行的拆解汇编代码

    009818A0  push        ebp  //把ebp寄存器中的值进行压栈,
                               //此时的ebp中存放的是invoke_main函数栈帧的ebp,esp-4
    
    • 1
    • 2

    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述

    009818A1  mov         ebp,esp//move指令会把esp的值存放到ebp中,
                                 //相当于产生了main函数的ebp,
                                 //这个值就是invoke_main栈帧的esp
    
    • 1
    • 2
    • 3

    在这里插入图片描述

    在这里插入图片描述

    009818A3  sub         esp,0E4h//sub会让esp中的地址减去一个16进制数字0xe4,
                                  //产生新的esp,此时的esp是main函数栈帧的esp,
                                  //此时结合上一条指令的ebp和当前的esp,ebp和esp之间维护了一个块栈空间,
                                  //这块栈空间就是为main函数开辟的,
                                  //就是main函数的栈帧空间,这一段空间中将存储main函数
                                  //中的局部变量,临时数据已经调试信息等。
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    在这里插入图片描述

    在这里插入图片描述

    009818A9  push        ebx  
    009818AA  push        esi  
    009818AB  push        edi
    //将寄存器ebx、esi、edi、的值压栈,esp-4
    //上面3条指令保存了3个寄存器的值在栈区,这3个寄存器的在函数随后执行中可能会被修改,所以先保存寄存器原来的值,以便在退出函数时恢复。
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    在这里插入图片描述
    在这里插入图片描述

    //1. 先把ebp-24h的地址,放在edi中
    //2. 把9放在ecx中
    //3. 把0xCCCCCCCC放在eax中
    //4. 将从edp-0x24h到ebp这一段的内存的每个字节都初始化为0xCC
    009818AC  lea         edi,[ebp-24h]
    //lea     load effective address
    009818AF  mov         ecx,9  
    009818B4  mov         eax,0CCCCCCCCh  
    009818B9  rep stos    dword ptr es:[edi]
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    上面的这段代码最后4句,等价于下面的伪代码:

    edi = ebp-0x24;
    ecx = 9;
    eax = 0xCCCCCCCC;
    for(; ecx = 0; --ecx,edi+=4)
    {
    *(int*)edi = eax;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    在这里插入图片描述

     	int a = 10;
    009818C5  mov         dword ptr [ebp-8],0Ah  
    //将10存储到ebp-8的地址处,
    //ebp-8的位置其实就是a变量
    	int b = 20;
    009818CC  mov         dword ptr [ebp-14h],14h  
    //将20存储到ebp-14的地址处,
    //ebp-14的位置其实就是b变量
    	int c = 0;
    009818D3  mov         dword ptr [ebp-20h],0  
    //将0存储到ebp-20的地址处,
    //ebp-20的位置其实就是c变量
    //以上汇编代码表示的变量a,b,ret的创建和初始化,
    //这就是局部的变量的创建和初始化
    //其实是局部变量的创建是在局部变量所在函数的栈帧空间中创建的
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    在这里插入图片描述

    //调用Add函数
    c = Add(a,b);
    //调用Add函数时的传参
    //其实传参就是把参数push到栈帧空间中
    009818DA  mov         eax,dword ptr [ebp-14h]  
    //传递b,将ebp-14h处放的20放在eax寄存器中
    009818DD  push        eax  
    //将eax的值压栈,esp-4
    009818DE  mov         ecx,dword ptr [ebp-8]  
    //传递a,将ebp-8处放的10放在ecx寄存器中
    009818E1  push        ecx 
    //将ecx的值压栈,esp-4
    
    //跳转调用函数
    009818E2  call        009813B6  
    009818E7  add         esp,8  
    009818EA  mov         dword ptr [ebp-20h],eax  
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    在这里插入图片描述

    call 指令是要执行函数调用逻辑的,在执行call指令之前先会把call指令的下一条指令的地址进行压栈操作,这个操作是为了解决当函数调用结束后要回到call指令的下一条指令的地方,继续往后执行。

    int Add(int x, int y)
    {
    00981770  push        ebp  
    //将main函数栈帧的ebp保存,esp-4
    00981771  mov         ebp,esp  
    //将main函数的esp赋值给新的ebp,ebp现在是Add函数的ebp
    00981773  sub         esp,0CCh  
    //给esp-0xCC,求出Add函数的esp
    00981779  push        ebx  
    //将ebx的值压栈,esp-4
    0098177A  push        esi  
    //将esi的值压栈,esp-4
    0098177B  push        edi  
    //将edi的值压栈,esp-4
    0098177C  lea         edi,[ebp-0Ch]  
    0098177F  mov         ecx,3  
    00981784  mov         eax,0CCCCCCCCh  
    00981789  rep stos    dword ptr es:[edi]  
    0098178B  mov         ecx,98C003h  
    00981790  call        0098131B  
    	int z = 0;
    //将0放在ebp-8的地址处,其实就是创建z
    00981795  mov         dword ptr [ebp-8],0  
    	z = x + y;
    //接下来计算的是x+y,结果保存到z中
    0098179C  mov         eax,dword ptr [ebp+8]  
    //接下来计算的是x+y,结果保存到z中
    0098179F  add         eax,dword ptr [ebp+0Ch]  
    //将ebp+12地址处的数字加到eax寄存中
    009817A2  mov         dword ptr [ebp-8],eax  
    //将eax的结果保存到ebp-8的地址处,其实就是放到z中
    	return z;
    009817A5  mov         eax,dword ptr [ebp-8]  
    //将ebp-8地址处的值放在eax中,其实就是
    //把z的值存储到eax寄存器中,这里是想通过eax寄存器带回计算的结果,
    //做函数的返回值。
    }
    009817A8  pop         edi  
    009817A9  pop         esi  
    009817AA  pop         ebx  
    009817AB  add         esp,0CCh  
    009817B1  cmp         ebp,esp  
    009817B3  call        00981244  
    009817B8  mov         esp,ebp  
    009817BA  pop         ebp  
    009817BB  ret  
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46

    代码执行到Add函数的时候,就要开始创建Add函数的栈帧空间了。
    在Add函数中创建栈帧的方法和在main函数中是相似的,在栈帧空间的大小上略有差异而已。

    1.将main函数的 ebp 压栈
    2.计算新的 ebp 和 esp
    3.将 ebx , esi , edi 寄存器的值保存
    4.计算求和,在计算求和的时候,我们是通过 ebp 中的地址进行偏移访问到了函数调用前压栈进去的参数,这就是形参访问。
    5.将求出的和放在 eax 寄存器中准备带回

    在这里插入图片描述

    图片中的 a’ 和 b’ 其实就是 Add 函数的形参 x , y。这里的分析很好的说明了函数的传参过程,以及函数在进行值传递调用的时候,形参其实是实参的一份拷贝。对形参的修改不会影响实参。

    🎉 函数栈帧的销毁

    当函数调用要结束返回的时候,前面创建的函数栈帧也开始销毁。
    那具体是怎么销毁的呢?我们看一下反汇编代码。

    009817A8  pop         edi  
    //在栈顶弹出一个值,存放到edi中,esp+4
    009817A9  pop         esi  
    //在栈顶弹出一个值,存放到esi中,esp+4
    009817AA  pop         ebx  
    //在栈顶弹出一个值,存放到ebx中,esp+4
    009817AB  add         esp,0CCh  
    //再将Add函数的ebp的值赋值给esp,相当于回收了Add函数的栈帧空间
    009817B1  cmp         ebp,esp  
    //弹出栈顶的值存放到ebp,栈顶此时的值恰好就是main函数的ebp,
    //esp+4,此时恢复了main函数的栈帧维护,
    //esp指向main函数栈帧的栈顶,ebp指向了main函数栈帧的栈底。
    009817B3  call        00981244  
    009817B8  mov         esp,ebp  
    009817BA  pop         ebp  
    009817BB  ret  
    //ret指令的执行,首先是从栈顶弹出一个值,此时栈顶的值就是call指
    //令下一条指令的地址,此时esp+4,
    //然后直接跳转到call指令下一条指令的地址处,继续往下执行
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    在这里插入图片描述
    但调用完Add函数,回到main函数的时候,继续往下执行,可以看到:

    009818E7  add         esp,8  
    //esp直接+8,相当于跳过了main函数中压栈的a'和b'
    009818EA  mov         dword ptr [ebp-20h],eax  
    //将eax中值,存档到ebp-0x20的地址处,
    //其实就是存储到main函数中ret变量中,
    //而此时eax中就是Add函数中计算的x和y的和,可以看出来,
    //本次函数的返回值是由eax寄存器带回来的。
    //程序是在函数调用返回之后,在eax中去读取返回值的
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    🎉 小知识:烫烫烫

    #include
    
    int main()
    {
    	char arr[5];
    	printf("%s\n", arr);
    	return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    在这里插入图片描述

    main函数调用时,在栈区开辟的空间的其中每一个字节都被初始化为0xCC,而arr数组是一个未初始化的数组,恰好在这块空间上创建的,0xCCCC(两
    个连续排列的0xCC)的汉字编码就是“烫”,所以0xCCCC被当作文本就是“烫”。

    总结

    图片展示的可能非常模糊,小嘟已经很努力的去表达了,请见谅!😘
    希望能够对大家有所帮助!如果大家感觉还不错,请务必一键三连,你们的支持是对小嘟最大的帮助!

  • 相关阅读:
    【云原生之kubernetes实战】在k8s环境下部署Lychee照片管理平台
    Webpack和JShaman相比有什么不同?
    [源码解析] NVIDIA HugeCTR,GPU版本参数服务器--- (4)
    随机森林RF模型超参数的优化:Python实现
    哪些场景需要额外注意线程安全问题
    【C++】类和对象的基础概念问题
    一文深度了解估值最高量子初创企业!PsiQuantum的愿景、技术及合作
    Linux scriptreplay回放你的高光时刻
    移动端性能测试(android/ios)
    【Spring】Bean生命周期
  • 原文地址:https://blog.csdn.net/weixin_61341342/article/details/125960259