【软件开发底层知识修炼】二十三 ABI-应用程序二进制接口三之深入理解函数栈帧的形成与摧毁

1 什么是函数栈帧

早在之前我们就已经认识到了函数栈帧的作用。只不过一直没有拿出来说。那么到底什么是函数栈帧呢?下面这幅图,很多人应该看过的:
在这里插入图片描述

图一

上图是函数运行时函数所需要的栈内存的结构。每个运行的函数都有这么一个栈结构。它记录了函数的运行状态信息等。至于为什么是上图的这种结构,这其实就是ABI的规范所规定的了。现在可能有的人还不懂上述这种结构的作用。不用着急,在后面我们就会详细说明上述结构的具体作用了。在那之前,我们先要知道以下三点;

ABI定义了函数调用时

  • 栈帧的内存布局(就是上图的布局)
  • 栈帧的形成方式(上图的形成方式)
  • 栈帧的销毁方式(函数调用结束后,上述栈帧就会消失)

以上都是ABI的规范内容。对于不同的平台很有可能上述的三点都不一样。那么相应的编译器一定要满足相应的ABI规范才可以。由此可见,ABI是多么重要。

1.1 函数栈帧中ebp寄存器

我们学过x86汇编的话,就应该知道寄存器是个什么东西。如果不懂可以看我另一个专栏《x86汇编》–点击链接查看

在函数栈帧中,最重要的寄存器有两个,一个是栈顶指针寄存器esp,一个是函数帧帧基址寄存器ebp。由于esp的作用很容易理解,它就是指向栈顶的寄存器,这里不再多说。我们想说的是ebp寄存器。它在函数调用的过程中可谓是一个纽带。用于连接调用函数和被调用函数的。

  • ebp为当前栈帧的基准,它存储的数据是上一个栈帧(即当前函数调用者的栈帧)的ebp的值。有时候我们喜欢叫做old_ebp。
  • 通过当前函数的ebp,可以获得当前函数的栈帧中存的当前函数的参数以及当前函数的局部变量。同时还可以通过ebp这个基准找到上一个函数(即调用者函数)的返回地址。 这就是为什么ebp被称为当前函数栈帧的基准。

上述的说的两段话,很多人都听过,也都知道。但是并不是所有人都知道,如何使用ebp来定位其他的参数。下面我们看一下图:
在这里插入图片描述

图二

注:在x8632位系统中,栈中的最小存储单位一般是4字节。所以每次偏移都是直接偏移4字节

  • 上图中,就是我们使用ebp这个基准来找到函数栈帧中的其他参数的。
  • ebp作为基准,存储的是上一个栈帧的ebp值。
  • ebp向上偏移4字节,存储的是函数返回地址。这也是当前函数栈帧形的第一个存储的值(但是一定不是函数发生调用时第一个入栈的,后面会说),用于在当前函数执行完之后能够返回到调用者的函数中继续执行
  • ebp向下偏移4字节就开始存储一些需要保存的寄存器的值。这些寄存器的值往往是调用者在之前执行的时候可能正在使用,但是现在突然跳转到其他函数执行,被调用的函数可能也需要使用一些调用者之前正在使用的寄存器,所以现在先要将那些寄存器压入栈中存起来,等被调用函数执行完返回给调用者时,再将其弹出,好能够让调用函数能够继续正常执行。
  • 在往下偏移就是存储的当前运行函数的局部变量以及临时变量了。主意这里不是存储的函数参数,函数参数在ebp往上偏移8字节处
  • 我们可以看到上面都是说的被调用者函数栈帧中内容。被调用者函数栈帧中并没有被调用者的参数。参数在哪里?实际上参数存储在ebp往上偏移8字节处,但是这个地方,已经不属于当前函数的栈帧了,而是属于调用者的栈帧。其实这里估计很多人不明白。我们要记住,函数参数,是存在于调用者的函数栈帧中而并非是存在被调用的函数的栈帧中。
  • ebp+8存储的是第一个参数,ebp+4(n+1)位置存储的是第n个参数。ABI规范中,还涉及到参数的入栈顺序,后面还会说明。
  • 我认为上述唯一需要注意的就是函数的参数是存储在调用者的函数栈帧中,而不是被调用函数的函数栈帧中。这一点在面试中也有问到,问你发生函数调用时是函数参数先入栈还是返回地址先入栈?乍一看以为返回地址是在函数栈帧的第一个位置就以为是函数栈帧先入栈,实际上是错的。发生函数调用时,函数的参数先入栈,只不过入的栈是属于调用者的栈而已。

