Chinaunix首页 | 论坛 | 博客
  • 博客访问: 144977
  • 博文数量: 43
  • 博客积分: 264
  • 博客等级: 二等列兵
  • 技术积分: 320
  • 用 户 组: 普通用户
  • 注册时间: 2012-05-25 08:46
文章分类

全部博文(43)

文章存档

2015年(4)

2014年(1)

2012年(38)

分类:

2012-06-15 10:05:33

从汇编的角度深入理解C语言中函数调用的各种细节

C语言中,程序就是由许多的函数调用组成的。因此深入理解函数调用的基本原理,应该是每一个C/C++程序员都要做到的。下面结合反汇编来探讨一下C语言函数调用背后的细节。

1. 要理解C语言中函数调用中得各种细节问题,首先我们必须要理解下面一些汇编指令和知识:
1> push %ebp 将寄存器ebp中得内容压入栈中;
2> pop  %ebp 请注意:这是将栈顶的内容弹出并且将其赋值给寄存器ebp;(并不是将寄存器ebp中得内容弹出来)

3> 而栈是有关寄存器 SSESPEBP 三个寄存器联合起来构成的一个数据结构:
SS :堆栈段寄存器(Stack Segment Register),其值为堆栈段的段值;
ESP EBP 分别是栈指针(Extend Stack Pointer Register)和基指针(Extend Base Pointer Register)。
这两个寄存器共同负责函数的调用和栈的操作。当一个函数被调用的时候,函数需要的参数被陆续压进栈内最后函数的返回地址也被压进。ESP指着栈顶,也就是返回地址。EBP则指着栈的栈底

4> 栈是从高地址向低地址增长的;也就是说 sub    $0x10, %esp 表示在栈上开辟出16字节的空间出来。

5> CS和EIP寄存器联合起来,告诉 CPU 该执行那一条指令:
CS:代码段寄存器(Code Segment Register),其值为代码段的段值;
EIP:Extend Instruction Pointer,有时也叫做 PC(Program Counter);

2. 函数调用的call func 指令的具体过程:
1> 先将函数func所需要的参数从右向左依次压栈,push arg_n, ... push_1,push arg_0,然后将返回地址ret_addr压栈;然后func会在堆栈上为自己函数中的局部变量分配空间,访问传进来的参数进行必要的运算;然后执行返回操作指令ret,ret指令将返回地址ret_addr弹出并赋值给寄存器EIP(pop %EIP)。
2> 返回地址:是指我们的函数调用 call func 指令的接下来一条指令的地址。目的是当函数func执行完成之后,接着 call func 下面的指令继续执行。

3. 实例分析1——没有参数的函数调用
  1. int func()
  2. {
  3.     int i = 1;
  4.     int j = 2;

  5.     return i+j;
  6. }

  7. int main()
  8. {
  9.     int m = func();
  10.     return m;
  11. }
