c语言程序运行时的栈与寄存器的变化

原创作品转载请注明出处

参考材料 《Linux内核分析》 MOOC课程http://mooc.study.163.com/course/USTC-1000029000 ”

作者:Casualet

 

我们在这里从汇编代码的角度, 给出一段简单的C语言程序运行过程中机器状态的变化情况. 我们的实验环境是Ubuntu 64位, 编译器gcc的版本是4.8.4. 

我们使用的c程序如下:

  1. int g(int x){
  2. return x + 3;
  3. }
  4. int f(int x){
  5. return g(x);
  6. }
  7. int main(void){
  8. return f(8) + 1;
  9. }

这个简单的c程序有一个main函数, 在main函数里调用了f函数, 然后f函数调用了g函数. 我们把其编译成32位的汇编代码, 使用的命令是:  gcc -S -o main.s main.c -m32. 这样,我们获得了汇编代码文件main.s,  打开以后可以看到这种效果:

  1. .file "test.c"
  2. .text
  3. .globl g
  4. .type g, @function
  5. g:
  6. .LFB0:
  7. .cfi_startproc
  8. pushl %ebp
  9. .cfi_def_cfa_offset 8
  10. .cfi_offset 5, -8
  11. movl %esp, %ebp
  12. .cfi_def_cfa_register 5
  13. movl 8(%ebp), %eax
  14. addl $3, %eax
  15. popl %ebp
  16. .cfi_restore 5
  17. .cfi_def_cfa 4, 4
  18. ret
  19. .cfi_endproc
  20. .LFE0:
  21. .size g, .-g
  22. .globl f
  23. .type f, @function
  24. f:
  25. .LFB1:
  26. .cfi_startproc
  27. pushl %ebp
  28. .cfi_def_cfa_offset 8
  29. .cfi_offset 5, -8
  30. movl %esp, %ebp
  31. .cfi_def_cfa_register 5
  32. subl $4, %esp
  33. movl 8(%ebp), %eax
  34. movl %eax, (%esp)
  35. call g
  36. leave
  37. .cfi_restore 5
  38. .cfi_def_cfa 4, 4
  39. ret
  40. .cfi_endproc
  41. .LFE1:
  42. .size f, .-f
  43. .globl main
  44. .type main, @function
  45. main:
  46. .LFB2:
  47. .cfi_startproc
  48. pushl %ebp
  49. .cfi_def_cfa_offset 8
  50. .cfi_offset 5, -8
  51. movl %esp, %ebp
  52. .cfi_def_cfa_register 5
  53. subl $4, %esp
  54. movl $8, (%esp)
  55. call f
  56. addl $1, %eax
  57. leave
  58. .cfi_restore 5
  59. .cfi_def_cfa 4, 4
  60. ret
  61. .cfi_endproc
  62. .LFE2:
  63. .size main, .-main
  64. .ident "GCC: (Ubuntu 4.8.4-2ubuntu1~14.04.1) 4.8.4"
  65. .section .note.GNU-stack,"",@progbits

