当前位置:首页 > 嵌入式培训 > 嵌入式学习 > 讲师博文 > 堆栈溢出一般是由什么原因导致的?

堆栈溢出一般是由什么原因导致的? 时间:2018-12-24      来源:华清远见

堆栈溢出一般都是由堆栈越界访问导致的。例如函数内局部变量数组越界访问,或者函数内局部变量使用过多,超出了操作系统为该进程分配的栈的大小也会导致堆栈溢出。深度解析:

首先要区分清楚堆、栈、堆栈这几个名词。堆(heap)和栈(stack)是两种不同的内存管理机制:

1.堆

堆被称为动态内存,由堆管理器(系统里的大人物,山高皇帝远不用去管它)管理,程序中可以使用malloc函数来(向堆管理器)申请分配堆内存,使用完后使用free函数释放(给堆管理器回收)。堆内存的特点是:在程序运行过程中才申请分配,在程序运行中即释放(因此称为动态内存分配技术)。

2.栈

栈是C语言使用的一种内存自动分配技术(注意是自动,不是动态,这是两个概念),自动指的是栈内存操作不用C程序员干预,而是自动分配自动回收的。C语言中局部变量就分配在栈上,进入函数时局部变量需要的内存自动分配,函数结束退出时局部变量对应的内存自动释放,整个过程中程序员不需要人为干预。

堆栈这个词纯粹是用来坑人的。堆就是堆(heap),栈就是栈(stack),根本没有另外一种内存管理机制叫堆栈。大多数时候有人说起堆栈,其实他想说的是栈,以前早些的时候,这方面的命名并不是特别准确。(别人说堆栈的时候,大家知道他其实想说的是栈就行了,自己就不要再用这个不准确的词了)。既然堆和栈都是用来管理内存的机制,使用时就有一定的规则。无视规则的错误使用(C语言设计时赋予了程序员很大的自由度,所以有些错误语言本身是不会检查的,全凭程序员自己把握。)就可以导致一些内存错误,如内存泄漏、溢出错误等。

3.存泄漏

内存泄漏主要发生在堆内存使用中。譬如我们使用malloc申请了内存,使用过后并未释放而丢弃了指向该内存的指针(这个指针是这段内存的唯一记录,程序中释放该段内存都靠这个指针了),那么这段堆内存就泄漏掉了(堆管理器以为程序还在使用,所以不会将这段内存再次分配给别的程序)。必须等到这个程序彻底退出后,系统回收该程序所使用的所有资源(申请的内存,使用的文件描述符等)时这些泄漏的内存才重新回到堆管理器的怀抱。

内存溢出在堆和栈中都有可能发生。参见章节示例1_2_stack_overflow.c中的8个示例函数,其中前三个函数与堆溢出有关,后五个函数与栈溢出有关。

4.堆溢出

函数heap_overflow中使用malloc申请了16字节动态内存,然后尝试去读写这16个内存之中的第n个。三个测试分别给n赋值9,99和9999999,得到的结果很有意思(见程序后面的注释,大家也可以自己编译运行测试),现在我们来探讨其中的原理。

n等于9的时候没什么好说的,本该正确运行,这个相信大家没有异议。n等于99的时候······竟然也可以正确运行,这个相信很多人就有点想不通了。我们申请的空间只有16字节啊,怎么竟然还可以访问第99个字节空间呢(这就是所谓的堆溢出访问)?这时候实际已经堆溢出了,但是为什么结果没有出错呢?原因在操作系统的内存分配策略中。譬如linux中内存是按照页(Page,一般是4K字节一个页)来管理的,操作系统给进程分配内存本质上都是以页为单位进行的。也就是说你虽然只要求了16个字节,但是实际分配给你这个进程的可能是一个页(4K字节)。这个页中只有这16个字节是你自己的“合法财产”,其他部分你不该去访问(一访问就堆越界)。但是因为操作系统对内存的访问权限管理是以页为单位的,因此本页内16字节之外的内存你(非法)访问时系统仍然不会报错,并且确实能够达成目的(示例中n等于99时读写仍然正确)。那是不是说堆越界是无害的,完全不用担心呢?显然不是。因为堆越界最大的伤害不是对自己,而是对“别人”。因为除了你申请的16字节外本页面内其他内存可能会被堆管理器分配给其他变量,你越界访问时意味着你可能践踏了其他变量的有效区域(譬如我们给第99个字节赋值为g时,很可能把别处动态分配的一个变量的一部分给无意识的修改了)。因此其他变量会“莫名其妙”的出错,而且最可怕的是这种出错编译器无法帮你发现,大多数时候隐藏的很深,极难发现,往往令调试者抓狂、痛不欲生。因此访问堆内存时应该极为小心,一定要检验访问范围,谨防堆访问越界。

最后一个示例中n等于9999999,这是我随便写的一个很大的数,执行结果为:段错误(Segmentation fault)。熟悉C语言的同学都知道,一般段错误都是因为程序访问了不该访问的区域(譬如试图写代码段),这里也不例外。什么原因?考虑下上文中提到的以页为单位的内存管理策略。给你分配了一个页(一般是4KB),你访问时索引值太大已经超出了这个页(跑到下个页甚至更后面的页面去了),那边的内存页面根本不归你使用,你试图读写的时候操作系统的内存管理部分就会一巴掌把你扇回来,给你个Segmentation fault。那个数字式我随便写的,你也可以自己试试先给个小数字,然后逐渐加大,总会有个临界点,过了那个点就开始段错误了。

5.栈溢出

func1到func5这五个示例用来演示栈溢出。

