Android Native backtrace指北

Android Native backtrace指北

前言

为什么需要打印出backtrace呢?因为,不想通过IDE工具去看代码了解整个构造。

概念介绍:Wind & Unwind

什么是stack wind以及stack unwind:

When program run, each function(data, registers, program counter, etc) is mapped onto the stack as it is called. Because the function calls other functions, they too are mapped onto the stack. This is stack winding.

Unwinding is the removal of the functions from the stack in the reverse order.

因此如果要打印出backtrace,其实要做的操作就是unwind。

目前公开的资料中有各种各样的方法,都是介绍的比较好的:

寄存器科普

用来做栈回溯的时候,一般都会牵扯到几个重要的寄存器操作,由于现在大部分的Android都是运行在ARM上的,所以我们就以ARM的寄存器为例来说明整个过程,在这之前,我们需要先了解一下这几个寄存器:

  • PC: program counter,程序计数器。程序当前运行的指令会放入到pc寄存器中
  • FP:frame pointer,帧指针。通常指向一个函数的栈帧底部,表示一个函数栈的开始位置。
  • SP:stack pointer,栈顶指针。指向当前栈空间的顶部位置,当进行push和pop时会一起移动。
  • LR:link register,在进行函数调用时,会将函数返回后要执行的下一条指令放入lr中,对应x86架构下的返回地址。

栈回溯FP的遍历

栈在内存空间的分配情况如图,从下往上是低地址往高地址

img

从这个图中,其实我们可以看出来,可以通过FP和SP来进行递归的解出所有的调用栈。

  • 假定当前运行在func1,当前的FP就指向了func1栈的栈顶(低地址为底,高地址为顶)
  • 我们先通过FP获取到栈顶,然后通过偏移获取到上一个栈的FP#1
  • 这个FP#1就指向了main的栈顶,然后就完成了一次遍历

综上所述,如果有多个调用栈的话,那么依次继续去找FP就可以达成回溯的目的了。

PC寄存器

PC寄存器的是只当前指令运行的地址,所以在每次做FP回溯的时候,可以顺便获取一下PC的值,这样的话就可以知道当前PC中运行的地址,通过这个地址再去读取对应的symbol,就可以知道当前运行在哪个函数,哪个line了。

一些回溯的做法

上述就是整个栈回溯的基本思路,在实操环节中,一般有几个现成的常规方案可以选。

  • backtrace方案

    • 需要#include <execinfo.h>,但是目前aosp源码和ndk中都已经没有了,所以在android是哪个无效
  • Callstack方案:

    • 需要#include <utils/CallStack.h>,在aosp源码中可以使用,但是ndk中无法使用

      • 1
        2
        3
        4
        CallStack callstack;
        callstack.update();
        //callstack.dump(fd);
        callstack. log ( "log_tag" );
  • Unwind方案

    • 需要#include "unwind.h"

      本次 使用的方案就是这一套

Unwind方案实操

函数说明

以NDK中现成的unwind方案为例,介绍一下整个实操的过程和函数的实现。

其中最重要的两个函数为:

  • _Unwind_Backtrace

    • 这个函数主要是用来做递归栈遍历的

    • 1
      2
      3
      4
      5
      6
      7
      8
      156 /* @@@ Use unwind data to perform a stack backtrace. The trace callback
      157 is called for every stack frame in the call chain, but no cleanup
      158 actions are performed. \*/
      159 typedef _Unwind_Reason_Code (*_Unwind_Trace_Fn)
      160 (struct _Unwind_Context *, void *);
      161
      162 extern _Unwind_Reason_Code LIBGCC2_UNWIND_ATTRIBUTE
      163 _Unwind_Backtrace (_Unwind_Trace_Fn, void *);
  • _Unwind_GetIP()

    • 这个函数会获取到当前遍历的栈中的pc值

    • 1
      173 extern _Unwind_Ptr _Unwind_GetIP (struct _Unwind_Context *);

工程化

遍历栈

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
struct backtrace_stack
{
void** current;
void** end;
};
static _Unwind_Reason_Code _unwind_callback(struct _Unwind_Context* context, void* data)
{
struct backtrace_stack* state = (struct backtrace_stack*)(data);
uintptr_t pc = _Unwind_GetIP(context);
if (pc) {
if (state->current == state->end) {
return _URC_END_OF_STACK;
} else {
*state->current++ = (void*)(pc);
}
}
return _URC_NO_REASON;
}
static size_t _fill_backtraces_buffer(void** buffer, size_t max)
{
struct backtrace_stack stack = {buffer, buffer + max};
_Unwind_Backtrace(_unwind_callback, &stack);
return stack.current - buffer;
}
//Usage
void* buffer[30];
int count = _fill_backtraces_buffer(buffer, 30);

