[C++]函数调用栈

 程序的执行过程可看作连续的函数调用。当一个函数执行完毕时,程序要回到调用指令的下一条指令(紧接call指令)处继续执行。函数调用过程通常使用堆栈实现,每个用户态进程对应一个调用栈结构(call stack)。编译器使用堆栈传递函数参数、保存返回地址、临时保存寄存器原有值(即函数调用的上下文)以备恢复以及存储本地局部变量。

     不同处理器和编译器的堆栈布局、函数调用方法都可能不同,但堆栈的基本概念是一样的。


程序的内存可以参考进程内存 
对于程序,编译器会对其分配一段内存,在逻辑上可以分为代码段,数据段,堆,栈 
代码段:保存程序文本,指令指针EIP就是指向代码段,可读可执行不可写 
数据段:保存初始化的全局变量和静态变量,可读可写不可执行 
BSS:未初始化的全局变量和静态变量 
堆(Heap):动态分配内存,向地址增大的方向增长,可读可写可执行 
栈(Stack):存放局部变量,函数参数,当前状态,函数调用信息等,向地址减小的方向增长,非常非常重要,可读可写可执行

这里写图片描述

这里主要关注的是栈的增长方向,是向低地址增长。

寄存器分配

Intel 32位体系结构包含8个四字节的寄存器。

这里写图片描述

他们的描述, 
EAX: 用于函数返回值 
EBX: 
ECX: 字符串和循环操作中的计数器 
EDX: 用于乘除法和I/O指针

ESI: 
EDI: 
ESP: 栈指针,指向堆栈顶部 
EBP: 基址寄存器,指向当前堆栈底部。

栈帧指针寄存器 
为了访问函数的局部变量,许多编译器使用帧指针寄存器FP(Frame Pointer) 记录栈帧基地址。局部变量和函数参数都可通过帧指针引用。因为他们到FP的距离不会受到压栈和出栈操作的影响。Intel 中使用EBP作为帧指针。当堆栈向下(低地址)增长时,以FP为基准,函数参数的偏移量为正值,而局部变量的偏移为负值。

栈帧结构

函数调用经常是嵌套的,在同一时刻,堆栈中会有多个函数的信息。每个未完成运行的函数占用一个独立的连续区域,称作栈帧(Stack Frame)。栈帧是堆栈的逻辑片段,当调用函数时逻辑栈帧被压入堆栈, 当函数返回时逻辑栈帧被从堆栈中弹出。栈帧存放着函数参数,局部变量及恢复前一栈帧所需要的数据等。

使用栈帧的一个好处是使得递归变为可能,因为对函数的每次递归调用,都会分配给该函数一个新的栈帧,这样就巧妙地隔离当前调用与上次调用。

栈帧的边界由栈帧基地址指针EBP和堆栈指针ESP界定(指针存放在相应寄存器中)。EBP指向当前栈帧底部(高地址),在当前栈帧内位置固定;ESP指向当前栈帧顶部(低地址),当程序执行时ESP会随着数据的入栈和出栈而移动。因此函数中对大部分数据的访问都基于EBP进行。

为更具描述性,以下称EBP为帧基指针, ESP为栈顶指针,并在引用汇编代码时分别记为%ebp和%esp。

函数调用栈的典型布局如图:

这里写图片描述

图中给出了主调函数(caller)和被调函数(callee)的栈帧布局。有两个假设,函数的返回值不是结构体或是联合体,否则第一个参数将位于12(%ebp)处。第二,每个参数都是4字节大小。此外,图中Argument(参数)和local Variable(局部变量)不是栈帧结构的必须部分。

函数调用时的入栈顺序 
实参N~1—>主调函数返回值—>主调函数帧基指针EBP—>被调函数局部变量1~N

1、主调函数将参数按照调用规定依次入栈从右向左 
2、指令指针EIP入栈以保存函数的返回值。 
3、进入被调函数时,被调函数将主调函数的帧基指针EBP入栈(push %ebp),并将主调函数的栈顶指针ESP值赋给被调函数的EBP,作为被调函数的EBP(mov %esp,%ebp) 
4、改变ESP的值来为局部变量预留空间。

