栈的介绍

1、内存的不同用途

进程使用的内存按照功能分为以下4个部分:
(1)代码区:这个区域存储着被装入执行的二进制机器代码,处理器会到这个区域取出指令并执行。
(2)数据区:用于存储全局变量
(3)堆区:进程可以在堆区动态地请求一定大小的内存,并在用完之后归还给堆区。动态分配和回收是堆区的特点。
(4)栈区:用于动态地存储函数之间的调用关系,以保证被调用函数在返回时恢复到母函数中继续执行。

协作过程如图所示:
协作过程图

内存中的栈区其实就是系统栈。系统栈由系统来维护。

2、函数调用过程

int func_B(int arg_B1, int arg_B2)
{
     int var_B1, var_B2;
     var_B1 = arg_B1 + arg_B2;
     var_B2 = arg_B1 - arg_B2;
     return var_B1 * var_B2;
}

int func_A(int arg_A1, int arg_A2)
{
     int var_A;
     var_A = func_B(arg_A1, arg_A2) + arg_A1;
     return var_A;
}

int main(int argc, char **argv, char **envp)
{
     int var_main;
     var_main = func_A(4,3);
     return var_main;
}

同一文件的不同函数的代码在内存代码区中的分布可能是相邻也可能相距很远。代码的调用也如下图所示。
代码指令所在位置和CPU在代码区中的取指轨迹

代码区精确的跳转是在与系统栈巧妙地配合过程中完成的。当函数被调用时,系统栈会为这个函数开辟一个新的栈帧,并把它压入栈中。这个栈帧中的内存空间被它所属的函数独占,正常情况下是不会和别的函数共享的。当函数返回时,系统栈会弹出该函数所对应的栈帧。
系统栈在函数调用中的变化

调用时的步骤:
(1)母函数调用子函数时,首先现在母函数所在栈帧里压入函数返回地址(一般为调用函数下面一条指令),然后为子函数创建新栈帧并压入系统栈(一般的步骤为push ebp[保存旧栈帧底部] -> mov ebp, esp[将现在的栈顶换做栈帧底部] -> sub esp, xxx[抬高栈顶,为新栈帧开辟空间])。
(2)子函数返回时,子函数的栈帧被弹出系统栈,母函数栈帧中的返回地址出现在栈顶,此时处理器按照这个返回地址重新跳到母函数代码区中执行。

3、寄存器与函数栈帧

(1)ESP:栈指针寄存器(extended stack pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶。
(2)EBP:基址指针寄存器(extended stack pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部。

在函数栈帧中,一般包含以下几类重要信息:
(1)局部变量:为函数局部变量开辟的内存空间。
(2)堆栈状态值:保存前栈帧的顶部和底部(实际上只保存前栈帧的底部,前栈帧的顶部可以通过堆栈平衡计算得到),用于在本栈中被弹出后恢复出上一个栈帧。
(3)函数返回地址:保存当前函数调用前的“断点”信息,也就是函数调用前的指令位置,以便在函数返回时能够恢复到函数被调用前的代码区中继续执行指令。

4、函数调用约定与相关指令

几种调用方式的差异

C SysCall StdCall BASIC FORTRAN PASCAL
参数入栈顺序 右->左 右->左 右->左 左->右 左->右 左->右
恢复栈平衡操作的位置 母函数 子函数 子函数 子函数 子函数 子函数

函数调用的约定

调用约定的声明 参数入栈顺序 恢复栈平衡的位置
__cdecl 右->左 母函数
__fastcall 右->左 子函数
__stdcall 右->左 子函数
默认情况下,VC会使用__stdcall调用方式 C++类成员函数都有一个this指针,在Windows平台中,这个指针一般是用ECX寄存器来传递的;但如果用GCC编译器编译,这个指针会作为最后一个参数压入栈中。

参数调用步骤

  1. 参数入栈:由右向左压入参数
  2. 返回地址入栈:当前代码区调用指令的下一条指令地址压入栈中,供函数返回时继续执行
  3. 代码区跳转:处理器从当前代码区跳转至被调用函数的入口处
  4. 栈帧调整:保存当前栈帧状态值,以备后面恢复栈帧时使用(EBP入栈);将当前栈帧切换到新栈帧(EBP<-ESP,更新栈帧底部);给新栈帧分配空间(将ESP减去所需空间的大小,抬高栈顶)

;调用前
push 参数3    ;假设函数有3个参数,那么参数将由右向左依次入栈
push 参数2
push 参数1
call 函数dizhi ;call指令将同时完成两项工作
              ; a) 向栈中压入当前指令的下一条指令在内存中的位置,即保存返回地址
              ; b) 跳转到所用函数的入口地址函数入口处
push ebp    ;保存旧栈帧的底部
mov ebp, esp    ;设置新栈帧的底部(栈帧切换)
sub esp, xxx    ;设置新栈帧的顶部(抬高栈顶,为新栈帧开辟空间)

函数调用时系统栈中的变化情况

函数返回时的步骤

  1. 保存返回值(通常将函数的返回值保存到EAX中)
  2. 弹出当前栈帧,恢复上一个栈帧:在栈帧平衡的基础上,给ESP加上栈帧的大小,降低栈顶,回收当前栈帧的空间;将当前栈帧底部保存的前栈帧EBP值弹入EBP寄存器,恢复上一个栈帧;将函数返回地址弹给EIP寄存器
  3. 跳转:按照函数返回地址跳回母函数中继续执行

add esp, xxx    ;降低栈顶,回收当前的栈帧
pop ebp            ;将上一个栈帧底部恢复到ebp
retn            ;这条指令有两个功能
                ;a) 弹出当前栈顶元素,即弹出栈帧中的返回地址。
                ;b) 让处理器跳转至弹出的返回地址,恢复调用前的代码区