stack overflow 就是栈溢出 函数间的互相调用,在计算机指令层面是怎么实现的,以及生命状况下会产生栈溢出这个错误编程
和前面几讲同样,咱们仍是从一个很是简单的C程序function_example.c 看起。数组
// 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); }
一、这个程序定义了一个简单的函数 add,接受两个参数 a 和 b,sass
二、返回值就是 a+b性能优化
三、main函数里则定义了两个变量 x 和 y,而后经过调用这个 add函数,来计算 u=x+ybash
四、最后把 u 的数值打印出来。数据结构
gcc -g -c function_example.c $ objdump -d -M intel -S function_example.o
[root@luoahong c]# objdump -d -M intel -S function_example.o function_example.o: file format elf64-x86-64 Disassembly of section .text: 0000000000000000 <add>: #include <stdio.h> 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 45 f8 mov eax,DWORD PTR [rbp-0x8] d: 8b 55 fc mov edx,DWORD PTR [rbp-0x4] 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: c9 leave 3d: c3 ret
能够看出来、在这段代码里,main 函数和上一节咱们讲的的程序执行区别并不大,它主要是把 jump 指令换成了函数调用的 call 指令。call 指令后面跟着的,仍然是跳转后的程序地址。函数
他们两个都是在原来顺序执行的指令过程里,执行一个内存地址的跳转指令、让指令从原来顺序执行的过程里跳开。重新的跳转后的位置开始执行布局
if…else 和 for/while 的跳转,是跳转走了就再也不回来,就在跳转后的新地址开始顺序地执行指令,就像徐志摩在《再别康桥》里写的“我挥一挥衣袖,不带走一片云彩”继续进行新的生活性能
函数调用的跳转,在对应函数的指令执行问了以后,还要再回到函数调用的地方,继续执行call以后的指令,就好像贺知章在《回乡偶书》里面写的那样:“少小离家老大回,乡音未改鬓毛衰”,无论走多远,最终仍是要回来测试
那么有没有一个能够不跳转回到原来开始的地方,替换掉对应的call指令,而后在编译器编译代码的时候,直接把函数调用编程对应的指令替换掉
若是函数 A 调用了函数 B,而后函数 B 再调用函数 A,咱们就得面临在 A 里面插入 B 的指令,而后在在 B 里面插入 A 的指令,这样就会产生无穷无尽地替换
就好像两面镜子面对面放在一起,任何一面镜子里面都会看到无穷多面镜子。
能不能把后面要调回来执行的指令地址给记录下来呢?就像前面PC寄存器同样,咱们能够专门设立一个“程序调用寄存器”,来存储接下来要跳转回来执行的
指令地址,等到函数调用结束,从这个寄存器里取出地址,再跳转到这个记录的地址、继续执行就行了
一、简单只记录一个地址是不够的
A-B-C 这一层一层的调用并无数量上的限制,在全部函数调用返回以前,每一次调用的返回地址都要记录下来,可是咱们的CPU寄存器数量并很少,想咱们呢使用的Intel i7 CPU 只有 16 个 64 位寄存器,调用的层数一多存不下了
最终,计算机科学家们想到了一个比单独记录跳转回来的地址更完善的办法,咱们在内存里面开辟一段空间,用栈这个后进先出的数据结构,栈就像一个乒乓球的桶后进先出的数据结构
一、什么是压栈
每次程序调用函数以前,咱们都把调用返回后的地址写在一个乒乓球上,而后塞进这个球桶。这个操做其实就是咱们常说的压栈
二、什么是出栈
若是函数执行完了,咱们就从球桶里取出最上面的那个乒乓球,很显然,这就是出栈
三、什么是栈底
拿到出栈的乒乓球,找到上面的地址,把程序跳转过去,就返回到了函数调用后的下一条指令了。若是函数 A 在执行完成以前又调用了函数 B,那么在取出乒乓球以前,咱们须要往球桶里塞一个乒乓球。
而咱们从球桶最上面拿乒乓球的时候,拿的也必定是最近一次的,也就是最下面一层的函数调用完成后的地址。乒乓球桶的底部,就是栈底
四、什么是栈顶
最上面的乒乓球所在的位置,就是栈顶。
五、什么是栈帧
在真实的程序里,压栈的不仅有函数调用完成后的返回地址,好比函数A在调用B的时候,须要传输一些参数数据,这些参数数据在寄存器不够用的时候也会压入栈中。整个函数A所占用的全部空间,就是函数A的栈帧
而实际的程序栈布局,顶和底与咱们的乒乓球桶相比是倒过来的,底在最上面,顶在最下面,这样的布局是由于栈底的内存地址是一开始就固定的,而一层层压栈以后,栈顶的内存地址是在逐渐变小而不断变大
一、对应上面函数 add 的汇编代码,咱们来仔细看看,main函数调用 add 函数时,add 函数入口在 0~1 行,add 函数结束以后在 12~13 行。
二、咱们在调用第 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 寄存器中,将程序的控制权返回到出栈后的栈顶。
经过引入栈,咱们能够看到,不管有多少层的函数调用,或者在函数A 里调用函数 B,再在函数B 里调用 A,这样的递归调用,咱们都只须要经过维持 和 rsp,这两个维护栈顶所在地址的寄存器,
就能管理好不一样函数之间的跳转。不过,栈的大小也是有限的。若是函数调用层数太多,咱们往栈里压入它存不下的内容,程序在执行的过程当中就会遇到栈溢出的错误,这就是大名鼎鼎的“stack overflow”。
要构造一个栈溢出的错误并不困难,最简单的办法,就是咱们上面说的 Infiinite Mirror Effect的方式,让函数 A 调用本身,而且不设任何终止条件。这样一个无限递归的程序,在不断地压栈过程当中,将整个栈空间填满并最终赶上 stack overflow。
int a() { return a(); } int main() { a(); return 0; }
一、无线递归
二、递归层数过深
三、巨大的数组(在栈空间里面建立很是占内存的变量)
咱们只要在GCC编译的时候,加上对应的一个让编译器自动化的参数-O,编译器就会再可行的状况下、进行这样的指令替换
#include <stdio.h> #include <time.h> #include <stdlib.h> int static add(int a, int b) { return a+b; } int main() { srand(time(NULL)); int x = rand() % 5 int y = rand() % 10; int u = add(x, y) printf("u = %d\n", u) }
为了不编译器优化掉太多的代码,我小小修改了一下function_example.c,让参数x和y都变成了,经过随机函数生成,并在代码的最后机上讲u经过printf打印出来的语句
gcc -g -c -O function_example_inline.c $ objdump -d -M intel -S function_example_inline.o
上面的 function_example_inline.c 的编译出来的汇编代码,没有把 add 函数单独编译成一段指令顺序,而是在调用 u = add(x, y) 的时候,直接替换成了一个 add 指令。
return a+b; 4c: 01 de add esi,ebx
除了依靠编译器的自动优化,还以在定义函数的地方,加上inline的关键字,来提示编译器对函数进行内联
内联带来的优化是,CPU须要执行的指令数变少了,根据地址跳转的过程不须要了,压栈和出栈的过程也不用了
不过内联并非没有代价,内联意味着,咱们把能够服用的程序指令在调用它的地方彻底展开了,若是一个函数在不少地方被调用了,那么久会展开不少次,整个程序占用的空间就会打了
这样没有调用其余函数,只会被调用的函数,咱们通常称之为叶子函数(或叶子过程)
这一节,咱们讲了一个程序的函数调用,在CPU指令层面是怎么执行的,其中必定须要牢记的就是程序栈这个新概念
咱们能够方便地经过压栈和出栈操做,使得程序在不一样的函数调用过程当中进行转移,而函数内联和溢出,
一个是咱们经常能够选择优化方案,另外一个则是咱们会唱遇到的成Bug
经过加入程序栈,咱们至关于在指令跳转的过程当中,加入一个“记忆“的功能,能在跳转去运行新的指令以后,再回到跳出去的位置,
可以实现更加丰富和灵活的指令执行流程。这个也为咱们在程序开发过程当中,提供了“函数”这样一个抽象,使得咱们在软件开发的过程当中,
能够复用代码和指令,而不是只是简单粗暴地复制、粘贴和指令