函数调用栈 剖析+图解[转]


栈:

在函数调用时,第一个进栈的是主函数中函数调用后的下一条指令(函数调用语句的下一条可执行语句)的地址,然后是函数的各个参数,在大多数的C编译器中,参数是由右往左入栈的,然后是函数中的局部变量。注意静态变量是不入栈的。
当本次函数调用结束后,局部变量先出栈,然后是参数,最后栈顶指针指向最开始存的地址,也就是主函数中的下一条指令,程序由该点继续运行。

当发生函数调用的时候,栈空间中存放的数据是这样的:

  • 1、调用者函数把被调函数所需要的参数按照与被调函数的形参顺序相反的顺序压入栈中,即:从右向左依次把被调函数所需要的参数压入栈;
  • 2、调用者函数使用call指令调用被调函数,并把call指令的下一条指令的地址当成返回地址压入栈中(这个压栈操作隐含在call指令中);
  • 3、在被调函数中,被调函数会先保存调用者函数的栈底地址(push ebp),然后再保存调用者函数的栈顶地址,即:当前被调函数的栈底地址(mov ebp,esp);
  • 4、在被调函数中,从ebp的位置处开始存放被调函数中的局部变量和临时变量,并且这些变量的地址按照定义时的顺序依次减小,即:这些变量的地址是按照栈的延伸方向排列的,先定义的变量先入栈,后定义的变量后入栈;

发生函数调用时,入栈的顺序为:

参数N<br /> 参数N-1 <br />参数N-1<br />参数N-2<br />.....<br />参数3<br />参数2<br />参数1<br />函数返回地址<br />上一层调用函数的EBP/BP<br />局部变量1<br />局部变量2<br />....<br />局部变量N<br />

函数调用栈如下图所示:



解释:
//EBP 基址指针,是保存调用者函数的地址,总是指向函数栈栈底,ESP被调函数的指针,总是指向函数栈栈顶。
首 先,将调用者函数的EBP入栈(pushebp),然后将调用者函数的栈顶指针ESP赋值给被调函数的EBP(作为被调函数的栈底,movebp,esp),此时,EBP寄存器处于一个非常重要的位置,该寄存器中存放着一个地址(原EBP入栈后的栈顶),以该地址为基准,向上(栈底方向)能获取返回地址、参数值,向下(栈顶方向)能获取函数的局部变量值,而该地址处又存放着上一层函数调用时的EBP值;
一般规律,SS:[ebp+4]处为被调函数的返回地址,SS:[EBP+8]处为传递给被调函数的第一个参数(最后一个入栈的参数,此处假设其占用4字节内存)的值,SS:[EBP-4]处为被调函数中的第一个局部变量,SS:[EBP]处为上一层EBP值;由于EBP中的地址处总是"上一层函数调用时的EBP值",而在每一层函数调用中,都能通过当时的EBP值"向上(栈底方向)能获取返回地址、参数值,向下(栈顶方向)能获取被调函数的局部变量值";
如此递归,就形成了函数调用栈;
Eg函数内局部变量布局示例:

#include <stdio.h>
#include <string.h>
struct C
{
  int a;
  int b;
  int c;
};
int test2(int x, int y, int z)
{
  printf("hello,test2\n");
  return 0;
}
int test(int x, int y, int z)
{
  int a = 1;
  int b = 2;
  int c = 3;
  struct C st;
  printf("addr x = %u\n",(unsigned int)(&x));
  printf("addr y = %u\n",(unsigned int)(&y));
  printf("addr z = %u\n",(unsigned int)(&z));
  printf("addr a = %u\n",(unsigned int)(&a));
  printf("addr b = %u\n",(unsigned int)(&b));
  printf("addr c = %u\n",(unsigned int)(&c));
  printf("addr st = %u\n",(unsigned int)(&st));
  printf("addr st.a = %u\n",(unsigned int)(&st.a));
  printf("addr st.b = %u\n",(unsigned int)(&st.b));
  printf("addr st.c = %u\n",(unsigned int)(&st.c));
  return 0;
} int main(int argc, char** argv)
{
  int x = 1;
  int y = 2;
  int z = 3;
  test(x,y,z);
  printf("x = %d; y = %d; z = %d;\n", x,y,z);
  memset(&y, 0, 8);
  printf("x = %d; y = %d; z = %d;\n", x,y,z);
  return 0;
}