1.2 Linux系统中的栈帧布局

中所周知,一般来说栈的增长方向是向下的,下面就给出一个图,表示在Linux系统下的栈帧的布局。由于与上线的中文的图几乎一样(只是画反了),这里就不再用过多的语言来描述下图

在这里插入图片描述

图三

2 函数调用时的‘前言’和‘后序’

上一节内容我们很清晰的认识了函数栈帧的结构以及函数栈帧中的重要的寄存器ebp的作用。下面就来详细说说函数发生调用时,具体的一些细节操作。我们先说调用过程中的一些细节,后面再给出具体的代码案例。看过代码案例再结合回来看,就基恩完全掌握了函数栈帧的作用了。

2.1 函数调用时发生的细节操作

  • 函数调用时发生的细节操作
  • 调用者一般通过call指令调用函数,调用的函数有参数的话先将参数以某种顺序压入到调用者的函数栈帧中,然后将返回地址压入栈中。从这个返回地址开始往后,就是新的被调用的函数的函数栈帧了。
  • 函数所需要的栈帧的空间大小,首先肯定是由编译器计算出来了,此时函数栈的大小已经是一个固定值,是一个字面常量了,所以函数栈帧的大小是固定的
  • 函数结束时,leave指令恢复上一个栈帧esp和ebp的值。
  • 函数返回时,ret指令将返回地址恢复到eip寄存器,即PC指针寄存器。

上一面的leave和ret可能还没讲明白,它们主要表现为下面的具体行为:
在这里插入图片描述

我们来解释一下上面几条指令的矩形行为:

  • move ebp, esp 。是将ebp(这个ebp是当前函数的ebp,它存的是上一个函数栈帧的ebp的值)赋值给esp。也就是说此时esp存的是上一个函数栈帧(调用者的函数栈帧)的ebp的值
  • pop ebp。是将栈顶指针,也就是esp指向的值(上面第一步的操作导致现在esp的值存储的是调用者的old_ebp的值)弹出给ebp寄存器。这一步操作完,此时ebp寄存器存的是调用者的基准了,不再是被调用函数的基准了。同时还需要注意,在发生pop之后,esp就会向上偏移4字节,此时esp就是指向返回地址的存储地址了(看上面函数栈帧的结构)。
  • pop eip 。 是将当前栈顶也就是esp指向的值(由上两步知此时esp指向的值返回地址,也就是调用者当时发生函数调用时压入的下一条即将要执行但是却因为发生函数调用而没有执行的指令的地址)弹出给eip寄存器。而eip寄存器的主要作用是:它保存的永远是CPU下一次即将要执行的指令的地址。 刚刚好,此时eip保存就是调用者当时发生函数调用时压入的下一条即将要执行但是却因为发生函数调用而没有执行的指令的地址,那么,顺理成章,CPU开始执行这条指令,我们又返回到了调用者开始继续执行函数。

2.2 函数调用时的前言和后序

什么是前言?什么是后序?

前言:

  • 函数发生调用时,总会保存调用者之前正在使用的一些通用寄存器的值,为了能够在函数调用返回时调用者能够继续正常执行程序。这个保存这些寄存器的值就是前言。

后序

  • 如上所说,函数调用返回时,会把之前在前言的过程中保存的寄存器的值给pop出来好让调用者继续正常执行程序。这就是后序。

前言和后序的具体汇编上的行为大概就是下面表格中所列的一些行为:
在这里插入图片描述

图四

其中push的操作就是保存寄存器的值。如果不理解上面的指令,那还需要加强一下汇编指令的学习。参考我其他的文章。

3 函数栈帧结构的实际代码案例分析

3.1 代码

#include <stdio.h>

