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

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

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在栈顶,储存栈顶的地址,叫做栈顶指针


栈底到栈顶地址由高变低。

0d3b6acd69c627cb8522093d70c9afa3.png


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



5. 如何调用堆栈



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

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


e44357d5a78a8901e764be2b3a5b4d90.png

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


f628b7deb39f4dbddcbcb8ffbf67d3c1.png


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


d80d4c83f9c378aa25a375162a0a934b.png



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


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

737d5c3b56c32c4bda1d4e4e1a7fe1ac.png



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


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

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

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


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



6.1 main函数栈帧的创建


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

f7916466bb97b33bb78e786ad3abc1e1.png

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

73dcb46dbbd0772e89ea6fd6b991490b.png


汇编指令讲解:


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

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


913d970a060c3a425617f31f457bd431.png


sub esp,0E4h:将esp中的值减0E4h。这时esp的值减小,esp的位置上移,这时就产生了main函数的esp。此时esp到ebp的一块很大的空间就为main函数的函数栈帧。


push ebx:将ebx的值压栈,此时ebx的值位于栈顶,栈顶指针esp的位置上移,esp的值减4。


push esi:将esi的值压栈,此时esi的值位于栈顶,栈顶指针esp的位置上移,esp的值减4。


push edi:将edi的值压栈,此时edi的值位于栈顶,栈顶指针esp的位置上移,esp的值减4。


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


b367ffb798118fc0bd6cb5839d4b12ea.png


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

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

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

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

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


模块对应过程图:


  1. 9a7bee47cbe25d014c7623b233d79d8b.png

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



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


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

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


8923ab3c07d83ac9c2aaec66d13f7d3b.png

汇编指令讲解:


6.2.1 局部变量初始化


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

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

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

   move dword ptr [ebp - 20h],0:将0,放到ebp - 32中,即ebp向上8个单位处。所对应数据在b变量向上3个单位处。



这三步对应的内存分布:

2e3c1f3fc6e9819e8358737585def38b.png


6.2.2 函数调用和传参


move eax,dowrd ptr [ebp - 14h]:将ebp - 14h放到eax寄存器中,也就是将20放到eax中。该步为局部变量b传参。

push eax:将eax的值压栈,此时eax的值位于栈顶,栈顶指针esp的位置上移,esp的值减4。

move ecx,dowrd ptr [ebp - 8]:将ebp - 8放到ecx寄存器中,也就是将10放到ecx中。该步为局部变量a传参。

push ecx:将ecx的值压栈,此时ecx的值位于栈顶,栈顶指针esp的位置上移,esp的值减4。



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


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


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

ba42742178c5f5dbba16e48114920dc1.png


模块对应过程图:


1b3c02f6180f2f61adbb9003dc93ebd2.png


6.3 Add函数调用过程


6.3.1 Add函数栈帧的创建


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


fbd52b7482b80002370b201183b1272e.png


汇编指令讲解:


   push ebp:将ebp的值压栈,此时ebp的值位于栈顶,栈顶指针esp的位置上移,esp的值减4。

   mov ebp,esp:将esp的值放到ebp中,此时ebp(来自main函数),移动到栈顶,和main函数的esp位置相同。

   sub esp,0CCh:将esp中的值减0CCh。esp的位置上移,这时就产生了add函数的esp。此时esp到ebp的一块很大的空间就为add函数的函数栈帧。

   push ebx:将ebx的值压栈,此时ebx的值位于栈顶,栈顶指针esp的位置上移,esp的值减4。

   push esi:将esi的值压栈,此时esi的值位于栈顶,栈顶指针esp的位置上移,esp的值减4。

   push edi:将edi的值压栈,此时edi的值位于栈顶,栈顶指针esp的位置上移,esp的值减4。

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

   mov ecx,33h:把33h放入ecx寄存器中。

   mov eax,0CCCCCCCCh:把0CCCCCCCCh放入eax寄存器中。

   rep stos dword ptr es:[edi]:将eax中0CCCCCCCCh的值拷贝33h次到相应地址,地址范围:ebp - OCCh ~ ebp



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

9ca3af635a279c6af466496f522e7f9e.png


汇编指令讲解:

   mov dword ptr [ebp-8],0 :将0放到ebp - 8中,即ebp向上2个单位处,该步骤为初始化局部变量z。

   mov eax,dword ptr [ebp+8] :将ebp + 8放到eax中,即ebp向下2个单位处,为main函数参数a传参的值。

   add eax,dword ptr [ebp+0Ch]:将ebp + 0Ch中的值加到eax中,ebp + 0Ch为ebp + 12,为main函数参数b传参的值,也就是10和20相加,此时eax中的值为30。

   mov dword ptr [ebp - 8],eax:将eax的值,放到ebp - 8中,ebp - 8就是局部变量z。

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



6.3.3 计算结果返回

2728dad6d771bc75ac27929d0e6b5a6f.png


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

模块对应过程图:


4e685057fcb61a7c6344fa1a086e4e2c.png


6.4 Add函数栈帧的销毁


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


125ba28ea40b685d7f5283660d9f50ff.png


汇编指令讲解:


   pop edi:出栈,将edi数据弹出,esp向下移动一个单位。

   pop esi:出栈,将esi数据弹出,esp向下移动一个单位。

   pop ebx:出栈,将ebx数据弹出,esp向下移动一个单位。

   mov esp,ebp:把ebp指向esp,也就是将Add的栈顶指针esp,移向Add函数的栈底指针处。

   pop ebp:出栈,把ebp的数据弹出,也就是将Add的栈底指针弹出,这时Add的栈顶和栈底指针均已出栈,Add函数栈帧销毁,且由于ebp弹出,这时的esp为维护main函数的esp,指向位置为call指令下一条指令的地址处。

   ret:从栈顶弹出一个值,此时栈顶的值为call指令的下一条指令,于是弹出该值,跳转到call指令下一条指令处,继续执行main函数。


模块对应过程图:


2179afe604bd823fde1570bca1c2aa50.png



6.5 调用结束


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

080e39e26c1fd5210d71e9e7a236de0f.png



   add esp,8:将esp + 8,原先esp由于call指令下一条指令的值弹出而指向下个元素,下两个元素为局部变量a,b传参时开辟的空间,esp + 8跳过两个单位,这时栈顶指针esp位于寄存器ebi处。

   mov dword ptr [ebp-20h],eax:将寄存器eax的值放入ebp - 20h(ebp - 32)中,也就是变量c中,此时c的值为30。

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

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



7. 总过程图


c85f764d28d9a753077cd7049ea8d892.png


8. 问题解答


局部变量是怎么创建的?

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

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

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

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

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

传参顺序从右往左。

形参和实参是什么关系?

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

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

函数是怎么调用的?

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

函数调用后如何返回?

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

返回值如何返回?

通过eax寄存器带回。





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





9. 结语


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

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

我是anduin,一个C语言初学者,希望我的博客可以为您带来帮助,我们下期见!







相关文章
|
4月前
|
存储 C语言
打通你学习C语言的任督二脉-函数栈帧的创建和销毁(上)
打通你学习C语言的任督二脉-函数栈帧的创建和销毁(上)
44 0
|
8月前
|
存储 编译器 程序员
|
8月前
|
存储 算法 编译器
【c语言技能树】函数的创建与销毁 --函数栈帧
以下内容可能乍一看有点费解,但在我讲的过程中再看就很容易理解啦,
232 0
|
10月前
|
存储 编译器 程序员
C语言代码函数栈帧的创建与销毁(修炼内功)
目录 在前期的学习中我们可能有很多困惑 例如:局部变量是怎么创建的 为什么局部变量的值是随机值 函数是怎么样传参的 传参的顺序是什么 形参和实参的关系是什么 函数调用是怎么做的 函数掉调用结束后怎么返回的 这篇博客我们来修炼自己的内功,掌握好这篇博客的大部分知识就已经很不错了 我们用到VS2013这个编译器,目的是为了看到更详细的函数封装内容 提示不要使用太过高级的编译器,因为越高级的编译器越不容易观察。同时这里需要注意的是在不同的编译器下,函数调用过程中栈帧的创建是略有差异的,不是完全相同的,具体细节取决于编译器
|
10月前
|
编译器 C语言
抽丝剥茧C语言(中阶)函数栈帧的创建与销毁——图解(上)
抽丝剥茧C语言(中阶)函数栈帧的创建与销毁——图解
|
10月前
|
C语言
抽丝剥茧C语言(中阶)函数栈帧的创建与销毁——图解(下)
抽丝剥茧C语言(中阶)函数栈帧的创建与销毁——图解
|
11月前
|
C语言
C语言番外-------《函数栈帧的创建和销毁》知识点+基本练习题+完整的思维导图+深入细节+通俗易懂建议收藏(二)
C语言番外-------《函数栈帧的创建和销毁》知识点+基本练习题+完整的思维导图+深入细节+通俗易懂建议收藏(二)
|
11月前
|
存储 编译器 C语言
C语言番外-------《函数栈帧的创建和销毁》知识点+基本练习题+完整的思维导图+深入细节+通俗易懂建议收藏(一)
C语言番外-------《函数栈帧的创建和销毁》知识点+基本练习题+完整的思维导图+深入细节+通俗易懂建议收藏(一)
|
存储 监控 编译器
【C语言进阶】函数栈帧的创建和销毁(内功修炼)
目录 前言 一、基础知识 1.1 什么是栈区? 1.2 寄存器 1.3 测试代码和一些其它的 二、函数栈帧的创建和销毁的过程 2.1 _tmainCRTStartup函数(调用main函数)栈帧的创建 2.2 main函数栈帧的创建 2.3 main函数内执行有效代码 2.4 Add函数栈帧的创建 2.5 Add函数内执行有效代码 2.6 Add函数栈帧的销毁 2.7 main函数代码继续执行 三、所需反汇编代码总览 四、总结
238 0
【C语言进阶】函数栈帧的创建和销毁(内功修炼)
|
存储 编译器 C语言
C生万物 | 反汇编深挖【函数栈帧】的创建和销毁
从汇编角度深度挖掘函数栈帧建立和销毁的全过程,保姆式教学,超详细解说
165 0
C生万物 | 反汇编深挖【函数栈帧】的创建和销毁