【C语言】函数栈帧的创建和销毁

文章详细介绍了函数栈帧的概念,栈在程序中的作用,以及与之相关的寄存器和汇编指令。通过分析函数的创建和销毁过程,解释了局部变量的创建、函数参数的传递方式和返回机制。主要内容包括栈帧与栈的关系,以及在函数调用中esp和ebp寄存器的角色,以及call和ret指令在函数调用和返回中的作用。
摘要由CSDN通过智能技术生成

在这里插入图片描述

👦个人主页:Weraphael
✍🏻作者简介:目前正在回炉重造C语言(2023暑假)
✈️专栏:【C语言航路】
🐋 希望大家多多支持,咱一起进步!😁
如果文章对你有帮助的话
欢迎 评论💬 点赞👍🏻 收藏 📂 加关注😍


前言

在学习C语言的时候,我们可能有很多困惑。比如:

  1. 局部变量是怎么创建?
  2. 为什么局部变量是随机值?
  3. 函数是怎么传参的?传参的顺序是怎样的?
  4. 形参和实参的关系?
  5. 函数调用是怎么做的?
  6. 函数调用结束后是怎么返回的?

这些都和函数栈帧的创建和销毁有关

一、什么是函数栈帧

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

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

  • 函数参数和函数返回值
  • 临时变量(包括函数的非静态的局部变量以及编译器自动生产的其他临时变量)
  • 保存上下文信息(包括在函数调用前后需要保持不变的寄存器)

二、什么是栈?

  • stack是现代计算机程序里最为重要的概念之一,几乎每一个程序都使用了栈,没有栈就没有函数,没有局部变量,也就没有我们如今看到的所有的计算机语言。
  • 在经典的计算机科学中,栈被定义为一种特殊的容器,用户可以将数据压入栈中(也就是入栈push),也可以将已经压入栈中的数据弹出(出栈pop),但是栈这个容器必须遵守一条规则:先进的数据后出
  • 在计算机系统中,栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据从栈顶弹出。压栈操作使得栈增大,而弹出操作使得栈减小。
  • 在经典的操作系统中,栈总是向下增长(由高地址向低地址)的

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

3.1 相关寄存器

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

3.2 相关汇编指令

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

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

2.1 预备知识

首先我们达成一些预备知识才能有效的帮助我们理解函数栈帧的创建和销毁:

  1. 每一次调用函数,都会在栈区上创建空间,就是函数栈帧的空间
  2. 这块空间的维护使用了两个寄存器:espebpebp记录的是栈底的地址,esp记录的是栈顶的地址。

如图所示:

在这里插入图片描述

2.2 函数栈帧的创建

2.2.1 main函数也是被其他函数调用

【演示代码】

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

在此之前,我们需要理解:main函数其实也是被其他函数调用的。我们可以通过函数调用堆栈来观察,因为它是反馈函数调用逻辑的,通过下图观察到,main函数调用之前,其实是由invoke_main函数来调用的

在这里插入图片描述

那我们可以确定,invoke_main函数也会有自己的栈帧,main函数和Add函数也会维护自己的栈帧,每个函数栈帧都有自己的ebpesp来维护栈帧空间。

在这里插入图片描述

2.2.2 分析反汇编(主函数的创建)

int main()
{
001C18B0  push        ebp  
001C18B1  mov         ebp,esp  
001C18B3  sub         esp,0E4h  
001C18B9  push        ebx  
001C18BA  push        esi  
001C18BB  push        edi  
001C18BC  lea         edi,[ebp-24h]  
001C18BF  mov         ecx,9  
001C18C4  mov         eax,0CCCCCCCCh  
001C18C9  rep stos    dword ptr es:[edi]
	int a = 20;
001C18D5  mov         dword ptr [ebp-8],14h  
	int b = 30;
001C18DC  mov         dword ptr [ebp-14h],1Eh  
	int c = 0;
001C18E3  mov         dword ptr [ebp-20h],0  
	c = Add(a, b);
001C18EA  mov         eax,dword ptr [ebp-14h]  
001C18ED  push        eax  
001C18EE  mov         ecx,dword ptr [ebp-8]  
001C18F1  push        ecx  
001C18F2  call        001C10B4  
001C18F7  add         esp,8  
001C18FA  mov         dword ptr [ebp-20h],eax  
	printf("%d\n", c);
001C18FD  mov         eax,dword ptr [ebp-20h]  
001C1900  push        eax  
001C1901  push        1C7B30h  
001C1906  call        001C10D2  
001C190B  add         esp,8  
	return 0;
001C190E  xor         eax,eax  
}
001C1910  pop         edi  
001C1911  pop         esi  
001C1912  pop         ebx  
001C1913  add         esp,0E4h  
001C1919  cmp         ebp,esp  
001C191B  call        001C1244  
001C1920  mov         esp,ebp  
001C1922  pop         ebp  
001C1923  ret 
  1. 创建main函数的栈帧