编译:gcc -g -Wall -o func func.c
反汇编:objdump -S -d func
  1. 080483b4 :
  2. int func()
  3. {
  4. 80483b4: 55       push %ebp         # 将ebp中的值压栈保存,后面会恢复
  5. 80483b5: 89 e5    mov %esp,%ebp     # 将esp的值赋给ebp,来作为新栈的“栈基址”
  6. 80483b7: 83 ec 10 sub $0x10,%esp    # 让esp向低地址增长,来为局部变量 m 分配空间,后面会恢复esp的值
  7. int i = 1;
  8. 80483ba: c7 45 f8 01 00 00 00 movl $0x1,-0x8(%ebp) # 为局部变量 i 分配空间
  9. int j = 2;
  10. 80483c1: c7 45 fc 02 00 00 00 movl $0x2,-0x4(%ebp) 为局部变量 j 分配空间
  11. return i+j;
  12. 80483c8: 8b 45 fc mov -0x4(%ebp),%eax   # j 的值赋值给eax,为 i + j 做准备
  13. 80483cb: 8b 55 f8 mov -0x8(%ebp),%edx   # i 的值赋值给edx,为 i + j 做准备
  14. 80483ce: 01 d0 add %edx,%eax            # i + j 
  15. }
  16. 80483d0: c9 leave
  17. 80483d1: c3 ret
  18. 080483d2
    :
  19. int main()
  20. {
  21. 80483d2: 55       push %ebp      # 将ebp中的值压栈保存,后面会恢复ebp的值
  22. 80483d3: 89 e5    mov %esp,%ebp  # 将esp的值赋给ebp,来作为新栈的“栈基址”
  23. 80483d5: 83 ec 10 sub $0x10,%esp # 让esp向低地址增长,来为局部变量m分配空间,后面会恢复esp的值
  24. int m = func();
  25. 80483d8: e8 d7 ff ff ff call 80483b4  # 调用函数func,没有参数要压栈,直接将下一条指令的
  26.                                             # 地址压栈用作返回之用,然后跳到函数func中去
  27. 80483dd: 89 45 fc mov %eax,-0x4(%ebp)       # 将函数func的返回值赋值给m
  28. return m;
  29. 80483e0: 8b 45 fc mov -0x4(%ebp),%eax  # 将m赋值寄存器eax,用于main函数的返回
  30. }
  31. 80483e3: c9 leave
  32. 80483e4: c3 ret
  33. 80483e5: 90 nop
  34. 80483e6: 90 nop
  35. 80483e7: 90 nop
上面反汇编中的注释已经大概的描述了函数调用的过程。
1> 下面我们要说说 leave ret 指令:
leave指令等价于:mov %ebp,%esp 和 push %ebp
leave指令是函数开头的 push %ebp 和 mov %esp,%ebp 逆操作
我们在注释中“# 将ebp中的值压栈保存,后面会恢复ebp的值, 后面会恢复esp的值”,leave指令的作用就是将ebpesp寄存器恢复到函数调用之前的值

我们注意到函数 main 和函数 func 后面的leave指令都是在函数调用体的外面也就是说,堆栈的清理工作是由调用者来负责的

我们在注释中对call func的注释为:
# 调用函数func,没有参数要压栈,直接将下一条指令的地址压栈用作返回之用,然后跳到函数func中去
而ret 指令的作用就是将返回地址从栈中弹出来,赋值给eip。这样函数调用完成之后,就可以继续执行了。
ret指令相当于: pop %eip.

所以下面两队操作是成对的互逆操作,用于完成函数调用的过程
call  <<==>>  ret
push %ebp  mov %esp, %ebp  <<==>>  leave

4. 实例分析2——有参数的函数调用
  1. int func(int i, int j)
  2. {
  3.     int m = i;
  4.     int n = j;

  5.     return m+n;
  6. }

  7. int main()
  8. {
  9.     int a = 1;
  10.     int b = 2;
  11.     int c;

  12.     c = func(a, b);
  13.     return c;
  14. }
