C语言学习 -- 函数栈帧的创建和销毁

Posted 庸人冲

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C语言学习 -- 函数栈帧的创建和销毁相关的知识,希望对你有一定的参考价值。

前言

在学习C语言的过程中,大家是否会存在一些困惑?比如:

  • 局部变量是如何创建的?
  • 为什么说局部变量未初始化时,其中存储的时随机值?
  • 函数到底时如何传参的?实参传递的顺序又是怎样的?
  • 形参和实参之间有着什么关系?
  • 函数调用结束后,结果是如何返回的?

这些问题大家有没有感觉到,貌似每天都在接触,但是真要去解答这些问题,还真不知道怎么去回答。

不过各位同志切莫慌张!本文将会详细讲解函数栈帧的创建与销毁的过程,通过观察整个过程,以上的这些问题都能得到答案。

函数栈帧是什么?

栈这个名词相信学习过C语言或者数据结构的小伙伴不会陌生,C语言中的栈(也被称作堆栈)其操作方式与数据结构中的栈相似,有着后进先出(LIFO)的特点。在C语言中,栈的地址是由高地址向低地址生长的。

那么函数栈帧和栈区有什么关系呢?当我们在函数调用时,系统会在在栈区中为该函数开辟的一块空间,该函数中的局部变量就保存在这块空间中,这块空间就被称作函数栈帧。函数栈帧主要由两个指针寄存器espebp来负责维护。

  • esp:栈指针寄存器,该寄存器指向了栈区最上面一个栈帧的顶部(低地址),esp指向的位置会随着进栈(push)和出栈(pop)的操作而发生改变。
  • ebp:基址指针寄存器,该寄存器指向了栈区最上面一个栈帧的底部(高地址)。

通俗来讲,函数栈帧在创建的过程中,esp会指向当前栈帧的栈顶空间,而ebp会指向当前栈帧的栈底空间,当然,除了这两个寄存器,CPU中还有许多其它的几个寄存器,如eax,ecx,ebx,esi,edi等等,当后面遇到时,再详细介绍。

汇编指令简介

在简单了解函数栈帧的概念后,接下我们就要观察函数栈帧的创建与销毁过程了,而对于整个过程的观察实际上就是观察CPU执行的每条指令,C语言作为高级语言,其中简单的几行代码,底层可能是几十个CPU的指令构成,而这些CPU指令就是由汇编代码组成的,所以这里先简单介绍下后面会遇到的汇编指令,当遇到不理解的指令时可以回来查阅此表。

指令中文名格式功能备注
PUSH进栈指令PUSH SRC将源操作数压入栈中源操作数允许为16位或32位通用寄存器、存储器和立即数以及16位段寄存器。
POP弹栈指令POP DEST从栈中弹出双字或字数据至目的操作数中目的操作数允许为16或32位通用寄存器、存储器和16位段寄存器。
MOV传送指令MOV DEST,SRC把一个字节,字,或双字从源操作数传送至目的操作数字(WORD):表示2个字节长度的数值。
双字(DWORD):表示4个字节长度的数值。
ADD加法指令ADD DEST,SRC目的操作数加源操作数,结果送至目的操作数。源操作数可以是通用寄存器、存储器或立即数。目的操作数可以是通用寄存器或存储器操作数。
SUB减法指令SUB DEST,SRC目的操作数减源操作数,结果送至目的操作数源操作数可以是通用寄存器、存储器或立即数。目的操作数可以是通用寄存器或存储器操作数。
LEA(load effective address)取有效地址指令LEA REC,MEM将源操作数的有效地址传送到通用寄存器。操作数REG为16位或32位通用寄存器,源操作数为16位或32位存储器操作数。
STOS串存储指针[REP] STOS DESTS将累加器eax中值存入es:[edi]所指的目的串存储单元中,每传递一次,都按DF(Direction Flag)值以及串元素类型自动修改地址指针edi若加重复前缀REP,则表示将累加器的值连续送目的串存储单元,直到计数器ecx= 0时为止。
CALL过程调用指令CALL LABEL用来调用一个过程,指挥处理器从新的内存地址开始执行。(这里用来调用函数)call指令会在调用过程之前保存返回地址。然后再进行转移。
RET段内过程返回指令RET从调用过程返回,继续执行主程序。
JMP无条件转移指令JMP TARGET使程序无条件地转移到指令规定的目的地址TARGET去执行指令。转移分为短转移、段内转移(近程转移)和段间转移(远程转移)