001C18B0 push ebp — 将ebp的值压入栈中。

在这里插入图片描述

因为ebp记录的是栈底的地址,esp记录的是栈顶的地址,所以当压栈的时候,esp应该更新。

001C18B1 mov ebp,espmove指令会把esp的值存放到ebp

在这里插入图片描述

001C18B3 sub esp,0E4hesp减去0E4h(228)这个值

sub会让esp中的地址减去一个八进制数字,产生新的esp,此时的espmain函数栈帧的esp,此时结合上一条指令的ebp和当前的espebpesp之间维护了一个块栈空间,这块栈空间就是为main函数开辟的,就是main函数的栈帧空间,这一段空间中将存储main函数中的局部变量。

在这里插入图片描述

001C18B9 push ebx
001C18BA push esi — 分别将寄存器ebx、esi、edi的值压栈
001C18BB push edi

上面3条指令保存了3个寄存器的值在栈区,这3个寄存器的在函数随后执行中可能会被修改,所以先保存寄存器原来的值,以便在退出函数时恢复。

在这里插入图片描述

001C18BC  lea         edi,[ebp-24h]  
001C18BF  mov         ecx,9  
001C18C4  mov         eax,0CCCCCCCCh  
001C18C9  rep stos    dword ptr es:[edi] 

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

edi = ebp-0x24;
ecx = 9;
eax = 0xCCCCCCCC;
for(; ecx = 0; --ecx,edi+=4)
{
	*(int*)edi = eax;
}
  1. lea其实是加载有效地址(load effective address),把ebp-24h的地址放在edi
  2. 9放在ecx
  3. 0xCCCCCCCC放在eax
  4. 将从edp-0x2hebp这一段的内存的每个字节都初始化为0xCC

在这里插入图片描述

接下来我们再来分析main函数中的核心代码:

	int a = 20;
001C18D5  mov         dword ptr [ebp-8],14h  
	int b = 30;
001C18DC  mov         dword ptr [ebp-14h],1Eh  
	int c = 0;
001C18E3  mov         dword ptr [ebp-20h],0  

dword ptr [ebp-8],14h它对应的汇编指令就是把14h20)这个值放到ebp-8a)这个地址里。以下也同理,其实就是在初始化变量。

在这里插入图片描述

补充知识:烫烫烫

在这里插入图片描述

假设我们没有为变量初始化,并且会看到程序输出这么一个奇怪的字,是因为main函数调用时,在栈区开辟的空间的其中每一个字节都被初始化为0xCC,而arr数组是一个未初始化的数组,恰好在这块空间上创建的,0xCCCC(两个连续排列的0xCC)的汉字编码就是,所以0xCCCC被当作文本就是

main函数调用函数Add

	c = Add(a, b);
// 传参的过程
001C18EA  mov         eax,dword ptr [ebp-14h]  
001C18ED  push        eax  
001C18EE  mov         ecx,dword ptr [ebp-8]  
001C18F1  push        ecx  
  1. 001C18EA mov eax,dword ptr [ebp-14h] — 将ebp-14h的值也就是b存入寄存器eax
  2. eax的值压栈
  3. 001C18EE mov ecx,dword ptr [ebp-8] — 将ebp-8的值也就是a存入寄存器ecx
  4. ecx的值压栈
  • 因此我们发现,函数传参的顺序应该先传b,最后再传a

在这里插入图片描述

001C18F2  call        001C10B4  
001C18F7  add         esp,8   (调完函数讲解)
001C18FA  mov         dword ptr [ebp-20h],eax  (调完函数讲解) 

call指令是要执行函数调用逻辑的,在执行call指令之前先会把call指令的下一条指令的地址进行压栈操作,这个操作是:为了解决当函数调用结束后要回到call指令的下一条指令的地方,继续往后执行。

在这里插入图片描述

接下来就来到Add函数定义的内部:

int Add(int x, int y)
{
004A1830  push        ebp  
004A1831  mov         ebp,esp  
004A1833  sub         esp,0CCh  
004A1839  push        ebx  
004A183A  push        esi  
004A183B  push        edi  
004A183C  lea         edi,[ebp-0Ch]  
004A183F  mov         ecx,3  
004A1844  mov         eax,0CCCCCCCCh  
004A1849  rep stos    dword ptr es:[edi]  
004A184B  mov         ecx,4AC003h  
004A1850  call        004A131B  
	int z = 0;
004A1855  mov         dword ptr [ebp-8],0  
	z = x + y;
004A185C  mov         eax,dword ptr [ebp+8]  
004A185F  add         eax,dword ptr [ebp+0Ch]  
004A1862  mov         dword ptr [ebp-8],eax  
	return z;
004A1865  mov         eax,dword ptr [ebp-8]  
}
004A1910  pop         edi  
004A1911  pop         esi  
004A1912  pop         ebx  
004A1920  mov         esp,ebp  
004A1922  pop         ebp  
004A1923  ret  

2.2.3 分析反汇编(Add函数的销毁)

首先我们逐步分析:

004A1830  push        ebp  
004A1831  mov         ebp,esp  
004A1833  sub         esp,0CCh  
004A1839  push        ebx  
004A183A  push        esi  
004A183B  push        edi  
004A183C  lea         edi,[ebp-0Ch]  
004A183F  mov         ecx,3  
004A1844  mov         eax,0CCCCCCCCh  
004A1849  rep stos    dword ptr es:[edi]  
004A184B  mov         ecx,4AC003h  
004A1850  call        004A131B  

以上这些操作和刚开始进入main函数其实是类似的,就是为Add函数建立栈帧。这里就不过多赘述了(如下图)

在这里插入图片描述

接下来开始执行Add函数内部的有效代码:

	int z = 0;
004A1855  mov         dword ptr [ebp-8],0  
	z = x + y;
004A185C  mov         eax,dword ptr [ebp+8]  
004A185F  add         eax,dword ptr [ebp+0Ch]  
004A1862  mov         dword ptr [ebp-8],eax  
	return z;
004A1865  mov         eax,dword ptr [ebp-8]  
  1. mov dword ptr [ebp-8],0 — 将0放入ebp-8地址中去
  2. mov eax,dword ptr [ebp+8] — 把ebp+8的值放入寄存器eax中。那ebp+8的值是多少呢?如上图,+8ebp向下走,也就指向了ecx - 20,其实就是a。同理,add eax,dword ptr [ebp+0Ch] — 就是把刚刚的eax的值加上ebp+0Ch(其实就是b)的结果再次存入寄存器eax中。从这可以看出形参只是实参的一份临时拷贝
  3. mov dword ptr [ebp-8],eax — 再把eax的值放入ebp-8的地址处
  4. 最后就是返回:mov eax,dword ptr [ebp-8] — 把ebp-8的值放入eax中,其实就是把值拷贝给了寄存器,eax是一个寄存器,它不会因为程序的销毁而销毁。

在这里插入图片描述

那么寄存器eax是如何把值给返回的呢?接着往下看

// pop - 出栈
004A1910  pop         edi  
004A1911  pop         esi  
004A1912  pop         ebx  
004A1920  mov         esp,ebp  
004A1922  pop         ebp  
004A1923  ret  
  1. edi、esi、ebx出栈
    在这里插入图片描述
  2. mov esp,ebp — 把ebp赋值给esp ,这其实是在将函数Add的空间还给操作系统
    在这里插入图片描述
  3. pop ebp — 将ebp出栈。注意:此时ebp要维护main函数的ebp,其实此时此刻ebpesp维护的是main函数的栈顶和栈底。
    在这里插入图片描述
  4. 注意:刚刚上面在执行call指令之前先会把call指令的下一条指令的地址进行压栈操作,这个操作是:为了解决当函数调用结束后要回到call指令的下一条指令的地方,继续往后执行。也就是ret这个指令其实是返回到call指令的下一条指令的地方,继续往后执行
    在这里插入图片描述
    然后执行add esp,8 — 就是将esp的地址加上8这一步就是将形参还给操作系统
    在这里插入图片描述
    最后 mov dword ptr [ebp-20h],eax — 就是将eax值给ebp-20h,也就是赋值给z
    在这里插入图片描述

五、总结

因此通过函数栈帧的创建和销毁

  1. 局部变量是怎么创建? — 在某函数的栈帧空间创建的
  2. 为什么局部变量是随机值?— 在栈区开辟的空间的其中每一个字节都被初始化为0xCC
  3. 函数是怎么传参的?传参的顺序是怎样的?— 再开一个新的空间存储参数,其顺序是从右向左的
  4. 形参和实参的关系?— 地址空间不同,也就是说形参是实参的临时拷贝
  5. 函数调用是怎么做的? 函数调用结束后是怎么返回的? — 看文章过程即可
  • 7
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值