打印输出如下:

addr x = 3220024704
addr y = 3220024708
addr z = 3220024712
addr a = 3220024684
addr b = 3220024680
addr c = 3220024676
addr st = 3220024664
addr st.a = 3220024664
addr st.b = 3220024668
addr st.c = 3220024672
x = 1; y = 2; z = 3;
x = 0; y = 0; z = 3;

局部变量在栈中布局示意图:


该图中的局部变量都是在该示例中定义的:

这个图片中反映的是一个典型的函数调用栈的内存布局;
<div style="color:blue">访问函数的局部变量和访问函数参数的区别:

局部变量总是通过将ebp减去偏移量来访问,函数参数总是通过将ebp加上偏移量来访问。对于32位变量而言,第一个局部变量位于ebp-4,第二个位于ebp-8,以此类推,32位局部变量在栈中形成一个逆序数组;第一个函数参数位于ebp+8,第二个位于ebp+12,以此类推,32位函数参数在栈中形成一个正序数组。</div>
Eg、研究函数调用过程:


#include <stdio.h>

int bar(int c,int d)
{
        int e=c+d;
        return e;
}

int foo(int a,int b)
{
        return bar(a,b);
}

int main(int argc,int argv)
{
        foo(2,3);
        return 0;
}

上面是一个很简单的函数调用过程,整个程序的执行过程是main调用foo,foo调用bar。
//查看反汇编文件(要查看编译后的汇编代码,其实还有一种办法是gcc -S text_stack.c,这样只生成汇编代码text_stack.s,而不生成二进制的目标文件。)

root@wangye:/home/wangye# gcc text_stack.c -g
root@wangye:/home/wangye# objdump -dS a.out 

反汇编结果很长,下面只列出我们关心的部分。


08048394 <bar>:
#include <stdio.h>

int bar(int c,int d)
{
 8048394:   55                      push   %ebp
 8048395:   89 e5                   mov    %esp,%ebp
 8048397:   83 ec 10                sub    $0x10,%esp
    int e=c+d;
 804839a:   8b 45 0c                mov    0xc(%ebp),%eax
 804839d:   8b 55 08                mov    0x8(%ebp),%edx
 80483a0:   8d 04 02                lea    (%edx,%eax,1),%eax
 80483a3:   89 45 fc                mov    %eax,-0x4(%ebp)
    return e;
 80483a6:   8b 45 fc                mov    -0x4(%ebp),%eax
}
 80483a9:   c9                      leave  
 80483aa:   c3                      ret    

080483ab <foo>:

int foo(int a,int b)
{
 80483ab:   55                      push   %ebp
 80483ac:   89 e5                   mov    %esp,%ebp
 80483ae:   83 ec 08                sub    $0x8,%esp
    return bar(a,b);
 80483b1:   8b 45 0c                mov    0xc(%ebp),%eax
 80483b4:   89 44 24 04             mov    %eax,0x4(%esp)
 80483b8:   8b 45 08                mov    0x8(%ebp),%eax
 80483bb:   89 04 24                mov    %eax,(%esp)
 80483be:   e8 d1 ff ff ff          call   8048394 <bar>
}
 80483c3:   c9                      leave  
 80483c4:   c3                      ret    

080483c5 <main>:

