backtrace简单实现
通常是在gdb中键入bt
来查看backtrace,但有时候我们需要跟踪某个资源,比如,在什么地方申请的,在哪些地方使用了,最后在哪些地方释放掉了。这时,我们可以使用backtrace/backtrace_symbols
来获取堆栈,结果是这样
backtrace() returned 8 addresses
./prog(myfunc3+0x5c) [0x80487f0]
./prog [0x8048871]
./prog(myfunc+0x21) [0x8048894]
./prog(myfunc+0x1a) [0x804888d]
./prog(myfunc+0x1a) [0x804888d]
./prog(main+0x65) [0x80488fb]
/lib/libc.so.6(__libc_start_main+0xdc) [0xb7e38f9c]
./prog [0x8048711]
实际使用时发现了两个限制:
backtrace/backtrace_symbols
实现使用了互斥锁- 为了看到函数名,需要在编译时指定
-rdynamic
选项,但不能看到静态函数的函数名
一
为了克服第一个限制
方法一
我们可以使用GCC的选项-finstrument-functions
结合一个TLS的stack
和sp
指针即可,比如:
#include <stdio.h>
#define _no_inst __attribute__((no_instrument_function))
#define STACK_DEPTH 100
static __thread void *stack[STACK_DEPTH];
static __thread int sp;
static void _no_inst __cyg_profile_func_enter(void *self, void *caller)
{
stack[sp++] = caller;
}
static void _no_inst __cyg_profile_func_exit(void *elf, void *caller)
{
sp -= 1;
}
void baz()
{
for (int i = 0; i < sp; ++i)
printf("%p\n", stack[i]);
}
void bar()
{
baz();
}
void foo()
{
bar();
}
int main(void)
{
foo();
}
编译运行
$ cc x.c -finstrument-functions
$ ./a.out
0x7fbd28a52565
0x55b10873d2b5
0x55b10873d275
0x55b10873d23a
这个选项的意思是,在每个函数进入和退出时插入__cyg_profile_func_enter
和__cyg_profile_func_exit
调用。对于不想被插值的函数,可以使用no_instrument_function
属性,也可以使用编译选项指定函数或者文件。
方法二
另一方面,根据x86函数调用的特点,我们可以发现,不开启优化时,GCC生成的可执行文件的函数都会有enter/leave
指令对。(针对64位程序)即进入函数时
push rbp
mov rbp, rsp
函数返回前
mov rsp, rbp
pop rbp
其中rbp
在函数中不会改变。
同时,call
指令可以看做是push
和jmp
的结合,这也就意味着,callee的rbp
加上8就是caller的返回地址(即rip
),通过这个地址,我们可以回到caller,重复执行rbp+8
即可得到函数的调用链,比如:
struct frame {
void *rbp;
void *rip;
};
int dump_stack(void **stack, int n)
{
struct frame *fp;
int depth;
asm volatile("movq %%rbp, %0" : "=r"(fp)::"memory");
for (depth = 0; depth < n && fp; ++depth) {
stack[depth] = fp->rip; // 保存caller
fp = fp->rbp; // 继续往上
}
return depth;
}
编译运行
$ ./a.out
0x55b99bda4213
0x55b99bda4287
0x55b99bda429c
0x55b99bda42b1
0x7f76f2b0e565
需要注意一点,以上实现完全依赖与enter/leave
对,如果开启优化,那么以上实现无法工作,这时需要使用-fno-omit-frame-pointer
编译选项进行编译。另外,以上实现的退出依赖于rbp
的初值为0,幸运的是GCC在_start
函数里总是有
xor ebp, ebp
二
为了克服第二个限制,我们需要从可执行文件的符号表中拿到函数的信息,就像readelf -s
那样,我们只需要解析.symtab
即可(因为.dynsym
是.symtab
的子集)。
比如这样:
Value Size Func
0x0000000000004370 0 deregister_tm_clones
0x00000000000043a0 0 register_tm_clones
0x00000000000043e0 0 __do_global_dtors_aux
0x0000000000004420 0 frame_dummy
0x0000000000004429 943 print_sym
0x00000000000047d8 1926 traverse_elf
0x0000000000004f5e 2549 read_elf
0x0000000000005b97 66 _sub_D_00099_0
0x0000000000005bd9 76 _sub_I_00099_1
0x0000000000004000 0 _init
0x0000000000000000 0 __errno_location@GLIBC_2.2.5
0x0000000000000000 0 printf
0x0000000000000000 0 __asan_register_globals
0x0000000000000000 0 fprintf
0x0000000000000000 0 __asan_report_load1_noabort
0x0000000000000000 0 __cxa_finalize@GLIBC_2.2.5
0x0000000000000000 0 __ubsan_handle_divrem_overflow
0x0000000000005953 580 main
0x0000000000000000 0 munmap@GLIBC_2.2.5
0x0000000000000000 0 __ubsan_handle_pointer_overflow
0x0000000000005ca8 0 _fini
0x0000000000000000 0 open@GLIBC_2.2.5
0x0000000000000000 0 mmap
0x0000000000000000 0 __asan_report_load4_noabort
0x0000000000000000 0 stat@GLIBC_2.33
0x0000000000004340 47 _start
0x0000000000000000 0 __asan_unregister_globals
0x0000000000000000 0 __stack_chk_fail@GLIBC_2.4
0x00000000000042a0 0 __asan_init
0x0000000000000000 0 __asan_report_store4_noabort
0x0000000000005c30 101 __libc_csu_init
0x0000000000000000 0 __asan_report_load8_noabort
0x0000000000000000 0 __asan_report_load2_noabort
0x0000000000000000 0 __asan_stack_malloc_2
0x0000000000000000 0 __ubsan_handle_type_mismatch_v1
0x0000000000000000 0 __asan_version_mismatch_check_v8
0x0000000000000000 0 __ubsan_handle_nonnull_arg
0x0000000000005ca0 5 __libc_csu_fini
0x0000000000000000 0 strerror
0x0000000000000000 0 __libc_start_main@GLIBC_2.2.5
0x0000000000000000 0 __ubsan_handle_negate_overflow
在拿到结果后,我们就可以使用以上backtrace拿到的地址,在结果列表中搜索得到对应的函数名。
但是,根据上面backtrace地址和elf中的地址肯定找不到,明显前者要比后者大很多,这是因为文件在编译时使用了-fpie -pie
选项,由于这个选项,程序可以被加载到任意地址,配合ASLR(Address Space Layout Randomization)程序每次backtrace的地址都会不同,如果使用file
命令查看会得到
read_sym: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=112248563fb98c14891fba198ddf2a4e456f413d, for GNU/Linux 3.2.0, with debug_info, not stripped
注意到,这个程序是pie executable
。在我们的实现中可以简单的判断Ehdr.eh_type == E_DYN
,如果为true
那么就是使用了-fpie -pie
编译的。
这时,我们需要额外的操作,将backtrace的地址转为elf中的地址。
我们注意到,/proc/[pid]/maps
记录了程序的地址映射,通过解析这个文件,我们可以使用backtrace拿到的地址减去maps中的起始地址再加上偏移量,即可转为elf中的地址。
比如:
$ cc backtrace.c -no-pie
& abby @ chaos in ~
$ ./a.out
0x4027dc => test_bt
0x402885 => caller3
0x4028a4 => caller2
0x4028c3 => caller1
0x402910 => main
0x7fbe6d207565 => (null)
& abby @ chaos in ~
$ cc backtrace.c -fpie -pie
& abby @ chaos in ~
$ ./a.out
0x55cca27607ef => test_bt
0x55cca2760898 => caller3
0x55cca27608b7 => caller2
0x55cca27608d6 => caller1
0x55cca2760923 => main
0x7ff6e572a565 => (null)
& abby @ chaos in ~/code (dev %)
$
最后
- 内核和C库都使用了
-fno-omit-frame-pointer
- 某些情况下AddressSanitizer启用了
detect_invalid_pointer_pairs=2
会报告dump_stack
错误