func1是典型的数组越界造成的栈溢出,压栈越界导致冲毁了函数调用堆栈结构,致使整个程序崩溃。由此可见,在C语言中数组访问时一定要小心检查,保证不越界。C语言为了追求最高的效率,并未提供任何数组访问动态检查(实际上也没有提供编译时数组访问是否越界的静态检查,其原因是C语言愿意相信程序员,而将检查的重任交给了程序员自己······果然是权力越大、责任越大啊!),因此“保卫世界和平的重任就靠你了”。

func2和func3是一对对比测试。其中调用了一个递归函数factorial,该函数用来求一个正整数n的阶乘。func2中n等于10,计算结果为3628800,是正确的(大家可以用计算器自己验证)。func3中n等于10000000,运行结果为段错误(其实即使不段错误,factorial函数本身也无法计算很大数字的阶乘,原因在于函数中使用unsigned int类型来存阶乘值,这个类型的取值范围非常有限,n稍微大一点就会溢出。但溢出只会导致计算结果不对,不会造成段错误的)。

怎么会段错误呢?因为递归次数太多,栈终于被撑爆了。递归函数运行时,实际上相当于不停在执行子函数调用,因此栈一直在分配而没有释放。若在栈使用完之前递归仍然没有结束返回(此时会逐层释放栈)就会发生段错误。这是栈溢出的另一个典型情况,请大家以后使用递归算法解决问题时注意这个限制。

func4和func5是一对对比测试。其中均定义了一个局部变量数组a,不同的是a的大小。func4中数组大小为1M(注意a的类型是int,因此这里单位是4字节),运行成功。而func5中数组大小为4M,运行时则发生段错误。相信有了上面上面的讲解,大家能够很容易想明白,局部变量分配太多把栈用完了,所以就段错误了,就这么简单。

以上,通过5个示例程序为大家演示了栈溢出的三种情况。一般来说,第一种情况是明显的错误,且每次执行都确定会发生错误。而后两种错误则稍微复杂一些,原因在于这两种错误都依赖于栈的大小。而栈的大小在操作系统中不是固定的,是可以人为设置的(譬如linux中使用ulimit –s来查看和设置用户进程栈大小)。这就会带来一些很“神奇”的bug,如程序在你的计算机中运行良好,调试通过。结果发给客户,10个客户中8个运行良好,另外两个会报错、死机······

这时候只要重新设置一个更大的用户栈容量就可以解决问题。所以大家在写代码时一定要注意,考虑到你的代码有可能潜在的问题。这样一旦问题暴露即可迅速定位,并最快的找到解决方案。不过更高级的做法是:在写代码时尽量减少可能存在的问题,让你的程序尽量更加健壮(robust)。

代码如下:

#include <stdio.h>

#include <string.h>

#include <stdlib.h>

// function prototype declaration

int heap_overflow(unsigned int n, char c);

void func1(void);

void func2(void);

void func3(void);

void func4(void);

void func5(void);

// 注意:每个函数需要单独执行测试,因此在测试每个函数时,需要将其他函数屏蔽。

int main(void) 

// 堆溢出访问演示

//heap_overflow(9, *t*); // The 9th element = t.

//heap_overflow(99, *g*); // The 99th element = g.

heap_overflow(9999999, *g*); // Segmentation fault

// 栈溢出访问演示 

//func1(); // stack smashing detected

//func2(); // factorial(10) = 3628800.

//func3(); // Segmentation fault

//func4(); // a[1048576-1] = 5.

//func5(); // Segmentation fault

return 0; 

 

int heap_overflow(unsigned int n, char c)

{

char *p = NULL;

p = (char *)malloc(16);

if (NULL == p)

{

printf("fail to get dynamic memory from heap.\n");

return -1;

}

memset(p, 0, 16);

*(p + n) = c;

printf("The %dth element = %c.\n", n, *(p + n));

free(p);

p = NULL;

return 0;

}

void func1(void)

{

char name[8]; 

strcpy(name, "linus tovards.");

printf("Hello, %s!", name); 

}

 

static unsigned int factorial(unsigned int n)

{

if (n == 1)

return 1;

else

return n * factorial(n - 1);

}

void func2(void)

{

printf("factorial(10) = %d.\n", factorial(10));

}

void func3(void)

{

printf("factorial(10000000) = %d.\n", factorial(10000000));

}

 

#define M (1 * 1024 * 1024)

#define N (4 * 1024 * 1024)

void func4(void)

{

int a[M];

a[M-1] = 5;

printf("a[%d-1] = %d.\n", M, a[M-1]);

}

void func5(void)

{

int a[N];

a[N-1] = 5;

printf("a[%d-1] = %d.\n", N, a[N-1]);

}

6.堆和栈溢出总结

答:1.函数调用层次太深。函数递归调用时,系统要在栈中不断保存函数调用时的现场和产生的变量,如果递归调用太深,就会造成栈溢出,这时递归无法返回。再有,当函数调用层次过深时也可能导致栈无法容纳这些调用的返回地址而造成栈溢出。 

2.动态申请空间使用之后没有释放。由于C语言中没有垃圾资源自动回收机制,因此,需要程序主动释放已经不再使用的动态地址空间。申请的动态空间使用的是堆空间,动态空间使用不会造成堆溢出。 

3.数组访问越界。C语言没有提供数组下标越界检查,如果在程序中出现数组下标访问超出数组范围,在运行过程中可能会内存访问错误。 

4.指针非法访问。指针保存了一个非法的地址,通过这样的指针访问所指向的地址时会产生内存访问错误。

上一篇:GPIO是什么?

下一篇:嵌入式:并行接口介绍

热点文章推荐
华清学员就业榜单
高薪学员经验分享
热点新闻推荐
前台专线:010-82525158 企业培训洽谈专线:010-82525379 院校合作洽谈专线:010-82525379 Copyright © 2004-2018 北京华清远见科技发展有限公司 版权所有 ,京ICP备16055225号,京公海网安备11010802025203号