(八)函数调用为何会发生“Stack Overflow”
一、一次函数调用分析
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); }
编译并objdump:
$ 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
分析:
- 可以看到main方法和add方法的入口处14行和0行都有一个push操作,紧接着是一个mov操作将rsp赋值给rbp,rbp正是上一步push的参数;
- 同时可以看到在add方法的结尾有一个pop指令,参数正是函数开头push的参数rbp;
- add函数和main函数结尾都有一个ret指令;
- main方法中1c行到32行
- 首先对两个内存地址分别赋值5和10
- 将刚刚赋值过的两个内存地址的值再次赋值给edx和eda
- 将dex和eda的值分别赋给esi和edi
- main函数34行执行了call指令,参数是add函数;
实际上PUSH指令就表示压栈,POP就表示出栈。其实上述的调用过程和if/else的指令都进行了指令地址跳转,区别在于通过if/else跳转后,指令顺序执行到下一行,而call命令执行的跳转函数完成后还会调回call命令下一行,这是如何实现的呢?
程序栈说明:
我们自己思考这个功能该如何实现呢?
第一种方法,我们可以将add方法的指令直接拼接到main方法指令行的对应位置,这个方法的问题是,假如main方法和add相互调用(循环调用)那么不断拼接无穷无尽,显然不行。
第二种方法,我们可以单独设置一个程序执行寄存器,每次函数执行都记录该函数执行需要返回的位置,这样等该函数执行完成,直接返回到记录的返回位置即可,但是在多级函数调用情况下,只记录一个跳转机制远远不够,因此计算机科学家实际使用了一种更好的办法;
实际栈结构:

- 在内存中开辟一块区域保存先进后出的栈结构来记录函数调用的返回地址
- A函数调用B函数时,在进入B函数后,首先将A函数调用B函数的下一行指令地址放入栈中,此之谓压栈,假若B函数又调用了C函数,就又在C函数一开始将B调用C的下一行指令地址压入栈顶
- C函数执行完成,从栈顶拿出要返回的地址并将指令跳转回该地址,此之谓出栈,随之B函数执行完成,此时又从栈中取出栈顶存放的指令地址并跳转执行;
- 如此,调用层级越深的函数所记录的跳转地址越接近于栈顶,如此弹栈过程遍类似于反向遍历;
汇编代码的表现:
- add方法0行,rbp表示的是当前栈的指针,此时push rbp表示将main函数栈帧的栈底地址压入了程序栈顶;
- 紧接着1行,mov rbp,rsp 将rsp的值赋给rbp,而rsp是一个始终指向栈顶的参数,此时rbp指向栈顶;
- add末尾pop rbp表示弹栈,将当前的栈顶出栈,由此维护好了栈;
- 由于call调用时PC寄存器将返回地址压栈,最后执行ret指令时将call指令压入栈顶的返回地址弹栈并替换到PC寄存器中,以此完成指令跳转。
二、构造一次Stack Overflow
由于栈的大小有限,因此当调用层级太多时,会由于压栈造成栈空间不足以致溢出,典型场景为不加限制的递归调用:
int a() { return a(); } int main() { a(); return 0; }
三、使用函数内联进行性能优化
程序编译时将实际函数调用产生的指令直接插入到调用位置,来替换对应的函数调用指令,称为函数内联。
这样做的好处是:减少了指令数、减少了函数调用时的压栈弹栈开销;
但是当目标函数被引用很多次时会多次展开,使得程序占用空间变得很大。
南山马

浙公网安备 33010602011771号