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]

实际使用时发现了两个限制:

  1. backtrace/backtrace_symbols实现使用了互斥锁
  2. 为了看到函数名,需要在编译时指定-rdynamic选项,但不能看到静态函数的函数名

为了克服第一个限制

方法一

我们可以使用GCC的选项-finstrument-functions结合一个TLS的stacksp指针即可,比如:

#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指令可以看做是pushjmp的结合,这也就意味着,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 %)
$     

最后