过程观察

在开始观察之前还需要注意一点,因为编译器的不同,函数栈帧在创建和销毁过程中存在一些细微的差别,这里我使用的时VS2013进行演示。

给出下面一段代码来演示整个过程:

#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;
}

这段代码基本上学过C的小伙伴都能看得懂,但是我们主要关心的不是这段代码,而是这段代码在运行过程中,内层中所发生的变化,以及对应的汇编指令指示cpu执行的操作。

调用main函数的函数

首先我先按下F10对这段代码进行调试,并查看调用堆栈窗口。

此时可以在调用堆栈窗口中查看当前被调用的函数栈帧,很明显是main函数,那么问题来了,main函数又是被谁调用的呢?我们先直接运行到程序结尾,看看main函数的返回值0被返回到哪里了?

上图中可以看到,当main函数结束时,调用堆栈窗口中显示正在运行的栈帧为__tmainCRTStartup(),而左边代码行指向了main()函数的调用处,那么我们就大概知道原来main()函数之前还有被其他函数调用,而且main返回值0是返回给了mainret

细心观察调用堆栈窗口还可以发现,在__tmainCRTStartup()下面还有一个mainCRTStartup()的函数栈帧,这个函数就是调用__tmainCRTStartup()的函数。

所以,我们可以得出结论在vs2013中main()函数的栈帧被创建之前,其实还有两个函数mainCRTStartup()__tmainCRTStartup()的栈帧被创建,mainCRTStartup()调用了__tmainCRTStartup()__tmainCRTStartup()再调用了main()

那么我们一开始的栈区内存图就可以改成下图:

main函数栈帧创建

当我们了解到这些后,下面就继续回到代码中,我们再次按F10调试代码,并打开反汇编窗口,此时窗口中显示的就是main函数中代码所对应的汇编指令。

这些代码是不是很熟悉,这不就是咱们上面表格里的汇编指令嘛?那么下面我们就一行一行运行看看再栈区中到底发生肾么事。

00961410 push        ebp 
// 首先来看第一行 push ebp, 这段代码执行的操作是将ebp寄存器中的值压入栈中, 也就是放在栈顶位置。

ebp 我们知道是指向当前栈帧底部的寄存器,那么它其中保存的就是当前栈帧底部的地址。此时main()函数还没有在栈区中开辟空间,ebpesp目前指向的其实是__tmainCRTStartup()函数栈帧的栈顶和栈底。通过调用内存窗口我们可以观察,espebp中此时存放的值。这两个地址值之间的空间就是为__tmainCRTStartup()函数开辟的空间。

之前说过esp所指向的位置一定是栈顶元素,push和pop操作会改变esp所改变esp中存放的值,使得esp指向了新的栈帧元素,所以当push ebp后,esp指向的就是存放ebp中值的地址。通过观察内存窗口,来看看esp中得值会发生什么变化。

这里需要额外说明一下,在push操作后esp减少的值是根据操作数的数据类型来决定的,当操作数为DWORD时,push操作会使得esp中的值减少4,DWORD其实就是unsigned int,而操作数是一个地址,我们知道在32位平台中,地址占用4个字节,是一个无符号整型,因此esp的值减少了4个字节。

执行完push ebp后的内存图:

00961411  mov     ebp,esp
// 接着再看第二行代码, 这段代码执行的操作是将esp中的值传给ebp

这说明ebpesp现在同时指向了同一块内存空间,我们可以在内存窗口中观察执行这条指令后两个寄存器中保存的地址。

内存图如下:

00961413  sub     esp,0E4h
// 第三段代码,执行的操作是,让esp中的值技减去0E4h, 0E4h是16进制数换算为10进制等于228

esp中的地址值减去228,也就是向下偏移了228个字节,这个操作就是在划分main函数的栈区空间。

esp指向这块新的空间后,ebpesp之间的空间就是为main函数所开辟的内存空间,我们还是看内存窗口:

内存图如下:

00961419  push        ebx  
0096141A  push        esi  
0096141B  push        edi  
// 接下来3次push 分别将ebx, esi, edi 三个寄存器中的压进栈中。
    
