backtrace 实现原理

栾烨华
2023-12-01

显示函数调用关系(backtrace/callstack)是调试器必备的功能之一,比如在gdb里,用bt命令就可以查看backtrace。在程序崩溃的时候,函数调用关系有助于快速定位问题的根源,了解它的实现原理,可以扩充自己的知识面,在没有调试器的情况下,也能实现自己backtrace。更重要的是,分析backtrace的实现原理很有意思。现在我们一起来研究一下:

glibc提供了一个backtrace函数,这个函数可以帮助我们获取当前函数的backtrace,先看看它的使用方法,后面我们再仿照它写一个。

#include <stdio.h>
#include <stdlib.h>
#include <execinfo.h>

#define MAX_LEVEL 4

static void test2()
{
int i = 0;
void* buffer[MAX_LEVEL] = {0};

int size = backtrace(buffer, MAX_LEVEL); 

for(i = 0; i < size; i++)
{
    printf("called by %p/n",    buffer[i]);
} 

return;

}

static void test1()
{
int a=0x11111111;
int b=0x11111112;

test2();
a = b; 

return;

}

static void test()
{
int a=0x10000000;
int b=0x10000002;

test1();
a = b; 

return;

}

int main(int argc, char* argv[])
{
test();

return 0;

}

编译运行它:
gcc -g -Wall bt_std.c -o bt_std
./bt_std

屏幕打印:
called by 0×8048440
called by 0×804848a
called by 0×80484ab
called by 0×80484c9

上面打印的是调用者的地址,对程序员来说不太直观,glibc还提供了另外一个函数backtrace_symbols,它可以把这些地址转换成源代码的位置(通常是函数名)。不过这个函数并不怎么好用,特别是在没有调试信息的情况下,几乎得不什么有用的信息。这里我们使用另外一个工具addr2line来实现地址到源代码位置的转换:

运行:
./bt_std |awk ‘{print “addr2line “$3″ -e bt_std”}’>t.sh;. t.sh;rm -f t.sh

屏幕打印:
/home/work/mine/sysprog/think-in-compway/backtrace/bt_std.c:12
/home/work/mine/sysprog/think-in-compway/backtrace/bt_std.c:28
/home/work/mine/sysprog/think-in-compway/backtrace/bt_std.c:39
/home/work/mine/sysprog/think-in-compway/backtrace/bt_std.c:48

backtrace是如何实现的呢? 在x86的机器上,函数调用时,栈中数据的结构如下:


参数N
参数… 函数参数入栈的顺序与具体的调用方式有关
参数 3
参数 2
参数 1

EIP 完成本次调用后,下一条指令的地址
EBP 保存调用者的EBP,然后EBP指向此时的栈顶。
----------------新的EBP指向这里---------------
临时变量1
临时变量2
临时变量3
临时变量…
临时变量5

(说明:下面低是地址,上面是高地址,栈向下增长的)

调用时,先把被调函数的参数压入栈中,C语言的压栈方式是:先压入最后一个参数,再压入倒数第二参数,按此顺序入栈,最后才压入第一个参数。

然后压入EIP和EBP,此时EIP指向完成本次调用后下一条指令的地址 ,这个地址可以近似的认为是函数调用者的地址。EBP是调用者和被调函数之间的分界线,分界线之上是调用者的临时变量、被调函数的参数、函数返回地址(EIP),和上一层函数的EBP,分界线之下是被调函数的临时变量。

最后进入被调函数,并为它分配临时变量的空间。gcc不同版本的处理是不一样的,对于老版本的gcc(如gcc3.4),第一个临时变量放在最高的地址,第二个其次,依次顺序分布。而对于新版本的gcc(如gcc4.3),临时变量的位置是反的,即最后一个临时变量在最高的地址,倒数第二个其次,依次顺序分布。

为了实现backtrace,我们需要:

1.获取当前函数的EBP。
2.通过EBP获得调用者的EIP。
3.通过EBP获得上一级的EBP。
4.重复这个过程,直到结束。

通过嵌入汇编代码,我们可以获得当前函数的EBP,不过这里我们不用汇编,而且通过临时变量的地址来获得当前函数的EBP。我们知道,对于gcc3.4生成的代码,当前函数的第一个临时变量的下一个位置就是EBP。而对于gcc4.3生成的代码,当前函数的最后一个临时变量的下一个位置就是EBP。

有了这些背景知识,我们来实现自己的backtrace:

