目录

引言:

1.什么是栈帧

2.接触C语言后各种跟栈帧有关的疑问

3.相关寄存器和汇编指令

        3.1.相关寄存器

        3.2.汇编指令

4.函数栈帧创建销毁过程分析

        4.1.分析的代码

        4.2.粗略分析

        4.2.1.函数栈帧开辟的简单分析

        4.2.2.main函数被谁调用

        4.2.3.总结

        4.3.深入分析(汇编角度)

        4.3.1.main函数

        4.3.1.1main函数栈帧的开辟

        4.3.1.2正式代码的执行

        4.3.2.add函数调用创建栈帧

        4.3.2.1.add函数调用前准备

        4.3.2.2.add函数的调用 

        4.3.2.3.add函数的栈帧的开辟

        4.3.2.4.正式代码的运行

        4.3.3.add函数栈帧的销毁与返回部分

5.回顾最初

        5.1.局部变量是怎么创建的

        5.2.为什么局部变量的值是随机值

        5.3.函数是怎么传参的

        5.4.传参的顺序是怎样的

        5.5.形参和实参是什么关系,为什么说形参是实参的临时拷贝

        5.6.函数调用是怎么做到的

        5.7.函数调用结束后又是怎么返回的

结语:


引言:

        在讲解完C语言基础的大部分后,你们肯定会遇到很多很多的问题,比如函数调用为什么调用多了会栈溢出,局部变量为什么不初始化会是随机值,还有程序运行时栈的分配到底是怎么做到的等等,通过这一篇的讲解 ,你们就会顿悟了

        该篇的内容其实算是拓展了,因为讲解的内容会比之前深入挺多,那么,话不多说,接下来我们进入函数栈帧的创建与销毁详解正篇

                        


1.什么是栈帧

        栈帧是什么,简单的说就是空间,那函数栈帧是什么呢,顾名思义,就是给函数分配的空间


2.接触C语言后各种跟栈帧有关的疑问

        我们先来总览一下你们遇到一些问题后可能产生的一些困惑

比如 :

        局部变量是怎么创建的

        为什么局部变量的值是随机值

        函数是怎么传参的

        传参的顺序是怎样的

        形参和实参是什么关系,为什么说形参是实参的临时拷贝

        函数调用是怎么做到的

        函数调用结束后又是怎么返回的

        当你们知道函数栈帧的创建和销毁后,这部分的的问题就全都迎刃而解了

        搞懂函数栈帧的创建和销毁,这是在修炼你们的内功,也就是沉淀,也能更容易搞懂后期更多的知识,因为函数栈帧已经略微涉及底层了

        那么,接下来就进入正题

        首先,我讲解函数栈帧使用的环境是VS2022的,在不同的编译器下,同一个代码运行时函数调用过程中栈帧的创建是略有差异的,具体细节是取决于编译器实现的


3.相关寄存器和汇编指令

        在讲解函数栈帧前,我们需要先了解俩个东西,分别是寄存器和汇编指令

        3.1.相关寄存器

        寄存器有很多种,比如eax,ebx,ecx,edx,ebp,esp等等

        我们要了解函数栈帧的话,我们必须知道俩个寄存器,一是ebp寄存器,二是esp寄存器

        这俩个寄存器中存放的是地址,而这俩个地址便是用来维护函数栈帧的

        至于具体怎么维护的,我们之后看过程分析便会清晰了

        3.2.汇编指令

        我们不需要知道全部的汇编指令,只需要知道一部分汇编指令就可以了,接下来过程分析时会用到的汇编指令如下

       这边汇编指令只是在我的见解下粗略的讲一下,给你们留个印象

push  a           压栈(将a的值压入栈顶)

pop    a           出栈(将栈顶的值弹出,并将栈顶的值赋给a)

mov   a,b        将b的值赋给a

sub    a,b        将a减去b(十六进制)

lea     a,b        将b的地址加载到a去

rep stos          从edi的位置开始向下的ecx个空间全部都改成eax的内容

call  a             调用a函数

nop                空操作,什么都不干,主要用于消耗一个时钟周期

add  a,b         将a的值加b

ret                  弹出栈顶的地址,然后跳到那去


4.函数栈帧创建销毁过程分析

        首先,我们先来看一下我们将要分析的代码的原码,因为代码越复杂,函数栈帧解析起来就越麻烦,所以我们用最简单的代码来解析函数栈帧

        4.1.分析的代码

#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>

int add(int a, int b)
{
    int z = a + b;
    return z;
}

