本文主要围绕三个问题对tombstone进行分析和介绍,debuggerd是如何监控进程并生成tombstone的?tombstone文件中的信息都是什么,是怎么获取的?tombstone文件应该怎么分析?
Tombstone简介
当一个native程序开始执行时,系统会注册一些连接到debuggerd的signal handlers。针对进程出现的不同的异常状态,Linux kernel会发送相应的signal给异常进程,debuggerd捕获这些signal,做出相应处理的同时(一般来说是退出异常进程),在/data/tombstones目录下生成一个tombstone。Tombstone文件以tombstone_XX形式命名,该文件个数上限可以进行设置,当超过上限时则每次覆盖时间最老的文件。Tombstone记录了崩溃的进程的基本信息,堆栈调用信息,内存信息等等。
Tombstone生成过程
为了更好地分析tombstone触发的过程,我们可以先了解一下Android中一个进程是如何跑起来的。
一个APP一般是存储在UFS,EMMC之类的存储设备上,当用户去点击图标的时候,操作系统会把这个APP从Flash里面加载到主存当中,通过类似fork()的命令去创建对应的进程。进程创建完毕之后通过类似exec的命令去加载APP的内容,然后由/system/bin/linker程序加载APP所用到的一些共享库。最终跳转到APP程序的入口处执行。
Debuggerd守护进程的初始化同样是在linker中进行的,下面我们首先分析linker部分中是如何去初始化debuggerd的。
Debuggerd初始化
Debuggerd是在linker中进行初始化的,我们从linker的起始位置开始进行分析,在/bionic/linker/arch/arm64/begin.S中可以看到,这里调用了__linker_init函数,对linker进行初始化,__linker_init应该要返回可执行程序的开始地址,之后跳转过去执行。
在__linker_init()方法中调用了__linker_init_post_relocation(),__linker_init_post_relocation()初始化linker的全局变量,之后调用linker_main()函数获取可执行程序的开始地址,然后跳转到开始地址继续执行。
可以看到linker_main()函数中对系统环境进行了确认,对系统属性进行了初始化设置,之后调用linker_debuggerd_init()初始化debuggerd。
linker_debuggerd_init()定义了一个callbacks结构体,之后把这个callbacks作为参数传递给debuggerd_init(),继续进行debuggerd的初始化。
在debuggerd_init()中首先把callbacks赋给g_callbacks,之后调用mmap为线程的栈分配空间,然后调用mprotect函数,设置stack对应的内存区的保护属性为可读可写,之后把栈的起始点定义在页末尾并对齐。最后调用debuggerd_register_handlers()去注册一些异常信号,而异常信号的处理函数是debuggerd_signal_handler()。
在debuggerd_register_handlers()函数中注册异常信号,可以通过这个函数看到都有哪些异常信号会被debuggerd捕获。
Debuggerd处理异常
当native进程出现了问题,则通过linux内核判断会发生信号,最终信号在被debuggerd捕获之后由debuggerd_signal_handler()函数处理。debuggerd_signal_handler()使用互斥锁pthread_mutex_lock()来保护线程,防止同一时间多个线程处理信号产生冲突。首先调用log_signal_summary()来输出一些信息,调用log_signal_summary()的目的是防止后面动作出错,最终无法定位到是哪个进程出现了错误,所以先在这里打印一些关键信息。可以在logcat里找到对应的信息。在这一部分中打印如下信息:信号的num、信号code、Fault addr出错时的地址、tid:对应的线程id和pid:对应的进程id。
之后debuggerd_signal_handler()调用debuggerd_dispatch_pseudothread(),通过clone系统调用clone出一个伪线程,去处理dispatch信号,原来的线程原地等待子线程的开始和结束。
在伪线程中通过fork去创建子线程,新创建的子线程中通过execle系统调用去执行crash_dump64程序,父进程等待crash_dump64进程退出。
在crash_dump64进程中,再fork出一个新进程,父进程通过fork_exit_read去等待子进程,子进程继续执行crash_dump的任务。
在crash_dump中,通过/proc/PID/cmdline获取进程的名字,通过/proc/PID/fd/获取此进程打开了多少文件,每个文件都有一个文件描述符。
循环遍历这个进程中的所有线程,对进程中的每一个线程进行ptrace操作,对目标线程读取其crashinfo。Crashinfo读取完毕后detach当前的线程。
之后在crash_dump()中调用tombstoned_connect()通过socket连接到tombstone进程。根据signal的si_val的值不同做出不同的判断,为0时dump tombstone,为1时只dump backtrace。
最终tombstone通过engrave_tombstone()函数生成,engrave_tombstone函数的第二个参数,unwinder,是输出 backtrace等信息的关键函数。
通过unwinder的初始化函数我们可以看到,unwinder初始化过程中获取了当前进程的内存和memory map。这些信息会在后面帮助debuggerd生成tombstone文件。
对tombstone的生成过程做一个小结,当Native进程发生了异常,操作系统会去异常向量表的地址去处理异常,然后发送信号。在debuggred_init()注册的信号处理函数就会捕获信号并处理,创建伪线程去启动crash_dump进程,crash_dump则会获取当前进程中各个线程的crash信息。tombstoned进程是开机就启动的,开机时注册好了socket等待连接。当crash_dump()去连接tombstoned进程的时候,根据传递的dump_type类型会返回一个/data/tombstones/下文件描述符。
crash_dump进程后续通过engrave_tombstone函数将所有的线程的详细信息写入到tombstone文件中,至此就在/data/tombstones下生成了此次对应的tombstone_XX文件。
Tombstone文件实例分析
上文中我们提到,tombstone通过engrave_tombstone()函数生成,在这一节中,我们将结合实例和代码来介绍tombstone文件中都包含哪些内容,这些内容都是如何获取和输出出来的。实例中的tombstone文件由Google提供的development.apk生成。
engrave_tombstone()中首先调用的是dump_header_info()函数打印tombstone头信息。
可以从tombstone实例中看到dump_header_info()函数输出的是一些和编译,CPU架构相关的信息。
调用dump_time_stamp()打印native crash发生的时间。
Tombstone实例如下,这里打印的时间是本机时间,因为实验机没有联网设置时间所以显示时间是2020-01-01 12:23:27+0800。
然后engrave_tombstone()要打印的是线程相关信息,首先调用unwinder的GetProcessMemory()方法,获取进程对应的内存信息,之后engrave_tombstone()把unwinder和获取到的内存信息作为参数传递给dump_thread()打印线程相关的信息。
dump_thread()中调用的第一个函数是dump_thread_info(),它的职责是打印出错的线程所属的进程pid,线程tid,线程名,进程名和出错线程对应的apk的uid。Tombstone实例如下:
然后dump_thread()调用dump_signal_info()函数打印引发这次tombstone的信号信息,dump_thread()在调用这个函数时把thread_info和进程对应的内存传递给了dump_signal_info()。在dump_signal_info函数中首先调用signal_has_si_addr()对信号的signo进行判断,可以在下面的函数中看到,只有在信号不是manually sent并且是某些特定信号的情况下,才会有对应的si_addr。
Tombstone实例如下,可以看到虽然SIGSEGV信号在上面列出的信号列表中,但是因为si_code是SI_USER,所以还是没有对应的信号地址。
之后调用dump_probable_cause(),通过分析signal_info打印可能的原因信息,主要依靠分析signal number和fault address来得出可能的结论。如果没有分析出可能的原因就不会打印出任何信息,在本文使用的这个tombstone的例子中就没有任何信息输出。
之后调用dump_registers()输出出错时寄存器的值,thread_info里面记录了错误发生时的寄存器信息,dump_registers将他们按顺序输出到tombstone文件里。Tombstone实例如下:
调用log_backtrace()打印backtrace信息,先在unwinder中调用unwind()方法解析内存中的信息,然后log_backtrace函数中调用了unwinder的FormatFrame()函数把获取的堆栈信息输出到tombstone文件中。调用unwinder的unwind()方法会导致保存的寄存器的值发生变化,所以在调用这个方法之前先对寄存器的值做一个备份。
unwind()函数中,首先根据pc寄存器的值找到函数unwind的段内存地址
然后根据unwind段中信息找到指令相关的编码数据,elf表等
接着根据入栈地址,分析函数上一级的栈底保存的sp和lr寄存器的值;最后更新寄存器信息。
在tombstone实例中,依次输出pc寄存器的值,对应的文件名,对应的函数名和offset。最近的frame中的pc寄存器的值可以直接从thread_info当中获取,后面的pc寄存器的值在unwind的过程中更新;后面的文件名可以根据memory map和pc寄存器中的地址得出;后面的function name和function offset是通过memory map和pc寄存器中的地址找到dex文件,解析dex文件可以得到对应的函数名和offset。
接下来调用dump_memroy_and_code()函数,这个函数只在主线程中调用,它循环遍历每一个寄存器,打印寄存器附近的memory信息。dump_memory_and_code()中主要是获取寄存器名和值,还有map_info的name属性编辑到label中,真正进行dump的部分在dump_memory()函数里。dump_memory()函数中从寄存器记录的地址addr的位置读取了256个byte的内存到data中,并输出到Tombstone文件,每16个byte一行,输出了16行,输出的第一段是对应的起始地址,第二第三段是内存的值,每一个byte用两个16进制数表示,第四段是对应的ascii码。
随后调用dump_all_maps()打印map信息,dump_threads()向dump_all_maps()传入unwinder和信号的对应地址,和dump_memroy_and_code()一样,dump_all_maps()只在主线程中被调用。Map信息在unwinder初始化的时候获取,map信息记录了进程对应的内存映射,包括开始地址,长度,访问权限,文件描述符,offset等信息。
从Tombstone实例中可以看到输出的信息依次为内存的起始和结束位置,对内存的操作权限,内存的offset,内存的长度,map的名字和buildID
至此dump_threads()函数运行完成,回到engrave_tombstone()函数中。随后engrave_tombstone()函数调用dump_logs()打印systemlog和mainlog的信息。Tombstone实例如下:
Tombstone文件分析方法
在tombstone中我们可以看到进程的pid和线程的tid,如果pid和tid相等,那么可以判断crash发生在这个进程的主线程中,后面的name属性表明了crash进程的名称和它在文件系统中的位置。在tombstone中我们还可以看到程序是因为什么信号导致了Crash以及出现错误的地址。根据这些信息可以初步判断crash的类型。下面列出一些信号的常见发送方和触发条件。
除了上面介绍的信息之外,我们主要要分析的是backtrace信息,我们可以通过addr2line工具去分析backtrace,根据backtrace提供的内存地址和符号库文件去找到代码出错的具体位置。我们可以通过addr2line工具找到是哪个文件下的哪个函数的哪一行发生了crash,在backtrace中越靠上的越接近最后被调用的函数。值得一提的是只有带symbol的so文件才能被定位到。本文中使用的实例中的一些库是在apex文件夹下的,这样的so文件里面是没有symbol的,所以即便找到了对应的so文件,也无法找到对应的代码段。
在可执行程序中都包含有调试信息(编译的时候需要加-g选项),addr2line根据程序源程序的行号和编译后的机器代码之间的对应关系Line Number Table去找找对应的行号。
另一个可以帮助我们解读tombstone文件的是工具是objdump,它可以反汇编指定so文件来得到对应的源代码和汇编代码,objdump工具和addr2line在同一个路径下。