函数的调用堆栈
在学习c++的过程中,有面向过程和面向对象两种编程方式。对于面向过程来说,函数的书写是最基本的,所以了解函数的调用过程和函数调用的底层原理也是必须要会的事情。
那么函数栈帧的开辟和回退是怎么进行的呢?
下面我们先用一个简单的例子,通过一个模拟模型和反汇编来了解一下函数堆栈的调用。
题外话:
1、说到汇编我们要知道,汇编的代码分为两种:
一种是inter的x86汇编(从右向左看),
一种是Unix上的AT&T汇编(从左向右看)
2、栈上的两个指针:在栈上开辟内存后,在栈*问变量和数据都是通过访问ebp指针的偏移量来实现。一个栈用两个指针来表示,其中ebp为栈底指针,esp为栈顶指针。从下往上看,栈底是高地址,栈顶是低地址。
3、在看反汇编的代码之前先介绍几个指令的含义
(1)mov 是用来移动值的,将值压栈
之前我们已经介绍过,在x86系统上反汇编的代码要从右向左看,下面我们用一个小例子来简单解释一下,之后就不再过多赘述了。
例如:
mov dword ptr[ebp-4] 0Ah
注:dword意思是double word 一个字表示2个字节,所以dword表示4个字节
这个语句的意思是将 OAh(立即数10) 移动到 ebp-4 这个地址表示的4个字节的内存中
(2)lea 是用来将指针压栈的
(3)call 是近址相对位移调用指令。
他执行两个动作,一是跳转函数,二是将下一行指令地址压栈。执行这两个动作的具体原因我们会在下面总结中穿插进去。
(4)push 入栈
(5)sub 减操作
(6)rep stos 循环拷贝指令
(7)pop 出栈
例子:
int sum(inta,int b)
{
int tmp = 0;
tmp = a+b;
return tmp;
}
int main()
{
int a = 10;
int b = 20;
int ret = 0;
ret = sum(a,b);
printf("ret = %d\n",ret);
return 0;
}
接下来我们通过结合上图和代码来总结一下函数堆栈调用过程
1.压栈
调用方:(每次压栈esp都向上移动)
1.将形参变量的地址和值压栈:由下向上将 a =10 ,b = 20 ,ret = 0 依次压入函数栈帧。
2.将实参压栈:从反汇编中可以看出先将 b的值 20 放到eax寄存器中,然后eax入栈,再将a的值 10 放到ecx寄存器中,然后ecx入栈。所以形参是在调用方开辟内存。从中我们可以看出,在调用函数时,实参是从右向左入栈
那么为什么会从右向左入栈呢?
我们暂且可以这样简单的理解,如果从左向右入栈的话,我们不知道有多少个参数,不能计算,所以这种做法不现实。
3.调用 call 指令,跳转到下一行指令的函数,将下一行指令的地址入栈。上文中我们说到call指令的两个动作,之所以要将将下一行指令的地址入栈,是为了方便函数不用每次都从头开始执行。
被调用方:
1.ebp压栈:将调用方函数的栈底地址记录到被调用方函数栈底中(上图中我们假设地址为0X100),为了方便清栈时esp回退到调用方栈底。将esp 赋给 ebp 。从上图中我们可以看到,紫色的框是调用方函数的栈帧。ebp压栈的内存就是被调用方函数的栈底。
2.被调用函数栈帧的开辟:sub esp 44h,对esp进行减等操作,开辟内存。
3.将开辟的内存初始化:将esp 和ebp 之间的内存全部初始化成0CCCCCCCCh
4.返回temp:将a = 10 和 b = 20 相加赋给temp。通过eax寄存器将temp的值带回
2.清栈:
1.将ebp赋值给esp,我们可以看到,在清栈时并没有将被调用方开辟栈帧中的数据清零,所以如果之后栈中的数据没有被覆盖,我们再访问数据的话还可以访问到
2.pop ebp,将ebp出栈,并将出栈的元素赋给ebp,esp下移。这时ebp又回到了调用方的栈底。
3. 将形参变量内存清栈,add esp,8
3.将eax寄存器的值赋给ret