详解栈溢出
Posted ᝰFour Years
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了详解栈溢出相关的知识,希望对你有一定的参考价值。
前言
在我们平时开发的过程中,经常会出现stack overflow的情况导致程序崩溃,通常的原因都是无限的递归调用导致的,也就是无限的调用函数,导致空间用完,可是为什么非要使用栈呢,它内部是怎么运行的,栈的空间一共有多大呢,在下面会一一的详解
为什么要使用程序栈?
我们先来看一段代码
// function_example.c
#include <stdio.h>
int static add(int a, int b)
{
return a+b;
}
int main()
{
int x = 5;
int y = 10;
int u = add(x, y);
}
是一个函数调用
$ gcc -g -c function_example.c
$ objdump -d -M intel -S function_example.o
我们将这个程序反汇编
int static add(int a, int b)
{
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
4: 89 7d fc mov DWORD PTR [rbp-0x4],edi
7: 89 75 f8 mov DWORD PTR [rbp-0x8],esi
return a+b;
a: 8b 55 fc mov edx,DWORD PTR [rbp-0x4]
d: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
10: 01 d0 add eax,edx
}
12: 5d pop rbp
13: c3 ret
0000000000000014 <main>:
int main()
{
14: 55 push rbp
15: 48 89 e5 mov rbp,rsp
18: 48 83 ec 10 sub rsp,0x10
int x = 5;
1c: c7 45 fc 05 00 00 00 mov DWORD PTR [rbp-0x4],0x5
int y = 10;
23: c7 45 f8 0a 00 00 00 mov DWORD PTR [rbp-0x8],0xa
int u = add(x, y);
2a: 8b 55 f8 mov edx,DWORD PTR [rbp-0x8]
2d: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
30: 89 d6 mov esi,edx
32: 89 c7 mov edi,eax
34: e8 c7 ff ff ff call 0 <add>
39: 89 45 f4 mov DWORD PTR [rbp-0xc],eax
3c: b8 00 00 00 00 mov eax,0x0
}
41: c9 leave
42: c3 ret
我们查看程序,他和上面指令的跳转查不多,但是他将上面的jump转换为了call指令,call跳转后仍是程序的地址,但值得注意的是,在add函数调用的时候,程序是先执行力一句push命令和mov命令,后来又执行了一条pop和ret命令,这其实就是我们要说的压栈和出栈
可以发现他和if else的命令有一点像,但是又完成不一致,在if else 中程序完成一次跳转,他的命令类似于goto,而函数是要回到原来执行的地方。
可是为什么非要跳转呢,我们为什么不将函数中的内容直接替换到这个地方,其实道理很简单,就是如果两个函数之间互相包含,那就会出现无限替换的情况,这样会导致程序的奔溃,就像向下的镜子
说到这里你可能想到了,我们可以去使用一个类似于pc寄存器的东西去记录这个地址,但是我们计算机中的寄存器单元是有限的,在函数调用过多的情况下是无法完成记录的,这时候科学家就想到了我们想到了我们经常使用的的数据结构,栈,利用他的先进后出这个特性,每一次函数调用都是一次压栈,而每个函数的结束就是一次出栈的过程,这样子程序的调用也就是按照规则来
就例如一个直直的乒乓球桶,第一个程序调用的时候,就将第一个乒乓球放入,如果第一个乒乓球内调用了第二个乒乓球,就继续向里面加球,反之则取出第一个乒乓球
在真实的程序中,压栈不只用函数的地址和返回值,还会有一些参数,在寄存器不够的时候,这些参数也会被放入内存中,这个就叫做函数的栈帧(stack Frame),在中文里是相框的意思,就是将函数的所有框了起来
在实际的内存布局中,底和顶是倒过来的,我们的桶也是倒过来的,这样的布局是因为内存地址一开始就是固定的,而一层层压栈后,程序的地址是在逐渐变小,而不是逐渐变大。因为逐渐变大的话有可能内存地址超出最大限制
我们在调用第 34 行的 call 指令时,会把当前的 PC 寄存器里的下一条指令的地址压栈,保留函数调用结束后要执行的指令地址。而 add 函数的第 0 行,push rbp 这个指令,就是在进行压栈。这里的 rbp 又叫栈帧指针(Frame Pointer),是一个存放了当前栈帧位置的寄存器。push rbp 就把之前调用函数,也就是 main 函数的栈帧的栈底地址,压到栈顶。
接着,第 1 行的一条命令 mov rbp, rsp 里,则是把 rsp 这个栈指针(Stack Pointer)的值复制到 rbp 里,而 rsp 始终会指向栈顶。这个命令意味着,rbp 这个栈帧指针指向的地址,变成当前最新的栈顶,也就是 add 函数的栈帧的栈底地址了。
而在函数 add 执行完成之后,又会分别调用第 12 行的 pop rbp 来将当前的栈顶出栈,这部分操作维护好了我们整个栈帧。然后,我们可以调用第 13 行的 ret 指令,这时候同时要把 call 调用的时候压入的 PC 寄存器里的下一条指令出栈,更新到 PC 寄存器中,将程序的控制权返回到出栈后的栈顶。
栈溢出
所以什么是栈溢出,就是我们无限的向这个栈中输入数据,导致栈的内存被用完,就是出现栈溢出现象学,通常会出现在无限的递归,或者递归的层数过深,又或者在栈区存放了大量的数据,都会导致栈溢出,下面程序为无限递归出口递归
int a()
{
return a();
}
int main()
{
a();
return 0;
}
如何使用内联优化
我们在前面说过,如果一个函数中存在另一个函数,就会出现无限的替换的情况,但是当我们的程序中没有那些调用的时候,我们就可以去使用内敛优化的方式,这样就可以减少一些程序的出栈和压栈的内耗,我们在GCC编译的时候,加上一个-O,编译器就会在可行的地方进行优化
内联带来的优化是,CPU 需要执行的指令数变少了,根据地址跳转的过程不需要了,压栈和出栈的过程也不用了。
但是内敛也是会有代价的,我们在程序指令的地方完成展开了,程序占用的空间就会变大
以上是关于详解栈溢出的主要内容,如果未能解决你的问题,请参考以下文章