函数私底下都在干什么?图解函数栈帧的创建与销毁
前言(什么是函数栈帧?)
函数栈帧是什么?相信很多人第一次听到时都一脸懵逼。我们不妨百度一下。
C语言中,每个栈帧对应着一个未运行完的函数。栈帧中保存了该函数的返回地址和局部变量。
栈帧也叫过程活动记录。也就是函数的活动记录。函数执行的环境。函数参数、函数的局部变量、函数执行完后返回到哪里等等。
首先应该明白,栈是从高地址向低地址延伸的。每个函数的每次调用,都有它自己独立的一个栈帧,这个栈帧中维持着所需要的各种信息。寄存器ebp指向当前的栈帧的底部(高地址),寄存器esp指向当前的栈帧的顶部(低地址)。
想要了解函数栈帧到底是什么一个东西,那么就必须要进入反汇编来观察,我们会发现反汇编界面有着很多CPU指令,想要弄清楚函数栈帧那么就必须搞懂这些指令。这对我们理解函数栈帧有着很大的帮助,本文按照顺序来介绍这些指令并梳理创建与销毁函数栈帧的过程。
首先我们写好一段代码:
#define _CRT_SECURE_NO_WARNINGS 1
#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;
}
在编译器下按F10进入调试界面,然后鼠标右击进入反汇编界面:
我们会发现在反汇编界面有着许多的cpu指令,那么下面就简单介绍一下cpu指令和寄存器。
寄存器的介绍
数据寄存器(EAX EBX ECX EDX)
- 寄存器EAX通常称为累加器(Accumulator),用累加器进行的操作可能需要更少时间。累加器可用于乘、除、输入/输出等操作,它们的使用频率很高;
- 寄存器EBX称为基地址寄存器(Base Register)。它可作为存储器指针来使用;
- 寄存器ECX称为计数寄存器(Count Register)。在循环和字符串操作时,要用它来控制循环次数;在位操作中,当移多位时,要用CL来指明移位的位数;
- 寄存器EDX称为数据寄存器(Data Register)。在进行乘、除运算时,它可作为默认的操作数参与运算,也可用于存放I/O的端口地址。
指针寄存器(ESP EBP)
- 寄存器EBP称为基址指针寄存器(栈底指针)。
- 寄存器ESP称为堆栈指针寄存器(栈顶指针)。
变址寄存器(ESI EDI)
- 寄存器ESI称为源变址寄存器 (Source Index);
- 寄存器EDI称为目的变址寄存器(Destination Index)。
函数栈帧的创建与销毁过程详解
1.push指令
push指令是压栈的操作,就是将一块内存地址压入栈中(从高地址开始压入):
在调用main函数之前,main函数还被其他函数所调用,这里不做赘述,很明显我们看到,ebp被压入栈中,push指令就是这么简单。
esp和ebp
这里的esp和ebp分别是两个寄存器,里面存放的是地址,这两个地址用用来维护函数栈帧的,esp维护栈顶,ebp维护栈底,当每次开辟新的内存空间时,esp会自动跳到栈顶。所以esp为栈顶指针而ebp是栈底指针。
2.mov指令
move指令是将地址中的数据赋给其他的地址例如:
009818B1 mov ebp,esp
就是将esp所存储的值也就是esp的地址赋给ebp,那么此时ebp的位置如下
3.sub指令和add指令
sub指令是减值操作,例如:
009818B3 sub esp,0E4h
这里就是esp的地址减去0E4h这么多的地址,那么此时esp指针应该上移:
相反add是加的指令
那么此时esp栈顶指针和ebp栈底指针之间所维护的空间就是main函数的栈帧,至于地址空间大小,一定是符合要求的,编译器是为我们考虑好的,所以我们不必担心开辟出来的空间不够啊之类的问题。
再次压入三个地址(寄存器地址)。
3.lea指令
lea指令和mov有着些许相似的地方,mov是赋内存中的值,而lea是赋地址。
例如:
lea edi,[ebp-24h]
就是将[ebp-24h]这个地址值直接赋给edi,而不是把[ebp-24h]处的内存地址里的数据赋给eax。
mov指令则恰恰相反
例如:
mov ebp,esp
则是把内存地址为esp处的数据赋给eax。
4.rep stos指令
009818BC lea edi,[ebp-24h]
009818BF mov ecx,9
009818C4 mov eax,0CCCCCCCCh
009818C9 rep stos dword ptr es:[edi]
rep指令的目的是重复其上面的指令.ECX的值是重复的次数.
STOS指令的作用是将eax中的值拷贝到ES:EDI指向的地址.
这段代码的意思也就是将ebp-24h向上的地址,执行循环9次,双字(一个字等于两个字节)也就是四个字节的内容全部初始化为0CCCCCCCCh 。
这里也就是将main函数的栈帧初始化为cccccccc
4.call指令
call指令用来调用函数,并且记录下行代码地址,这里开始进入main函数
开始执行有效代码:
- 将a初始化为10:十六进制0a就是10
- 将b初始化为20:
- 将c初始化为0:
b和c的位置都间隔2个地址空间(8字节),这是编译器所决定的
传参
下面执行c=Add(a,b),传参
ebp-14h也就是b的位置,也就代表编译器是从右往左边传参的
调用Add函数并记录下一行代码地址
那么指令执行!
进行运算,我们可以看出其实参数加减是在寄存器中运行的,最后eax寄存器的值就是30,是把值都放在eax累加器的,那么为什么形参是实参的临时拷贝也就解释的通了。改变形参不会改变实参。
5.pop指令
pop指令就是出栈,销毁函数栈帧
此时esp和ebp指向同一位置,Add函数栈帧被销毁
ebp内存地址弹出,那么此时ebp栈底指针也就要返回,指向在main函数中的ebp
此时,esp和ebp返回到main函数里面!
6.ret指令
返回到记录的call指令记录的下一条代码的位置
返回,那么就会弹出call指令记录的位置且执行esp+8,那esp的位置就是:
ebp-20h就是c的位置,eax的值就是30,将30赋值给c,那么c的值就是30了
至此,函数栈帧的创建与销毁逻辑大概就介绍完毕。
总结
想要解释好函数栈帧画图是必不可少的环节,图文相结合才能更好地理解函数栈帧,还要对于cpu的指令进行一定了解。
函数栈帧算是比较底层的东西,希望本文对你以后的学习能有所帮助!