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 解析函数栈帧的创建和销毁
3.3.1 预备知识
首先我们达成一些预备知识才能有效的帮助我们理解,函数栈帧的创建和销毁。
1. 每一次函数调用,都要为本次函数调用开辟空间,就是函数栈帧的空间。
2. 这块空间的维护是使用了2个寄存器: esp 和 ebp , ebp 记录的是栈底的地址, esp 记录的是栈顶 的地址。
如图所示:
3.3.2 函数的调用堆栈
函数调用堆栈是反馈函数调用逻辑的,那我们可以清晰的观察到 ,main 函数调用之前,是由 invoke_main 函数来调用main函数。
那我们可以确定, invoke_main 函数应该会有自己的栈帧, main 函数和 Add 函数也会维护自己的栈 帧,每个函数栈帧都有自己的 ebp 和 esp 来维护栈帧空间。
那接下来我们从main函数的栈帧创建开始讲解:
3.3.4 准备环境
为了让我们研究函数栈帧的过程足够清晰,不要太多干扰,我们可以关闭下面的选项,让汇编代码中排 除一些编译器附加的代码:
3.3.5 转到反汇编
调试到main函数开始执行的第一行,右击鼠标转到反汇编。得到的代码整理如下:
转化为伪代码和图片可以得到:
小知识:烫烫烫~
下面的程序输出“烫”这么一个奇怪的字,是因为main函数调用时,在栈区开辟的空间的其中每一 个字节都被初始化为0xCC,而arr数组是一个未初始化的数组,恰好在这块空间上创建的,0xCCCC(两 个连续排列的0xCC)的汉字编码就是“烫”,所以0xCCCC被当作文本就是“烫”。
接下来我们再分析main函数中的核心代码:
上面已经告诉大家看反汇编的方法啦,代码太长就不放在文章里面了,大家可以自己实现,对照下面的图进行理解:
拓展了解:
其实返回对象时内置类型时,一般都是通过寄存器来带回返回值的,返回对象如果时较大的对象时,一 般会在主调函数的栈帧中开辟一块空间,然后把这块空间的地址,隐式传递给被调函数,在被调函数中 通过地址找到主调函数中预留的空间,将返回值直接保存到主调函数的。
具体可以参考《程序员的自我 修养》一书的第10章。 到这里已经给大家完整的演示了main函数栈帧的创建,Add函数站真的额创建和销毁的过程,相信大家 已经能够基本理解函数的调用过程,函数传参的方式,也能够回答文章开始处的问
Q&A
局部变量是如何创建的?
局部变量是在函数或代码块内部声明的变量。当程序执行到包含局部变量声明的函数或代码块时,该变量会被创建并分配内存空间。局部变量只在声明它的函数或代码块内部可见,超出其作用域范围后就会被销毁
为什么局部变量不初始化内容是随机的?
栈上的数据并不会被自动初始化为特定的值。当程序执行到声明局部变量的语句时,编译器会为该变量分配一块内存空间,但并不会对其进行初始化操作。因此,该内存空间中原本存储的是之前使用过的数据,导致局部变量的内容是随机的。
函数调用时参数时如何传递的?
在传值调用中,实参的值被复制到形参中,函数内部对形参的修改不会影响实参的值。
传参的顺序是怎样的?
按照声明的顺序来确定的,,而不是按照实参在函数调用中的顺序。
函数的形参和实参分别是怎样实例化的?
形参的实例化:函数的形参是在函数定义或声明时指定的参数,用于接收函数调用时传递的实参的值。
实参的实例化:实参是函数调用时传递给函数的值,它们可以是常量、变量或表达式。
函数的返回值是如何带会的?
在栈桢中,函数的返回值通常存储在栈的某个位置上,例如在栈桢的顶部或者是返回地址的下一个位置。当函数执行完毕后,程序会将该位置上的值取出,并传递给调用函数。由于该值存储在main栈桢中了,在函数调用结束后并不会立即被销毁,因此函数的返回值也就不会被销毁,可以被传递给调用函数。
tips: 如果函数声明的返回类型是void,则表示函数没有返回值,可以使用return语句来提前结束函数的执行。