此时,被调函数帧基指针指向被调函数的栈低。以该地址为基准,向上(栈底方向,高地址方向)可获取主调函数的返回地址、参数值,向下(栈顶方向,底地址方向)能获取被调函数的局部变量值,而该地址处又存放着上一层主调函数的帧基地址。本级调用结束后,将EBP指针赋给ESP,使得ESP再次指向被调函数函数栈底以释放局部变量;再将已压栈的主调函数帧基指针弹出到EBP,并弹出到EIP。ESP继续上移越过参数,最终回到函数调用前的状态。即恢复原来主调函数的栈帧。

EBP指针在当前函数运行过程中(未调用其他函数时)保持不变。在函数调用前,ESP指针指向栈顶地址,也是栈底地址。在函数完成现场保护之类的初始化工作后,ESP会始终指向当前函数栈帧的栈顶,此时,若当前函数又调用另一个函数,则会将此时的EBP视为旧EBP压栈,而与新调用函数有关的内容会从当前ESP所指向位置开始压栈。

在多线程(任务)环境,栈顶指针指向的存储器区域就是当前使用的堆栈。切换线程的一个重要工作,就是将栈顶指针设为当前线程的堆栈栈顶地址。

 以下代码用于函数栈布局示例:

//StackFrame.c
#include <stdio.h>
#include <string.h>

struct Strt{
    int member1;
    int member2;
    int member3;
};

#define PRINT_ADDR(x)     printf("&"#x" = %p\n", &x)
int StackFrameContent(int para1, int para2, int para3){
    int locVar1 = 1;
    int locVar2 = 2;
    int locVar3 = 3;
    int arr[] = {0x11,0x22,0x33};
    struct Strt tStrt = {0};
    PRINT_ADDR(para1); //若para1为char或short型,则打印para1所对应的栈上整型临时变量地址!
    PRINT_ADDR(para2);
    PRINT_ADDR(para3);
    PRINT_ADDR(locVar1);
    PRINT_ADDR(locVar2);
    PRINT_ADDR(locVar3);
    PRINT_ADDR(arr);
    PRINT_ADDR(arr[0]);
    PRINT_ADDR(arr[1]);
    PRINT_ADDR(arr[2]);
    PRINT_ADDR(tStrt);
    PRINT_ADDR(tStrt.member1);
    PRINT_ADDR(tStrt.member2);
    PRINT_ADDR(tStrt.member3);
    return 0;
}

int main(void){
    int locMain1 = 1, locMain2 = 2, locMain3 = 3;
    PRINT_ADDR(locMain1);
    PRINT_ADDR(locMain2);
    PRINT_ADDR(locMain3);
    StackFrameContent(locMain1, locMain2, locMain3);
    printf("[locMain1,2,3] = [%d, %d, %d]\n", locMain1, locMain2, locMain3);
    memset(&locMain2, 0, 2*sizeof(int));
    printf("[locMain1,2,3] = [%d, %d, %d]\n", locMain1, locMain2, locMain3);
    return 0;
}

StackFrame

编译链接并执行后,输出打印如下: 
这里写图片描述

函数栈布局示例如下图所示。为直观起见,低于起始高地址0xbfc75a58的其他地址采用点记法,如0x.54表示0xbfc75a54,以此类推。

这里写图片描述

内存地址从栈底到栈顶递减,压栈就是把ESP指针逐渐往地低址移动的过程。而结构体tStrt中的成员变量memberX地址=tStrt首地址+(memberX偏移量),即越靠近tStrt首地址的成员变量其内存地址越小。因此,结构体成员变量的入栈顺序与其在结构体中声明的顺序相反。

函数调用以值传递时,传入的实参(locMain1~3)与被调函数内操作的形参(para1~3)两者存储地址不同,因此被调函数无法直接修改主调函数实参值(对形参的操作相当于修改实参的副本)。为达到修改目的,需要向被调函数传递实参变量的指针(即变量的地址)。

【扩展阅读】函数局部变量布局方式

与函数调用约定规定参数如何传入不同,局部变量以何种方式布局并未规定。编译器计算函数局部变量所需要的空间总数,并确定这些变量存储在寄存器上还是分配在程序栈上(甚至被优化掉)——某些处理器并没有堆栈。局部变量的空间分配与主调函数和被调函数无关,仅仅从函数源代码上无法确定该函数的局部变量分布情况。

基于不同的编译器版本(gcc3.4中局部变量按照定义顺序依次入栈,gcc4及以上版本则不定)、优化级别、目标处理器架构、栈安全性等,相邻定义的两个变量在内存位置上可能相邻,也可能不相邻,前后关系也不固定。若要确保两个对象在内存上相邻且前后关系固定,可使用结构体或数组定义。

