怎样获得正确的callchain
承刚
本文针对在某些场景下通过perf或tcmalloc等功能获取的callchain不准确的问题进行了分析。
开启GCC的O*优化选项时,会默认开启一个称为“-fomit-frame-pointer”的优化选项。该选项会将栈底指针寄存器EBP用作通用寄存器。这在一定程度上会减少GCC生成的Binary文件的footprint,并多提供一个通用寄存器。我们知道,如果程序的footprint较小,那么程序执行期间触发L1I Cache的Miss Rate就会降低。从而能够加快程序的执行。在加上多出一个通用寄存器所起的作用,可以说优化选项“-fomit-frame-pointer”还是能起到一定作用的。
本文通过以下实验测试了这条优化选项的加速比。 实验中编写了一个很简单的benchmark,如图1所示。
#include <stdio.h>
#include "ptime.h"
#define LIFE (u64)1e9
int do_some(int i)
{
return i++;
}
int main(void)
{
u64 start_t = rdclock();
u64 sum = 0;
while (1) {
if (rdclock() - start_t > LIFE)
break;
sum += do_some(1);
}
printf("sum: %llu\n", sum);
return 0;
}图1. Benchmark
图1中的程序在1秒钟内不断调用一个函数,最后输出计算结果。通过对比计算结果的不同,便能够初略估计出“-fomit-frame-pointer”选项带来的优化加速比。 上述实验得到的数据如下:
1)未开启“-fomit-frame-pointer”选项:5428437
2)开启“-fomit-frame-pointer”选项:5525344
由此可以得出该选项的优化加速比为:1.79%。
通过上述实验,可以得知在如此简单的程序中,“-fomit-frame-pointer”选项都能够带来一定的性能优化。在性能优化上精益求精的GCC便将其作为了-O*的默认选项。 但是这项优化带来了很大的麻烦,开启该选项后,便无法通过常规的方式获得程序的callchain了。如果EBP寄存器扮演栈底指针的角色,我们就能够通过它获得函数的返回地址,以及上个frame的返回地址。以图1中的程序为例,函数do_some()的汇编指令如图2所示。
0000000000400538 <do_some>: 400538: 55 push %rbp
400539: 48 89 e5 mov %rsp,%rbp
40053c: 89 7d fc mov %edi,0xfffffffffffffffc(%rbp)
40053f: 8b 45 fc mov 0xfffffffffffffffc(%rbp),%eax
400542: 83 45 fc 01 addl $0x1,0xfffffffffffffffc(%rbp)
400546: c9 leaveq
400547: c3 retq
图2. do_some()函数的汇编指令
在执行do_some()函数之初,首先将rbp寄存器压栈。此时rbp中是上个frame的栈底地址。而栈底保存的是该函数的返回地址,也恰恰就是我们需要的callchain上的一个函数地址。利用此机制,便能够遍历stack上的每个frame,并拿到每个frame的返回地址。从而获得整个callchain。
但是,当开启了优化选项“-fomit-frame-pointer”之后,RBP寄存器就不再保存栈底指针了。因此再通过上述方式获得callchain,就是错误的。这也正是大家在利用perf的callchain功能进行profiling时,看到的是一堆混乱的地址或符号的原因。
callchain功能是程序调试,性能分析的必备功能。既然无法通过遍历用户栈的frame来获取callchain,系统就必须提供其它的方式。目前的GCC通过CFI(Call Function Instructions)机制,在编译出的ELF文件中加入了一个名为“eh_frame”的section。该section能够提供callchain相关的信息。
开源软件“libunwind”支持了对“eh_frame”的分析。只要能够提供某个时刻的指令地址与相应进程的stack,libunwind就能够正确获取此时的callchain。待我弄懂libunwind的原理后,会另外撰文详述。