在《Linux下C语言程序的内存布局(内存模型)》中我们讲到,程序的虚拟地址空间分为多个区域,栈(Stack)是其中地址较高的一个区域。栈(Stack)可以存放函数参数、局部变量、局部数组等作用范围在函数内部的数据,它的用途就是完成函数的调用。
栈内存由系统自动分配和释放:发生函数调用时就为函数运行时用到的数据分配内存,函数调用结束后就将之前分配的内存全部销毁。所以局部变量、参数只在当前函数中有效,不能传递到函数外部。**
栈的概念
在计算机中,栈可以理解为一个特殊的容器,用户可以将数据依次放入栈中,然后再将数据按照相反的顺序从栈中取出。也就是说,先放入的数据最后才能取出,而最后放入的数据必须先取出。这称为先进后出(First In Last Out)原则。
放入数据常称为入栈或压栈(Push),取出数据常称为出栈或弹出(Pop)。如下图所示:
图:数据的出栈和入栈
可以发现,栈底始终不动,出栈入栈只是在移动栈顶,当栈中没有数据时,栈顶和栈底重合。
从本质上来讲,栈是一段连续的内存,需要同时记录栈底和栈顶,才能对当前的栈进行定位。在现代计算机中,通常使用ebp寄存器指向栈底,而使用esp寄存器指向栈顶。随着数据的进栈出栈,esp 的值会不断变化,进栈时 esp 的值减小,出栈时 esp 的值增大。
ebp 和 esp 都是CPU中的寄存器:ebp 是 Extend Base Pointer 的缩写,通常用来指向栈底;esp 是 Extend Stack Pointer 的缩写,通常用来指向栈顶。
如下图所示是一个栈的实例:
栈的大小以及栈溢出
对每个程序来说,栈能使用的内存是有限的,一般是 1M~8M,这在编译时就已经决定了,程序运行期间不能再改变。如果程序使用的栈内存超出最大值,就会发生栈溢出(Stack Overflow)错误。
一个程序可以包含多个线程,每个线程都有自己的栈,严格来说,栈的最大值是针对线程来说的,而不是针对程序。
栈内存的大小和编译器有关,编译器会为栈内存指定一个最大值,在 VC/VS 下,默认是 1M,在 C-Free 下,默认是 2M,在 Linux GCC 下,默认是 8M。
函数的调用和栈是分不开的,没有栈就没有函数调用,本节就来讲解函数在栈上是如何被调用的。
栈帧/活动记录
当发生函数调用时,会将函数运行需要的信息全部压入栈中,这常常被称为栈帧(Stack Frame)或活动记录(Activate Record)。活动记录一般包括以下几个方面的内容:
1) 函数的返回地址,也就是函数执行完成后从哪里开始继续执行后面的代码。例如:
int a, b, c;
func(1, 2);
c = a + b;
站在C语言的角度看,func() 函数执行完成后,会继续执行c=a+b;语句,那么返回地址就是该语句在内存中的位置。
注意:C语言代码最终会被编译为机器指令,确切地说,返回地址应该是下一条指令的地址,这里之所以说是下一条C语言语句的地址,仅仅是为了更加直观地说明问题。
2) 参数和局部变量。有些编译器,或者编译器在开启优化选项的情况下,会通过寄存器来传递参数,而不是将参数压入栈中,我们暂时不考虑这种情况。
3) 编译器自动生成的临时数据。例如,当函数返回值的长度较大(比如占用40个字节)时,会先将返回值压入栈中,然后再交给函数调用者。
当返回值的长度较小(char、int、long 等)时,不会被压入栈中,而是先将返回值放入寄存器,再传递给函数调用者。
4) 一些需要保存的寄存器,例如 ebp、ebx、esi、edi 等。之所以要保存寄存器的值,是为了在函数退出时能够恢复到函数调用之前的场景,继续执行上层函数。
栈溢出(Stack Overflow)
我们先来看下面的一个例子:
#include <stdio.h>
int main(){
char str[10] = {0};
gets(str);
printf("str: %s\n", str);
return 0;
}
在 main() 函数内部定义一个字符数组,并通过 gets() 为它赋值。
在VS2010 Debug模式下运行程序,当输入的字符不超过10个时,可以正确输出,但是当输入的字符过多时,就会出现运行时错误。例如输入”12345678901234567890”,就会出现下面的错误:
这是为什么呢?我们不妨先来看一下 main() 函数的栈:
局部数组也是在栈上分配内存,当输入”12345678901234567890” 时,会发生数组溢出,占用“4字节空白内存”、“old ebp”和“返回地址”所在的内存,并将原有的数据覆盖掉,这样当 main() 函数执行完成后,会取得一个错误的返回地址,该地址上的指令是不确定的,或者根本就没有指令,所以程序在返回时出错。
C语言不会对数组溢出做检测,这是一个典型的由于数组溢出导致覆盖了函数返回地址的例子,我们将这样的错误称为“栈溢出错误”。
注意:这里所说的“栈溢出”是指栈上的某个数据过大,覆盖了其他的数据,和《栈(Stack)是什么?栈溢出又是怎么回事?》一节中提到的栈溢出不是一回事。
局部数组在栈上分配内存,并且不对数组溢出做检测,这是导致栈溢出的根源。除了上面讲到的 gets() 函数,strcpy()、scanf() 等能够向数组写入数据的函数都有导致栈溢出的风险。
下面是使用 strcpy() 函数导致栈溢出的例子:
#include <stdio.h>
#include <string.h>
int main(){
char *str1 = "这里是C语言中文网";
char str2[6] = {0};
strcpy(str2, str1);
printf("str: %s\n", str2);
return 0;
}
将 str1 复制到 str2,显然超出了 str2 的接受范围,会发生溢出,覆盖返回地址,导致 main() 函数返回时出错。
栈溢出一般不会产生严重的后果,但是如果有用户精心构造栈溢出,让返回地址指向恶意代码,那就比较危险了,这就是常说的栈溢出攻击。