【C语言】程序员筑基功法——《函数栈帧的创建与销毁》

《函数栈帧的创建与销毁》

【C语言】程序员筑基功法——《函数栈帧的创建与销毁》_第1张图片

文章目录

  • 1. 前言
  • 2. 问题引入
  • 3. 前提准备
    • 3.1 寄存器
    • 3.2 汇编指令
  • 4. 函数栈帧的维护
  • 5. 如何调用堆栈
  • 6. 函数栈帧的创建和销毁
    • 6.1 main函数栈帧的创建
    • 6.2 main函数局部变量的创建和函数调用
      • 6.2.1 局部变量初始化
      • 6.2.2 函数调用和传参
    • 6.3 Add函数调用过程
      • 6.3.1 Add函数栈帧的创建
      • 6.3.2 局部变量初始化和计算过程
      • 6.3.3 计算结果返回
    • 6.4 Add函数栈帧的销毁
    • 6.5 调用结束
  • 7. 总过程图
  • 8. 问题解答
  • 9. 结语

1. 前言

编程之路,大道至简。在学习编程时,我们需要一些“功法”来帮助自己修炼,让自己和他人之间拉开距离。本篇功法致力于讲解函数栈帧部分知识,让你对从前无法解答的函数相关知识,做出更好的理解,接下来,让anduin带领大家入门这篇功法——《函数栈帧的创建与销毁》。

2. 问题引入

在学习基础知识时,我们对于以下问题都有许多困惑?
夺命七连招你是否能接住?

  • 局部变量是怎么创建的?
  • 为什么未初始化局部变量的值是随机值?
  • 函数如何传参?传参顺序是怎样的?
  • 形参和实参是什么关系?
  • 函数是怎么调用的?
  • 函数调用后如何返回?
  • 返回值如何返回?

如果无法做出“招式”来应对,不必担忧,这是每一名程序员在筑基时总会遇到的瓶颈期,只要吃透这本功法,筑基就入门了,让我们一起修炼!

3. 前提准备

在学习函数栈帧的销毁和创建前,我们需要对讲解过程中的一些寄存器和汇编指令初步了解。

3.1 寄存器

  • eax:常用寄存器,常用于存储函数调用的返回值
  • esp(重要):栈顶寄存器,记录栈顶的地址
  • ebp(重要):栈底寄存器,记录栈底的地址

其他寄存器均为常用寄存器,用于保留数据。

3.2 汇编指令

  • mov:数据移动
  • sub:减法命令
  • add:加法命令
  • push:压栈,从栈顶放一个元素,改变esp的位置
  • pop:出栈,从栈顶删除一个元素,改变esp的位置
  • call:函数调用
  • lea:load effective address 加载有效地址
  • rep:重复指令
  • stos:把寄存器中的值拷贝到指定地址处

4. 函数栈帧的维护

在创建函数时,操作系统都会在栈区上为函数开辟一块空间。而ebpesp就分别处于栈顶栈底,他们之间的区域就是这个函数的函数栈帧。ebp在栈底,储存栈底的地址,叫做栈底指针,esp在栈顶,储存栈顶的地址,叫做栈顶指针
栈底到栈顶地址由高变低。
【C语言】程序员筑基功法——《函数栈帧的创建与销毁》_第2张图片

图示中,esp和ebp维护的是main函数的函数栈帧,但是当调用另一个函数时,esp和ebp就需要维护调用函数的函数栈帧

5. 如何调用堆栈

调用堆栈是编译器的一种机制,可以在程序调用多个函数时,追踪每个函数在完成执行时应该返回控制的点,观察函数之间的调用关系。

要调用堆栈。这一操作需要按F10,进入调试状态,再在窗口中点击调用堆栈

【C语言】程序员筑基功法——《函数栈帧的创建与销毁》_第3张图片

继续按F10,到程序结束时,调用堆栈界面会出现如下情况:

【C语言】程序员筑基功法——《函数栈帧的创建与销毁》_第4张图片

__tmainCRTStartup()mainCRTStartup()这两个函数又是什么?在crtexe.c文件中观察:

【C语言】程序员筑基功法——《函数栈帧的创建与销毁》_第5张图片

所以发现main函数是被__tmainCRTStartup()调用的而__tmainCRTStartup()又被mainCRTStartup()调用,而Add函数又被main函数调用。而平常main函数的返回值,就被放在mainret中。