由于以点开头的都是链接时候用到的信息, 跟实际的代码执行逻辑没有关系, 为了方便分析,我们给出删除了以点开头的行以后的代码版本:

  1. g:
  2. pushl %ebp
  3. movl %esp, %ebp
  4. movl 8(%ebp), %eax
  5. addl $3, %eax  
  6. popl %ebp
  7. ret
  8. f:
  9. pushl %ebp
  10. movl %esp, %ebp
  11. subl $4, %esp
  12. movl 8(%ebp), %eax
  13. movl %eax, (%esp
  14. call g
  15. leave
  16. ret
  17. main:
  18. pushl %ebp
  19. movl %esp, %ebp
  20. subl $4, %esp
  21. movl $8, (%esp)
  22. call f
  23. addl $1, %eax
  24. leave
  25. ret

在这里,我们可以清晰地看到汇编代码和三个3函数之间的对应关系.我们补充两张代码的图例:

接下来我们从main函数开始分析:

首先是

  1. pushl %ebp
  2. movl %esp, %ebp
  3. subl $4, %esp
这三条指令的作用是保存栈的信息. 我们将栈想象成一段内存空间, 其中ebp寄存器指向栈底位置, esp寄存器指向栈顶位置,栈底位置是高地址,栈顶位置是低地址.  当进入main函数时,需要使用一段新的栈空间, 也就是说, 如果原来栈是如图1的栈空间, 现在,进入main函数后,执行了上面的3条指令,变成了图2到图3的情况.

      图1

               图2

                图3

这样, 从ebp开始,到esp 就是属于main函数的栈. main函数执行完, 需要清空这个栈, 返回原来的状态, 但是怎么返回呢? 因为我们保存了100这个信息, 所以我们知道, 在调用main函数以前,ebp的值是100, esp的值是88, 所以我们可以返回. 这也就是为什么要做上面这三个步骤. 然后我们继续执行指令, 把数字8放在esp指向的位置, 得到如下的结果:

                图4

接下来,调用函数f, 这一步会把eip压栈. eip指向的是call的下一条指令, addl $1, %eax. 进入f函数以后, 又进行以下三步:

  1. pushl %ebp
  2. movl %esp, %ebp
  3. subl $4, %esp

这个的效果和前面讲的是一样的, 结果图如下:

                图5

然后,movl  8(%ebp), %eax 表示把ebp+8地址所在位置的值放到eax中, 在这里,这个值是正好是8. (对应c语言,我们发现原来要做的事情是int x参数传递.所以说, 在32位的x86情况下, 函数的参数传递是通过栈来实现的, 我们在使用call 指令调用函数前, 先把函数需要的参数压栈, 然后再使用call指令导致eip压栈, 然后进入新的函数后, 旧的ebp压栈, 新的ebp指向的位置存了这个刚压栈的旧的ebp. 所以, 我们通过新的ebp指向的位置, 可以通过计算的方法, 得到函数需要的参数).  接下来, movl %eax, (%esp)  会把eax的值放到esp指向的内存的位置, 然后调用 g函数, 又可以压栈call指令的下一条指令的地址, 得到的结果图是:

                图6

然后,我们进入了g函数, 执行了前两条指令,得到的结果是:

                图7

第三条指令, 和前面说过的用法相同, 是把8这个数字放在%eax中.下一个指令把数字+3,所以现在eax中的数字是11.  接下来的popl %ebp, ebp的值变成了72,因为这个时候esp执行的位置存放的值就是72,这个值正好就是之前存放的上一个函数的ebp的值, 所以得到如下的图:

                图8

然后, ret执行,会把leave的地址弹到eip中, 就可以执行leave 指令了.得到的图是: 

                图9

leave 指令类似一条宏指令, 等价于

   movl %ebp, %esp

   popl %ebp

我们知道,ebp=72指向的位置存了82这个数,正好是上一次存的旧的ebp的值, 所以经过这步得到如下的图.

                图10

这样, 又遇到了一次ret, 开始执行main 函数中的addl $1, %eax, 由于eax 的值是11, 所以现在变成了12. 然后又碰到leave 指令, 达到清栈的目的, 效果图如下:

                图11

于是, 栈恢复了初始的状态. 我们可以看到, 在main函数之后, 有一个ret指令. 由于我们之前进入main函数的时候没有考虑地址压栈, 那部分是操作系统来管理的,  所以这里不考虑这条指令的执行.

总结:

一个函数的执行过程, 会有自己的一段从ebp 到esp的栈空间.  对于一个函数, ebp指向的位置的值是调用这个函数的上一个函数的栈空间的ebp的值. 这种机制使得leave指令可以清空一个函数的栈, 达到调用之前的状态. 由于在这个栈设置之前, 有一个eip压栈的过程, 所以leave 以后的ret正好对应了上一个函数的返回地址, 也就是返回上一个函数时要执行的指令的地址. 另外,由于对于一个函数的栈空间来说, ebp指向的位置存了上一个ebp的值, 再往上是上一个函数的返回地址, 再往上是上一个函数压栈传参数的值, 所以我们知道了自己的当前ebp, 就可以通过栈的机制来获得参数.

 

posted @ 2016-02-27 21:36  Casualet  阅读(4854)  评论(1编辑  收藏  举报