基于DWARF的栈回溯
引
使用fp(frame pointer)
进行栈回溯的前提是在汇编中有显式的操作frame pointer
的部分,那么如果没有这部分我们该如何做栈回溯呢?
栈回溯的三步骤
在进行栈回溯的时候,我们一般会做如下的事情:
- 找到上一级调用函数的栈帧
- 找到调用函数返回地址
- 基于返回地址转换成调用函数名
例如在基于fp
的回溯中,对于main->foo->bar
的调用,我们从bar
开始做回溯:

在fp
回溯的场景中,这三件事通过如下的方式实现:
- 找到上一级调用函数的栈帧:通过保存在运行栈中的
rsp
指针值进行向上回溯; - 找到调用函数返回地址:通过栈中找返回地址即可;
- 基于返回地址转换成调用函数名:通过调试信息(
debug info
)来进行转换。
那么,在去掉fp
信息以后,会影响的就是第一点:如何找到上一级调用函数的栈帧?
函数调用流程
在函数调用的时候,一般会做如下的事情:
- 压入参数
- 压入返回地址
- 压入
RBP
(开启fp
)
而这些操作在编译的时候其实都已经确定了,那么是不是在编译的时候我们就可以计算出两个栈帧之间对应的差值呢?
DWARF与CFI
DWARF(Debugging With Attributed Record Formats)
是一种计算机程序的格式,它是为了让开发人员能够在不同的操作系统和硬件平台上共享二进制代码而设计的。DWARF格式包含了程序中调试信息的元数据,包括变量名、函数名、行号等等。这些信息可以帮助开发人员在程序出现问题时进行调试和分析。DWARF
格式通常与编译器一起使用,编译器会将调试信息嵌入到生成的可执行文件中。在DWARF
标准中,定义了一种名为CFI(Call Frame Information)
的标准,用来帮助我们进行栈回溯。
例如我们用gcc
进行编译时,携带-g
选项就会默认带上DWARF
调试信息,我们可以用file
命令查看:

这里携带了DWARF
格式的调试信息,我们可以用readelf
查看:

这里由debug
开头的就是DWARF
相关的信息。一般我们会用strip
去掉调试信息,尝试一下:

可以看到这里file
以后已经不再提示with debug_info
了,并且readelf
也无法得到debug_*
段的信息了,此时这个二进制就没有调试信息了。
注意,这里的debug_*
段在执行时是不会被加载的,那么,我们借助什么来做运行时的栈回溯呢?
在LSB
格式中,我们定义了eh_frame
段,这部分内容不会被strip
掉,并且内容符合DWARF
中CFI
的标准,一般libunwind
就是通过这部分信息来做栈回溯的。
实际上帮助我们回溯的是CFI信息,后续我们统一将其称为基于DWARF的回溯。
我们可以用readelf -wF
来查看ELF
中的相关CFI
信息:

这里我们看到的部分称为FDE(Frame Description Entry)
,具体怎么使用且看下文。
基于DWARF的栈回溯流程
为了介绍DWARF
的回溯,我们以如下的程序作为例子:

这次我们通过-fomit-frame-pointer
选项来进行编译,直接关闭掉fp
指针。我们直接查看foo
函数执行的相关情况:

我们查看对应的FDE
:

CIE也是eh_frame的信息。此处不深究。
每个函数对应一个FDE
,FDE
条目记录了一些自己的信息,和对应函数的信息。我们该如何去做回溯呢?
我们假设从foo
开始回溯,此时的rsp
寄存器的值为A
,此时运行的指令是40074b
这条指令。
首先我们在foo
对应的函数中找到40074b
这条指令:

这里的CFA
就是调用者的栈帧地址,因此我们直接把当前RSP
寄存器中的值拿出来进行计算,算出CFA = RSP + 8 = A + 8
,这样我们就知道了上一级调用者的栈帧地址。同时我们可以计算出ra
也就是返回地址的内容:RA = c - 8 = A + 8 - 8 = A
。根据RA
的值,我们可以继续拿着这个信息向上回溯,因为RA
一定是调用者也即这里的main
函数的地址,我们进入到main
的FDE
中重复该步骤,寻找RA
对应的条目,并进行回溯。不停循环我们就可以持续不断地向上回溯,直到完成整个调用栈的回溯。
我们用一张图简单的表达一下:

值得注意的是,这种回溯方式还可以帮助我们回溯到其他的通用寄存器信息:

和计算RA
寄存器一致的,我们也是先算出CFA
的地址,然后再进行相关运算即可。例如在40082b
位置时,r14 = c - 24 = rsp + 48 - 34 = rsp + 24
。
perf中基于DWARF
的栈回溯
在使用perf
进行profiling
的时候,我们可以通过如下的语句进行抓取:

这里的-g
选项就是进行栈回溯的选项,默认通过fp
来进行回溯,我们不妨用我们的例程进行一次尝试:

在结果中我们发现如下的调用栈:

在结果中,有多种调动栈,foo
、bar
、main
等函数都有出现过,但是它们的调用栈都是有问题的。这是因为在进行fp的回溯的时候,缺少信息无法向上回溯导致的。只能知道自己的函数名,找不到自己的栈帧导致了错误的回溯。
我们尝试通过DWARF
的方式来进行回溯:

可以看到,这样我们就成功的实现了栈回溯。
小结
今天我们介绍了基于DWARF
的栈回溯,对比基于fp
的栈回溯方式,CFI
通过运行时调试信息的方式来帮助回溯。
你是否对CFI
还是一知半解呢?我们是否有什么其他的栈回溯方式呢?我们后面接着介绍关于栈回溯的相关内容。