上面我们说到,每一个函数被调用时都会开辟空间,那么这么多函数被调用,对于这个程序,在栈区上函数的栈帧就是这样:

【C语言】程序员筑基功法——《函数栈帧的创建与销毁》_第6张图片

6. 函数栈帧的创建和销毁

通过以上两部分内容的了解,我们对函数栈帧有了一个初步的认识,接下来就进入正题。

以简单函数作为讲解案例:

 
#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\n", c);
	return 0;
}

鼠标右击代码,转到反汇编,查看汇编代码,并取消勾选符号名,从汇编角度进行讲解:

6.1 main函数栈帧的创建

首先,由于main函数是被__tmainCRTStartup()所调用的,所以一开始栈区为:

【C语言】程序员筑基功法——《函数栈帧的创建与销毁》_第7张图片

接着我们再观察main函数中的汇编代码:

【C语言】程序员筑基功法——《函数栈帧的创建与销毁》_第8张图片

汇编指令讲解:

  1. push ebp:将ebp中的值压栈,此时ebp的值位于栈顶,栈顶指针esp的位置要移到栈顶的ebp处,而地址从上到下由低到高,所以esp处的地址的值减 4

image-20220720155442144

  1. move ebp,esp:将esp的值放入ebp中,也就是说栈底指针ebp的值改变,ebp位置移动到__tmainCRTStartup的esp处,此时esp和ebp的值相等。产生了main函数的ebp

image-20220720160826836

  1. sub esp,0E4h:将esp中的值减0E4h。这时esp的值减小,esp的位置上移,这时就产生了main函数的esp。此时esp到ebp的一块很大的空间就为main函数的函数栈帧
  2. push ebx:将ebx的值压栈,此时ebx的值位于栈顶,栈顶指针esp的位置上移,esp的值减4。
  3. push esi:将esi的值压栈,此时esi的值位于栈顶,栈顶指针esp的位置上移,esp的值减4。
  4. push edi:将edi的值压栈,此时edi的值位于栈顶,栈顶指针esp的位置上移,esp的值减4。

​ 查看内存监视,发现以上三个寄存器的次序为edi -> esi -> ebx,且esp的值与edi的地址:0x00dEF66C相同。

image-20220720181721774

  1. lea edi,[ebp+FFFFFF1Ch]:显示符号名,将[ebp - 0E4h]加载到edi中。这时的这个位置就是之前main函数的函数栈帧顶部的位置,将这个值放入edi。

  2. move ecx,39h:把39h放入ecx寄存器中。

  3. move eax,0CCCCCCCCh:把0CCCCCCCCh放入eax寄存器中。

  4. rep stos dword ptr es:[edi]:rep重复指令,将ecx寄存器中数据的值为次数,每次重复ecx的值都会减少,stos表示把eax的值拷贝到指定的地址,word为两个字节,d为double,也就是双字,告诉stos一次拷贝双字的地址,也就是拷贝CCCCCCCC到目的地址。

    拷贝的地址的范围:ebp - 0E4h ~ ebp

模块对应过程图:

【C语言】程序员筑基功法——《函数栈帧的创建与销毁》_第9张图片

经以上过程,main函数的函数栈帧就开辟完成

6.2 main函数局部变量的创建和函数调用

函数栈帧开辟完成后,需要创建局部变量,以及调用Add函数,我们查看以下过程的汇编代码:

注:之前开辟的CCCCCCCC的区域大小均为4个字节

【C语言】程序员筑基功法——《函数栈帧的创建与销毁》_第10张图片

汇编指令讲解:

6.2.1 局部变量初始化

  1. move dword ptr [ebp - 8],0Ah:将0Ah(十进制:10)放到ebp - 8中,即ebp向上2个单位处。

(想象一下,如果这里变量并没有初始化,那么这一动作,在变量中放的值为CCCCCCCC,这就是烫烫烫,就是随机值)

  1. move dword ptr [ebp - 14h],14h:将14h(10进制:20),放到ebp - 20中,即ebp向上5个单位处。所对应数据在a变量向上3个单位处(位置取决于编译器)。
  2. move dword ptr [ebp - 20h],0:将0,放到ebp - 32中,即ebp向上8个单位处。所对应数据在b变量向上3个单位处。

这三步对应的内存分布:

【C语言】程序员筑基功法——《函数栈帧的创建与销毁》_第11张图片

6.2.2 函数调用和传参

  1. move eax,dowrd ptr [ebp - 14h]:将ebp - 14h放到eax寄存器中,也就是将20放到eax中。该步为局部变量b传参
  2. push eax:将eax的值压栈,此时eax的值位于栈顶,栈顶指针esp的位置上移,esp的值减4。
  3. move ecx,dowrd ptr [ebp - 8]:将ebp - 8放到ecx寄存器中,也就是将10放到ecx中。该步为局部变量a传参
  4. push ecx:将ecx的值压栈,此时ecx的值位于栈顶,栈顶指针esp的位置上移,esp的值减4。

​ 通过这四步不难观察到函数传参为从右向左传参。

  1. call 009F11E0:调用Add函数,请记住这条指令的下一条指令的地址009F1A50,紧接着按F11,观察内存变化,ecx上方两个单位处的值改变,这个值为该指令的的下一条指令的地址,即让把call下一条指令的地址压栈

这五条命令对应的内存分布:

image-20220721153052608

模块对应过程图:

【C语言】程序员筑基功法——《函数栈帧的创建与销毁》_第12张图片

6.3 Add函数调用过程

6.3.1 Add函数栈帧的创建

该过程汇编指令和main函数栈帧创建的汇编指令相似:

【C语言】程序员筑基功法——《函数栈帧的创建与销毁》_第13张图片

汇编指令讲解:

  1. push ebp:将ebp的值压栈,此时ebp的值位于栈顶,栈顶指针esp的位置上移,esp的值减4。
  2. mov ebp,esp:将esp的值放到ebp中,此时ebp(来自main函数),移动到栈顶,和main函数的esp位置相同。
  3. sub esp,0CCh:将esp中的值减0CCh。esp的位置上移,这时就产生了add函数的esp。此时esp到ebp的一块很大的空间就为add函数的函数栈帧
  4. push ebx:将ebx的值压栈,此时ebx的值位于栈顶,栈顶指针esp的位置上移,esp的值减4。
  5. push esi:将esi的值压栈,此时esi的值位于栈顶,栈顶指针esp的位置上移,esp的值减4。
  6. push edi:将edi的值压栈,此时edi的值位于栈顶,栈顶指针esp的位置上移,esp的值减4。
  7. lea edi,[ebp-0CCh] :显示符号名,将[ebp - 0CCh]加载到edi中,这时的这个位置就是之前add函数的函数栈帧顶部的位置,将这个值放入edi。
  8. mov ecx,33h:把33h放入ecx寄存器中。
  9. mov eax,0CCCCCCCCh:把0CCCCCCCCh放入eax寄存器中。
  10. rep stos dword ptr es:[edi]:将eax中0CCCCCCCCh的值拷贝33h次到相应地址,地址范围:ebp - OCCh ~ ebp

6.3.2 局部变量初始化和计算过程

【C语言】程序员筑基功法——《函数栈帧的创建与销毁》_第14张图片

汇编指令讲解:

  1. mov dword ptr [ebp-8],0 :将0放到ebp - 8中,即ebp向上2个单位处,该步骤为初始化局部变量z
  2. mov eax,dword ptr [ebp+8] :将ebp + 8放到eax中,即ebp向下2个单位处,为main函数参数a传参的值
  3. add eax,dword ptr [ebp+0Ch]:将ebp + 0Ch中的值加到eax中,ebp + 0Ch为ebp + 12,为main函数参数b传参的值,也就是10和20相加,此时eax中的值为30。
  4. mov dword ptr [ebp - 8],eax:将eax的值,放到ebp - 8中,ebp - 8就是局部变量z

当Add函数中x和y变量相加时,发现形参并不是Add函数中创建的,而是我使用了main函数传参时压栈压进去的空间,说明形参是实参的一份临时拷贝

6.3.3 计算结果返回

image-20220721171815026

  • mov eax,dword ptr [ebp-8] :结果返回时,函数结束调用,局部变量z销毁,为了让值安全返回,将z = 30放入eax寄存器中。

模块对应过程图:

【C语言】程序员筑基功法——《函数栈帧的创建与销毁》_第15张图片

6.4 Add函数栈帧的销毁

