C/C++ 函数调用过程,压栈出栈
在x86的计算机系统中,内存空间中的栈主要用于保存函数的参数,返回值,返回地址,本地变量等。一切的函数调用都要将不同的数据、地址压入或者弹出栈。因此,为了更好地理解函数的调用,我们需要先来看看栈是怎么工作的。
栈是什么?
简单来说,栈是一种LIFO形式的数据结构,所有的数据都是后进先出。这种形式的数据结构正好满足我们调用函数的方式:父函数调用子函数,父函数在前,子函数在后;返回时,子函数先返回,父函数后返回。栈支持两种基本操作,push和pop。push将数据压入栈中,pop将栈中的数据弹出并存储到指定寄存器或者内存中。
这里是一个push操作的例子。假设我们有一个栈,其中黄色部分是已经写入数据的区域,绿色部分是还未写入数据的区域。现在我们将0x50压入栈中:
// 将0x50的压入栈
push $0x50
我们再来看看pop操作的例子:
// 将0x50弹出栈
pop
这里有两点需要注意的,第一,上面例子中栈的生长方向是从高地址到低地址的,栈是向下生长的,因此这里也用这种形式的栈;第二,pop操作后,栈中的数据并没有被清空,只是该数据我们无法直接访问。有了这些栈的基本知识,我们现在可以来看看在x86-32bit系统下,C语言函数是如何调用的了。
栈帧是什么?
栈帧,也就是stack frame,其本质就是一种栈,只是这种栈专门用于保存函数调用过程中的各种信息(参数,返回地址,本地变量等)。栈帧有栈顶和栈底之分,其中栈顶的地址最低,栈底的地址最高,SP(栈指针)就是一直指向栈顶的。在x86-32bit中,我们用 %ebp
指向栈底,也就是基址指针;用 %esp
指向栈顶,也就是栈指针。下面是一个栈帧的示意图:
一般来说,我们将 %ebp
到 %esp
之间区域当做栈帧(也有人认为该从函数参数开始,不过这不影响分析)。并不是整个栈空间只有一个栈帧,每调用一个函数,就会生成一个新的栈帧。在函数调用过程中,我们将调用函数的函数称为“调用者(caller)”,将被调用的函数称为“被调用者(callee)”。在这个过程中,1)“调用者”需要知道在哪里获取“被调用者”返回的值;2)“被调用者”需要知道传入的参数在哪里,3)返回的地址在哪里。同时,我们需要保证在“被调用者”返回后,%ebp
, %esp
等寄存器的值应该和调用前一致。因此,我们需要使用栈来保存这些数据。
为什么参数从右至左压栈
1.C方式参数入栈顺序(从右至左)的好处就是可以动态变化参数个数。通过栈堆分析可知,自左向右的入栈方式,最前面的参数被压在栈底。这样的话,除非知道参数个数,否则是无法通过栈指针的相对位移求得最左边的参数。这样就变成了左边参数的个数不确定,正好和动态参数个数的方向相反。
2. 更符合习惯。
采用这种顺序,是为了让程序员在使用C/C++的“函数参数长度可变”这个特性时更方便。
什么是“函数参数长度可变”?printf就是一个例子,它的参数的个数就是可变的,链接(1)中介绍了如何自己写一个参数长度可变的函数。
看下面这句话 printf("%d %d %d",1,2,3),在采用从右向左的参数入栈顺序时,参数出栈顺序时"%d %d %d",1,2,3。
如果采用从左向右的入栈顺序,则出栈顺序变为3,2,1,"%d %d %d"。
普通函数、类成员函数、类虚函数的调用过程
- 普通函数调用流程
- 开辟栈帧空间
- 函数参数从右至左进行压栈
- 函数返回地址进行压栈
- 函数局部变量进行压栈
-
普通成员函数调用流程(大体)
- 由于函数地址在编译期间已确定,所以直接找到该函数地址
- this指针,作为隐含参数传入该函数
- 之后的调用和普通函数调用方式一致
- 注意:如果该函数中,使用了实例的成员变量,由于this指针为null,程序会报错。
-
虚函数调用流程(大体)
- 查找this指针(也就是实例)的地址
- 根据this指针,查找虚函数表(函数指针数组)的地址
- 从虚函数表中,取出相应的函数地址
参考文档: https://www.cnblogs.com/sddai/p/9762968.html
参考文档:https://blog.csdn.net/hnyzyty/article/details/46427219