// 那么这三个寄存器是用来做什么的呢?下面简单介绍一下:
// ebx: 基地址(base)寄存器,它可作为存储器指针来使用。
// esi/edi: 分别叫做"源/目标索引寄存器"(source/destination index),因为在很多字符串操作指令中, DS:ESI指向源串,而ES:EDI指向目标串。
// 后面就会用到这个edi寄存器

上面三个寄存器的值存放在esp指向地址后的地址空间。同时esp中的地址值会减少12,指向存放edi值的空间。

内存图如下:

// 再看接下来的4行代码
0096141C  lea         edi,[ebp+FFFFFF1Ch]  // lea在上面的表格中介绍过, 这句代码执行的操作是将[ebp+FFFFFF1Ch] 这个有效地址,加载到edi中
00961422  mov         ecx,39h              // 将39h这个值,赋值给ecx寄存器      
00961427  mov         eax,0CCCCCCCCh       // 将0CCCCCCCCh这个值,赋值给eax
0096142C  rep stos    dword ptr es:[edi]   // 至于最后一段代码执行的操作,我们得先具体了解上面3个代码的作用

首先,第一段代码赋给edi的值,通过计算我们可以得到为0x1005CFD60, edi是32位寄存器, 而这个值有36位,所以最高4位被截断,得到0x005CFD60,而这个值恰好就是esp之前所指向的地址。

第二段代码中的ecx寄存器,是计数寄存器,在循环和字符串操作时,需要使用它来控制循环次数,这个寄存器是REP前缀指令的内定计数器。既然是个计数器,那么对于它的赋值大概率是用来循环的,我们对39h进行换算发现对应的10进制数是57,57是这个数是干什么的呢?我们先暂且记住它,接着看后面的代码。

第三段代码中的eax寄存器,是累加寄存器,它是很多加法乘法指令的默认寄存器,同时这个寄存器也用来保存函数的返回值。

再看第四段代码:

rep stos   dword ptr es:[edi] 

rep stos这个指令在上面表格也提到过,它的作用是将累加器eax中值存入es:[edi]所指的目的串存储单元中,每传递一次,都按DF(Direction Flag)值以及串元素类型自动修改地址指针edi。若加重复前缀REP,则表示将累加器的值连续送目的串存储单元,直到计数器ecx= 0时为止。补充:DF决定了执行该指令后edi中的值是增加还是减少,没有设置默认是增加。

现在再看这个指令的功能,再结合上面3段代码的功能,是不是大家可以猜出来这四段代码要执行的操作了?

没错,上面4段代码的整体要执行的操作就是将eax中的值存入edi所指向的存储单元,每次传递4个字节的数据,并且每传递一次,edi的地址都会+4,重复执行直到ecx = 0 为止,ecx中的值是57,没执行一次-1,也就是执行了57次循环,每次循环覆盖4个直接的内容,总共覆盖了228个字节的内容,228这个数字是不是很熟悉?这不就是我们main函数的内存空间大小嘛?

所以,上面4段代码最后实现的结果就是,对main函数的内存空间进行初始化,每个字节的值都初始化位0xcc,0xcc对应的字符就是汉字“烫”,这也就是为什么我们使用printf()打印字符,如果越界访问会打印出一堆烫烫烫的原因了。

同样查看内存窗口可以看到,main函数的内存中间都被初始化为0xcc:

内存图如下:

我们再来梳理一下前面执行的所有代码:

00961410  push        ebp                    // 将ebp的值压栈
00961411  mov         ebp,esp                // 将esp的值赋给ebp, 此时esp与ebp指向了同一块空间
00961413  sub         esp,0E4h               // 使得esp向下偏移0E4h(228)个字节
00961419  push        ebx                    // 将ebx的值压栈
0096141A  push        esi                    // 将esi的值压栈
0096141B  push        edi                    // 将edi的值压栈
0096141C  lea         edi,[ebp+FFFFFF1Ch]    // 使得edi指向了0x005CFD60这块地址空间
00961422  mov         ecx,39h                // 给ecx赋值为39h(57)
00961427  mov         eax,0CCCCCCCCh         // 给eax赋值为0CCCCCCCCh 
0096142C  rep stos    dword ptr es:[edi]     // 对main函数开辟的内存空间进行初始化

main函数局部变量的创建