结合之前的知识,我们可以看到在显示定义了一个结构体:

1
2
3
4
5
struct backtrace_stack
{
void** current;
void** end;
};

其中包含了两个成员变量,都是指向了void*的指针,而void*其实是一个指向任意类型的指针。

函数的开始,我们定一个一个void* buffer[30];,buff是一个void**类型的指针,有效长度为30。带入到函数中:

1
struct backtrace_stack stack = {buffer, buffer + max};

stack变量中的成员currentbuff的地址,而endbuffer + max的地址,实际也就是void* buffer[30];最后的有效地址。

因此回到代码中,整个遍历的过程:_Unwind_Backtrace(_unwind_callback, &stack)就是在回调函数_unwind_callback中不断填充每个栈的PC值到current变量里,最终直到所有的栈遍历完,或者是达到了end才停止。

解析&可读

这个部分我们就要用到dlfcn.h中的函数dladdr,通过这个函数来继续出当前的addr是属于哪个so库的,并得到响应so库的一些信息。

这里会用到的一个关键数据结构为:

1
2
3
4
5
6
27 typedef struct {
28 const char* dli_fname;
29 void* dli_fbase;
30 const char* dli_sname;
31 void* dli_saddr;
32 } Dl_info;
  • dli_fname:so库在系统中的绝对路径名
  • dli_fbase:so库在该进程中起始地址
  • dli_sname:获取到pc值对应最接近的函数名,如果无法获取到则为null(这里有坑)
  • dli_saddr:获取到pc值UI应最进阶的函数地址,如果无法获取到则为null
1
2
3
4
5
6
7
8
9
for (int idx = 2; idx < count; ++idx) {
......
Dl_info info;
if (dladdr(addr, &info)){
......
}
......
}

因此解析的过程其实就是把我们之前填充的void* buff[30]中的PC值们一个一个通过dladdr查询&解析出来,这样就获得了一些可读的backtrace了。

示例图:

在runtime的时候可以打印出来的结果是这样的:

img

其中无法解析的部分,参考后面的章节《一些要注意的坑》

对于无法解析的部分,我们可以使用addr2line工具做进一步的解析。

img

一些要注意的坑

CFLAG代码优化:-o,-g

unwind的时候如果编译的so库中没有对应的symbol,那么就无法读取到函数名,因此要注意在编译的CFLAGS中增加-o0-g参数,一般偷懒,推荐直接增加-ggdb,这样后续库也可以直接用来gdb调试,以android studio工程为例,我们找到整个工程的CMakeLists.txt,加入以下代码:

1
2
add_compile_options(-ggdb)
add_compile_options(-o0)

None Strip

Android Studio在做assemble的时候会对so做strip操作,什么是strip操作呢,其实跟java还有python中的sttrip()函数很像,就是去掉一些”无用“的信息,很不幸,大部分调试debug的信息以及symbols都是无用的,这个牵扯到elf格式中section的一些知识,这边可以先埋一个坑。

所以我们需要人为的去掉这个strip的操作,避免编译出来的so在临门一脚的时候被删除了”symbols“的信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
plugins {
id 'com.android.application'
......
}
......
android {
......
packagingOptions {
doNotStrip "*/arm64-v8a/*.so"
......
}
}
dependencies {
......
}

lnline函数不能解析

实际运行中,我还发现lnline的函数是无法解析的,后来通过查阅资料才知道原来dladdr只能查到全局符号,本地符号是无法读到的,因此在使用nm命令查看so库的时候,只有那些类型是“T”的符号才可以读到函数名,t的本地符号就没办法解析了,所以inline函数无法被解析,只能后续通过addr2line工作来做解析。

  • 这边又埋了一个坑:inline函数的实际原理是什么?
    • 通过nm可以看到,inline函数可以被加载到内存的多个地方,因此相比较global的函数,inline函数的运行是不存在跳转的,是连续的地址读取。

最后的小结

本来打算写一份unwind揭秘之类的文档,后来发现整个东西超出了我现在的能力,因为frame trace和unwind其实是两个东西,早先在做optee的时候写的backtrace其实是在整个so打包完成以后,通过nm命令把所有的符号导出来写到特定的section,然后在需要的时候再去读对应的section,从而找出每个函数名的过程。虽然有一些编译&指令集&寄存器相关的操作,但是跟这边的unwind不是一个事情。

这篇文章的成文主要是讲述了怎么在NDK端写一个可以用的trace函数供大家使用,顺便科普一下FP,SP,LR,PC这几个寄存器的作用。

挖下的坑有:

  1. inline函数,它的好处坏处
  2. android中backtrace的实现,纯ndk开发能否做
  3. libunwind.so和android runtime中的libunwindstack.so有什么区别