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

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

在这里插入图片描述

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


前言

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

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

以上的问题都和函数栈帧的创建和销毁有关。

说明:在不同的编译器下,函数调用过程中栈帧的创建是略有差异的,具体细节取决于编译器的实现。本篇博客的开发环境是vs2019,不同的开发环境呈现的结果可能不一样。

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

1.1 相关寄存器

  • eax:通用寄存器。用于存储算术运算的结果、存放函数的返回值等。

  • ebx:通用寄存器。用于存储临时数据等。

  • ecx:通用寄存器。通常被用作计数寄存器,在循环和重复操作中非常常见。例如,在 LOOP 指令中,ECX 用于控制循环的迭代次数。

  • edx:通用寄存器。

  • ebp(本章重点):存放的是地址,记录的是栈底的地址。

  • esp(本章重点):存放的是地址,记录的是栈顶的地址。

  • eip:指令寄存器,保留当前指令的下一条指令的地址。

1.2 相关汇编指令

  • mov:它用于将数据从一个位置复制到另一个位置。

  • push:数据入栈。同时esp栈顶寄存器也要发生改变。

  • pop: 数据弹出。同时esp栈顶寄存器也要发生改变。

  • sub:减法命令。

  • add:加法命令。

  • call:函数调用。它的主要功能是,当执行 call 指令时,call指令的下一条指令的地址会被压入栈中。这使得程序可以在子程序执行完成后返回到正确的位置继续执行。

  • ret:恢复返回地址。 当执行到 ret 指令时,CPU会从栈中弹出返回地址,并将这个地址加载到程序计数器中,继续执行该地址处的指令。ret 指令就是通过从栈中弹出这个地址来实现返回功能。

以上看的可能会有点懵,待会在讲述函数栈帧是如何创建的大家可能就明白了。

二、什么是函数栈帧

  • 每一次函数调用时,都会在栈上为该函数分配一个新的空间,这个空间被称为函数栈帧。需要注意的是,我们以上所说的栈通常被称为系统栈,它由操作系统自动管理的

  • 这个系统栈和数据结构的栈的操作是一样的。唯一区别是:系统栈是向下增长的,由高地址到低地址

  • 正在调用的函数栈帧空间通常是通过两个寄存器来维护的,分别是espebp。其中,ebp记录的是栈底的地址,esp记录的是栈顶的地址

三、演示代码

#include <stdio.h>

int Add(int x, int y)
{
    int z = 0;
    z = x + y;
    return z;
}

int main()
{
    int a = 5;
    int b = 3;
    int c = 0;
    c = Add(a, b);
    printf("%d\n", c);

    return 0;
}

四、解析函数栈帧的创建

4.1 main函数是被另一个函数调用

我们可以通过 函数调用堆栈 来观察,因为它是反馈函数调用逻辑的。

请添加图片描述

通过上图观察到:main()函数调用之前,其实是由invoke_main()函数来调用的;而invoke_main()函数是由__scrt_common_main_seh()函数调用的;而__scrt_common_main_seh()函数是由__scrt_common_main()函数调用的;而__scrt_common_main()函数又是由mainCRTStartup()函数调用的。

而我们一直认为,程序运行起来就是一个进程,再加上main函数是程序的入口点,理应由操作系统调用。而出现以上原因我认为是:编译器会在 main 函数之前插入一些初始化代码,比如初始化运行时环境(设置堆栈、分配内存、处理命令行参数等,以确保程序能够正常运行)。这是编译器的设计机制。(了解即可)

请添加图片描述

4.2 分析反汇编

栈帧的创建和销毁涉及特定的指令(如pushpopcallret 等),这些指令在反汇编中清晰可见,从而能够准确地理解栈的变化。

请添加图片描述

接下来我将带领大家一步一步分析汇编代码,看看函数(main函数)的栈帧是如何创建的。

  • main函数栈帧还没有创建之前

请添加图片描述

  • 00FF18B0 push ebp:将寄存器ebp的值压入栈中。因为esp记录的是栈顶的地址,所以当压栈的时候,esp应该更新。

请添加图片描述

  • 00FF18B1 mov ebp,esp:将esp的值赋值给ebp

请添加图片描述

  • 00FF18B3 sub esp,0E4h:将栈指针寄存器esp的值减少十六进制0E4h(十进制228)。注意:减少一定是往低地址偏移的。

请添加图片描述

  • 00FF18B9 push ebx
  • 00FF18BA push esi
  • 00FF18BB push edi

以上指令就是分别将寄存器ebx、esi、edi的当前值压入栈中。

请添加图片描述

  • 00FF18BC lea edi,[ebp-24h]lea指令是加载有效地址(load effective address),意思就是将地址ebp - 24h加载到寄存器edi
  • 00FF18BF mov ecx,9:将值9赋值给寄存器ecx
  • 00FF18C4 mov eax,0CCCCCCCCh:将十六进制0CCCCCCCCh加载到寄存器eax中。
  • 00FF18C9 rep stos dword ptr es:[edi]:这条指令用于将 eax 寄存器中的值(0CCCCCCCCh)写入到内存中,目标地址由 edi 指定。rep 前缀表示重复操作,stos 表示存储 EAX 的值到目标地址。这里的计数器由 ecx 决定,所以会将 0CCCCCCCCh 写入到 [ebp-24h] 指向的地址,重复 9 次。