#define PRINT_STACK_FRAME_INFO() do                        \
{                                                          \
    char* ebp = NULL;                                      \
    char* esp = NULL;                                      \
                                                           \
                                                           \
    asm volatile (                                         \
        "movl %%ebp, %0\n"                                 \
        "movl %%esp, %1\n"                                 \
        : "=r"(ebp), "=r"(esp)                             \
        );                                                 \
                                                           \
   printf("ebp = %p\n", ebp);                              \
   printf("previous ebp = 0x%x\n", *((int*)ebp));          \
   printf("return address = 0x%x\n", *((int*)(ebp + 4)));  \
   printf("previous esp = %p\n", ebp + 8);//调用者函数栈帧最后一个值,也就是被调用者函数栈帧的第一个参数                 \
   printf("esp = %p\n", esp);                              \
   printf("&ebp = %p\n", &ebp);                            \
   printf("&esp = %p\n", &esp);                            \
} while(0)

void test(int a, int b)
{
    int c = 3;
    
    printf("test() : \n");
    
    PRINT_STACK_FRAME_INFO();//打印test函数的函数栈帧信息
    
    printf("&a = %p\n", &a);
    printf("&b = %p\n", &b);
    printf("&c = %p\n", &c);
}

void func()
{
    int a = 1;
    int b = 2;
    
    printf("func() : \n");
    
    PRINT_STACK_FRAME_INFO();//打印func函数的函数栈帧信息。
    
    printf("&a = %p\n", &a);
    printf("&b = %p\n", &b);
    
    test(a, b);  //func函数中发生函数调用
}

int main()
{
    printf("main() : \n");
    
    PRINT_STACK_FRAME_INFO();  //打印main函数的函数栈帧信息
    
    func();  //main函数中发生函数调用。

    return 0;
}
  • 先将该函数编译运行得到结果,再慢慢分析

在这里插入图片描述

3.2 分析函数栈帧的形成与摧毁

下面我们来根据上述代码的运行结果,来分析上述代码中的函数栈帧结构的形成过程与摧毁过程

3.21 函数栈帧的形成

上述代码比较简单,可以看出函数的调用关系为:main—> func—>test

首先在程序运行起来后,内存中只有main函数的函数栈帧。这里我们忽略main函数的参数,并且main函数也没有局部变量,这里就不看main函数的函数栈帧。

  • func函数中,有两个局部变量,a和b。首先开始构建func函数的函数栈帧。如下图:

在这里插入图片描述

图五-func函数栈帧的形成图

注释:上面图中,main函数的.text段,说法有误,应该是text段中的main函数即将要执行的下一条指令

  • 上面的func函数栈帧行程图中,已经标示的非常详细,可以对比文章前面的图一与上面frame.c程序的运行结果,看看各个地址值是否正确。当然你自己运行的结果的各个值有可能与我的不一样。可以自己画图看看。
  • 上述我只是给出了最终形成的func函数栈帧,具体的形成过程其实也很简单
  1. main函数使用call指令调用函数func,因为没有传参数给func函数,所以就没有入参这一步骤
  2. 首先将main函数下一条即将要马上执行的指令的地址压入栈中,如上图的的return address
  3. 然后将main函数栈帧的ebp的值(注意不是ebp对应内存存的值,而是ebp本身的值)
    压入栈中,此时这个地址就是func函数栈帧的ebp的值。在将main函数的ebp的值压栈后,就会将此时的存main函数栈帧ebp的地址,也就是上图的0xbfa370b8这个值,赋值给ebp寄存器。此时的ebp的值立马就变了。
  4. 然后就是将main函数可能正在使用的通用寄存器压栈保存。这里到底是谁来做的?实际上是在当初编译程序的时候,由于函数栈帧的大小就已经由编译器计算出来了,编译器也就生成了一些指令,这些指令负责此时的寄存器的压栈操作。注意,这些动作实际上都可以说成是编译器的行为。虽然编译该程序是在执行这些动作之前完成的,但是毕竟那些指令是编译器产生的。
  5. 然后保存func函数的局部变量。如上图5
    • 然后保存的是一些其他信息。这些其他信息一直没有搞明白的是什么。这次看得出来,保存的有ebp的值。至于为什么保存它,目前我还没有详细研究。有待考证。
  • 因为func函数在最后调用了test()函数,并且test函数有参数,所以在调用test函数时,test函数的函数栈帧开始形成,形成过程与上述的func函数的形成过程类似。形成后,test函数的函数栈帧大致如下图所示:
    在这里插入图片描述
图六-test函数栈帧的形成图

