文章目录
栈帧
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函数的函数中去继续执行。