函数栈帧的创建和销毁

Hello,大家好!!!这里是小周为您带来的呕心沥血之作------C语言秘籍之函数栈帧的创建和销毁!!跟着小周学定会让你C语言功力大成,称霸武林,话不多说,马上开讲!!!!!

本章主题:

  • 什么是函数栈帧?

  • 理解函数栈帧能解决什么问题?

  • 函数栈帧的创建和销毁解析?

1、什么是函数栈帧

我们在写C语言代码的时候,经常会把一个独立的功能抽象为函数,所以C程序是以函数为基本单位的。那函数是如何调用的?函数的返回值又是如何待会的?函数参数是如何传递的?这些问题都和函数栈帧有关系。

函数栈帧(stack frame)就是函数调用过程中在程序的调用栈(call stack)所开辟的空间,这些空间是用来存放:

  • 函数参数和函数返回值

  • 临时变量(包括函数的非静态的局部变量以及编译器自动生产的其他临时变量)

  • 保存上下文信息(包括在函数调用前后需要保持不变的寄存器)

2、理解函数栈帧能解决什么问题呢

理解函数栈帧有什么用呢?

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

  • 局部变量是如何创建的?

  • 为什么局部变量不初始化内容是随机的?

  • 函数调用时参数时如何传递的?传参的顺序是怎样的?

  • 函数的形参和实参分别是怎样实例化的?

  • 函数的返回值是如何带会的?

让我们一起走进函数栈帧的创建和销毁的过程中。

3、函数栈帧的创建和销毁解析

3.1 什么是栈

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

        在经典的计算机科学中,栈被定义为一种特殊的容器,用户可以将数据压入栈中(入栈,push),也可以将已经压入栈中的数据弹出(出栈,pop),但是栈这个容器必须遵守一条规则:先入栈的数据后出栈(First In Last Out, FIFO)。就像叠成一叠的书,先叠上去的书在最下面,因此要最后才能取出。

        在计算机系统中,栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据从栈顶弹出。压栈操作使得栈增大,而弹出操作使得栈减小。

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

在我们常见的i386或者x86-64下,栈顶由成为 esp 的寄存器进行定位的。

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

相关寄存器

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

相关汇编命令

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

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

在不同的编译器当中,出现的情况各有不同,每一个函数调用,都要在栈区上创建一块空间

#include<stdio.h>
​
int Add(int x, int y)
{
    int z = 0;
    z = x + y;
    return z;
}
int main()
{
    int a = 10;
    int b = 10;
    int c = 10;
​
    c = Add(a, b);
​
    printf("%d\n", c);
}

我们调试然后打开调用堆栈

我们点一下F10,发现main函数被调用,那是谁调用的呢?怎样看出来呢?

我们右击选择显示外部代码

这样就可以看清楚main函数被谁调用了

我们发现是 __scrt_common_main_seh() 调用 invoke_main() ,然后invoke_main() 调用main函数

在栈区中是这样的

具体怎么做的,我们来研究

我们重新调试一下

这个时候,右击鼠标,转到反汇编

你可以自己改变反汇编的位置,我将他放在右边好为大家讲解

因为之前我给大家将是invoke_main() 调用的main函数,现在来到了main函数,这时之前的invoke_main() 函数的函数栈帧已经创建好了

int main()
{
00F518B0  push        ebp  
00F518B1  mov         ebp,esp  
00F518B3  sub         esp,0E4h  
00F518B9  push        ebx  
00F518BA  push        esi  
00F518BB  push        edi  
00F518BC  lea         edi,[ebp-24h]  
00F518BF  mov         ecx,9  
00F518C4  mov         eax,0CCCCCCCCh  
00F518C9  rep stos    dword ptr es:[edi]  
00F518CB  mov         ecx,0F5C003h  
00F518D0  call        00F5131B  
    int a = 10;
00F518D5  mov         dword ptr [ebp-8],0Ah  
    int b = 10;
00F518DC  mov         dword ptr [ebp-14h],0Ah  
    int c = 0;
00F518E3  mov         dword ptr [ebp-20h],0  
​
    c = Add(a, b);
00F518EA  mov         eax,dword ptr [ebp-14h]  
00F518ED  push        eax  
00F518EE  mov         ecx,dword ptr [ebp-8]  
00F518F1  push        ecx  
00F518F2  call        00F510B4  
00F518F7  add         esp,8  
00F518FA  mov         dword ptr [ebp-20h],eax  
​
    printf("%d\n", c);
00F518FD  mov         eax,dword ptr [ebp-20h]  
00F51900  push        eax  
00F51901  push        0F57B30h  
00F51906  call        00F510D2  
00F5190B  add         esp,8  
}
00F5190E  xor         eax,eax  
00F51910  pop         edi  
00F51911  pop         esi  
00F51912  pop         ebx  
00F51913  add         esp,0E4h  
00F51919  cmp         ebp,esp  
00F5191B  call        00F51244  
00F51920  mov         esp,ebp  
00F51922  pop         ebp  
00F51923  ret  
00F518B0  push        ebp 

