从汇编层面看函数调用的过程

文章目录

栈帧

C语言中,每个栈帧对应着一个未运行完的函数,栈中保存了一个函数调用所需要维护的信息,这常常被称为堆栈帧或活动记录。

堆栈帧包括的内容

  • 函数的返回地址和参数
  • 临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量。
  • 保存的上下文:包括在函数调用前后需要保持不变的寄存器

我们知道每一次函数调用都是一个过程,一个函数的活动记录用ebp和esp这两个寄存器划定范围

  • ebp(帧指针):存放了指向函数栈帧栈底的地址
  • esp(栈指针):存放了指向函数栈帧栈顶的地址

函数调用

函数调用步骤:

  • 参数入栈:按照函数调用惯例将所有或一部分参数压入栈中,如果有其他参数没有入栈,那么使用特定的寄存器传递
  • 返回地址入栈: 将当前代码区调用指令的下一条指令地址压入栈中,供函数返回时继续执行
  • 代码跳转: 跳转到被调用函数的函数体执行

函数体的标准开头:

  • push ebp:将调用者的ebp压栈处理,保存指向栈底的ebp的地址,方便函数返回之后的现场恢复
  • mov ebp,esp:将当前栈帧切换到新栈帧(将esp值装入ebp,更新栈帧底部), 这时ebp指向栈顶,此时栈顶就是old ebp
  • sub esp,XXX:在栈上分配XXX字节的临时空间
  • push XXX:如有需要,保存名为XX的寄存器(可保存多个)

函数调用的参数显然存储在函数调用者的栈帧中,不是被调用函数的栈帧中

函数返回

函数返回步骤:

  • mov eax, xxx保存被调用函数的返回值到 eax 寄存器中
  • mov esp,ebp:恢复esp,回收局部变量空间
  • pop ebp: 将上一个栈帧底部位置恢复到 ebp
  • ret:弹出当前栈顶元素,从栈中取到返回地址,并跳转到该位置

gdb反汇编出来的代码主要分为3个部分

  • 指令地址
  • 指令相对于当前函数起始地址以字节为单位的偏移
  • 指令

从汇编层面看函数调用的过程

举例分析

#include<stdio.h>
 
 int sum(int a,int b)
  {
      int s=a+b;
      return s;
  }
  
  
  int main(int argc,char *argv[])
  {
    int n=sum(1,2);
    printf("n=%d\n",n);
 
    return 0;
  }

0x080483db <+0>:	push   %ebp

需要说明的是:gdb反汇编输出的结果中的指令地址和偏移只是gdb为了让我们更容易阅读代码而附加上去的,保存在内存中的以及被CPU执行的代码只有上图中的指令部分

   0x080483db <+0>:	push   %ebp
   0x080483dc <+1>:	mov    %esp,%ebp
   0x080483de <+3>:	and    $0xfffffff0,%esp
   0x080483e1 <+6>:	sub    $0x20,%esp
   0x080483e4 <+9>:	movl   $0x2,0x4(%esp)
   0x080483ec <+17>:	movl   $0x1,(%esp)
   0x080483f3 <+24>:	call   0x80483c4 <sum>
   0x080483f8 <+29>:	mov    %eax,0x1c(%esp)
   0x080483fc <+33>:	mov    $0x80484e4,%eax
   0x08048401 <+38>:	mov    0x1c(%esp),%edx
   0x08048405 <+42>:	mov    %edx,0x4(%esp)
   0x08048409 <+46>:	mov    %eax,(%esp)
   0x0804840c <+49>:	call   0x80482f4 <printf@plt>
   0x08048411 <+54>:	mov    $0x0,%eax
   0x08048416 <+59>:	leave  
   0x08048417 <+60>:	ret    

注意:上面反汇编的结果中的第一行代码的最左边有一个=>符号,该符号表示这条指令是CPU将要执行的下一条指令。也就是ebp寄存器目前的值为0x080483db,当前的状态是前一条指令已经执行完毕,这一条指令还未执行。

使用i r ebp esp eip查看rbp,rsp和rip这3个寄存器的值

(gdb) i r ebp esp eip
ebp            0xbffff3d8	0xbffff3d8
esp            0xbffff35c	0xbffff35c
eip            0x80483db	0x80483db <main>


为了更加清晰的理解程序的执行流程,现在我们对从main函数的第一条指令开始,一直到main函数的所有指令进行分析

第1条指令:

 0x080483db <+0>:	push   %ebp

这条指令把栈基地址寄存器ebp的值临时保存在mian函数的栈帧中,因为 main函数需要使用这个寄存器来存放自己的栈基地址,而调用者在调用main 函数之前也可能把它的栈基地址保存在了这个寄存器里面,所以main函数需要把这个寄存器里面的值先保存起来,等main函数执行完后返回时再把这个寄存器恢复原样,如果不恢复原样,main函数返回后调用者使用ebp寄存器时就会出现问题,因为在执行调用者的代码时ebp本来应该指向调用者的栈帧,现在却指向了main函数的栈帧。

在这条指令之前,代码还在使用调用者的栈帧,执行完这条指令之后,代码开始使用main函数的栈帧,目前main函数的栈帧里面只保存有调用者的ebp这一个值。

第2条指令

0x080483dc <+1>:	mov    %esp,%ebp

这条指令把ebp的值复制到esp寄存器,让其指向main函数栈帧的起始位置,执行完这条指令之后,esp和ebp寄存器具有相同的值,它们都指向了main函数的栈帧的起始位置。

第3条指令:

 0x080483de <+3>:	and    $0xfffffff0,%esp

