(一)前言
我们在学习c语言的时候,都调用过函数吧。尤其是实现递归时,函数一层一层地不断调用自身,因为处理不得当,最终因为内存栈区耗尽,导致栈溢出错误。比如,当我们运行下面这个最简单的递归调用代码:
int main()
{
printf("hehe\n");
main();
return 0;
}
我们会出现下图的结果,表示栈溢出,即内存中的栈区被分配完了。
那么,我们自然而然会产生2个问题:
- 内存中的栈区是什么意思,有什么作用,除了栈区是否还有其他功能区?
- 函数在调用的过程中,内存是如何为函数分配资源的呢?
(二)内存的区域划分
我们将内存划分为3个区域,地址从低到高分别为:静态区,堆区,栈区。如图:
通过上图,我们可以了解到,栈区主要存放局部变量和函数参数,那么,在函数调用过程中,我们主要都是在栈区开辟空间,如果栈区被开辟完了(一直调用函数,而没有释放空间),那么就会栈溢出。那么,栈区的开辟具体过程是怎样的呢?我们接着往下看。
(三)函数栈帧的创建
下面我们以这段简单的实现加法功能的函数调用代码来探究函数栈帧的创建与销毁细节:
int Add(int a, int b)
{
int c = a + b;
return c;
}
int main()
{
int a = 0;
int b = 0;
int c = Add(a,b);
return 0;
}
一开始,栈区为main函数开辟空间。其中,ebp,esp是寄存器,里面分别存放当前程序运行状态的栈底和栈顶的地址,如下所示:
需要注意两个的两个方面,(一)此图的高地址在下面,低地址在上方,这是为了更方便理解和表述。(二)main函数也是函数,需要被其他程序调用,因此,可以看到main函数下面就是调用main函数的程序,这里简单了解即可。
首先执行1,2两步,如图:
在调用函数Add之前,我们需要做好”准备工作“,如保存重要信息,以便Add函数返回时,能恢复main函数的原始数据。除此之外,还需要为Add函数提前分配好空间。
所以第3步,将寄存器ebp中的地址压入栈中保存下来,以便函数返回时能ebp能找到它原来的”位置“。此时,esp会自动指向栈顶元素的位置。如图:
第4步,我们需要将参数压入栈顶,函数调用的参数,是从右往左的顺序压入的栈中的,因此先压入b,再压入a。 **此时,esp会自动指向栈顶元素的位置。 如图:
第5步,将ebp的值修改为esp的值,即将ebp指向esp所指向的位置,然后esp的值根据编译器的计算,会自动减少若干字节数,esp就指向了低地址的某个地址,这样就为Add函数开辟了空间。如图:
第6步就是要先再Add函数栈中开辟c的空间,然后根据a,b的值计算出a+b放入c中,那么如何找到a,b的值呢,程序根据ebp所指向的位置,往高地址找即可找到之前被提前压入栈中的ecx和eax的值,里面就分别是a和b的值。
到此,函数栈帧的创建过程就结束了,下一步就算return返回,意味着栈帧进入销毁阶段。现在我们,不妨思考一下,如果Add函数中,还需要继续调用其他函数,会怎样呢?是不是又要往上开辟新的栈区了呀,栈区大小是有限的,一直开辟下去,是不是就会溢出了呀!如图:
(四)函数栈帧的销毁
return c语句的执行背后相当于将c的值放入寄存器eax中,然后esp的值修改为ebp,即esp指向ebp指向的位置,如图:
然后三次出栈,esp指向main()栈顶,最后一次出栈,会导致ebp指向main函数栈底(之前存入栈中的值ebp-main赋值给ebp),如图:
最后在卖弄函数中开辟c的空间,然后将eax中的值赋给c,如图:
之后就算main函数的回收了,原理与Add回收一样,在此就不再赘述了,感兴趣的小伙伴可以自己尝试分析分析。