编译:gcc -g -Wall -o func func.c
反汇编:objdump -S -d func
  1. 080483b4 :
  2. int func(int i, int j)
  3. {
  4. 80483b4: 55       push %ebp
  5. 80483b5: 89 e5    mov %esp,%ebp
  6. 80483b7: 83 ec 10 sub $0x10,%esp
  7. int m = i;
  8. 80483ba: 8b 45 08 mov 0x8(%ebp),%eax  # 这里将a赋值m是关键,因为压栈了返回地址并执行了指令:
  9.                                       # push %ebp  mov %esp,%ebp 所以 ebp+8 正好访问到
  10.                                       # 栈顶的参数,栈顶的参数也就是a
  11. 80483bd: 89 45 f8 mov %eax,-0x8(%ebp) # 将实参a,通过形参i赋值给局部变量m
  12. int n = j;
  13. 80483c0: 8b 45 0c mov 0xc(%ebp),%eax  # 这里将a赋值m是关键,因为压栈了返回地址并执行了指令:
  14.                                       # push %ebp  mov %esp,%ebp 所以 ebp+14 正好访问到
  15.                                       # 第二个的参数,也就是参数b
  16. 80483c3: 89 45 fc mov %eax,-0x4(%ebp) # 将实参b,通过形参j赋值给局部变量n
  17. return m+n;
  18. 80483c6: 8b 45 fc mov -0x4(%ebp),%eax # 为 m + n 做准备
  19. 80483c9: 8b 55 f8 mov -0x8(%ebp),%edx # 为 m + n 做准备
  20. 80483cc: 01 d0 add %edx,%eax
  21. }
  22. 80483ce: c9 leave
  23. 80483cf: c3 ret
  24. 080483d0
    :
  25. int main()
  26. {
  27. 80483d0: 55       push %ebp
  28. 80483d1: 89 e5    mov %esp,%ebp
  29. 80483d3: 83 ec 18 sub $0x18,%esp
  30. int a = 1;
  31. 80483d6: c7 45 f4 01 00 00 00 movl $0x1,-0xc(%ebp) # 为局部变量a分配空间,将1赋值给a
  32. int b = 2;
  33. 80483dd: c7 45 f8 02 00 00 00 movl $0x2,-0x8(%ebp) 为局部变量b分配空间,将2赋值给b
  34. int c;
  35. c = func(a, b);
  36. 80483e4: 8b 45 f8    mov -0x8(%ebp),%eax    # 注意下面两行从右向左依次压栈参数,先将b压栈
  37. 80483e7: 89 44 24 04 mov %eax,0x4(%esp)
  38. 80483eb: 8b 45 f4    mov -0xc(%ebp),%eax    # 注意下面两行从右向左依次压栈参数,然后将a压栈
  39. 80483ee: 89 04 24    mov %eax,(%esp)
  40. 80483f1: e8 be ff ff ff call 80483b4  # b, a 依次压栈之后,调用函数func
  41. 80483f6: 89 45 fc    mov %eax,-0x4(%ebp)    # 将函数func的返回值赋值给c
  42. return c;
  43. 80483f9: 8b 45 fc    mov -0x4(%ebp),%eax    # main 函数返回 c
  44. }
  45. 80483fc: c9 leave
  46. 80483fd: c3 ret
  47. 80483fe: 90 nop
  48. 80483ff: 90 nop
结论:
1> 调用有参数的函数时,是先依次从右向左压栈各个参数,然后压栈返回地址,然后跳到被调用函数地址去执行;
2> 被调用函数访问传递过来的参数:
被调用函数执行完 push %ebp 和 mov %esp,%ebp 两条指令之后,ebp + 8 和 esp + 8 就可以访问到栈顶的参数,也就是函数最左边的参数,也就是函数的第一个参数。ebp + 12 可以访问到第二个参数......

本来第一个参数应该在栈顶,但是因为压栈了返回地址,和被调用函数中执行了 push %ebp ,所以在被调用函数中访问第一个参数是ebp + 8 和 esp + 8 而不是 esp + 0,被调用函数执行了 sub $0x10,%esp 之后,就只能通过 ebp + 8 来访问传给他的第一个参数了。

3> 到此,我们已经可以完全理解函数调用的整个过程了。

5. 关于C语言中的不定参数
我们知道C中的printf, scanf等函数是不定参数函数,也就是说它们的参数是可以变化的。
比如: printf("i = %d, j = %d\n", 10, 12)或者:printf("%d", 100)
第一个printf有三个参数,第二个printf却只有两个参数。这是怎么实现的呢?
这就是C语言的:从右到左依次传递参数的功效了。
因为C从右向左传递参数,所以 ebp + 8 访问到的总是printf的第一个参数,也就是格式字符串"i = %d, j = %d\n",所以通过分析格式字符串中的 %d, %s, %p 的个数,所以也就可以确定传递给printf函数的参数的个数了

但是,如果是从左向右传递参数,就没有办法找到格式字符串的地址了。






阅读(1429) | 评论(0) | 转发(0) |
给主人留下些什么吧!~~