下面大致说一下上面的test函数中的栈帧形成的过程。

  1. 在func函数中调用test函数,func使用call指令调用test函数。因为func给test函数传了参数a,b。在Linux系统中ABI规范参数入栈顺序是从右向→左,所以先将参数入栈,且参数b先入栈,a再入栈。
  2. 然后将func函数本身接下啦要执行的指令的地址压入栈中。
  3. 然后将func函数的ebp值的地址也就是0xbfa370b8,压入栈中,此时test函数栈帧中的ebp就指向这个值。
  4. 然后压入一些寄存器,比如func函数使用的通用寄存器,需要保存起来。可以看到teat栈帧中寄存器的大小占8字节,很有可能是因为func函数有两个局部变量,所以func函数使用了两个通用寄存器,此时都需要保存起来。
  5. 然后就是将test函数的局部变量c压入栈中。
  6. 然后将ebp这个地址压入栈中,以方便找到当前函数栈帧的ebp在哪。实际上这里我也不太明白为什么还要将ebp这个地址压入栈,毕竟当前的寄存器EBP肯定已经存的就是这个地址了。
  7. 如果有其他信息还需要压入其他信息的。

3.22 函数栈帧的摧毁

上面一小节讲了函数栈帧的形成。当函数执行完之后相应的函数栈帧就会销毁。下面我们来看看函数栈帧是如何销毁的?

由上线的fram.c代码知道,函数的调用时main—>func—>test

那么当test函数执行完之后,就会返回到func函数中继续执行,返回到func函数后,test的栈帧就销毁了。那么如何从test栈帧返回到func栈帧?如下图:

在这里插入图片描述

不知是否还记得上面讲过这些指令的意思不记得的话,最好回去看看。那么在函数结束并且返回,就是执行上述指令的。

  • test函数栈帧的摧毁过程
  1. move ebp, esp 就是将ebp存的值,也就是0xbfa37088,赋值给esp寄存器。那么现在由于esp的值变了,如下图:

在这里插入图片描述

图七-test函数栈帧的摧毁一
  1. pop ebp 就是将当前栈顶指针指向的值(也就是0xbfa370b8)弹出并赋值给ebp寄存器,此时ebp等于0xbfa370b8,它所在位置存储的是func函数的ebp值。并且esp指针向上偏移4字节。如下图:
    在这里插入图片描述
图七-test函数栈帧的摧毁二
> 此时由于esp已经指向了func函数的栈帧的顶部,ebp也是func函数栈帧的ebp了。所以此时test函数栈帧就差一步就要摧毁了。看下一步:
  1. pop eip 就是将当前栈顶指针指向的值弹出,并赋值给eip寄存器。同时esp向上偏移4字节。如下图:
    在这里插入图片描述
图七-test函数栈帧的摧毁三

注意上面的eip寄存器的用处;eip保存的始终是CPU即将要执行的指令地址。所以此时保存的是func函数中某一条指令的地址,此时开始在func函数中执行。

  1. 好了,现在test函数栈帧已经摧毁。并且也返回到了func函数中执行。那么就回到了下图的样式:

在这里插入图片描述

注意上面的esp指向的应该是test函数的参数,这里没有显示出来。

  • func函数栈帧的摧毁过程

现在回到func函数中执行,由于此时func也是最后一条指令,func函数也要结束并返回了。

  1. 同样是需要先执行move ebp esp。得到如下样式的栈帧图:
    在这里插入图片描述

  2. 然后执行pop ebp指令得到如下图所示:
    在这里插入图片描述

  3. 最后执行ret指令,pop eip 。得到如下图:

在这里插入图片描述

  1. 好了,最终func函数也返回结束了,接下来就指向下main函数的栈帧了:
    在这里插入图片描述

至于main函数栈帧的摧毁,与上线两个一样。只不过main函数的返回,是返回给操作系统的了。这里就不再赘述。

4 总结

本文学习起来异常艰难,但是学会了受益匪浅。

本文使我学会了以下:

  • 栈帧是函数调用时形成的链式内存结构
  • ebp是构成栈帧的核心基准寄存器
  • 深入掌握了函数栈帧的形成与摧毁

欢迎加我好友共同探讨学习交流!欢迎指正文章中的错误!!!

  • 3
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值