前言
我们都知道函数是个很神奇的东西,程序员可以自己写个函数完成对应的功能,但是这里有很多实现的小细节。具体我们来用一段短小精悍的代码来解释函数栈帧的创建和销毁。
在理解的同时,我们要关注以下这几个问题:
1.局部变量是怎么创建的?
2.为什么局部变量的值是随机值?
3.函数是怎么传参的?传参的顺序是怎么样的?
4.形参和实参是什么关系?
5.函数调用是怎么做的?
6.函数调用是结束后怎么返回的?
注意:不同编译器下,函数调用过程中栈帧的创建有差异的,具体细节取决于编译器的实现。
正题
1.了解寄存器
1.1 寄存器是什么?
寄存器是CPU内部用来存放数据的一些小型存储区域,用来暂时存放参与运算的数据和运算结果。详细一点就是寄存器就是一种常用的时序逻辑电路,但这种时序逻辑电路只包含存储电路。寄存器的存储电路是由锁存器或触发器构成的,因为一个锁存器或触发器能存储1位二进制数,所以由N个锁存器或触发器可以构成N位寄存器。寄存器是中央处理器内的组成部分。寄存器是有限存储容量的高速存储部件,它们可用来暂存指令、数据和位址。
1.2 常见的寄存器
4个数据寄存器(eax、ebx、ecx、edx)
2个变址和指针寄存器(esi、edi)
2个指针寄存器(esp、ebp)
esp:寄存器存放当前线程的栈顶指针
ebp:寄存器存放当前线程的栈底指针
esp和ebp这两个寄存器中存放地址,这两个地址用来维护函数栈帧
具体寄存器知识还有很多,这里我们先做了解。
1.3 什么是栈,什么是栈帧?
1.3.1 什么是栈?
在数据结构中,栈是限定仅在表尾进行插入或删除操作的线性表。栈是一种数据结构,它按照后进先出的原则存储数据,先进入的数据被压入栈底,最后的数据在栈顶,需要读数据的时候从栈顶开始弹出数据。
在计算机系统中,栈也可以称之为栈内存是一个具有动态内存区域,存储函数内部(包括main函数)的局部变量和方法调用和函数参数值,是由系统自动分配的,一般速度较快;存储地址是连续且存在有限栈容量,会出现溢出现象程序可以将数据压入栈中,也可以将数据从栈顶弹出。压栈操作使得栈增大,而弹出操作使栈减小。栈用于维护函数调用的上下文,离开了栈函数调用就没法实现。
1.3.2 什么是栈帧?
每一次函数的调用,都会在调用栈(call stack)上维护一个独立的栈帧(stack frame).每个独立的栈帧一般包括:
- 函数的返回地址和参数
- 临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量
- 函数调用的上下文
栈是从高地址向低地址延伸,一个函数的栈帧用ebp 和 esp 这两个寄存器来划定范围,ebp 指向当前的栈帧的底部,esp 始终指向栈帧的顶部。
1.4 常见的反汇编指令
add:加法指令,第一个是目标操作数,第二个是源操作数,格式为:目标操作数 = 目标操作数 + 源操作数
sub:减法指令,格式和add格式一样
call:调用函数,一般函数的参数放在寄存器中
ret:跳转会调用函数的地方。对应于call,返回到对应的call调用的下一条指令,若有返回值,则放入eax中
push:把一个32位的操作数压入堆栈中,这个操作在32位机中会使得esp被减4(字节),也就是说,push指令一旦下达,esp的指针偏移量就会减4
pop:与push相反,esp每次加4(字节),一个数据出栈
mov:数据传送。第一个参数是目的操作数,第二个参数是源操作数,就是把源操作数拷贝到目的一份
lea:取得第二个参数地址后放入到前面的寄存器(第一个参数)中。(lea就是load effective address的意思)
stos:串行存储指令,它实现把eax中的数据放入到edi所指的地址中,同时edi后移4个字节
jmp:无条件跳转指令,对应于大量的条件跳转指令
cmp:进行比较两个操作数的大小,为第一个操作减去第二个操作数,但不影响第两个操作数的值
以上知识目前只了解,待我能力杠杠的时候,会深度去探讨和研究这些汇编和底层的东西,现在只需要知道即可。
上面的准备知识已经完毕,下面我们一起来通过代码调试到汇编,从汇编的角度去看问题。
2.探讨函数栈帧的创建和销毁
首先展示代码:
#include <stdio.h> //观察函数栈帧 int Add(int x, int y) { int z = 0; z = x + y; return z; } int main() { int a = 10; int b = 20; int c = 0; c = Add(a, b); printf("%d\n", c); return 0; }
2.1 main函数被谁调用?
我们一定要知道一个知识:每个函数调用都要创建自己的空间。
有了这句话我们就想知道main函数是不是也是被别的函数调用的呢?是的,main函数也是被调用的,我们用vs2022x86版本来看(不同的版本和不同的编译器其中的实现都有所不同)。
论:main函数是被其他函数调用的,具体是被invoke_main这个函数调用的,而这个invoke_main这个函数被_srct_common_main_seh这个函数调用的,这个_srct_common_main_seh函数是被_srct_common_main这个函数调用的。而这个_srct_common_main这个函数是被mainCRTStartup调用的。
这就搞清楚了main函数被调用的过程。再次理解了每个函数都是被调用的,都创建了自己的空间。
2.2 怎么在栈区开辟空间以及如何利用空间?
首先要知道这个知识点:函数的创建都是在栈上开辟空间的。
其次,函数既然是栈上开辟空间,那么如何利用呢?看图解释:
2.3 汇编角度深入函数栈帧的创建和销毁
在x86情况下,调试起来看这段代码的整个流程:
2.3.1 从main函数开始理解栈帧
这是main函数里面的反汇编代码:
3 问题问答
这张图就可以解决上述问题。
再来对上述问题进行总结:
1.局部变量是怎么创建的?
回答:就是把对应的数据放进寄存器中。
2.为什么局部变量不初始化的值是随机值?
回答:在创建函数栈帧时,按照对应的设置个数,把栈顶指针指向的地址,向下按照对应的设置个数进行设置,设置为0xCCCCCCCC的值,所以我们看到有时候就是随机值的现象。
3.函数是怎么传参的?传参的顺序是怎么样的?
数据放进寄存器传参,传参的顺序是从右向左的。
4.形参和实参是什么关系?
形参就是实参的一份临时拷贝,用的寄存器存储数据,形参使用的时候直接找到寄存器中存储的数据直接使用。
5.函数调用是怎么做的?
首先开辟函数栈帧,然后进行相应的操作。
6.函数调用是结束后怎么返回的?
值是通过寄存器返回的。函数是通过ret指令返回到进入函数时记录的地址。