电脑中的任何指令都是在
CPU
上的运行的,但是CPU本身只负责运算不负责存储,数据一般都是存储在内存和寄存器(储存最常用的数据)。
想要理解函数栈帧的创建和销毁,首先必须了解四个知识点:内存地址布局、寄存器、常用汇编指令及内存模型。
首先要了解在我们的内存中地址是由高到低存放的,而且由有数据要占用内存空间时先用高地址再用低地址,我们在操作系统中称这个过程为压栈。每一个函数的调用,都要在内存地址中分配空间,函数调用结束后,函数所占的内存空间会返回给系统。
栈帧就是一个函数执行的环境:函数参数、函数的局部变量、函数执行完后返回到哪里等等。
首先应该明白,栈是从高地址向低地址延伸的。每个函数的每次调用,都有它自己独立的一个栈帧,这个栈帧中维持着所需要的各种信息。寄存器ebp指向当前的栈帧的底部(高地址),寄存器esp指向当前的栈帧的顶部(低地址)。
观察内存布局,建议不要使用太高级的编译器,越高级的编译器越不容易学习与观察汇编过程。
寄 存 器 名 称 | 寄 存 器 功 能 |
---|---|
eax | 累加寄存器,相对于其他寄存器,在运算方面比较常用。 |
ebx | 基地址寄存器,在内存寻址时存放基地址。 |
ecx | 计数寄存器,用于循环操作,比如重复的字符存储操作,或者数字统计。 |
edx | 作为EAX的溢出寄存器,总是被用来放整数除法产生的余数。 |
esi | 源变址寄存器,主要用于存放存储单元在段内的偏移量。通常在内存操作指令中作为“源地址指针”使用。 |
edi | 目的变址寄存器,主要用于存放存储单元在段内的偏移量。 |
eip | 控制寄存器,存储CPU下次所执行的指令地址(存放指令偏移地址) |
esp | 栈顶指针 ,堆栈的顶部是地址小的区域,压入堆栈的数据越多,esp也就越来越小。在32位平台上,esp 每次减少4字节。栈指针寄存器(extended stack pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶 。是CPU机制决定的,push、pop 指令会自动调整esp的值 |
ebp | 基址指针,指栈的栈底指针 。基址指针寄存器(extended base pointer),一般与esp配合使用,可以存取某时刻的esp,这个时刻就是进入一个函数内后,CPU会将esp 的值赋给ebp,此时就可以通过ebp对栈进行操作,比如获取函数参数,局部变量等。其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部 。 |
为了方便各位理解,这里都采用小例子来帮助说明。
说明:因为ebp是栈底指针,push即在esp栈顶指针处放入所要调用的函数的ebp指针。
说明:因为edi是最后进行压栈操作的,因此edi最先出栈。
说明:将反汇编中执行sub指令的上一条最终结果的地址减去0E4H的结果保存在esp中。
move指令:用于将一个数据从源地址传送到目标地址,源操作地址的内容不变。
说明:将esp的地址传给ebp,而esp的地址不改变。
lea指令:是“load effective address”的缩写,简单的说,lea指令可以用来将一个内存地址直接赋给目的操作数。
说明:将ebp-24h的地址直接赋给edi。
rep指令:重复前缀指令,英文缩写 repeat。能够引发其后字符串指令被重复。
stos指令:串存储指令,英文缩写 store string。
说明:通常这四条语句都是连在一起运行。
rep指令:重复其上面的指令,ecx的值是重复的次数,每执行一次,ecx 减 1,直到 ecx 减至0。
stos指令:将 eax中的值拷贝到es:[edi]指向的地址。
dword:一个word代表两个字节,dword即doubleword代表四个字节。
ptr:pointer缩写 即指针。
[ ]:[ ]里的数据是一个地址值,这个地址指向一个双字型数据。一次拷贝双字(4个字节)的数据到目的地址。
es:[edi]指向目的串。
多种指令合在一起意思是从ebp-24h的地址处向高地址的内存处存放0CCCCCCCCh的内容,重复39h次,每次赋予四个字节的空间。
说明:上一条最后操作的结果是esp,给 esp 加8,也就是 esp向高地址方向移动 8字节 。
说明:执行这条命令之后,就自动返回刚才call指令的下一行。
声明:此次演示的电脑是windows 10、编译环境 为vs2013(debug、Win32)。
以下代码为演示使用代码:
#include
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;
}
将光标放在int main下面的大括号处,按下F10,在编译器上方的调试处,选择窗口,再选择调用堆栈。
继续按F11去运行,当箭头指向return 0处再按F11
,我们会跳转到以下画面,并且调用堆栈中出现了调用main函数的函数。这里我们可以证明main函数的确也是被其它函数所调用的,此时main函数已经调用完成即已经出栈,可见栈中已经没有main函数。
在此页面往上翻,可以找到调用main函数的函数与调用main函数的函数的函数,这里是嵌套调用,并且能观察出main函数是被__tmainCRTStartup函数调用的,而 __tmainCRTStartup又是被mainCRTStartup调用的。
将光标放在main函数下面的大括号中重新开始调试,先不要按F10,此时在代码页面右击鼠标点击转入反汇编,进入到如下页面,该页面我已截图并且后面加上每一行指令所执行的步骤的具体分析(这只是其中一部分):
如果要观察压栈出栈的全过程,我将以画图的形式进行展示。按上面的执行顺序执行。注:将最后四步都归于第七步来分析。
第一步:说明:因为main函数是被__tmainCRTstartup函数所调用的,因此__tmainCRTstartup函数在栈中的地址肯定是比main函数高的,直线下方为main函数的函数栈帧。(因为画图空间有限,__tmainCRTstartup函数只说明是在main函数下方,我们只要对main函数进行探讨)
push是压栈操作,放入ebp。
第二步:说明:将esp的地址传给ebp。
第三步:将ebp的地址减少0E4H传给esp,ebp的地址不改变。
注:如果是第一次点入反汇编,可能减去的字节不是0E4h,而是FFFFFF1Ch。这里我们可以鼠标右键,选择“显示符号名”即可。
第四、五、六步:说明:都是进行进行压栈操作。
第七步:说明:因为画图空间有限,内存内容被改变为CCCCCCCC有39h次(16进制),每次四个字节,更改内容的起始位置到末尾改变的内容只画个大概,但始到末准确。
继以上的图,开始创建局部变量,为了避免图过于混乱,先将图中赋值的内容清空。
说明:以上带有h的数值都是16进制数字,0Ah转化为10进制为10;14h转化为10进制为20,20h转化为10进制为32。每一个矩形代表4个字节。
注:此处如果只是显示将值赋给a或b或c,仍是双击鼠标点击“显示符号名”来解决。
此时为调用Add函数做准备,我将过程分为三步。
第一步:压入eax并赋值。
注:当执行完第三步后会直接跳转到以下页面,再按一次F11才能进入调用Add函数的反汇编中,当调用完Add函数后才执行第三步下面的执行命令。
第二步:
第四步:赋值操作。
经过上述的详解,我们可见Add函数的调用与main函数的调用的形式大相径庭,必须要对函数调用非常熟悉与理解。
第一步:出栈操作。
第一步:在执行add指令前已经回收了IP地址00B310E1的内容,回收IP地址内容后esp的地址增加了4个字节,执行add指令后esp的地址再增加八个字节,因为编译器原因,实际上esp与edi的地址不相等,我们这里可以不用深度去研究。
第二步:eax的值赋给ebp-20h的地址处,而eax经过Add函数的处理现在已经为30。而ebp-20h地址处正好为变量c的地址。
我们通过例子来观察函数栈帧的创建与销毁,相信你对它已经有了非常深刻的了解。我们学习完后,对以下问题都能够回答吗?
比如:
1.局部变量是怎么创建的?
2.为什么局部变量的值是随机值?
3.函数是怎么传参的?传参的顺序是怎样的?
4.形参和实参是什么关系?
5.函数调用是怎么做的?
6.函数调用是结束后怎么返回的?
如果这篇文章让你对了解函数栈帧的创建与销毁有帮助,麻烦点个赞支持一下谢谢,原创不易,侵权必究!