综上,这段代码的作用是将内存中从 ebp-24h 开始,大小为36字节全部初始化为 0CCCCCCCC

请添加图片描述

所以,以上这些操作不就是在给main函数创建栈帧,并将main函数的内存空间初始化为0CCCCCCCC。说明:不同的编译器可能会初始化不同的值。

在这里插入图片描述

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

请添加图片描述

接下来开始剖析main函数的正文段代码:

请添加图片描述

  • 00FF18D5 mov dword ptr [ebp-8],5:该指令将数值 5 存储到栈中 ebp-8的位置。这通常是用来初始化一个局部变量。

请添加图片描述

  • 00FF18DC mov dword ptr [ebp-14h],3:该指令将数值 3 存储到栈中 ebp-14h的位置。这通常是用来初始化一个局部变量。

请添加图片描述

  • 00FF18E3 mov dword ptr [ebp-20h],0:该指令将数值 0 存储到栈中 ebp-20h的位置。这通常是用来初始化一个局部变量。
    请添加图片描述

五、解析函数是怎么被调用的

请添加图片描述

  • 00FF18EA mov eax,dword ptr [ebp-14h]:将ebp-14h内存位置的值加载到 eax 寄存器中。
  • 00FF18ED push eax :将eax压入栈中

请添加图片描述

  • 00FF18EE mov ecx,dword ptr [ebp-8]:将ebp-8内存位置的值加载到 ecx 寄存器中。
  • 00FF18F1 push ecx :将ecx 压入栈中

请添加图片描述

综上,我们可以得出一个结论:函数传参的顺序是从右往左的;并且传参的时候是先将参数值写入到寄存器中,然后再压栈,因此,实参和形参只是只是值相同,但空间是独立的,所以说形参是实参的临时拷贝,改变形参并不会影响实参

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

请添加图片描述

接下来就是调用Add函数:

请添加图片描述

在执行Add函数的正文代码之前,以下这些指令和前面分析main函数一样,就是为Add函数创建栈帧并初始化内存空间。

请添加图片描述

我直接给出图了:

请添加图片描述

接下来我们再来分析Add函数的正文段代码:

  • 00FF1795 mov dword ptr [ebp-8],0:该指令将数值 0 存储到栈中 ebp-8的位置。这通常是用来初始化一个局部变量。

请添加图片描述

请添加图片描述

  • 第一条指令:传入函数的第一个参数加载到 eax 寄存器中
  • 第二条指令:将第二个参数的值加到 eax 寄存器中。此时,eax 中存储的是两个参数的和
  • 第三条指令:将 eax 中的值存储到 ebp-8 所指向的内存位置。

请添加图片描述

六、函数调用结束后是怎么返回的

请添加图片描述

我们发现:程序是将ebp-8的值放入eax寄存器中。也就是说,如果函数有返回值,它会将返回值拷贝给寄存器,保存到寄存器中的值在函数返回后(函数栈帧销毁)仍然存在,直到下一个函数调用或进一步的运算覆盖该寄存器的内容

七、解析函数栈帧的销毁

请添加图片描述

  • 00FF17A8 pop edi
  • 00FF17A9 pop esi
  • 00FF17AA pop ebx

分别将ediesiebx出栈

请添加图片描述

  • 00FF17B8 mov esp,ebp:将ebp的值赋值给esp。这其实是在将函数Add的空间还给操作系统

请添加图片描述

  • 00FF17BA pop ebp:从栈中弹出保存的栈底指针的值,恢复上一级调用的栈帧。

请添加图片描述

  • 00FF17BB ret:恢复返回地址。 当执行到 ret 指令时,CPU会从栈中弹出返回地址,并将这个地址加载到程序计数器中,继续执行该地址处的指令。

请添加图片描述

子程序结束后,返回到主函数继续执行

请添加图片描述

  • 00FF18F7 add esp,8 :将esp的地址加上8这一步就是将形参的内存空间还给操作系统

请添加图片描述

  • 00FF18FA mov dword ptr [ebp-20h],eax:将 eax 寄存器中的值(返回值)存储到 ebp-20h 所指向的内存位置

请添加图片描述

剩下调用printf函数已经销毁main函数栈帧我这里就不继续赘述了。

请添加图片描述

八、总结

  • 局部变量是怎么创建?

局部变量是存储在栈上的,所以,先要为当前函数调用创建函数栈帧,然后在栈帧中为局部变量分配空间。

  • 为什么局部变量是随机值?

vs2019中,创建栈帧的时候给空间初始化默认是0CCCCCCCC。不同的编译器可能会有不同的行为。

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

开始调用函数之前,就已经把参数从右往左进行压栈,当真正调用函数时,是通过栈顶指针和偏移量来访问。

  • 形参和实参的关系?

传参的时候是先将参数值写入到寄存器中,然后再压栈,因此,实参和形参只是只是值相同,但空间是独立的,所以说形参是实参的临时拷贝,改变形参并不会影响实参

  • 函数调用是怎么做的?

函数调用时会指向call指令,但在执行call指令之前先会把call指令的下一条指令的地址进行压栈操作,这个操作是为了保证子程序执行完毕后能正确回到call指令的下一条指令的地方,继续往后执行。

  • 函数调用结束后是怎么返回的?

从栈中弹出当前函数的栈帧出栈;回收内存空间;如果函数有返回值,这个值通常会被存放在特定的寄存器中;最后执行ret指令回到call指令的下一条指令,继续执行后续的指令。

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值