int main()
{
    int a = 1;
    int b = 2;
    int c = 0;
    c = add(a, b);
    printf("%d", c);
    return 0;
}

        4.2.粗略分析

        4.2.1.函数栈帧开辟的简单分析

        首先,我们都知道,每一个函数调用,都会在栈区开辟一个空间,我们来看图

        接下来,我们来看代码,我们现在所学的代码运行时都是先进入main函数,其实main函数也是被调用的,这个我们之后具体会讲,那么,接下来,我们来分析下main函数的函数栈帧

        因为这块空间是专门为main函数开辟的,所以称这块空间为main函数的函数栈帧

        那么这个空间是怎么维护的呢,就是由我们之前提过的那俩个寄存器来维护的——ebp和esp寄存器,这俩个寄存器存的分别是什么呢,ebp存的是高地址的地址,esp存的是低地址的地址

        正在调用哪个函数,esp和ebp维护的就是哪个函数的函数栈帧,esp和ebp之间的空间就是为函数开辟的空间,这是我们要明白的基本的前提

        通常我们把ebp叫做栈底指针,esp叫做栈顶指针,为什么这么说呢,栈区的使用习惯一般来说是使用高地址,再使用低地址,从高地址向低地址空间被消耗,如果还想使用空间,相当于在上面使用空间,即靠近低地址的部分使用空间

        那么,通过这些的讲解,我们就可以对上图进行优化,如图


        4.2.2.main函数被谁调用

        那么,此时,就会有人有疑问了,为什么main函数也是被调用的呢,又是被谁调用的呢,这在每个编译器下调用情况都不同,我就以VS2022为例,给你们演示一下

        首先,我们F10开始调试后,点击调试--窗口--调用堆栈,如图

        然后再将显示外部代码勾上,此时,到main函数为止的函数调用顺序就都知道了,越下面的函数越早调用,如图

        所以,在VS2022的环境下

main函数是被invoke_main函数调用的

invoke_main函数又是被_scrt_common_main_seh函数调用的

_scrt_common_main_seh函数又是被_scrt_common_main函数调用的

_scrt_common_main函数又是被mainCRTStartup调用的

