在 C/C++ 函数调用的整个过程中内存空间进行了什么操作?本文对 C/C++ 函数调用原理进行扼要说明。
一、预备知识
(一) 内存中数据的地址
地址在内存中存放时可能会跨越连续若干个存储单元(一个存储单元的大小为 1 个字节),而每个存储单元都有自己的编号,这个编号称为地址。机器规定以最小的编号作为该数据的地址。
请看以下代码和图 1。
uint8_t a = 7;uint16_t b = 500;uint32_t c = 1000;
图 1. 内存中数据的地址
变量 a 占据编号为 24 的内存单元,变量 b 占据编号为 25~26 的内存单元,变量 c 占据编号为 27~30 的内存单元。
依据约定,变量 a,b,c 的地址分别为 24,25,27。
(二) 堆(Heap)和堆栈(Stack)
堆栈亦称为栈,能够在函数运行之前自动分配足够的空间资源,函数运行完毕后自动回收资源。
堆的空间资源不同于栈,要获取它必须由程序员手动申请,然后由操作系统根据一定的算法进行分配。操作系统只有在进程结束时会自动回收该进程对应的堆空间资源,不过最好由程序员手动释放资源。
(三) 代码段(Text Section)
每个函数经过编译生成的二进制机器指令皆存储在内存空间中的代码段。
(四) 栈帧(Stack Frame)
上文提到,栈能够为函数运行分配足够的空间资源,这种资源便称为栈帧。
栈帧的分配是从高地址向低地址逐步执行的。
一个栈帧大小不是无限的,其最靠近低地址的一端称为栈顶,最接近高地址的一端称为栈底,
栈顶地址和栈底地址各自保存在专门的寄存器里边,这两个专门的寄存器存放的值都是地址,故亦可分别称之为栈顶指针、栈底指针。
一个栈帧栈底地址减去栈顶地址所得的值决定了该栈帧的大小,可以通过让栈顶指针自增与自减分别控制栈帧的缩小与扩大。一个函数栈帧的结构如图 2 所示(下文会提到返回地址),假设该函数局部变量个数不为 0,且有调用其他函数。
图 2. 单个函数栈帧的结构
(五) 堆栈指针寄存器和基址指针寄存器
堆栈指针寄存器和基址指针寄存器都属于通用寄存器。
堆栈指针寄存器用来存放栈帧的栈顶地址,根据数据位数不同可以分为三种,16位的 sp,32位的 esp,64位的 rsp,为说明方便,下文以 esp 为例进行阐述。
基址指针寄存器用来存放栈帧的栈底地址,根据数据位数不同可以分为三种,16位的 bp,32位的 ebp,64位的 rbp,为说明方便,下文以 ebp 为例进行阐述。
(六) 指令寄存器 ip
该寄存器总是存放下一条执行指令的所在地址。
(七) 入栈指令 push 和出栈指令 pop
push 和 pop 都属于汇编指令。
入栈操作分为两步。第一步栈顶指针自减以扩大栈帧空间;第二步,将某个寄存器的值保存新开辟的位置上。
出栈操作只有一步。第一步,栈顶指针自增以缩小栈帧空间,将原先最靠近栈顶的值赋予某个寄存器。
如图 3 所示。
图 3. 入栈指令 push 和出栈指令 pop
(八) 函数调用指令 call
call 也属于汇编指令。
调用一个函数时,一定会执行 call 指令,汇编中调用 printf 函数的写法如下。
call printf
call 指令包括两个步骤,第一步是让当前指令寄存器 ip 的值入栈,作为返回地址,第二步是将指令寄存器 ip 的值修改为接下来即将调用的函数第一条机器指令的所在地址,从而实现跳转。
(九) 函数参数入栈顺序
函数参数入栈顺序为从右到左。
func(1, 'A', 3.14);
该函数参数入栈顺序为 3.14
,'A'
,1
。
(十) 不同函数的机器指令段的共性
每个函数的机器指令段的开头,都有以下几步操作:
第一步,在栈帧中保存上一栈帧的栈底地址,汇编指令为
push ebp
。第二步,将上一栈帧的栈顶地址作为当前函数栈帧的栈底地址,汇编指令为
mov ebp, esp
。第三步,为当前函数的局部变量开辟足够的空间,汇编指令为
sub esp, M
,M 为局部变量占用栈帧空间的字节数。
每个函数的机器指令段的末尾,都有以下几步操作:
第一步,将 esp 恢复为为局部变量开辟空间之前的值,汇编指令为
mov esp, ebp
,恢复后,esp 的值恰好是上一栈帧栈底地址的地址。第二步,将 ebp 恢复为上一栈帧的栈底地址,汇编指令为
mov ebp, [esp]
,恢复后,esp 的值恰好是存放返回地址的地址。第三步,将 eip 恢复为 call 指令第一步骤所操作的值,汇编指令为
mov eip, [esp]
,恢复后,esp 的值恰好为刚执行完的函数的第一个形参的入栈地址。第四步,将 esp 值恢复为为刚执行完的函数的参数开辟空间之前的值,汇编指令为
pop ...
,恢复后,esp 的值恰好是当前栈帧最靠近 0 地址的局部变量的地址。
二、C/C++ 函数调用过程剖解
以下列程序为例。
#include <stdio.h>int main(void){ int apple = 10; int pear = 20; int total = 0; printf("apple = %d, pear = %d.\n", apple, pear); total = apple + pear; return 0; }
printf
函数调用之前,参数从右向左入栈。调用 call 指令,此时存储在指令寄存器 ip 中的值是
printf
函数下一条语句total = apple + pear;
对应的机器指令的地址,该地址入栈,同时指令寄存器 ip 的值修改为printf
函数在代码段中的第一条指令的地址。根据“一、(十)”可知,开始执行
printf
函数时,会进行三步操作——在printf
函数栈帧中保存main
函数栈帧的栈底地址;将main
函数栈帧的栈顶地址作为printf
函数栈帧的栈底地址;为printf
函数的局部变量开辟足够的空间。三步操作执行完之后便开始执行printf
函数的主体机器指令段。根据“一、(十)”可知,
printf
函数的主体机器指令段执行完毕后,便开始收尾工作——将 esp 恢复为为printf
函数局部变量开辟空间之前的值;将 ebp 恢复为main
函数栈帧的栈底地址;将 eip 恢复为语句total = apple + pear;
对应的机器指令地址;将 esp 值恢复为为printf
函数的参数开辟空间之前的值,恢复后,esp 的值恰好是total
的地址。
作者:HubbardHuang
链接:https://www.jianshu.com/p/9f547d8428c3