堆栈和堆
我们将应用程序的运行过程想象为这个旅行团正在开一个大Party,比如说十八大?大会中往往会有各种小会,比如说我们的18大就有各种各样的分组讨论。这些小会,我们可以称之为“函数”。函数的运行需要一些上下文,正如这些小会的讨论不能脱离大会的主题一样,我们开小会需要知道大会给我们提供了什么,茶水啊,饮料啊,点心啊之类的,这些东西给了我们,我们就需要个地方放,但只要我们的小会开完了,这些东西就不需要了,我们放这些东西的地方也就不需要了。为了满足这些小会的需要,我们就给这些小会安排了一个大房间,每个小会开的时候都是用这个房间,但这个房间不会完全归某个小会使用,这个小会需要多大的地方放东西就安排多大地方,开完会就把这块地方还给我们。为了方便我们打扫房间,我们规定,凡是放在这个房间的东西,必须按照先后顺序,先放的放在下面,后放的,放在上面,我们清理的时候,则从上往下清理。我们把这个大房间称为“堆栈”或者“栈”。应用程序中的函数,会有一些运行需要的上下文,比如说环境变量,参数等,这些就是放在堆栈中的,一旦函数运行完成,它所占用的堆栈空间就被清理回收了。
但是,(但是如此之多,总叫人无奈),我们开的小会中可能会产生一些文件,决议之类的东西是给大会用的,这些东西不能因为我们的小会开完了就销毁,这些文件或者决议也需要有个地方放。我们就又准备了一个更大的房间,用来存放这些东西。每个小会需要多大的地方需要自己申请,比如说,我们“山西代表团讨论会”需要一个1立方米大小的文件柜,就提出申请(malloc/new),大会组织方就给我们从这个大房间划出我们需要的那么大的文件柜给我们。我们把这样的大房间称为“堆”。我们在这个大房间申请的文件柜需要我们自己来管理,比如说,可能我们的大会开了一段时间后,这个文件柜里的东西已经不需要了,我们就可以把这个文件柜里的东西销毁,清空这个文件柜,如果我们忘了做这个事情,那这个文件柜就会被一直占用着,直到整个大会开完(程序运行结束),任何人都不能用这个文件柜。这就是所谓的“内存泄露”。
函数调用
(这一部分比较技术性,可能会有一定的理解困难)
当一个函数被调用的时候,应用程序做的第一件事情就是在栈上开一块空间给这个函数使用(放茶水点心之类的)。我们下面看一个最简单的函数的汇编表示:
函数原型(C)
void fun2()
{
}
int fun1(int x)
{
fun2();
return x;
}
int main()
{
fun1(1);
return 0;
}
汇编:
int fun1(int x)
{
00EF13D0 push ebp //保存栈底指针
00EF13D1 mov ebp,esp //将栈底指针移动到栈顶
00EF13D3 sub esp,0C0h //增加栈空间
00EF13D9 push ebx
00EF13DA push esi
00EF13DB push edi
00EF13DC lea edi,[ebp-0C0h] //保存栈空间的大小
00EF13E2 mov ecx,30h //初始化寄存器
00EF13E7 mov eax,0CCCCCCCCh //初始化寄存器
00EF13EC rep stos dword ptr es:[edi] //初始化栈内存
fun2();
00EF13EE call fun2 (0EF10EBh) //调用函数fun2,后面的地址是fun2的内存地址
return x;
00EF13F3 mov eax,dword ptr [x] //通过eax寄存器保存返回值
}
00EF13F6 pop edi
00EF13F7 pop esi
00EF13F8 pop ebx
00EF13F9 add esp,0C0h
00EF13FF cmp ebp,esp
00EF1401 call __RTC_CheckEsp (0EF1140h)
00EF1406 mov esp,ebp
00EF1408 pop ebp
00EF1409 ret
下面是地址0EF10EBh的指令:
fun2:
00EF10EB jmp fun2 (0EF1420h)
我们看到它跳转到了地址0EF1420h,我们再看地址0EF1420h是什么:
void fun2()
{
00EF1420 push ebp
00EF1421 mov ebp,esp
00EF1423 sub esp,0C0h
00EF1429 push ebx
00EF142A push esi
00EF142B push edi
00EF142C lea edi,[ebp-0C0h]
00EF1432 mov ecx,30h
00EF1437 mov eax,0CCCCCCCCh
00EF143C rep stos dword ptr es:[edi]
}
00EF143E pop edi
00EF143F pop esi
00EF1440 pop ebx
00EF1441 mov esp,ebp
00EF1443 pop ebp
00EF1444 ret
我们可以看到函数调用实际上就是通过一个地址跳转指令实现的。
现在我们可以回答一个问题:
为什么递归深度太深的话,会造成栈溢出?
从上面的汇编我们可以看出每次进入一个函数,首先作的事情就是开辟栈空间,这个栈空间直到这个函数调用结束后,才会释放,因而,每次递归都回开辟新的栈空间,而在内层递归结束之前没有机会释放这块栈空间。栈空间的大小是有限的,所以递归太深就会用完这些栈空间,导致栈溢出。
为什么我听说尾递归不会造成问题?这里实际上是因为编译器优化的原因,编译器会将尾递归优化为普通的一条jmp指令,而不会为其开辟新的栈空间,如果没有编译器优化的话,一样会造成问题的。
下面是一个尾递归和其对应的未优化的汇编代码:
int fun1(int x)
{
return fun1(x);
}
这是一个死递归,永远都不会执行完,但没关系,我们这里不关心这个问题,我们关心的是它是怎么使用的栈空间。我们看汇编:
int fun1(int x)
{
004013D0 push ebp
004013D1 mov ebp,esp
004013D3 sub esp,0C0h
004013D9 push ebx
004013DA push esi
004013DB push edi
004013DC lea edi,[ebp-0C0h]
004013E2 mov ecx,30h
004013E7 mov eax,0CCCCCCCCh
004013EC rep stos dword ptr es:[edi]
return fun1(x);
004013EE mov eax,dword ptr [x]
004013F1 push eax
004013F2 call fun1 (04011EFh)
004013F7 add esp,4
}
004013FA pop edi
004013FB pop esi
004013FC pop ebx
004013FD add esp,0C0h
00401403 cmp ebp,esp
00401405 call __RTC_CheckEsp (0401140h)
0040140A mov esp,ebp
0040140C pop ebp
0040140D ret
下面是开启编译器优化后的汇编代码:
return fun1(x);
001D1F40 jmp fun1 (01D1F40h)
这就是函数调用,以及在函数调用过程中对堆栈的使用。