消失的调用栈帧-基于fp的栈回溯原理解析

169 阅读7分钟

消失的调用栈

在某次调用栈的采集中,我们发现调用栈少了一层,如下是我们的调用示例代码:

示例代码

我们通过如下的命令进行编译:

编译

按照我们的理解,这里采集到的调用栈应该会出现main() -> foo() -> bar()的调用栈,但是在实际采样中,我们发现采集到的是如下的调用栈:

bar()不见了

从调用栈里我们会发现少了bar()函数而是直接到了malloc函数,这是怎么回事呢?

栈回溯的原理

需要先了解程序运行时栈的相关流程,如果不了解的同学可以先去了解一下。简单的说就是系统借助栈来进行函数调用。

首先我们让ChatGPT给我们介绍一些相关的概念:

  • fpframe pointer,帧指针,指在函数调用过程中用于定位和访问函数栈帧的指针。它指向当前函数的栈帧的起始位置,栈帧包含了函数的局部变量、参数以及其他与函数执行相关的信息。通过帧指针,程序可以在运行时准确地访问和管理函数的局部数据。帧指针通常是在编译器生成的汇编代码中使用,并且在调试和性能分析中也经常被使用。
  • 栈帧:stack frame ,是指在函数调用过程中,为了维护函数的局部变量、参数以及其他与函数执行相关的信息而在运行时创建的一块连续的内存空间。每当一个函数被调用时,就会在程序的栈空间中创建一个新的栈帧,用于存储该函数的局部变量、参数以及其他必要的信息。当函数执行完毕后,它的栈帧会被销毁,从而释放相应的内存空间:
栈帧 - https://www.huamo.online/2019/06/25/%E6%B7%B1%E5%85%A5%E7%A0%94%E7%A9%B6goroutine%E6%A0%88/
  • 栈回溯:指在程序运行过程中,通过查看当前程序的调用栈(Call Stack)来确定程序执行过程中的函数调用路径。栈回溯通常用于调试程序,特别是在程序出现异常或错误时,通过查看栈回溯信息可以快速定位问题所在的位置。栈回溯的目的是为了帮助程序员理解程序执行过程中的函数调用关系,从而更好地进行程序调试和错误排查。通过查看栈回溯信息,程序员可以了解到程序执行到哪个函数、该函数是被哪个函数调用的、函数调用的参数和返回值等信息,从而更好地理解程序执行的过程和问题所在。
  • RSP寄存器是栈指针(Stack Pointer)的通用寄存器表示形式。栈指针是一个指向当前栈顶的寄存器,用于指示当前程序的栈空间的结束位置。在函数调用过程中,栈指针被用于跟踪函数调用栈的状态,以便正确地分配和释放栈空间。压栈的时候会自动减少一定字节,例如32位机器会减少4字节。
  • 运行地址可以通过范围来判断属于哪个函数,有了运行地址都可以知道函数。

在讲述中,我们统一将SP/RSP/ESP寄存器介绍为RSPBP/RBP/EBP寄存器介绍为RBP,以32位机器为例,并且不考虑返回地址等信息。

在前文中的图中,我们可以看到RSP寄存器是栈指针,指向栈顶的位置;而最常见的一种栈回溯方式是通过RBP寄存器来记录下上层调用者的RSP指针的值来进行栈回溯的。

具体是怎么实现的呢?我们通过结合main -> foo -> bar的汇编和调用来进行讲解。首先,我们假设当前是main函数:

main

接着,main函数通过call 命令去调用foo函数,foo汇编如下所示:

foo汇编

call命令执行的时候,会将返回地址压进去;此外可以看到这里通过push操作将RBP寄存器压栈了,此时RSP会发生变化,并且将此时RSP的值放到了RBP中,如图所示:

foo

也即:在调用后,系统会将调用者的RBP记录到栈中,并更新RBP为当前函数的RSP留给下次被调用的函数使用。RBP总是保存栈中的地址。

接着,foo函数会去调用barbar函数的汇编如下图所示:

bar汇编

也是和foo的调用一致,会先将RBP压栈并设置RBP = RSP,此时的调用栈情况:

bar

那么,我们怎么去进行栈回溯呢?

当抓到当前的运行地址时,可以通过当前的RBP找到栈中的记录的的地址,从而一层层的向上回溯:

RBP回溯

那能找到RBP信息以后我们该如何进行函数的判断呢?我们注意到RBP是被调用函数首先执行的部分,而调用函数会通过call来调用,call的时候会压入返回地址,这个返回地址肯定是属于调用函数的,我们可以基于这个来进行判断。例如在main调用foo的时候,压入的地址肯定是在main函数中的,所以我们在foo的部分可以向上找到返回地址,从而判断foo是被谁调用的。

此处进行地址和函数转换的时候需要调试信息(debug info),否则会找不到函数名。

因此,整个栈回溯流程就变成了:

栈回溯

基于这个回溯流程,我们就可以得到bar -> foo -> main的回溯,反一下就是相应的调用栈了。

我们可以得出一个结论:**只有被调用函数才能保存调用函数信息。**也即bar函数是由foo函数来协助找到的。

调用栈消失的原因

为了解决前面提到的问题,我们对二进制进行了反汇编:

objdump -d -S back > back.asm

bar函数中,我们发现其调用了下一级函数:

call

我们去查看这个地址的信息:

400600

我们发现这里并没有去和前几次调用一样去做push %rbp的操作,结合我们前文所说,从这里进行栈回溯的时候就自然而然的不能找到bar函数了,如图所示:

回溯

这里我们假设该函数为malloc。可以设想后续的调用如果没有压栈RBP的操作的话都是一致的。

那么为什么这里没有做压栈操作呢?这是因为push %rbpmov %rsp,%rbp是实实在在写在汇编代码中的,如果每次调用都执行的话会带来性能损耗,也即**通过frame pointer方式获取调用栈会多执行指令,会带来一定的性能损耗。**因此在实际场景中,都默认不编译这两条汇编,例如在g++中如果要携带这类信息需要通过-fno-omit-frame-pointer选项来保留frame pointer信息。这里我们自己编写的代码是通过-fno-omit-frame-pointer选项进行编译的,但是在调用标准库的时候,标准库的代码并不一定是通过-fno-omit-frame-pointer编译的,因此可能会出现缺少某一层调用栈的情况。

例如我们通过-fomit-frame-pointer选项编译了同样的代码,其中foo函数的汇编如下图:

foo无push RBP操作

总结

本文主要基于一个消失的调用栈的例子,分析了基于fp的栈回溯的原理。然而,在实际的生产环境中,很多时候都是不开启-fno-omit-frame-pointer选项的,那这个时候我们该如何去做调用栈分析呢?我们下一篇文章再介绍别的栈回溯方式与原理。

参考资料