1

我们打开监视窗口通过地址来看一下

按一下F10,预想的情况应该是esp地址降低

确实是这样,那我们再打开内存看一下

同样证明压栈成功,我们再接着来

00F518B1  mov         ebp,esp 

2

按一下F10,预想的情况应该是ebp的值变成与esp相同的值

00F518B3  sub         esp,0E4h 

3

按一下F10,确实如此,esp的值对应减小了,0E4h-8进制数字是228

因为现在已经调用main函数里面去了,该为main函数做点什么了,所以这个空间就是main函数的函数栈帧

0x007BFB74  01 18 f5 00  ..?.
0x007BFB78  23 10 f5 00  #.?.
0x007BFB7C  23 10 f5 00  #.?.
0x007BFB80  00 b0 90 00  .??.
0x007BFB84  90 fb 7b 00  ??{.
0x007BFB88  65 fb 80 79  e?€y
0x007BFB8C  9c fb 7b 00  ??{.
0x007BFB90  b3 ee 80 79  ??€y
0x007BFB94  ee b3 03 56  ??.V
0x007BFB98  13 00 00 00  ....
0x007BFB9C  b8 fb 7b 00  ??{.
0x007BFBA0  b8 fb 7b 00  ??{.
0x007BFBA4  3c 14 81 79  <.?y
0x007BFBA8  b8 fb 7b 00  ??{.
0x007BFBAC  b3 ee 80 79  ??€y
0x007BFBB0  ee b3 03 56  ??.V
0x007BFBB4  13 00 00 00  ....
0x007BFBB8  d4 fb 7b 00  ??{.
0x007BFBBC  d4 fb 7b 00  ??{.
0x007BFBC0  3c 14 81 79  <.?y
0x007BFBC4  23 10 f5 00  #.?.
0x007BFBC8  80 68 14 01  €h..
0x007BFBCC  c0 ca 7d 76  ??}v
0x007BFBD0  e0 fb 7b 00  ??{.
0x007BFBD4  b3 ee 80 79  ??€y
0x007BFBD8  ee b3 03 56  ??.V
0x007BFBDC  13 00 00 00  ....
0x007BFBE0  fc fb 7b 00  ??{.
0x007BFBE4  fc fb 7b 00  ??{.
0x007BFBE8  3c 14 81 79  <.?y
0x007BFBEC  ee b3 03 56  ??.V
0x007BFBF0  13 00 00 00  ....
0x007BFBF4  c0 ca 7d 76  ??}v
0x007BFBF8  10 fc 7b 00  .?{.
0x007BFBFC  db ca 7d 76  ??}v
0x007BFC00  02 00 00 00  ....
0x007BFC04  b9 ab 72 77  ??rw
0x007BFC08  00 00 00 00  ....
0x007BFC0C  00 00 00 00  ....
0x007BFC10  24 fc 7b 00  $?{.
0x007BFC14  fc 19 81 79  ?.?y
0x007BFC18  02 00 00 00  ....
0x007BFC1C  7a 33 f7 ee  z3??
0x007BFC20  c0 ca 7d 76  ??}v
0x007BFC24  40 fc 7b 00  @?{.
0x007BFC28  33 d0 7d 79  3?}y
0x007BFC2C  00 00 00 00  ....
0x007BFC30  23 10 f5 00  #.?.
0x007BFC34  00 00 00 00  ....
0x007BFC38  5e ef 80 79  ^?€y
0x007BFC3C  74 60 8e 79  t`?y
0x007BFC40  54 fc 7b 00  T?{.
0x007BFC44  42 f2 80 79  B?€y
0x007BFC48  23 10 f5 00  #.?.
0x007BFC4C  01 00 00 00  ....
0x007BFC50  01 00 00 00  ....
0x007BFC54  60 fc 7b 00  `?{.
0x007BFC58  78 fc 7b 00  x?{.

这就是预开辟好的,我们继续往下走

00F518B9  push        ebx  
00F518BA  push        esi  
00F518BB  push        edi 

4

再按F10

再按F10

lea-load effective address 加载有效地址

00BE182C  lea     edi,[ebp-24h] 
00BE182F  mov     ecx,9 
00BE1834  mov     eax,0CCCCCCCCh 
00BE1839  rep stos   dword ptr es:[edi]
​
//下面的代码是在初始化main函数的栈帧空间。
//1. 先把ebp-24h的地址,放在edi中
//2. 把9放在ecx中
//3. 把0xCCCCCCCC放在eax中
//4. 将从edp-24h到ebp这一段的内存的每个字节都初始化为0xCC
1个word两个字节,dword就是双字,4个字节

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

edi = ebp-0x24;
ecx = 9;
eax = 0xCCCCCCCC;
for(; ecx = 0; --ecx,edi+=4)
{
	*(int*)edi = eax;
}

小知识:烫烫烫~

之所以上面的程序输出 这么一个奇怪的字,是因为 main 函数调用时,在栈区开辟的空间的其中每一 个字节都被初始化为0xCC ,而 arr 数组是一个未初始化的数组,恰好在这块空间上创建的, 0xCCCC (两个连续排列的0xCC )的汉字编码就是 ,所以 0xCCCC 被当作文本就是
	int a = 10;
00F518D5  mov         dword ptr [ebp-8],0Ah  

我们可以通过调试来看,确实内存中a的值改为了10

int b = 10;
00F518DC  mov         dword ptr [ebp-14h],0Ah  

b的值是10

    int c = 0;
00F518E3  mov         dword ptr [ebp-20h],0  

//调用Add函数	
	c = Add(a, b);
//调用Add函数时的传参
//其实传参就是把参数push到栈帧空间中
00F518EA  mov         eax,dword ptr [ebp-14h] //传递b,将ebp-14h处放的10放在eax寄存器中 
00F518ED  push        eax  					  //将eax的值压栈,esp-4

00F518EE  mov         ecx,dword ptr [ebp-8]   //传递a,将ebp-8处放的10放在ecx寄存器中
00F518F1  push        ecx                     //将ecx的值压栈,esp-4

//跳转调用函数
00F518F2  call        00F510B4  
00F518F7  add         esp,8  
00F518FA  mov         dword ptr [ebp-20h],eax  

这时按F11

这个00F518F7这是谁呢?call指令把add函数的地址压栈了

再按F11

这些是在为Add函数的栈帧做准备,此时main函数的函数栈帧变大了

此时来到了add函数内部,我们发现很熟悉

00F51770  push        ebp  
00F51771  mov         ebp,esp  
00F51773  sub         esp,0CCh  
00F51779  push        ebx  
00F5177A  push        esi  
00F5177B  push        edi  
00F5177C  lea         edi,[ebp-0Ch]  
00F5177F  mov         ecx,3  
00F51784  mov         eax,0CCCCCCCCh  
00F51789  rep stos    dword ptr es:[edi]

我不多做介绍,直接看图

    int z = 0;
00F51795  mov         dword ptr [ebp-8],0 

    z = x + y;
00F5179C  mov         eax,dword ptr [ebp+8]  
00F5179F  add         eax,dword ptr [ebp+0Ch]  
00F517A2  mov         dword ptr [ebp-8],eax  

那我们发现这x和y在哪里呀?在下面已经存好了

形参是从右往左传,b先压栈,然后是a压栈,证明了形参不是在Add函数内部创建的,而是回来找了传参传过去的那个空间

return z;
00F517A5  mov         eax,dword ptr [ebp-8]  

z出函数自动销毁,先把z的值放在eax寄存器中,否则出函数以后,数据会丢失

00F517A8  pop         edi  
00F517A9  pop         esi  
00F517AA  pop         ebx  

跟之前对比,我们发现esp的地址也确实增大了,因为弹出栈,栈中元素减少,下方是高地址

00F517B8  mov         esp,ebp  
00F517BA  pop         ebp  
00F517BB  ret 

在Add函数中创建栈帧的方法和在main函数中是相似的,在栈帧空间的大小上略有差异而已。

  1. 将main函数的 ebp 压栈

  2. 计算新的 ebp 和 esp

  3. 将 ebx ,esi, edi 寄存器的值保存

  4. 计算求和,在计算求和的时候,我们是通过 ebp 中的地址进行偏移访问到了函数调用前压栈进去的参数,这就是形参访问。

  5. 将求出的和放在 eax 寄存器中准备带回

5

大家看好这个ret,在视频里我没演示,因为怕大家乱,再按一下F10我们发现

回到了main函数,接着执行,因为当时进Add函数之前,我们就把00F518F7这个地址存进去了

00F518F7  add         esp,8 

esp的地址加8跳过形参的两个空间,将形参的空间还给操作系统

6

00F518FA  mov         dword ptr [ebp-20h],eax
//将eax中的值,存到ebp-0x20的地址处,其实就是存储到main函数中ret变量中
//而此时eax中就是Add函数中计算的x和y的和,可以看出来,本次函数的返回值是由eax寄存器带回来的
//程序是在函数调用返回之后,在eax中去读取返回值的。

返回值是首先放到寄存器里面去,当我们真的回到这个函数里来的时候,再把这个值放到c中,返回值就带回来了

    printf("%d\n", c);
00F518FD  mov         eax,dword ptr [ebp-20h]  
00F51900  push        eax  
00F51901  push        0F57B30h  
00F51906  call        00F510D2  
00F5190B  add         esp,8  
}
00F5190E  xor         eax,eax  
00F51910  pop         edi  
00F51911  pop         esi  
00F51912  pop         ebx  
00F51913  add         esp,0E4h  
00F51919  cmp         ebp,esp  
00F5191B  call        00F51244  
00F51920  mov         esp,ebp  
00F51922  pop         ebp  
00F51923  ret  

这些代码和Add函数中的很类似,大家下去可以自己琢磨琢磨调试一下,这里我不再多做讲解,不懂得可以来问小周

拓展了解:

        其实返回对象时内置类型时,一般都是通过寄存器来带回返回值的,返回对象如果时较大的对象时,一般会在主调函数的栈帧中开辟一块空间,然后把这块空间的地址,隐式传递给被调函数,在被调函数中通过地址找到主调函数中预留的空间,将返回值直接保存到主调函数的。具体可以参考《程序员的自我修养》一书的第10章。

        到这里我们给大家完整的演示了main函数栈帧的创建,Add函数栈帧的创建和销毁的过程,相信大家已经能够基本理解函数的调用过程,函数传参的方式,也能够回答本章开始提出的问题了。

问题解答:

1、局部变量怎么创建的?

        局部变量的创建是,首先我为这个函数分配好栈帧空间,栈帧空间里面我们初始化好一部分空间,然后给我的局部变量在栈帧里分配一点空间

2、为什么局部变量不初始化内容是随机的?

        因为随机值是我们放进去的(0xCCCCCCCC),初始化是把随机值覆盖了

3、函数调用时参数时如何传递的?传参的顺序是怎样的?

        当我们调用那个函数的时候,我们已经push将对应的参数从右向左开始压栈压进去,当我们真正进入形参的这个函数时,其实在对应的栈帧空间,通过指针的偏移量找到了形参

4、函数的形参和实参是什么关系?

        形参确实是我在压栈时候开辟的空间,他和我的实参只是值相同,空间是独立的,实参是我形参的一份临时拷贝,改变形参不会影响实参

5、函数的返回值是如何带回的?

        调用之前就把call指令的下一条指令的地址记住了,压入栈里面了,把调用这个函数的上一个函数的栈帧的ebp存进去了,当我们函数调用完返回的时候,弹出ebp,就能够找到上一个函数调用的ebp,然后指针往下走找到esp的地址,回到上一个函数的栈帧空间,由于我们记住了call指令的下一条指令的地址,所以当我们函数调用返回时,就可以跳转到对应的位置,返回值是放到寄存器里面去,当我们真的回到这个函数里来的时候,再把这个值放到对应需要存放的变量中,返回值就带回来了

好啦!!!学到这你已经很厉害了!!!给自己竖一个大拇指!!!你是最棒的!!!今天小周就带大家学到这里。小周写一篇高质量详细的博客也也花了好多时间,看到这里请动动你的小手来为小周点赞推广评论一下吧!!!你们的点赞推广评论是我继续输出高质量博客的动力!!!谢谢大家!!!

欲知后事如何,且听下回分解

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值