虽然中间调用的函数每个版本下的编译器可能都不一样,但我用多个不同版本试了一下,发现开始的函数基本都是mainCRTStartup

        那么 ,这个main函数被谁调用能否可以验证呢

        其实是可以的,我们只需要双击对应的调用堆栈就可以了,那么我们来验证一下

        首先,main函数被invoke_main函数调用已经没问题了,如下图

        接下来,我们再来验证invoke_main函数是否被_scrt_common_main_seh函数调用,如下图

        接下来,我们再来验证_scrt_common_main_seh函数是否被_scrt_common_main函数调用,如下图

        最后,我们再来验证_scrt_common_main函数是否被mainCRTStartup调用,如下图

        那么main函数被谁调用的顺序我们就研究完了


        4.2.3.总结

        经过上面的分析,我们就又可以对图进行优化了,因为最先调用的函数所开辟的空间的地址是在最高的,在该函数里调用函数,空间会往下延伸开辟,即(向低地址开辟),所以开始时候的栈帧分配就如下图了,这也解释了为什么一直函数递归会导致栈溢出,因为一直往下申请空间,栈空间不够了,自然就栈溢出了

        那么,接下来,对于函数栈帧的创建你们肯定已经有了大概的了解,但是至于具体的函数栈帧是怎么创建的,函数栈帧又是怎么销毁的,esp和ebp又是怎么维护函数栈帧的,等等,这些我们都还不知道,那么,接下来我们就通过4.1当中的代码来进入深度讲解


        4.3.深入分析(汇编角度)

        那么,接下来我们就来深入的分析一下函数栈帧的创建与销毁的过程

        首先,我们开始调试后,鼠标右击,然后进入反汇编,因为我们是通过汇编代码来了解这个程序是怎么运行的,如下图(鼠标右击最好在调试箭头那一行右击)

        汇编代码出现后,可以把反汇编选项的显示符号名去掉,这样就会显示地址,而不是显示符号了,看起来更清晰(因为我们要看具体的地址来分析内存的布局),如下图

        那么,接下来,我们就开始一步步进行分析,我将函数栈帧的创建和销毁分为了三个大块,分别是main函数部分,add函数调用创建栈帧部分,add函数栈帧的销毁与返回部分,接下来,我们一块一块来看


        4.3.1.main函数

        4.3.1.1main函数栈帧的开辟

        在没有调用main函数之前,空间是这样的(只看调用main函数的函数,更前面的函数就不画了,因为最后面讲解没影响),如下图

        在进入main函数之前,esp和ebp维护的是调用main函数的那个函数

        调用main函数后刚开始的操作(push)是压栈操作,push ebp就是将ebp的值放到了栈里,这个ebp的值是调用main函数的函数的ebp,push完后esp就会从1位置到2位置,如下图 

        因为esp维护的是栈顶,这个也可以通过监视来观察,在监视窗口直接输入esp和ebp就可以了在执行push前,esp和ebp分别如图

        当我们F10一走(先点汇编代码的部分,再按F10,不然会走主代码了),esp的值就变小了,因为esp维护的是低地址,所以拓展会向低地址拓展,如图

        我们可以发现,push走完后esp的位置向低地址走了4个字节

        通过监视能知道是有东西压进去了,但此时你并不能通过这么点信息就能看出压进去的值是ebp的值。

        如果此时你依旧不清楚内存里是否真的压进去了,我们可以通过调试窗口内存来观察,直接搜esp就可以了,如图

        那么因为push完后,esp指向的是ebp的值,那么我们来看,ebp的值是009fff04,我们再来看esp指向的第一个值也是00 9f ff 04(倒着读),如图

        所以确实把这个值压进去了


        接下来是mov的指令,mov的指令就是把2号位的值赋值给1号位,如图

        既然把esp的值给了ebp,就相当于ebp指向的是esp指向的位置,通过图来看的话会更明显,如图,ebp从1位置到了2位置

        我们也可以通过值的监视来看到变化,mov前

        mov后


        接下来我们来看下一步,sub就是减,如图就是esp减去0E4h

        0E4h是个十六进制数,如果想知道,只需要监视里放入这个就可以看了,如图,十进制下就是228

        我们来看sub前后即可

        sub前

        sub后

        这意味着什么呢,这意味着esp所指向的位置已经变了,我们来通过图来看一下

        这样ebp和esp所维护的栈区域就变了

        这块深蓝色的空间就是为main函数预开辟的空间

        这个时候我们可以看一下esp和ebp在哪里,这样我们就可以知道编译器给main函数预开辟的空间有多大了

        因为内存监视里面的布局是和我们图里一样的,即越往下地址越高,所以我们可以在内存中先找到ebp的位置,然后往上找esp的位置

        如图(我们可以发现,给main函数预开辟的空间还是挺大的)

        这一块的空间都是为main函数预开辟的,每次为main函数开辟多少空间我们是不知道的,是由编译器决定的,但是我们是可以通过计算来算出开辟了多少空间的,即上一步的sub


        之后紧接着就是三个push,就是给栈顶上压了三个元素ebx,esi,edi,至于这三个元素代表了什么不用管,每一次的push,esp的值都会往上挪,这三步push操作完后的空间如图

        当然,我们也可以通过监视来看到变化,如下

        push前

        push第一次(ebx)

        push第二次(esi)

        push第三次(edi)


        接下来的lea是load effective address(加载有效地址),他的作用就是将2的地址加载到1中去,如图

        就相当于是个edi放了一个地址,那么空间分布图就变了,如图

        所以这步走完,edi的地址会变,我们来看


        接下来俩步的mov就是将9放入ecx,将0CCCCCCCCh放入eax,我们来看运行后的变化,mov前

mov后


        这三步走完后的下一步便是关键,上面的操作都是在为了这一步做铺垫

        这句的操作是从edi的位置开始向下的ecx个空间全部都改成eax的内容,因为是dword,所以要改4个字节,因为word是2个字节,dword相当于就是double word,就是4个字节,我们可以通过图来观察

        我们先找到edi的位置,再将内存的字节间隔调到4,再通过ecx找到会变化的区域,如下图

rep stos前