堆栈

 函数调用时的具体步骤如下:

 1) 主调函数将被调函数所要求的参数,根据相应的函数调用约定,保存在运行时栈中。该操作会改变程序的栈指针。

 注:x86平台将参数压入调用栈中。而x86_64平台具有16个通用64位寄存器,故调用函数时前6个参数通常由寄存器传递,其余参数才通过栈传递。

 2) 主调函数将控制权移交给被调函数(使用call指令)。函数的返回地址(待执行的下条指令地址)保存在程序栈中(压栈操作隐含在call指令中)。

 3) 若有必要,被调函数会设置帧基指针,并保存被调函数希望保持不变的寄存器值。

 4) 被调函数通过修改栈顶指针的值,为自己的局部变量在运行时栈中分配内存空间,并从帧基指针的位置处向低地址方向存放被调函数的局部变量和临时变量。

 5) 被调函数执行自己任务,此时可能需要访问由主调函数传入的参数。若被调函数返回一个值,该值通常保存在一个指定寄存器中(如EAX)。

 6) 一旦被调函数完成操作,为该函数局部变量分配的栈空间将被释放。这通常是步骤4的逆向执行。

 7) 恢复步骤3中保存的寄存器值,包含主调函数的帧基指针寄存器。

 8) 被调函数将控制权交还主调函数(使用ret指令)。根据使用的函数调用约定,该操作也可能从程序栈上清除先前传入的参数。

 9) 主调函数再次获得控制权后,可能需要将先前的参数从栈上清除。在这种情况下,对栈的修改需要将帧基指针值恢复到步骤1之前的值。

 步骤3与步骤4在函数调用之初常一同出现,统称为函数序(prologue);步骤6到步骤8在函数调用的最后常一同出现,统称为函数跋(epilogue)。函数序和函数跋是编译器自动添加的开始和结束汇编代码,其实现与CPU架构和编译器相关。除步骤5代表函数实体外,其它所有操作组成函数调用。

以下介绍函数调用过程中的主要指令。

 压栈(push):栈顶指针ESP减小4个字节;以字节为单位将寄存器数据(四字节,不足补零)压入堆栈,从高到低按字节依次将数据存入ESP-1、ESP-2、ESP-3、ESP-4指向的地址单元。

 出栈(pop):栈顶指针ESP指向的栈中数据被取回到寄存器;栈顶指针ESP增加4个字节。

这里写图片描述

可见,压栈操作将寄存器内容存入栈内存中(寄存器原内容不变),栈顶地址减小;出栈操作从栈内存中取回寄存器内容(栈内已存数据不会自动清零),栈顶地址增大。栈顶指针ESP总是指向栈中下一个可用数据。

 调用(call):将当前的指令指针EIP(该指针指向紧接在call指令后的下条指令)压入堆栈,以备返回时能恢复执行下条指令;然后设置EIP指向被调函数代码开始处,以跳转到被调函数的入口地址执行。

 离开(leave): 恢复主调函数的栈帧以准备返回。等价于指令序列movl %ebp, %esp(恢复原ESP值,指向被调函数栈帧开始处)和popl %ebp(恢复原ebp的值,即主调函数帧基指针)。

 返回(ret):与call指令配合,用于从函数或过程返回。从栈顶弹出返回地址(之前call指令保存的下条指令地址)到EIP寄存器中,程序转到该地址处继续执行(此时ESP指向进入函数时的第一个参数)。若带立即数,ESP再加立即数(丢弃一些在执行call前入栈的参数)。使用该指令前,应使当前栈顶指针所指向位置的内容正好是先前call指令保存的返回地址。

以上文章参考这里写链接内容

知乎上也有相关的问题http://www.zhihu.com/question/22444939

这里总结下 
1、函数栈是从高地址向低地址增长的,这也说明了栈内存大小是有限制的。 
2、ESP:栈指针寄存器(extended stack pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶 
EBP:基址指针寄存器(extended base pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部 
EIP:指令寄存器(extended instruction pointer), 其内存放着一个指针,该指针永远指向下一条待执行的指令地址。

3、函数的形参是从右向左入栈的,就是最右边的参数地址最高,最左边的参数地址最低,这是为了便于ebp寻址


猜你喜欢

转载自blog.csdn.net/Nercececece/article/details/80235997