AND 指令:在两个操作数的对应位之间进行(按位)逻辑与(AND)操作,并将结果存放在目标操作数esp中.

第4条指令:

  0x080483e1 <+6>:	sub    $0x20,%esp

该指令esp寄存器的值减去了32,让esp指向了栈空间中一个更低的位置,这条指令看似只是简单地修改了esp寄存器地值,其本质是给main函数地局部变量和临时变量预留了32个字节的栈空间,为什么说是预留而不是分配,因为栈的分配是由操作系统自动完成的,程序启动时操作系统就会为我们分配一大块内存用作函数调用栈,程序最终使用了多少栈内存由ebp和esp来确定。

该指令完成后,从esp指令所指位置到ebp所指的这一段占内存就构成了main函数的完整栈帧

第5和第6条指令:

0x080483e4 <+9>:	movl   $0x2,0x4(%esp)
0x080483ec <+17>:	movl   $0x1,(%esp)

这两条指令在给sum函数准备参数,我们可以看到,传递给sum的第一个参数放在了esp寄存器里面,第二个参数放在了esp+0x4里面(这里用到了esp加偏移量的方式来访问栈内存)。

第7条指令:

0x080483f3 <+24>:	call   0x80483c4 <sum>

参数准备好了之后,接着执行call指令调用sum函数

call指令的执行过程

  • 开始执行它的时候eip指向的是call指令的下一条指令,也就是说eip寄存器的值是0x080483f8 这个地址
  • 在call指令执行过程中,call指令会把当前eip的值(0x080483f8 )入栈
  • 然后把eip的值修改为call指令后面的操作数,这里是0x80483c4
  • 也就是sum函数第一条指令的地址,这样cpu就会跳转到sum函数去执行
  • 这样eip就指向了sum函数的第1条指令,sum函数执行完成返回之后需要执行的指令的地址0x080483f8也已经保存到了main函数的栈帧之中

现在使用disass sum 来看sum函数反汇编的结果如下:

   0x080483c4 <+0>:	push   %ebp
   0x080483c5 <+1>:	mov    %esp,%ebp
   0x080483c7 <+3>:	sub    $0x10,%esp
   0x080483ca <+6>:	mov    0xc(%ebp),%eax
   0x080483cd <+9>:	mov    0x8(%ebp),%edx
   0x080483d0 <+12>:	lea    (%edx,%eax,1),%eax
   0x080483d3 <+15>:	mov    %eax,-0x4(%ebp)
   0x080483d6 <+18>:	mov    -0x4(%ebp),%eax
   0x080483d9 <+21>:	leave  
   0x080483da <+22>:	ret    

sum函数的前2条指令跟main函数前两条指令一模一样

0x080483c4 <+0>:	push   %ebp
0x080483c5 <+1>:	mov    %esp,%ebp

这里sum函数保存了main函数的ebp寄存器的值,并使ebp指向了自己栈帧的起始位置。

第3条指令:

 0x080483c7 <+3>:	sub    $0x10,%esp

是给sum函数地局部变量和临时变量预留了16个字节的栈空间

接下来几条指令:

   0x080483ca <+6>:	mov    0xc(%ebp),%eax
   0x080483cd <+9>:	mov    0x8(%ebp),%edx
   0x080483d0 <+12>:	lea    (%edx,%eax,1),%eax
   0x080483d3 <+15>:	mov    %eax,-0x4(%ebp)
   0x080483d6 <+18>:	mov    -0x4(%ebp),%eax

把ebp+0xc的值放到eax寄存器中,把ebp+0x8的值放到edx中,lea指令将edx和eax的值求和,并将结果放在eax寄存器中。

通过rbp寄存器加偏移量的方式把eax寄存器中的值保存在当前栈帧的合适位置,然后又取出来放入寄存器,这里有点多此一举,因为我们编译的时候未给gcc指定优化级别,gcc编译程序时默认不做任何优化,所以代码看起来比较啰嗦。

0x080483da <+22>:	ret 

该指令把esp指向的栈单元中的0x080483f8取出给eip寄存器,同时esp加8,这样eip寄存器中的值就变成了main函数中调用sum的call指令的下一条指令,于是就返回到main函数中继续执行


继续执行main函数中的指令

	mov    %eax,0x1c(%esp)

把sum函数的返回值放入esp+0x1c所指的内存。

后面的几条指令:

   0x080483fc <+33>:	mov    $0x80484e4,%eax
   0x08048401 <+38>:	mov    0x1c(%esp),%edx
   0x08048405 <+42>:	mov    %edx,0x4(%esp)
   0x08048409 <+46>:	mov    %eax,(%esp)
   0x0804840c <+49>:	call   0x80482f4 <printf@plt>

首先为printf函数准备参数然后调用printf函数,在此就不分析它们了,因为调用printf和sum的过程差不多。

0x08048411 <+54>:	mov    $0x0,%eax

该指令的作用在于把main函数的返回值0放在eax寄存器中,等main返回后调用main函数的函数可以拿到这个返回值

0x08048416 <+59>:	leave  

该指令相当于如下两条指令:

mov  %ebp, %esp
pop  %ebp

leave指令首先把ebp寄存器中的值复制给esp,这样esp就指向了ebp所指的栈单元,然后把使该内存单元中的值pop给ebp寄存器,这样ebp和esp的值就恢复成刚刚进入main函数时的状态了。

到此main函数就只剩下ret指令

0x08048417 <+60>:	ret  

这条指令执行完成后就会完全返回到调用main函数的函数中去继续执行

上一篇:编译器C-Free V352注册算法分析


下一篇:LINUX - 堆栈