rep stos后

        我们会发现其实ccc的赋值就是从edi开始到ebp结束,这个操作就相当于是给main函数预开辟的空间里从edi开始到ebp的空间全改为了CCCC,如图


        接下来的三步操作如下

        mov就是将ecx赋值为2AC008h

        call就是调用了后面那个地址的函数

        nop就是一个空操作,什么都不干,主要用于消耗一个时钟周期

        这也是在对函数栈帧进行操作,但因为call函数里面调用的函数用到了比较多的不同汇编指令,还有多重函数调用,就不展开讲了,感兴趣的可以在call处按俩下F11进入查看

        只需要知道这部分也是在为main函数预处理函数栈帧就可以了


        4.3.1.2正式代码的执行

        main函数栈帧开辟完后,接下来就要执行正式有效的代码了,首先就是a变量的创建

        这个就是将1的这个值放到ebp-8这个位置,假设图中一行是四个字节的话,那么ebp-8就在这个位置,如图

        其实,为a开辟的空间就是这个空间,因为我们给a初始化了,所以这块空间的值变成了1,但如果我们没有给创建的变量初始化,这个空间的值就会默认为0CCCCCCCh,但0CCCCCCCh代表的是内存未初始化,如果内存未初始化,就会输出随机的东西

        所以这就是为什么创建局部变量不初始化的话会是随机值的原因,如果变量不初始化,我们是不是会经常打印出来烫烫烫烫或者随机值,随机值解释完了,那烫烫烫烫是怎么来的,也是因为内存里放了CCCCCC这样的东西

        我们来看这行运行完后的变化,如下图

        可以发现a的值已经成功放进去了


        然后之后的b和c也是同理,运行完如下图

        我们也可以发现,b,c,的位置跟跟前一个变量的位置都刚好差了俩个整形的字节,这也是VS为创建的变量分配的规律,同一个数据类型下,隔着的距离便是这个数据类型的字节

        那么接下来,就到了函数调用的环节


        4.3.2.add函数调用创建栈帧

        因为要调用add函数了,所以编译器会在栈区先做一点准备工作

        4.3.2.1.add函数调用前准备

        所有的准备过程如下图

        首先,用了mov指令,将b的值赋给了eax

        然后将eax压入了栈中

        随后,又用mov指令,将a的值赋给了ecx

        随后又将ecx压入了栈中

        再之后的call便是调用add函数了,那么,在进行完上面的四步后(其实这四步便是在传形参,下面讲完后你们就会意识到了),栈区内存分布是这样的,如下图

        经过多次的操作,现在其实main函数的栈帧并没有图中那么点,因为我们曾说过,esp和ebp之间的那块空间是维护的函数栈帧,所以其实main函数的栈帧其实一直在变,现在main函数的真实栈帧如下图

        那么,接下来便是add函数的调用了


        4.3.2.2.add函数的调用 

        call指令调用函数时,如果想知道调用的是什么函数只需要将显示符号名勾上就可以了,如下图

        由此我们可知,调用的确实是add函数

        用call时记住指令的地址,就是这个地址(call指令的下一个指令的地址),我们来看看这个地址有什么奇妙之处,如图

        特别注意:遇到call如果想进调用的函数内部就按F11,不要按F10

        在用call之前,我们先来注意一下esp的位置,即栈顶的位置,如图

        这便是esp的位置,那么接下来,我们来看call走完后esp有没有变化(记住,是按F11)

        我们发现,esp的位置变小了,且栈顶的元素竟然与call指令下一条指令的地址一样了,这说明call指令执行时,把 call指令的下一条指令的地址压入了栈中(因为俩个地址一模一样)

        那么,此时栈顶又多了一个地址,所以现在栈区内的空间分布如下图

        那么,为什么要将这个地址压入栈中呢?

        因为调用完函数后要回来,这个压到栈里的地址就是call指令的下一条指令的地址,回来的时候就会找到这个地址,接着往下执行

        至于怎么找地址返回会在之后的汇编代码中出现,到时会讲解


        4.3.2.3.add函数的栈帧的开辟

        在这之后我们继续按F11,就会真正的进入add函数的里头,如下图

        我们会发现,其实add函数栈帧的开辟也跟main函数栈帧的开辟基本一样,那么,这里就直接放add函数的栈帧开辟完后的内存分配图啦

        add函数的栈帧预开辟完是这样子的

        4.3.2.4.正式代码的运行

        接下来,我们来看调用的函数的代码是怎么实现的,总共只有4个汇编指令

        首先,我们来看第一个,mov指令,将eax的值赋为abp+8位置的4个字节的数,那么,abp+8是在哪里呢,我们通过图来看一看

        我们发现,ebp+8的位置便是a的值,那么,我们看一看mov后eax的值是不是变成了a的值

        eax确实变成了a的值

        接下来,就是add指令,让eax的值再加上ebp+0Ch位置的值,我们发现,ebp+0Ch的位置便是b的值,那么,此时,add指令运行完后,eax的值就会变成3,我们来验证下

        eax确实变成了3

        然后第三步自然就是将eax的值赋值给了ebp-8的位置的值

        通过这三步里面的前面俩步,我们发现这里运算是用之前压进去的数进行计算的,这也就解释了为什么形参是实参的临时拷贝,也解释了为什么改变形参不会影响实参

        接下来就是将返回的值带回给原函数了,这个时候,要带回的值是通过eax寄存器来存储的,所以最后一条mov指令是将值赋值给了eax

        那么,正式代码运行到返回也就结束了,接下来,这个add函数的栈帧就应该销毁了,所以接下来,我们进入函数栈帧的销毁与返回部分的讲解


        4.3.3.add函数栈帧的销毁与返回部分

        这部分的汇编代码也不多,就只有这么点

        首先便是三个pop

        第一个pop将栈顶元素赋值给了edi并弹出       

        第二个pop将栈顶元素赋值给了esi并弹出 

        第三个pop将栈顶元素赋值给了ebx并弹出

        在进行完这三个pop指令后,栈区空间的分布就变成了如下的样子

 

        接下来的add将esp加了0CCh,我们通过上图便可看出,esp回到了现在ebp所指向的位置,如下图

        接下来的这三个指令

        这部分的第一个cmp指令是为了接下来对call指令调用函数时候的预处理操作,然后第三个mov操作是因为调用过函数后esp变了,再将esp变回来,这部分比较复杂,就不分析了

        接下来我们来看最后的俩行汇编代码

        首先第一个pop,将栈顶的元素弹出储存到ebp寄存器里,我们可以发现,现在图中栈顶存的ebp的那个元素是main函数的ebp的值,所以当我们弹出后,ebp就回到了main函数时候的ebp,也就是如下图

        那么现在,是不是就跟进入add函数前一模一样了,此时,add函数的函数栈帧就销毁完全了


        最后的那个ret便是回到main函数的关键,为什么这么说呢

        因为ret指令就是弹出栈顶的地址,然后跳到那去(这么一说你应该就知道为什么要将call指令的下一个指令的地址压入栈中了吧)因为栈顶存的是call指令的下一个指令的地址,所以跳转的时候,自然就跳回去了

        没ret前       

 

        ret后

        所以存这个地址就是为了让函数调用完后能回来

        不仅要走的出去,还要回的来,这就是函数栈帧的创建和销毁的一个严谨的过程

        接下来的这步add操作便是将临时拷贝的实参去掉,add执行完后栈区空间就变成了如下所示

        最后的那个mov就是将eax的值(即a+b的值)存到了c中

        那么,到了这里,函数栈帧的创建和销毁就讲完了,剩余的那些代码就不接着往下讲了


