显示函数调用关系(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的机器上,函数调用时,栈中数据的结构如下:
(说明:下面低是地址,上面是高地址,栈向下增长的)
调用时,先把被调函数的参数压入栈中,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的实现原理。