图解!深入浅出函数调用栈

共 3687字,需浏览 8分钟

 ·

2022-07-24 12:29

今天来和你聊聊函数调用,为什么会想到这个话题,因为最近突然想到,当然学习C语言的时候遇到的拦路虎——函数,函数在我们高中阶段的定义可能是有定义域X对应到值域Y的一种关系,而在这类函数定义中,我们总能拿到一个结果Y。而一个函数调用另一个函数,这个过程到底是怎么回事呢?

如果你有过程序的调试经历,你肯定非常熟悉下面这个画面。这是一个代码断点的调试功能。

可以看到,它是从start方法开始到main方法的,如果我们继续往下,main又调DemoClass的print方法,还能调其他的方法。那我们打一个断点就能获取整个函数的调用链,这是基于什么原理呢?为什么这个断点能找到它所在的函数位置呢?

下面就来一起看看C函数调用栈,全文脉络。

首先来回顾下一些基础知识。

前置知识

  • 栈是一种容器,具有后进先出的特性,我们函数调用的过程设计就利用了栈的特性,调用一个新的函数时,进行压栈Push,这个函数执行完进行出栈Pop。
  • 函数栈帧是一种数据结构,它保存这一个函数调用所需的信息,比如参数,局部变量,返回地址等等。
  • 在32位操作系统进行C函数调用时,ESP寄存器总是指向栈顶地址,EBP寄存器指向的存储旧EBP的起始地址。

程序地址空间

先来看张图。程序运行时主要分为用户空间和内核空间,我们主要来讲讲用户空间。

  • 栈空间:它用于维护函数调用的上下文,也就是我们本文的重点,离开了栈,那么函数调用就无法实现了。它通常 在用户空间的的最高地址开始,向下增长,也就是由高往低增长。
  • 堆空间:堆空间是用来容纳应用程序动态分配内存的区域,当我们用malloc函数时就是在这片区域分配内存,它是由低地址往高地址增长,也就是向上增长。
  • 可读可写区:这里主要包含程序的data段,以及未初始化的变量段。
  • 只读区:这里包含了text段以及rodata段,好像在安卓系统上text段是可写的,这里有待探究,有深入研究过的读者可以一起探讨。
  • 预留空间:也叫保留区,,它不是一个单一的内存区域,而是对内存中受保护而禁止访问区域的总称,很小块。

什么是调用栈

前置知识中提到了栈其实是一种容器,一种数据结构。这是我们计算机程序里的重要概念,在技术系统中,栈则是一个具有容器属性的动态内存区域,程序可以将数据压入栈中,也可以出栈。在程序地址空间里也提到栈的空间总是向下增长的。

而函数调用栈,是将一个个函数的所用的信息,称之为活动记录或者栈帧,按照调用的顺序依次压入栈中,等最上层的函数执行完了,就弹出相应的栈帧,栈帧主要包括以下几个内容:

  • 函数的返回地址和参数
  • 本地变量
  • 调用前后上下文

前面提到了EBP寄存器指向了一个旧的EBP起始地址,ESP执行栈顶,一个栈帧的具体结构如下图。

上图,参数内容之后便是当前函数的栈帧,EBP固定执行旧的EBP起始地址,而旧的EBP存储着上一个函数的执行地址,这样等到末尾出栈之后就能按层级返回上一级函数了,而ESP总是执行栈顶,会随着函数的调用或这些不断变化。

那么EBP可以用来做什么呢?

根据上图,可以很容易想到,EBP可以根据地址的加减,来获取响应的栈帧内容,比如获取返回地址 ebp-4就是 返回地址,参数也可以用ebp-8、ebp-12来获取,为什么是-4呢,我们在开头约定了是在32位机器下,4个字节就是32位了。所以EBP寄存器可以用来追踪我们的函数调用链,从而定位相关出错问题。

调用过程