int main(int argc,int argv)
{
 80483c5:   55                      push   %ebp
 80483c6:   89 e5                   mov    %esp,%ebp
 80483c8:   83 ec 08                sub    $0x8,%esp
    foo(2,3);
 80483cb:   c7 44 24 04 03 00 00    movl   $0x3,0x4(%esp)
 80483d2:   00 
 80483d3:   c7 04 24 02 00 00 00    movl   $0x2,(%esp)
 80483da:   e8 cc ff ff ff          call   80483ab <foo>
    return 0;
 80483df:   b8 00 00 00 00          mov    $0x0,%eax
}

//我们用gdb跟踪程序的执行,直到bar函数中的int e = c + d;语句执行完毕准备返回时,这时在gdb中打印函数栈帧。

wangye@wangye:~$ gdb text_stack 
GNU gdb (GDB) 7.0.1-debian
Copyright (C) 2009 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "i486-linux-gnu".
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>...
Reading symbols from /home/wangye/text_stack...done.
(gdb) start
Temporary breakpoint 1 at 0x80483cb: file text_stack.c, line 16.
Starting program: /home/wangye/text_stack 

Temporary breakpoint 1, main (argc=1, argv=-1073744732) at text_stack.c:16
16      foo(2,3);
(gdb) s
foo (a=2, b=3) at text_stack.c:11
11      return bar(a,b);
(gdb) s
bar (c=2, d=3) at text_stack.c:5
5       int e=c+d;
(gdb) disassemble 
Dump of assembler code for function bar:
0x08048394 <bar+0>: push   %ebp
0x08048395 <bar+1>: mov    %esp,%ebp
0x08048397 <bar+3>: sub    $0x10,%esp
0x0804839a <bar+6>: mov    0xc(%ebp),%eax
0x0804839d <bar+9>: mov    0x8(%ebp),%edx
0x080483a0 <bar+12>:    lea    (%edx,%eax,1),%eax
0x080483a3 <bar+15>:    mov    %eax,-0x4(%ebp)
0x080483a6 <bar+18>:    mov    -0x4(%ebp),%eax
0x080483a9 <bar+21>:    leave  
0x080483aa <bar+22>:    ret    
End of assembler dump.
(gdb) si
0x0804839d  5       int e=c+d;
(gdb) si
0x080483a0  5       int e=c+d;
(gdb) si
0x080483a3  5       int e=c+d;
(gdb) si
6       return e;
(gdb) si
7   }
(gdb) bt
#0  bar (c=2, d=3) at text_stack.c:7
#1  0x080483c3 in foo (a=2, b=3) at text_stack.c:11
#2  0x080483df in main (argc=1, argv=-1073744732) at text_stack.c:16
(gdb) info re
record     registers  
(gdb) info regi
eax            0x5  5
ecx            0x4c2f5d43   1278172483
edx            0x2  2
ebx            0xb7fcaff4   -1208176652
esp            0xbffff3c8   0xbffff3c8
ebp            0xbffff3d8   0xbffff3d8
esi            0x0  0
edi            0x0  0
eip            0x80483a9    0x80483a9 <bar+21>
eflags         0x282    [ SF IF ]
cs             0x73 115
ss             0x7b 123
ds             0x7b 123
es             0x7b 123
fs             0x0  0
gs             0x33 51
(gdb) info regi
eax            0x5  5
ecx            0x4c2f5d43   1278172483
edx            0x2  2
ebx            0xb7fcaff4   -1208176652
esp            0xbffff3c8   0xbffff3c8
ebp            0xbffff3d8   0xbffff3d8
esi            0x0  0
edi            0x0  0
eip            0x80483a9    0x80483a9 <bar+21>
eflags         0x282    [ SF IF ]
cs             0x73 115
ss             0x7b 123
ds             0x7b 123
es             0x7b 123
fs             0x0  0
gs             0x33 51
(gdb) x/20 $esp
0xbffff3c8: -1073744904 134513689   -1208175868 5
0xbffff3d8: -1073744920 134513603   2   3
0xbffff3e8: -1073744904 134513631   2   3
0xbffff3f8: -1073744776 -1209406298 1   -1073744732
0xbffff408: -1073744724 -1208084392 -1073744800 -1