#ifdef NEW_GCC
#define OFFSET 4
#else
#define OFFSET 0
#endif/NEW_GCC/

int backtrace(void** buffer, int size)
{
int n = 0xfefefefe;
int* p = &n;
int i = 0;

int ebp = p[1 + OFFSET];
int eip = p[2 + OFFSET]; 

for(i = 0; i < size; i++)
{
    buffer[i] = (void*)eip;
    p = (int*)ebp;
    ebp = p[0];
    eip = p[1];
} 

return size;

}

对于老版本的gcc,OFFSET定义为0,此时p+1就是EBP,而p[1]就是上一级的EBP,p[2]是调用者的EIP。本函数总共有5个int的临时变量,所以对于新版本gcc, OFFSET定义为5,此时p+5就是EBP。在一个循环中,重复取上一层的EBP和EIP,最终得到所有调用者的EIP,从而实现了backtrace。

现在我们用完整的程序来测试一下(bt.c):

#include <stdio.h>

#define MAX_LEVEL 4
#ifdef NEW_GCC
#define OFFSET 4
#else
#define OFFSET 0
#endif/NEW_GCC/

int backtrace(void** buffer, int size)
{
int n = 0xfefefefe;
int* p = &n;
int i = 0;

int ebp = p[1 + OFFSET];
int eip = p[2 + OFFSET]; 

for(i = 0; i < size; i++)
{
    buffer[i] = (void*)eip;
    p = (int*)ebp;
    ebp = p[0];
    eip = p[1];
} 

return size;

}

static void test2()
{
int i = 0;
void* buffer[MAX_LEVEL] = {0};

backtrace(buffer, MAX_LEVEL); 

for(i = 0; i < MAX_LEVEL; i++)
{
    printf("called by %p/n",    buffer[i]);
} 

return;

}

static void test1()
{
int a=0x11111111;
int b=0x11111112;

test2();
a = b; 

return;

}

static void test()
{
int a=0x10000000;
int b=0x10000002;

test1();
a = b; 

return;

}

int main(int argc, char* argv[])
{
test();

return 0;

}

写个简单的Makefile:

CFLAGS=-g -Wall
all:
gcc34 $(CFLAGS) bt.c -o bt34
gcc $(CFLAGS) -DNEW_GCC bt.c -o bt
gcc $(CFLAGS) bt_std.c -o bt_std

clean:
rm -f bt bt34 bt_std

编译然后运行:
make
./bt|awk ‘{print “addr2line “$3″ -e bt”}’>t.sh;. t.sh;

屏幕打印:
/home/work/mine/sysprog/think-in-compway/backtrace/bt.c:37
/home/work/mine/sysprog/think-in-compway/backtrace/bt.c:51
/home/work/mine/sysprog/think-in-compway/backtrace/bt.c:62
/home/work/mine/sysprog/think-in-compway/backtrace/bt.c:71

对于可执行文件,这种方法工作正常。对于共享库,addr2line无法根据这个地址找到对应的源代码位置了。原因是:addr2line只能通过地址偏移量来查找,而打印出的地址是绝对地址。由于共享库加载到内存的位置是不确定的,为了计算地址偏移量,我们还需要进程maps文件的帮助:

通过进程的maps文件(/proc/进程号/maps),我们可以找到共享库的加载位置,如:

00c5d000-00c5e000 r-xp 00000000 08:05 2129013 /home/work/mine/sysprog/think-in-compway/backtrace/libbt_so.so
00c5e000-00c5f000 rw-p 00000000 08:05 2129013 /home/work/mine/sysprog/think-in-compway/backtrace/libbt_so.so

libbt_so.so的代码段加载到0×00c5d000-0×00c5e000,而backtrace打印出的地址是:
called by 0xc5d4eb
called by 0xc5d535
called by 0xc5d556
called by 0×80484ca

这里可以用打印出的地址减去加载的地址来计算偏移量。如,用 0xc5d4eb减去加载地址0×00c5d000,得到偏移量0×4eb,然后把0×4eb传给addr2line:

addr2line 0×4eb -f -s -e ./libbt_so.so

屏幕打印:
/home/work/mine/sysprog/think-in-compway/backtrace/bt_so.c:38

栈里的数据很有意思,在上一节中,通过分析栈里的数据,我们了解了变参函数的实现原理。在这一节中,通过分析栈里的数据,我们又学到了backtrace的实现原理。

 类似资料: