前言
栈帧也叫过程活动记录,是编译器用来实现函数调用过程的一种数据结构。C语言中,每个栈帧对应着一个未运行完的函数。从逻辑上讲,栈帧就是一个函数执行的环境:函数调用框架、函数参数、函数的局部变量、函数执行完后返回到哪里等等。栈是从高地址向低地址延伸的。每个函数的每次调用,都有它自己独立的一个栈帧,这个栈帧中维持着所需要的各种信息。寄存器ebp指向当前的栈帧的底部(高地址),寄存器esp指向当前的栈帧的顶部(低地址)。
Add()函数调用深度剖析
我们以Add()函数为例深入的研究一下函数的调用过程。
先看一段简单的代码:
#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 ret = Add(a, b) ;
printf("ret = %d\n", ret) ;
return 0;
}
当开始剖析程序调试的时候, 查看【调用堆栈】(按F10进入调试->窗口->调用堆栈,或快捷键ctrl+alt+C),用VS2013调试 如下图:
我们发现其实main函数在 __tmai nCRTStartup 函数中调用的,而 __tmai nCRTStartup 函数是在 mai nCRTStartup 被调用的。我们知道每一次函数调用都是一个过程。这个过程我们通常称之为: 函数的调用过程。这个过程要为函数开辟栈空间(运行时堆栈), 用于本次函数的调用中临时变量的保存、 现场保护。 这块栈空间我们称之为函数栈帧。所以函数调用过程实际上就是函数栈桢创建与销毁。
而栈帧的维护我们必须了解ebp和esp两个寄存器。 在函数调用的过程中这两个寄存器存放了维护这个栈的栈底和栈顶指针。比如:调用main函数, 我们为main函数分配栈帧空间, 那么栈帧维护如下:
ebp存放了指向函数栈帧栈底的地址。esp存放了指向函数栈帧栈顶的地址。
注意:ebp指向当前位于系统栈最上边一个栈帧的底部,而不是系统栈的底部。严格说来,“栈帧底部”和“栈底”是不同的概念;esp所指的栈帧顶部和系统栈的顶部是同一个位置。
1 . main函数开始。 要展开main函数的调用就得为main函数创建栈帧, 那我们先来看main函数栈帧的创建。转到反汇编可以更清晰的看到过程:
过程分析:
- 首先mainCRTStartup(),__mainCRTStartup()函数的调用,调main()函数;
- 将ebp压栈处理,保存指向栈底的ebp的地址(方便函数返回之后的现场恢复),此时esp指向新的栈顶位置;
- 将esp的值赋给ebp,产生新的ebp;
- 给esp减去一个16进制数0E4H(为main函数预开辟空间);
- push ebx、esi、edi;
- lea指令,加载有效地址;
- 初始化预开辟的空间为0xcccccccc;
- 创建变量a与b。
2. 接下来Add函数的调用。
参数传递过程:(从右到左传参)
过程分析:
- 将b存入寄存器eax,对b进行实例化_b ;
- 将a存入寄存器ecx,对a进行实例化_a ;
- call指令的调用,先要压栈call指令下一条指令的地址,然后跳转到Add()函数的地方。
执行call指令的时候按F11 , 来到了这里。
再按F11 就进入Add函数的执行代码处。Add函数栈帧的创建:
过程分析:
- 首先将main()函数ebp压栈处理,保存指向main()函数栈帧底部的ebp的地址(方便函数返回之后的现场恢复),此时esp指向新的栈顶位置;
将esp的值赋给ebp,产生新的ebp,即Add()函数栈帧的ebp;
给esp减去一个16进制数0E4H(为Add()函数预开辟空间);push ebx、esi、edi;
lea指令,加载有效地址;
初始化预开辟的空间为0xcccccccc;
创建变量z;
获取形参的a和b再相加,将结果存储到z中;
将结果存储到eax寄存器,通过寄存器带回函数的返回值。
*剩下的就是是函数返回部分:**
过程分析:
- pop 3次,edi、esi、ebx依次出栈,esp 向下移动;
- 将ebp赋给esp,使esp指向ebp指向的地方;
- ebp 出栈,将出栈的内容给ebp(即main()函数ebp),回到main()函数的栈帧;
- ret 指令,出栈一次,并将出栈的内容当做地址,并跳转到该地址处 。
注: 栈帧这部分内容在不同的编译器上实现存在差异, 但是思想都是一致的。
对于函数调用具体过程剖析完了,我们来个小测验:
在VC6.0环境中, 下面代码的结果是什么?
#include <stdio.h>
void fun()
{
int tmp = 10;
int *p = (int *)(*(&tmp + 1));
*(p - 1) = 20;
}
int main()
{
int a = 0;
fun();
printf("a = %d\n", a);
return 0;
}
事实上在不同平台下这段代码有不同的输出,可自行验证。
此处提供VS编译器答案:20
结语
堆和栈的关系
我们平时说的堆栈其实是指栈,而实际上堆和栈是两种不同的内存分配。简单罗列如下各方面的异同点。
- 堆需要用户在程序中显式申请,栈不用,由系统自动完成。申请/释放堆内存的API,在C中是malloc/free,在C++中是new/delete。申请与释放一定要配对使用,否则会造成内存泄漏(memory leak),久而久之系统就无内存可用了,出现OOM(Out Of Memory)错误。一般在return/exit或break/continue等语句时容易忘记释放内存,所以检查内存泄漏的代码时要关注这些语句,看它们前面是否有必要的释放语句free/delete。
堆的空间比较大,栈比较小。所以申请大的内存一般在堆中申请;栈上不要有较大的内存使用,比如大的静态数组;而且除非算法必要,否则一般不要使用较深的迭代函数调用,那样栈消耗内存会随着迭代次数的增加飞涨。
关于生命周期。栈较短,随着函数退出或返回,本函数的栈就完成了使用;堆就要看什么时候释放,生命周期就什么时候结束。
关于函数调用即栈桢创建与销毁就浅析到此!
部分资料来源于网络,版权属其原著者所有,只供学习交流之用。如有侵犯您的权益,请联系【公众号:码农印象】删除,可在下方评论,亦可邮件至ysluckly.520@qq.com。互动交流时请遵守宽容、换位思考的原则。