这里我们又用了几个新的gdb命令,简单解释一下:info registers可以显示所有寄存器的当前值。在gdb中表示寄存器名时前面要加个$,例如p $esp可以打印esp寄存器的值,在上例中esp寄存器的值是0xbffff3c8,所以x/20 $esp命令查看内存中从0xbffff3c8 地址开始的20个32位数。在执行程序时,操作系统为进程分配一块栈空间来保存函数栈帧,esp寄存器总是指向栈顶,在x86平台上这个栈是从高地址向低地址增长的,我们知道每次调用一个函数都要分配一个栈帧来保存参数和局部变量,现在我们详细分析这些数据在栈空间的布局,根据gdb的输出结果图示如下:



图中每个小方格表示4个字节的内存单元,例如b: 3这个小方格占的内存地址是0xbffff3f4~0xbffff3f7,把地址写在每个小方格的下边界线上,是为了强调该地址是内存单元的起始地址。我们从main函数的这里开始看起:

foo(2,3);  
80483cb:    c7 44 24 04 03 00 00    movl   $0x3,0x4(%esp)  
80483d2:    00   
80483d3:    c7 04 24 02 00 00 00    movl   $0x2,(%esp)  
80483da:    e8 cc ff ff ff          call   80483ab <foo>  
return 0;  
80483df:    b8 00 00 00 00          mov    $0x0,%eax  

要调用函数foo先要把参数准备好,第二个参数保存在esp+4指向的内存位置,第一个参数保存在esp指向的内存位置,可见参数是从右向左依次压栈的。然后执行call指令,这个指令有两个作用:

  • foo函数调用完之后要返回到call的下一条指令继续执行,所以把call的下一条指令的地址134513631压栈,同时把esp的值减4,esp的值现在是0xbffff3ec。
  • 修改程序计数器eip,跳转到foo函数的开头执行。

现在看foo函数的汇编代码:

080483ab <foo>:  
  
int foo(int a,int b)  
{  
 80483ab:   55                      push   %ebp  
 80483ac:   89 e5                   mov    %esp,%ebp  
 80483ae:   83 ec 08                sub    $0x8,%esp  

push %ebp指令把ebp寄存器的值压栈,同时把esp的值减4。esp的值现在是0xbff1c414,下一条指令把这个值传送给ebp寄存器。这两条指令合起来是把原来ebp的值保存在栈上,然后又给ebp赋了新值。在每个函数的栈帧中,ebp指向栈底,而esp指向栈顶,在函数执行过程中esp随着压栈和出栈操作随时变化,而ebp是不动的,函数的参数和局部变量都是通过ebp的值加上一个偏移量来访问,例如foo函数的参数a和b分别通过ebp+8和ebp+12来访问。所以下面的指令把参数a和b再次压栈,为调用bar函数做准备,然后把返回地址压栈,调用bar函数:

return bar(a,b);  
 80483b1:   8b 45 0c                mov    0xc(%ebp),%eax  
 80483b4:   89 44 24 04             mov    %eax,0x4(%esp)  
 80483b8:   8b 45 08                mov    0x8(%ebp),%eax  
 80483bb:   89 04 24                mov    %eax,(%esp)  
 80483be:   e8 d1 ff ff ff          call   8048394 <bar>  
}  
 80483c3:   c9                      leave    
 80483c4:   c3                      ret   

现在看bar函数的指令:

int bar(int c,int d)  
{  
 8048394:   55                      push   %ebp  
 8048395:   89 e5                   mov    %esp,%ebp  
 8048397:   83 ec 10                sub    $0x10,%esp  
    int e=c+d;  
 804839a:   8b 45 0c                mov    0xc(%ebp),%eax  
 804839d:   8b 55 08                mov    0x8(%ebp),%edx  
 80483a0:   8d 04 02                lea    (%edx,%eax,1),%eax  
 80483a3:   89 45 fc                mov    %eax,-0x4(%ebp)  

这次又把foo函数的ebp压栈保存,然后给ebp赋了新值,指向bar函数栈帧的栈底,通过ebp+8和ebp+12分别可以访问参数c和d。bar函数还有一个局部变量e,可以通过ebp-4来访问。所以后面几条指令的意思是把参数c和d取出来存在寄存器中做加法,计算结果保存在eax寄存器中,再把eax寄存器存回局部变量e的内存单元。
在gdb中可以用bt命令和frame命令查看每层栈帧上的参数和局部变量,现在可以解释它的工作原理了:如果我当前在bar函数中,我可以通过ebp找到bar函数的参数和局部变量,也可以找到foo函数的ebp保存在栈上的值,有了foo函数的ebp,又可以找到它的参数和局部变量,也可以找到main函数的ebp保存在栈上的值,因此各层函数栈帧通过保存在栈上的ebp的值串起来了。
现在看bar函数的返回指令:

return e;  
 80483a6:   8b 45 fc                mov    -0x4(%ebp),%eax  
}  
 80483a9:   c9                      leave    
 80483aa:   c3                      ret   

bar函数有一个int型的返回值,这个返回值是通过eax寄存器传递的,所以首先把e的值读到eax寄存器中。然后执行leave指令,这个指令是函数开头的push %ebp和mov %esp,%ebp的逆操作:

  • 把ebp的值赋给esp,现在esp的值是0xbffff3d8。
  • 现在esp所指向的栈顶保存着foo函数栈帧的ebp,把这个值恢复给ebp,同时esp增加4,esp的值变成0xbffff3dc。

最后是ret指令,它是call指令的逆操作:

  • 现在esp所指向的栈顶保存着返回地址,把这个值恢复给eip,同时esp增加4,esp的值变成0xbffff3e0。
  • 修改了程序计数器eip,因此跳转到返回地址0x80483c2继续执行。

地址0x80483c2处是foo函数的返回指令:

80483c3:    c9                      leave    
80483c4:    c3                      ret   

重复同样的过程,又返回到了main函数。注意函数调用和返回过程中的这些规则:

  • 参数压栈传递,并且是从右向左依次压栈。
  • ebp总是指向当前栈帧的栈底。
  • 返回值通过eax寄存器传递。

这些规则并不是体系结构所强加的,ebp寄存器并不是必须这么用,函数的参数和返回值也不是必须这么传,只是操作系统和编译器选择了以这样的方式实现C代码中的函数调用,这称为CallingConvention,Calling Convention是操作系统二进制接口规范(ABI,Application BinaryInterface)的一部分。

推荐阅读更多精彩内容

  • 原文地址:C语言函数调用栈(一)C语言函数调用栈(二) 0 引言 程序的执行过程可看作连续的函数调用。当一个函数执...
    小猪啊呜阅读 2,928评论 1 18
  • 首先寄存器使用惯例:eip :指令地址寄存器,保存程序计数器的值,当前执行的指令的下一条指令的地址值,16位中为i...
    扎Zn了老Fe阅读 716评论 0 0
  • 一、温故而知新 1. 内存不够怎么办 内存简单分配策略的问题地址空间不隔离内存使用效率低程序运行的地址不确定 关于...
    SeanCST阅读 4,667评论 0 24
  • 堆栈是连续的地址空间,且向低地址端生长。 esp 是堆栈指针ebp 是基址指针那两条指令的意思是将栈顶指向ebp的...
    wyrover阅读 301评论 0 1
  • 孤独的夜 冷清的街 一杯寂寞酒 伤情的心 流下的泪 苦涩尝一口 今天的我 明天的你 从此说分手
    哑哑溪阅读 64评论 5 2