随着计算结果的返回,函数也将结束调用,这时Add的函数栈帧开始销毁

【C语言】程序员筑基功法——《函数栈帧的创建与销毁》_第16张图片

汇编指令讲解:

  1. pop edi:出栈,将edi数据弹出,esp向下移动一个单位。
  2. pop esi:出栈,将esi数据弹出,esp向下移动一个单位。
  3. pop ebx:出栈,将ebx数据弹出,esp向下移动一个单位。
  4. mov esp,ebp:把ebp指向esp,也就是将Add的栈顶指针esp,移向Add函数的栈底指针处
  5. pop ebp:出栈,把ebp的数据弹出,也就是将Add的栈底指针弹出,这时Add的栈顶和栈底指针均已出栈,Add函数栈帧销毁,且由于ebp弹出,这时的esp为维护main函数的esp,指向位置为call指令下一条指令的地址处。
  6. ret:从栈顶弹出一个值,此时栈顶的值为call指令的下一条指令,于是弹出该值,跳转到call指令下一条指令处,继续执行main函数

模块对应过程图:
【C语言】程序员筑基功法——《函数栈帧的创建与销毁》_第17张图片

6.5 调用结束

当Add函数栈帧销毁后,栈顶值为call指令下一条指令,弹出该值并跳转到该指令处,汇编指令继续执行:

【C语言】程序员筑基功法——《函数栈帧的创建与销毁》_第18张图片

  1. add esp,8:将esp + 8,原先esp由于call指令下一条指令的值弹出而指向下个元素,下两个元素为局部变量a,b传参时开辟的空间,esp + 8跳过两个单位,这时栈顶指针esp位于寄存器ebi处。
  2. mov dword ptr [ebp-20h],eax:将寄存器eax的值放入ebp - 20h(ebp - 32)中,也就是变量c中,此时c的值为30。

这就说明,程序在函数结束调用后,从eax中读取返回值。

而接下来的过程就是main函数销毁栈帧的过程,这里就不再赘述,有兴趣可以自己理解一下…

7. 总过程图

【C语言】程序员筑基功法——《函数栈帧的创建与销毁》_第19张图片

8. 问题解答

  • 局部变量是怎么创建的?

    所在的函数栈帧创建完成并初识化为CCCCCCCC后,在函数栈帧区域内,以一块空间作为该局部变量的区域。

  • 为什么未初始化局部变量的值是随机值?

    函数栈帧创建完毕后,区域内存放的值为CCCCCCCC,若变量未初始化,则该区域值不变,于是生成随机值。

  • 函数如何传参?传参顺序是怎样的?

    实参在传参的时候,会从右往左传参,参数依次放入寄存器中,并压栈,当函数调用时,函数会通过指针偏移量来找到该参数,

    传参顺序从右往左。

  • 形参和实参是什么关系?

    形参和实参的值相同,但是调用函数找到形参是通过指针偏移量来寻找的,所以改变形参的值不会对实参造成影响。

    形参是实参的一份临时拷贝。

  • 函数是怎么调用的?

    以原函数的栈顶作为调用函数的栈底指针,为调用函数开辟新的栈帧,用call指令调用函数。

  • 函数调用后如何返回?

    调用前在栈顶压入call指令下一条地址,并将上一个函数的栈顶指针作为调用函数的ebp,在函数调用结束后,ebp出栈,找到上一次函数的ebp,回到栈帧空间,由于记住了call指令的下一条地址,用ret指令返回该地址,回到call指令的下方。

  • 返回值如何返回?

    通过eax寄存器带回。

在学习这门功法后,这夺命七连招你是否接住了?如果接住了,那么恭喜你,你的功法已入门。

9. 结语

到这里,本篇功法就到此为止了,编程之路,路途遥远,虽然可能会遇到根骨欠佳(基础不扎实),招式杀伤力低(刷题少),空有其形(画图能力不太行),元神不稳(编码习惯不足),但这些都可以通过自身的努力来完善,希望在我们可以共同在编程之路上领悟我们自己的法则。

如果觉得本篇功法还不错的话,还请道友留下宝贵的三连!

我是anduin,一个C语言初学者,希望我的博客可以为您带来帮助,我们下期见!
【C语言】程序员筑基功法——《函数栈帧的创建与销毁》_第20张图片

你可能感兴趣的:(C语言进阶,c语言,开发语言)