5.回顾最初

        接下来,学完了函数栈帧的创建和销毁,接下来我们再重新回想一下一开始我们可能存在的问题,是不是都知道了为什么

        5.1.局部变量是怎么创建的

        在该函数的栈帧中从高地址往下分配一块确切的空间,这块空间便是局部变量

        5.2.为什么局部变量的值是随机值

        若没有对空间进行赋值,即初始化,那么空间里的元素为0xCCCCCCCCh,这代表着内存未初始化,若不初始化直接使用便会出现随机值或者烫烫烫的情况

        5.3.函数是怎么传参的

        将实参的值压到栈顶,在调用函数时候使用的便是被压到栈顶的值

        5.4.传参的顺序是怎样的

        一般先传低地址,再传高地址

        5.5.形参和实参是什么关系,为什么说形参是实参的临时拷贝

        因为形参是将实参的值压到了栈顶处,在调用函数时改变形参,只会改变栈顶处元素的值,但是函数没有改变实参位置处的值,而且函数返回时,形参位置的值便会消失(因为esp的地址已经比形参的地址高了)所以可以当成函数一 返回,形参就消失了,所以形参是实参的临时拷贝

        5.6.函数调用是怎么做到的

        通过ebp和esp的维护,在将调用下一步的汇编地址压完后,将esp赋值给ebp,随后,将esp接着往下延伸,这样,预开辟的新的函数栈帧就形成了,函数调用就做到了

        5.7.函数调用结束后又是怎么返回的

        先将esp变回到ebp的位置,接着再通过pop指令让ebp回到原函数的位置,接着再用ret将汇编地址跳回到原函数下一步的位置,就做到了


注:寄存器是集成到CPU上的,跟函数没关系 ,都可以使用


结语:

        该篇主要是修炼内功,知道底层才好在之后对难点进行剖析

        希望以上内容对你有所帮助,感谢观看,若觉得写的还可以,可以分享给朋友一起来看哦,毕竟一起进步更有动力嘛,接下来应该就是对指针相关内容的博客啦

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