其实上面介绍的这10行代码,都是在为main函数开辟空间所作准备,这些都是编译器帮我们完成的,我们自己编写并没有体现在其中,那么接下来,就继续看看我们自己编写的代码如何被执行的。

// 下面三行代码就是为局部变量a,b,c开辟内存空间,并进行初始化
0096142E  mov         dword ptr [ebp-8],0Ah   // 将0Ah的值赋值到ebp-8这块空间上,0Ah就是10的16进制, ebp-8就是变量a的地址
00961435  mov         dword ptr [ebp-14h],14h // 将14h的值赋值到ebp-14h这块空间上,14h是20的16进制, ebp-14就是变量b的地址
0096143C  mov         dword ptr [ebp-20h],0   // 将0这个值复制到ebp-20h这块空间上,ebp-20h就是变量c的地址

我们来看看,变量a,b,c对应的内存空间是都在那里,ebp我们知道指向了main函数的底部,那么 -8,-14h,-20h,就是分别在main函数开辟空间的底部向下移动了8个字节,20个字节,32个字节(因为栈的地址是从高到低分配的所以是减,这里又说是从底部地址开始减,可能有些绕,需要注意下)。

我们还是看内存窗口中,a,b,c三个局部变量的地址和其中的值:

内存图如下:

补充:在VS2013编译器中,局部变量的空间相差8个字节,而在不同编译器下会有一些差别。

Add函数的创建

当创建完3个局部变量后,接下来就是调用Add函数了。

// 下面的代码,包含ADD函数调用和返回后剩余的操作
00961443  mov         eax,dword ptr [ebp-14h] // 将ebp-14h中的值, 传递给eax 
00961446  push        eax                     // 将eax中的值压栈
00961447  mov         ecx,dword ptr [ebp-8]   // 将ebp-8中的值, 传递给ecx
0096144A  push        ecx                     // 将ecx中的值压栈
0096144B  call        009610E1                // 调用ADD函数, 处理器将从009610E1的地址处开始执行, call指针会在调用函数前, 将下面 add指令的地址保存, 也就是会将00961450的压栈。
// 这两段代码等ADD函数返回后再来关注
00961450  add         esp,8  
00961453  mov         dword ptr [ebp-20h],eax  

我们先看在函数调用前的四段代码,执行了什么操作:

前两段代码中,ebp-14不就是局部变量b的地址嘛?那么也就是把变量b的值赋给eax,并将eax存储的值压栈。

后两段代码中,ebp-8不就是局部变量a的地址嘛?那么也就是把变量a的值赋给ecx,并将ecx存储的值压栈。

那么这四段代码是在干什么呢?大家还有没有印象在我们调用Add函数时,就是将a,b的值作为实参传给了函数的形参。

那么这些操作其实就是函数传参的操作,并且传参的过程是先传递右边的参数b,再传递左边的参数a,因为a,b是局部变量,出了作用域就无法操作了,所以参数的传递是依靠两个寄存器来实现的。

而且还有一点需要注意,我们目前还并没有调用Add函数,也就不存在开辟为Add函数开辟空间,而这两个变量的值被执行压栈操作,其实不是定义在Add函数中的空间中的,它们的位置位于main函数和Add函数之间,这两个值其实就只是变量a,b的一份临时拷贝,并且压栈后,esp指针也会指向新的栈顶元素。

内存窗口布局:

内存图如下:

当函数传参完毕后,下面就要开始调用Add函数了(因为误操作导致VS被关掉,只能重新调试,所以地址会和之前的不同,请大家见谅)。

在调用Add函数前,我们需要关注call指令下一条指令的地址,上面说过这条地址会在函数调用前被push进栈,我们F11进行下一步来观察是不是这样的。


从上面的内存窗口可以看到,在执行call指令后,操作转到006C10E1的地址处执行,而call指令的下一条指令的地址也被push进栈。那么将这个地址保存起来有什么用呢?其实它的作用很简单,就是为了结束Add函数后,可以返回main函数继续执行操作做准备的。

内存图如下:

那么我们接着再看这条jmp指令:

006C10E1  jmp         006C13C0  // 这条指令的操作是,跳转到006C13C0的位置执行指令

再次按下F11,我们就跳转到了Add函数所对应的汇编指令处:

这些指令看着是不是很眼熟?特别是前10行代码几乎和main函数创建时的指令一模一样,所以这10行指令也是在为Add函数创建函数栈帧,并初始化。当我们执行完这十条指令后,ebpesp这两个指针就开始负责维护Add函数的栈帧空间了。

内存中的布局大概是这样的:

内存图如下:

Add()函数局部变量的创建

当初始化Add栈帧的空间后,接下来就是创建局部变量z:

int z = 0;
006C13DE  mov         dword ptr [ebp-8],0    // 将0的值,传递到ebp-8的位置中,也就是变量z的空间中。

内存图如下:


接着执行 z = x + y 的操作:

z = x + y;
006C13E5  mov         eax,dword ptr [ebp+8]    // 将ebp+8的值以双字传递到eax中
006C13E8  add         eax,dword ptr [ebp+0Ch]  // 让eax中的值 加上 ebp+0ch中的值, 并赋值给eax
006C13EB  mov         dword ptr [ebp-8],eax    // 将eax的值以双字传递到ebp-8中

ebp+8其实就是函数传参时,存放变量a中值的那块空间,所以这块空间其实代表了形参x的内存空间。

ebp+0ch是存放变量b中值的那块空间,也就是形参y的内存空间。

因为这两块空间其实并不在Add函数的空间内,它们的作用域也没有覆盖到这个位置,所以借助了eax将两个值相加并重新赋给了eax

第三行指令,将eax的值以双字传递到ebp-8中,ebp-8不就是我们刚才为变量z开辟的内存空间嘛?所以这两个值相加的结果就被赋值给了变量z。

当完成上面三行指令后,就等于完成了 z = x + y的操作,这样我们就知道,原来形参的在函数中是这样被使用的,是不是很精妙?

完成加法操作后的内存图:

Add函数的销毁

当变量z得到了相加后的结果,下一步就是要将这个结果返回给调用处,那么是如何将这个结果进行返回的呢?

下面就要进行返回结果和销毁Add函数的操作了:

//一下是return z 所要执行的操作
006C13EE  mov         eax,dword ptr [ebp-8]  // 将ebp-8的值传递给eax
}
006C13F1  pop         edi                    // 将栈顶元素弹出至edi中
006C13F2  pop         esi                    // 将栈顶元素弹出至esi中
006C13F3  pop         ebx                    // 将栈顶元素弹出至ebx中
006C13F4  mov         esp,ebp                // 将ebp的值赋给esp, 即esp重新指向了ebp的位置
006C13F6  pop         ebp                    // 将栈帧元素弹出ebp中
006C13F7  ret                                // 执行ret指令

第一行指令,把ebp-8即变量z的值存储在了eax中,我们知道到Add函数被销毁后,变量z就被销毁了,那么这个返回值其实就是通过eax返回给调用处的。

第二至四行,都是弹栈指令,并且将栈帧元素的值放到对应的寄存器中,每次pop,esp就会+4指向上一个元素,而除了edi的值因为初始化操作发生了变化,其他2个寄存器并没有改变,所以执行3次pop后,edi的值会改变为初始化前的值,其他两个寄存器的值不变,并且esp重新指向了之前的空间。

内存图如下:



第五行指令,使得esp重新指向了ebp所指向的位置,此时,为Add函数所开辟的空间,就等于被回收了,我们就不能对这块空间进行有效访问了。

第六行指令,将栈顶元素的值弹至ebp中,此时的栈帧元素就是espebp所指向的那块空间,其中保存的值就是main函数的栈帧底部的地址,把这个地址交给ebp就使得ebp重新指向了main函数的栈帧底部。这里大家就应该明白为什么每次创建函数栈帧要把ebp之前指向的地址保存在当前栈帧的底部了吧?其目的就是为了当前栈帧被销毁后,ebp可以重新指向主调函数栈帧底部的位置。我只能说这个设计太精妙了!

执行指令后ebpesp所指向的地址:
内存图如下:


最后一行指令ret,会返回到主程序继续执行下面的指令中,那么应该返回到哪里呢?大家还记不记得在执行call指令的时候,我们是将call指令的下一条指令,也就是add指令保存起来,而esp此时正指向了这块空间,所以当执行ret指令后,cpu会返回继续执行add指令。

当执行ret后,esp+4,此时esp又指向了保存数字10的那块空间(形参x的空间)。

我们再接着看后面的add指令:

006C1450  add         esp,8  // 将esp中的值+8, 并赋给esp

这行指令使得esp向上跳过8个字节,指向了保存原来edi中值的地址,跳过的8个字节就是形参x,y的空间,所以形参x,y都被回收,并且此时ebpesp又重新开始维护main函数的栈帧。

执行到这里Add函数就彻底被销毁了,大家是不是很奇怪,被销毁了为什么保存的值都还在?其实这里说的销毁,其实是指这空间在当前程序中不能在进行访问,如果访问就属于非法访问,这也就是为什么,当我们越界访问后,会出现一些随机值,因为这些值可能就是在创建栈帧的过程中使用过的空间。

Add函数返回值执行的操作

大家可能就有疑问了,那Add函数的返回值去哪儿了?Add函数的空间都被销毁了它的返回值怎么传递回来?大家先不要慌张,我们接着看下面的指令:

006C1453  mov         dword ptr [ebp-20h],eax // 将eax的值以双字传递到 ebp-20h这块空间中

我们回忆一下,eax中的值是什么?不就是之前变量z的值嘛?也就是30。

ebp-20h是哪块空间?ebp现在指向了main函数栈帧的底部,它的值减32(十进制),咦~好像是变量c的空间啊,哦那么我们就知道了,原来这条指令的操作就是将Add函数的返回值通过eax赋值给变量c啊!

可以看到执行完这条指令后,c中的值被赋值为0x0000001e也就是30。

内存图如下:

剩下的指令就是调用printf()函数进行打印,和销毁main()函数的操作,这里就不再介绍,主要是printf()涉及到源码了,自己还没达到这个水平不敢瞎写,而销毁main()函数的操作,大体上和销毁Add()函数一致,所以这里也不再详细说明。

总结

通过对整个函数栈帧创建于销毁过程的观察,我们现在就可以回答文章开头提出的几个问题:

  1. 局部变量是如何创建的?

    局部变量的创建是在栈帧中,以ebp所指向的高地址为基础,向低地址处给变量分配空间,并且在vs2013中,两个变量之间会有8个字节的空隙。

  2. 为什么说局部变量未初始化时,其中存储的时随机值?

    这是因为函数栈帧在创建时,会默认对栈帧中的空间进行初始化,这个初始化的值并不是固定的,而是由编译器决定的,所以不同的编译器初始化的值也不一定相同,并且在栈帧创建的过程中,我们还在栈区中保存了很多寄存器变量中的值,这些值随着栈帧的销毁并不会真的销毁,依然保存在栈区空间中,只是我们无法进行有效访问了。而当我们不对局部变量进行初始化时,其中保存的值,也就不可知了。

  3. 函数到底时如何传参的?实参传递的顺序又是怎样的?

    函数传参实际上就是在两个函数栈帧中间,临时创建了一块空间用来保存传递的值,并且值传递是依靠寄存器变量来实现的。形参并没有在被调函数的内存空间中创建变量,实际上也是通过ebp+n来找到这些参数的。

    传递的顺序是从右往左传递。

  4. 形参和实参之间有着什么关系?

    形参其实就是实参的一份临时拷贝罢了,当被调函数栈帧销毁后,形参会紧接着被销毁。

  5. 函数调用结束后,结果是如何返回的?

    函数调用结束后,会将返回值保存到寄存器eax当中,当执行ret指令后,就会将eax中保存的值传递给调用处的空间,这就是函数返回值的具体实现。

以上就是函数栈帧创建与销毁的全部内容,这篇文章博主真的是实打实肝了一天,写着写着自己都写懵了。所以,如果文章能帮助到您还请能给个大拇指👍。

当然因为本人水平有限,目前也还在学习当中,文章中难免会出现纰漏,还恳请各位大佬发现问题后,能帮忙指出,感激不尽![抱拳]

以上是关于C语言学习 -- 函数栈帧的创建和销毁的主要内容,如果未能解决你的问题,请参考以下文章

图解C/C++语言底层:函数调用过程之函数栈帧的创建和销毁(上)

C语言深入逐汇编详解函数栈帧的创建和销毁过程

函数栈帧的创建与销毁,带你了解代码底层原理

函数栈帧的创建和销毁——“C”

C语言学习 -- 函数栈帧的创建和销毁

图解C/C++底层:函数栈帧的创建和销毁(下篇)