一段C语言和汇编的对应分析,揭示函数调用的本质

一段C语言和汇编的对应分析,揭示函数调用的本质

2018年09月30日 13:32:19 sdulibh 阅读数:17

本文作者周平,原创作品转载请注明出处

首先对会涉及到的一些CPU寄存器和汇编的基础知识罗列一下:

  •  
  • 16位、32位、64位的CPU寄存器名称有所不同,比如指令地址寄存器ip,在16位中叫ip,32位中叫eip,64位叫rip
  •  
  • 32位的汇编指令通常以l结尾,比如movl相当于mov的含义
  •  
  • ebp : 堆栈基地址 寄存器,这个寄存器保存的是当前执行绪的栈底地址
  •  
  • esp : 堆栈栈顶 寄存器,这个寄存器保存的是当前执行绪的栈顶地址
  •  
  • eip : 指令地址 寄存器,这个寄存器保存的是指令所在的地址,CPU会不断的根据eip所指向的指令去内存取指令并执行,并自行累加取下一条指令逐条执行。eip无法直接赋值,callretjmp等指令可以起到修改eip的作用
  •  
  • %用于直接寻址寄存器,$用于表示立即数。movl $8, %eax表示把立即数8存到eax
  •  
  • ()用于内存间接寻址,比如movl $10, (%esp)表示将立即数10保存到esp所指向的内存地址中
  •  
  • 8(%ebp)表示先找到 ebp所指向的地址值+8后得到的地址
  •  
  • 栈地址值是向下增长的,即栈顶从高地址向低地址移动

准备工作

准备一段C代码:

 
  1. int g(int x)

  2. {

  3.     return x+5;

  4. }

  5.  
  6. int f(int x)

  7. {

  8.     return g(x);

    扫描二维码关注公众号,回复: 3883155 查看本文章
  9. }

  10.  
  11. int main(void)

  12. {

  13.     return f(10)+1;

  14. }

使用实验楼环境

编译成汇编代码

使用如下命令编译上面的c代码

gcc -S -o main.s main.c -m32

去掉不重要的部分后,得到:

汇编代码结果为:

 
  1. g:

  2. pushl %ebp

  3. movl %esp, %ebp

  4. movl 8(%ebp), %eax

  5. addl $5, %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 $10, (%esp)

  22. call f

  23. addl $1, %eax

  24. leave

  25. ret

分析

具体的逐步分析,这里就省了,老师课上讲的很详细了,这里主要是要进行思考和归纳。

首先,我们看到3个C函数对应生成了3个部分的汇编代码,分别用函数名作为标号隔开了

 
  1. int g(int x) -> g:

  2. int f(int x) -> f:

  3. int main(void) -> main:

我们知道程序是从main函数开始执行的,那么当程序被加载并运行时,上面的汇编代码会被加载到内存的某一个区域。而且,CPU中的很多寄存器都会初始化,当然其中最重要的是eip,因为eip是指向下一条将要执行的命令所在的内存地址,所以此时的eip应该指向main标号下的pushl %ebp

 
  1. main:

  2. eip ->  pushl %ebp

程序开始执行…

我们捆绑着看,首先先看这两条:

 
  1. pushl %ebp  :将基地址压栈。(ebp : 堆栈基地址 寄存器,这个寄存器保存的是当前执行绪的栈底地址

  2. movl %esp, %ebp:将栈顶地址赋值给栈的基地址。(esp : 堆栈栈顶 寄存器,这个寄存器保存的是当前执行绪的栈顶地址         (查看定义ESP是栈顶指针,EBP是存取堆栈指针)

再观察一下整个代码,有没有发现不仅仅是main函数,函数fg的开头也是这两个指令。分析一下,不难得出,这两条指令是指将当前栈基地址压栈后,重新将基地址定位到栈顶,这个含义其实是保存好当前的基地址,重新开始一个新的栈。由于函数可以调函数,这里的当前基地址,实际上是上一个函数的栈基地址。例如,在f函数中的这两句指令,实际上保存的是main函数的栈基地址。

接着来分析两句:

 
  1. subl $4, %esp

  2. movl $10, (%esp)

对照C代码不难发现,这是参数进栈,将立即数10,保存到栈顶(esp所指向的内存地址是栈顶)。而在f函数中也可以发现类似的语句:

 
  1. subl $4, %esp

  2. movl 8(%ebp), %eax

  3. movl %eax, (%esp)

所以,我们可以得出结论是,在调用函数前需要把参数逐个压栈,而压栈的顺序根据笔者的测试是从右向左的

接着调用call指令,跳转到f函数,我们知道call指令等同于下面的伪代码:

 
  1. pushl %eip+1

  2. movl %eip f   (是不是此处写错了,应该是:movl f, %eip  

即把call指令的后一条指令进栈后,将eip赋值为目标函数的第一个指令地址。这样做显而易见:当所调用的函数结束后,需要返回当前函数继续执行,所以必须要保存下一条指令,否则回来的时候就找不到了。

来到f函数,首先是保存main函数的栈基地址,然后需要调用g函数,于是需要参数先进栈:

 
  1. subl $4, %esp

  2. movl 8(%ebp), %eax

  3. movl %eax, (%esp) (查看定义ESP是栈顶指针,EBP是存取堆栈指针)

这里重点思考一下,f函数是如何获得main函数传递过来的参数的,我们看到

movl	8(%ebp), %eax

为什么参数是从8(%ebp)中获得的呢?我们知道8(%ebp)表示的是以ebp为基准向栈底回溯8个字节得到,为什么是8个字节呢?

回想一下,在main函数中完成了参数进栈后做了两件事情:

  1.  
  2. 由于call f指令的作用,call f下一条指令的地址被压栈了,这占用率4个字节
  3.  
  4. 进入f函数后,立即将main函数的栈基地址进栈了,而且将ebp靠向了栈顶esp,这又占用了4个字节

于是通过8(%ebp)可以找到前一个函数的第一个整型参数的值。

一张图告诉你怎么回事:(栈地址值是向下增长的,即栈顶从高地址向低地址移动

看过了进入函数,调用函数的过程,再看一下函数是如何退出的。观察mainf不难发现,退出函数使用的是如下指令

 
  1. leave

  2. ret

leave指令相当于如下指令:

 
  1. movl %ebp, %esp   (查看定义ESP是栈顶指针,EBP是存取堆栈指针)

  2. popl %ebp

  •  
  • 第一条语句是将esp重置到ebp,可以理解为清空当前函数所使用的栈
  •  
  • 第二条语句是将栈顶值赋值给ebp,并弹出,栈顶值是什么呢?通过上面的分析不难发现,此时的栈顶值实际上是前一个函数的栈基地址,所以第二条语句的意思就是把ebp恢复到前一个函数的栈基地址

接着ret就是相当于,恢复指令指向:

popl %eip
 

为什么g函数没有leave呢?因为g函数内部没有任何的变量声明和函数调用栈一直都是空的,所以编译器优化了指令

总结

最后,通过这个例子,总结一下函数调用的过程:

进入函数:

  1.  
  2. 当前栈基地址压栈(当前栈基地址实际上是前一个函数的栈基地址)

调用其他函数:

  1.  
  2. 参数从右到左进栈
  3.  
  4. 下一条指令地址进栈

退出函数:

  1.  
  2. 栈顶esp归位,回到本函数的ebp
  3.  
  4. 基地址回退到上一个函数的基地址
  5.  
  6. eip退回到上一个函数即将要执行的那条语句的地址上

猜你喜欢

转载自blog.csdn.net/weixin_41632560/article/details/82976715