函数调用:为什么会发生 stackoverflow?
为什么我们需要程序栈?
- 函数调用和 if…else 和 for/while 循环有点像。
- 它们两个都是在原来顺序执行的指令过程里,执行了一个内存地址的跳转指令,让指令从原来顺序执行的过程里跳开,从新的跳转后的位置开始执行。
- 但是,这两个跳转有个区别,if…else 和 for/while 的跳转,是跳转走了就不再回来了,就在跳转后的新地址开始顺序地执行指令。
- 函数调用的跳转,在对应函数的指令执行完了之后,还要再回到函数调用的地方,继续执行 call 之后的指令。
- 我们在内存里面开辟一段空间,用栈这个后进先出(LIFO,Last In First Out)的数据结构。
- 栈就像一个乒乓球桶,每次程序调用函数之前,我们都把调用返回后的地址写在一个乒乓球上,然后塞进这个球桶,这个操作其实就是我们常说的压栈。
- 如果函数执行完了,我们就从球桶里取出最上面的那个乒乓球,很显然,这就是出栈。
- 拿到出栈的乒乓球,找到上面的地址,把程序跳转过去,就返回到了函数调用后的下一条指令了。
- 如果函数 A 在执行完成之前又调用了函数 B,那么在取出乒乓球之前,我们需要往球桶里塞一个乒乓球。
- 而我们从球桶最上面拿乒乓球的时候,拿的也一定是最近一次的,也就是最下面一层的函数调用完成后的地址。
- 乒乓球桶的底部,就是栈底,最上面的乒乓球所在的位置,就是栈顶。
- 在真实的程序里,压栈的不只有函数调用完成后的返回地址。
- 比如函数A在调用B的时候,需要传输一些参数数据,这些参数数据在寄存器不够用的时候也会被压入栈中。
- 整个函数A所占用的所有内存空间,就是函数A的栈帧(Stack Frame)。
- 实际的程序栈布局,顶和底与我们的乒乓球桶相比是倒过来的。
- 底在最上面,顶在最下面,这样的布局是因为栈底的内存地址是在一开始就固定的。
- 而一层层压栈之后,栈顶的内存地址是在逐渐变小而不是变大。
如何构造一个 stack overflow?
- 通过引入栈,无论有多少层的函数调用,或者在函数 A 里调用函数 B,再在函数 B 里调用 A,这样的递归调用,我们都只需要通过维持 rbp 和 rsp,这两个维护栈顶所在地址的寄存器,就能管理好不同函数之间的跳转。不过,栈的大小也是有限的。如果函数调用层数太多,我们往栈里压入它存不下的内容,程序在执行的过程中就会遇到栈溢出的错误,这就是大名鼎鼎的“stack overflow”。
如何利用函数内联进行性能优化?
- 把一个实际调用的函数产生的指令,直接插入到相应的位置,来替换对应的函数调用指令。
- 如果被调用的函数里,没有调用其他函数,这个方法还是可以行得通的。
- 事实上,这就是一个常见的编译器进行自动优化的场景,我们通常叫函数内联(Inline)。
- 我们只要在 GCC 编译的时候,加上对应的一个让编译器自动优化的参数 -O,编译器就会在可行的情况下,进行这样的指令替换。
- 除了依靠编译器的自动优化,你还可以在定义函数的地方,加上 inline 的关键字,来提示编译器对函数进行内联。
- 内联带来的优化是,CPU 需要执行的指令数变少了,根据地址跳转的过程不需要了,压栈和出栈的过程也不用了。
- 不过内联并不是没有代价,内联意味着,我们把可以复用的程序指令在调用它的地方完全展开了。
- 如果一个函数在很多地方都被调用了,那么就会展开很多次,整个程序占用的空间就会变大了。
- 这样没有调用其他函数,只会被调用的函数,我们一般称之为叶子函数(或叶子过程)。