2 构造Stack Overflow
通过引入栈,我们可以看到,无论有多少层的函数调用,或者在函数A里调用函数B,再在函数B里调用A
这样的递归调用,我们都只需要通过维持rbp和rsp,这两个维护栈顶所在地址的寄存器,就能管理好不同函数之间的跳转
不过,栈的大小也是有限的。如果函数调用层数太多,我们往栈里压入它存不下的内容,程序在执行的过程中就会遇到栈溢出的错误,这就是stack overflow
构造一个栈溢出的错误
并不困难,最简单的办法,就是我们上面说的Infiinite Mirror Effect的方式,让函数A调用自己,并且不设任何终止条件
这样一个无限递归的程序,在不断地压栈过程中,将整个栈空间填满,并最终遇上stack overflow。
int a() { return a(); } int main() { a(); return 0; }
除了无限递归,递归层数过深,在栈空间里面创建非常占内存的变量(比如一个巨大的数组),这些情况都很可能给你带来stack overflow
相信你理解了栈在程序运行的过程里面是怎么回事,未来在遇到*这个错误的时候,不会完全没有方向了。
3 利用函数内联实现性能优化
上面我们提到一个方法,把一个实际调用的函数产生的指令,直接插入到的位置,来替换对应的函数调用指令。尽管这个通用的函数调用方案,被我们否决了,但是如果被调用的函数里,没有调用其他函数,这个方法还是可以行得通的。
事实上,这就是一个常见的编译器进行自动优化的场景,我们通常叫函数内联(Inline)
只要在GCC编译的时候,加上对应的一个让编译器自动优化的参数-O,编译器就会在可行的情况下,进行这样的指令替换。
案例
为了避免编译器优化掉太多代码,小小修改了一下function.c,让参数x和y都变成了,通过随机数生成,并在代码的最后加上将u通过printf打印
[hadoop@JavaEdge Documents]$ vim function.c [hadoop@JavaEdge Documents]$ gcc -g -c -O function.c [hadoop@JavaEdge Documents]$ objdump -d -M intel -S function.o function.o: file format elf64-x86-64 Disassembly of section .text: 0000000000000000 <main>: { return a+b; } int main() { 0: 53 push rbx 1: bf 00 00 00 00 mov edi,0x0 6: e8 00 00 00 00 call b <main+0xb> b: 89 c7 mov edi,eax d: e8 00 00 00 00 call 12 <main+0x12> 12: e8 00 00 00 00 call 17 <main+0x17> 17: 89 c3 mov ebx,eax 19: e8 00 00 00 00 call 1e <main+0x1e> 1e: 89 c1 mov ecx,eax 20: bf 67 66 66 66 mov edi,0x66666667 25: 89 d8 mov eax,ebx 27: f7 ef imul edi 29: d1 fa sar edx,1 2b: 89 d8 mov eax,ebx 2d: c1 f8 1f sar eax,0x1f 30: 29 c2 sub edx,eax 32: 8d 04 92 lea eax,[rdx+rdx*4] 35: 29 c3 sub ebx,eax 37: 89 c8 mov eax,ecx 39: f7 ef imul edi 3b: c1 fa 02 sar edx,0x2 3e: 89 d7 mov edi,edx 40: 89 c8 mov eax,ecx 42: c1 f8 1f sar eax,0x1f 45: 29 c7 sub edi,eax 47: 8d 04 bf lea eax,[rdi+rdi*4] 4a: 01 c0 add eax,eax 4c: 29 c1 sub ecx,eax #include <time.h> #include <stdlib.h> int static add(int a, int b) { return a+b; 4e: 8d 34 0b lea esi,[rbx+rcx*1] { srand(time(NULL)); int x = rand() % 5; int y = rand() % 10; int u = add(x, y); printf("u = %d\n", u); 51: bf 00 00 00 00 mov edi,0x0 56: b8 00 00 00 00 mov eax,0x0 5b: e8 00 00 00 00 call 60 <main+0x60> 60: b8 00 00 00 00 mov eax,0x0 65: 5b pop rbx 66: c3 ret
上面的function.c的编译出来的汇编代码,没有把add函数单独编译成一段指令顺序,而是在调用u = add(x, y)的时候,直接替换成了一个add指令。
除了依靠编译器的自动优化,你还可以在定义函数的地方,加上inline的关键字,来提示编译器对函数进行内联。
内联带来的优化是,CPU需要执行的指令数变少了,根据地址跳转的过程不需要了,压栈和出栈的过程也不用了。
不过内联并不是没有代价,内联意味着,我们把可以复用的程序指令在调用它的地方完全展开了。如果一个函数在很多地方都被调用了,那么就会展开很多次,整个程序占用的空间就会变大了。
这样没有调用其他函数,只会被调用的函数,我们一般称之为叶子函数(或叶子过程)
3 总结
这一节,我们讲了一个程序的函数间调用,在CPU指令层面是怎么执行的。其中一定需要你牢记的,就是程序栈这个新概念。
我们可以方便地通过压栈和出栈操作,使得程序在不同的函数调用过程中进行转移。而函数内联和栈溢出,一个是我们常常可以选择的优化方案,另一个则是我们会常遇到的程序Bug。
通过加入了程序栈,我们相当于在指令跳转的过程种,加入了一个“记忆”的功能,能在跳转去运行新的指令之后,再回到跳出去的位置,能够实现更加丰富和灵活的指令执行流程。这个也为我们在程序开发的过程中,提供了“函数”这样一个抽象,使得我们在软件开发的过程中,可以复用代码和指令,而不是只能简单粗暴地复制、粘贴代码和指令。
4 推荐阅读
可以仔细读一下《深入理解计算机系统(第三版)》的3.7小节《过程》,进一步了解函数调用是怎么回事。
另外,我推荐你花一点时间,通过搜索引擎搞清楚function.c每一行汇编代码的含义,这个能够帮你进一步深入了解程序栈、栈帧、寄存器以及Intel CPU的指令集。
参考
深入浅出计算机组成原理