上面介绍了调用栈,这里具体来看看一个函数调用链的怎么形成的。

  • 根据栈帧的结构图,首先将参数入栈。

  • 执行完这个函数之后,返回回来得接着执行,所以要将当前指令的下一条指令压入栈中,然后跳到函数体执行。

  • 将EBP压入栈中,此时的ebp还是保存着调用函数的ebp,也就是Old EBP。

  • 此时EBP其实指向栈顶的,所以将EBP的值赋给ESP,ESP就指向栈顶了。

  • 在栈区分配空间,保存old函数用到的寄存器数据。因为被调用函数执行完之后,要回到之前的函数执行,那么之前函数用到的数据得保护起来,以便于后续正常执行。

  • 被调用函数执行完,恢复相关寄存器数据,同时恢复ESP以前的数据,回收分配的空间,以及恢复EBP的数据。

  • 最后从栈帧中取到返回地址,并回到调用函数处下一条指令执行。

上面的几个步骤就是一个函数调用另一个函数的过程了,如果是多个函数调用,形成一条链,也是类似的。

调用约束

在一个函数调用另外一个函数的时候,有一些数据即可以由调用者保存,也可以有被调用者保存,那么这个时候其实就出现了两种约束:调用者约束和被调用者约束

如果在调用函数里要使用某个寄存器,可能需要先把它的值保存下来,防止破坏了别的代码保存在这里的数据。这种约定叫做被调用者约束,也就是使用寄存器的人要保护好寄存器里原有的信息。某个函数如果使用了某个寄存器,但它又要调用别的函数,为了防止别的函数把自己放在寄存器中的数据覆盖掉,要自己保存在栈桢中。这种约定叫做调用者约束

举例说明

准备代码

我们这里准备了一个带有参数X的函数test,然后利用main函数去调用它。

#include<stdio.h>
int test(int x) {
    int a = 10;
    int b = 20;
    int c = 30;
    int d = 40;
    return x + a + b + c + d;
}
int main() {
    int a = test(0);
    printf("a: %d\n",a);
    return 0;
}

编译

利用gcc编译器,编译成汇编代码,因为机器码我们根本读不懂,而汇编代码和CPU指令几乎是一对一的。相关编译指令:

gcc -S -o main.s main.c

变成汇编后的代码,由于笔者是用苹果电脑编译的,所以没有出现上面提到的EBP这些,rbp可以看成上面提到的 EBP,rsp可以看成ESP

_test:                                 
 pushq %rbp 
 movq %rsp, %rbp
 movl %edi, -4(%rbp)
  ....
 addl -20(%rbp), %eax
 popq %rbp
 retq

解读代码

函数调用

上面的汇编代码我们可以看到第一步:保存了%ebp的值,随着将%esp的值赋给%ebp,使新的%ebp指向栈顶。

看文字可能不太明确,我们来看一副对比图。

那其实这是被调用者做的事情,调用者也做了两件事:第一,将被调用函数的参数按照从右到左的顺序压入栈中。第二,将返回地址压入栈中。这两件事是调用者负责的,所以压入的栈就属于调用者的栈帧。

函数返回

我们可以注意到最后有一个 retq 指令,它其实就是相当于 pop + jum。

它首先将数据(返回地址)弹出栈并保存到EIP中,然后处理器根据这个地址无条件地跳到相应位置获取新的指令。

函数返回的过程就是调用ret这个返回指令,将执行完的函数的数据在栈中清理干净,然后回到调用前的地方继续执行,上面我们不也提到了,在函数调用另一个函数前会保存下一条执行吗?回来之后就可以继续执行。

总结

回到开篇的问题:在debug编译条件下,编译器会对代码进行很多调试信息的插入操作,这些信息能够为我们debug提供重要的支持,包括行号等等信息,当然也离不开EBP和ESP这两个在栈空间最重要的寄存器,我们能够断点调试都是因为编译器的强大,编译之美呀。后续也会和你分享编译方面的知识,记得长期持有我这只潜力股。

本文从函数调用的过程以及原理知识一起探讨了C函数调用的前前后后,希望对你有所帮助。

end


浏览 20
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报