文章目录
前言
🎈在我们初学c语言的时候,是否对这些问题总是有疑惑?
1.局部变量是如何创建的?
2.为什么局部变量不初始化的值是随机的?
3.函数调用时参数时如何传递的?传参的顺序是怎样的?
4.函数的形参和实参分别是怎样实例化的?
5.函数的返回值是如何代回的?
想要解决这些困惑,我们必须了解C语言底层的一个运行机制——函数栈帧的创建和销毁。
一、什么是函数栈帧?
我们在写C语言代码的时候,经常会把一个独立的功能抽象为函数,所以C程序是以函数为基本单位的。 那函数是如何调用的?
函数的返回值又是如何代回的? 函数参数是如何传递的? 这些问题都和函数栈帧有关系。想要了解函数栈帧,我们必须先了解什么是栈。
栈(stack)是现代计算机程序里最为重要的概念之一,几乎每一个程序都使用了栈,没有栈就没有函数,没有局部变量,也就没有我们如今看到的所有的计算机语言。
在经典的计算机科学中,栈被定义为一种特殊的容器,用户可以将数据压入栈中(入栈,push),也可以将已经压入栈中的数据弹出(出栈,pop),但是栈这个容器必须遵守一条规则:先入栈的数据后出栈。就像叠成一叠的书,先叠上去的书在最下面,因此要最后才能取出。
🎈在计算机系统中,栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据从栈顶弹出。压栈操作使得栈增大,而弹出操作使得栈减小。在经典的操作系统中,栈总是向下增长(由高地址向低地址)的。在我们常见的i386或者x86-64下,栈顶由成为esp的寄存器进行定位的。
(如图为栈区的大致演示)
🎈了解了什么是栈,我们来看看什么是函数栈帧?
函数栈帧(stack frame)就是函数调用过程中在程序的调用栈(call stack)所开辟的空间,这些空间是用来存放:
1️⃣函数参数和函数返回值
2️⃣临时变量(包括函数的非静态的局部变量以及编译器自动生产的其他临时变量)
3️⃣保存上下文信息(包括在函数调用前后需要保持不变的寄存器)。
二、认识相关寄存器和汇编指令
分析函数栈帧的创建于销毁是从反汇编角度分析的,因此我们要先认识汇编语言中几个相关的寄存器和汇编指令:
相关寄存器:
寄存器名称 | 功能 |
---|---|
ebp | 栈底寄存器 |
esp | 栈顶寄存器 |
eax | 通用寄存器,保留临时数据,常用于返回值 |
ebx | 通用寄存器,保留临时数据 |
eip | 指令寄存器,保存当前指令的下一条指令的地址 |
注:栈区是通过ebp和esp两个寄存器进行维护的,执行哪一个函数这两个寄存器就维护哪个函数的栈帧。
汇编指令:
汇编指令 | 作用 |
---|---|
move | 数据转移指令 |
push | 数据入栈,同时esp栈顶寄存器也要发生改变 |
pop | 数据弹出到指定位置,同时esp栈顶寄存器也要发生改变 |
sub | 减法命令 |
add | 加法命令 |
call | 调用函数:1. 压入返回地址 2. 转入目标函数 |
jump | 通过修改eip,转入目标函数,进行调用 |
ret | 恢复返回地址,压入eip,类似pop eip命令 |
三、函数栈帧的创建与销毁的解析
1.预备知识
首先我们达成一些预备知识才能有效的帮助我们理解,函数栈帧的创建和销毁。
1.每一次函数调用,都要为本次函数调用开辟空间,就是函数栈帧的空间。
2.这块空间的维护是使用了2个寄存器: esp 和ebp , ebp 记录的是栈底的地址, esp 记录的是栈顶的地址。
3.函数栈帧的创建和销毁过程,在不同的编译器上实现的方法大同小异,本次演示以VS2019为例。
2.具体解析
🎈先看总体思路
以该简单的加法程序为例,我们利用它的反汇编程序分析函数的栈帧与销毁:
下面是该程序对应的反汇编:
1️⃣main函数栈帧的创建
由于main函数是另外一个函数invoke_main调用的,在invoke_main 函数之前的函数调用我们就暂时不考虑了。那我们可以确定, invoke_main 函数应该会有自己的栈帧, main函数和Add 函数也会维护自己的栈帧,每个函数栈帧都有自己的ebp和esp来维护栈帧空间。那接下来我们从main函数的栈帧创建开始讲解:
第一、二行:
将调用main函数的函数(既invoke_main函数)的ebp压栈,再将invoke_main函数的esp赋给ebp,则ebp此刻与esp重合(如图)
第三至六行:
给esp减去一个值0E4h,则此时esp向上移动0E4h个字节(注意不是向下,因为在栈中,上面是低地址,下面是高地址)。此时的esp是main函数栈帧的esp,此时结合上一条指令的ebp和当前的esp,ebp和esp之间维护了一个块栈空间,这块栈空间就是为main函数开辟的,就是main函数的栈帧空间,这一段空间中将存储main函数 中的局部变量,临时数据以及调试信息等。
然后再按顺序压入三个寄存器的值ebx、esi、edi(这3个寄存器的在函数随后执行中可能会被修改,所以先保存寄存器原来的值,以便在退出函数时恢复)。
执行后效果如下⭕
第七至十行:
将[ebp-24h]这个地址存入edi寄存器中,将数值9存入ecx寄存器中,将0CCCCCCCCh传入eax寄存器中,最后第十行rep stos dword ptr es:[edi] 的意思是将从ebp-24h到ebp这一段的9*4(dword:双词,一个词两个字节,这里意思就是九个dword)字节的内存全部初始化为0xCCCCCCCC。
执行后效果如下⭕
至此,main函数栈帧的创建就完成了,可以看到,未初始化的空间被赋值为0xCCCCCCCC,可以认为是随机值。而因为0xCCCC(两个连续排列的0xCC)的汉字编码就是“烫”,0xCCCC被当作文本就是“烫”,所以在我们平时打印一个未初始化的变量时,会看到“烫烫”这一奇怪的现象。
2️⃣初始化变量
接下来到了程序的真正核心部分,也就是我们平时的创建变量。那么在函数栈帧中是如何创建以及初始化变量的呢?其实也不难理解。
我们可以看到,第一行反汇编将0Ah(10)赋到了ebp-8这个位置,所以ebp-8就是变量a的地址。(b和ret变量同理)
执行后效果如下⭕
可见,这里的初始化是将原来的值0xCCCCCCCC覆盖为初始化后的值。而且,a、b、ret的地址并不是固定的
3️⃣函数的传参
函数传参是调用函数的关键,也是函数调用之前的准备工作(从函数栈帧的创建与销毁过程我们可知,函数传参是在调用函数之前完成的)
第一行:将ebp-14h这个地址中放的20(也就是b的值)赋到eax寄存器中,然后压入eax;同理,将ebp-8这个地址中放的10(也就是a的值)赋到ecx寄存器中,然后压入ecx。
执行后效果如下⭕
由此我们可知,函数传参的顺序是从右到左的。并且,形参和实参的空间不同,改变形参不会改变实参。
4️⃣Add函数的调用
当我们进入call指令后,便进入的Add函数的运行,与main函数相同,Add函数也需要先创建属于它自己的函数栈帧。当然,call 指令是要执行函数调用逻辑的,在执行call指令之前先会把call指令的下一条指令的地址进行压栈操作,这个操作是为了解决当函数调用结束后要回到call指令的下一条指令的地方,继续往后执行。
接下来我们就真正地进入Add函数中
a.Add函数栈帧的创建
第一、二行:
将ebp(main函数的ebp)压入,然后将main函数的esp赋给ebp,则ebp此刻与esp重合(如图(展示上半部分))
第三至六行:
给esp减去一个值0E4h,则此时esp向上移动0CCh个字节,与main函数栈帧创建同理,此时esp和edp之间维护的块栈空间就是为Add函数创建的栈帧空间。
然后再按顺序压入三个寄存器的值ebx、esi、edi。
执行后效果如下⭕
第七至十行
与main函数同理,初始化这片空间,此处不再赘述,直接上图
执行后效果如下⭕
b.Add函数加法功能实现模块
接下来到了关键部分,也就是Add函数到底是如何进行加法运算的?
如图,创建z变量,是把0这个值赋到ebp-8这个地址中。而在加法运算中,将edp+8中的值移入eax寄存器中,由刚刚我们的推导中可以发现,edp+8中的值刚好是我们的形参a’,里面的值就是a的值10。同样的道理,将eax(现在的值是10)和ebp+0Ch中的值(也就是b的值20)相加,计算出来eax的值为30。最后再将eax的值移动到ebp-8这个地址中,也就是移入z的地址,这时z便等于30了。
而这里的return z返回值是先将z的值移入eax寄存器中,待跳出Add函数后eax的值依然为z的值,在main函数中利用eax代回z的值即可
执行后效果如下⭕
c.Add函数栈帧的销毁
上面Add函数已经完成他的使命了,接下来就需要销毁他的函数栈帧,并重新返回main函数。
如图,三个寄存器的值先依次弹出栈并将其值存放到对应寄存器中。然后将edp的值赋给esp(中间三行可忽略),这样一来esp和edp再次重合,相当于回收了Add函数的栈。接着弹出ebp地址(这里的epb地址是main函数的ebp地址)并存放到ebp寄存器中。
而ret指令的执行,首先是从栈顶弹出一个值,此时栈顶的值就是call指令下一条指令的地址,此时esp+4,然后直接跳转到call指令下一条指令的地址处,继续往下执行。
执行后效果如下⭕
这样一来便很好地回归了main函数,而且能够接着函数调用后的程序继续运行。
5️⃣形参的销毁与返回值的代回
图中红框内的第一行指令为销毁形参,栈顶寄存器esp加8直接跳过了两个形参的空间。而第二行便进行的返回值的代回,我们知道此时eax中的值为30,将其代入ebp-20h(也就是ret的地址)便得到我们想要的结果。
最终结果如下⭕
介绍到此为止,后面printf函数的调用和main函数的销毁与上面同理,不再进行赘述。
总结
了解了函数栈帧的创建与销毁的全过程,便可以回答开头我们提出的五个问题了。
1.局部变量是如何创建的?
答:利用mov指令,将指定的值移动到系统给定的地址中。若局部变量未初始化,则为随机值。
2.为什么局部变量不初始化的值是随机的?
答:函数栈帧创建过程中,为栈帧中的空间都赋予了0xCCCCCCCC的值(不同编译器的值可能不同),若变量不初始化则为这个值,可以认为这个值就是随机值。
3.函数调用时参数时如何传递的?传参的顺序是怎样的?
答:在main函数中创建形参的临时空间并赋予其与实参相同的值(利用eax寄存器)。到子函数中需要调用参数时,便通过地址的移位去找到形参的位置并读入。从右到左。
4.函数的形参和实参分别是怎样实例化的?
答:分别创建自己的空间,改变形参的值不会改变实参。
5.函数的返回值是如何代回的?
答:通过地址的移位去找到形参的位置并读入其中的值。
本文到这里就结束啦!如有错误欢迎指正。记得给个三连哦~🎉