当程序呈现反常时,咱们通常依靠调用栈来展开剖析。它标明晰程序运转到某个位置时的函数调用联系。这个联系在开发者眼中是函数名和行号,但它背后其实是函数调用时跳转指令的地址。换言之,函数名和行号仅仅指令地址的代号,是方便人类可读的一层外套。因而,假如想要得到下面的调用栈信息,第一步需求搜集每一帧的跳转指令地址,咱们称它为“栈回溯”或“栈展开”(stack unwind),第二步才是将指令地址转换为人类可读的字符串信息。
序号 ELF文件中的偏移 ELF文件的称号 函数名+函数中的偏移
#00 pc 0000000000056270 /apex/com.android.runtime/lib64/bionic/libc.so (abort+168)
#01 pc 00000000000bf9dc /apex/com.android.runtime/lib64/bionic/libc.so (__pthread_internal_find(long, char const*)+200)
#02 pc 00000000000bf8f4 /apex/com.android.runtime/lib64/bionic/libc.so (__pthread_internal_gettid(long, char const*)+12)
#03 pc 00000000000c017c /apex/com.android.runtime/lib64/bionic/libc.so (pthread_kill+52)
这个调用栈是从tombstone中截取的一个片段。Tombstone,中文译为“石碑”,标明进程在native层产生crash时留下的终究影像。这儿的pc值,比如0x56270,标明的是指令地址相关于libc.so文件开始位置的偏移值。而abort
加号后边的168,标明的则是指令地址相关于abort
函数开始位置的偏移值。
那么怎么回溯出调用栈中每一帧的跳转指令地址呢?
回溯本质上是调用的逆进程,因而为了弄明白回溯,咱们首要要了解调用。
众所周知,寄存器的数量是有限的。因而跟着调用的产生,势必会呈现caller(调用者)和callee(被调用者)运用同一个寄存器的状况。解掉这个抵触有三种思路:
- Caller不依靠这些寄存器,因而不要求它们在调用进程中坚持不变。
- Caller在调用前将这些寄存器保存在栈上,调用后再康复它们。
- Callee在运用这些寄存器之前将它们暂存到栈上,回来时再康复它们。
上述第二和第三种思路的差异在于暂存值保存在谁的栈上。第二种思路知道caller依靠哪些寄存器,第三种思路知道callee运用哪些寄存器,因而从削减暂存范围的角度而言,二者各有优劣。AArch64架构选用的是第三种思路,因而寄存器也被分成不同的类型。X19-X28这些”Callee-saved Registers”标明callee运用前需求将它们暂存。在栈上分配空间并履行暂存操作的代码称为prologue(序幕),而回来前履行康复操作并开释栈空间的代码称为epilogue(结尾)。
X0-X7 | X8-X15 | X16-X23 | X24-X30 |
---|---|---|---|
Parameter and Result Registers (X0-X7 ) |
XR (X8 ) |
IP0 (X16 ) |
Callee-saved Registers (X24-X28 ) |
– | Corruptible Registers (X9-X15 ) |
IP1 (X17 ) |
FP (X29 ) |
– | – |
PR (X18 ) |
LR (X30 ) |
– | – | Callee-saved Registers (X19-X23 ) |
– |
除了callee-saved寄存器以外,其实还有个要害信息需求暂存:回来地址。AArch64选用LR(Link Register,也即x30)寄存器来保存回来地址,因而prologue中也需求暂存它。如下是libbinder.so中的BpBinder::linkToDeath
函数,能够看到prologue中除了暂存x19-x25这些callee-saved registers外,也暂存了x30。不过这儿需求注意一点,x30大多数状况下都暂存在栈上,但也有或许暂存在其他寄存器之中,这取决于编译器。
android::BpBinder::linkToDeath(android::sp<android::IBinder::DeathRecipient> const&, void*, unsigned int)():
7207c: d503233f paciasp
72080: d101c3ff sub sp, sp, #112
72084: a90367fe stp x30, x25, [sp, #48]
72088: a9045ff8 stp x24, x23, [sp, #64]
7208c: a90557f6 stp x22, x21, [sp, #80]
72090: a9064ff4 stp x20, x19, [sp, #96]
72094: d53bd058 mrs x24, TPIDR_EL0
上面咱们说想要回溯出每一帧的跳转指令地址,可是这儿prologue暂存的是回来地址,那么二者之间有什么联系呢?
通常而言,回来地址指向跳转指令的下一条指令。因而拿到这些回来地址后,咱们能够减去一定偏移,将它转换为跳转指令的地址。关于指令长度固定的架构,比如A64(指令长度为4字节),咱们能够减去固定的偏移。但关于指令长度变长的架构,比如T32(指令长度为2字节或4字节),就无法减去固定的偏移。一个卓有成效的方法是减一,因为不论指令长度为多少,减完之后的地址都会落在上一条指令的范围内。因而实践操作中通常用回来地址减一得到跳转指令的地址。
那么现在的问题就变成了:怎么找到每一帧的LR暂存值?
这儿分叉出两条不同的技能道路。一种是依据DWARF调试信息的常规方法,另一种是依靠编译选项敞开的快速方法。
DWARF全称为”Debugging With Arbitrary Record Formats”,是一种调试信息的标准,现在现已发布到第五个版别。这个标准规矩了各种调试信息的存储格局,其间与调用栈回溯相关的信息为”Call Frame Information”。它的首要目的便是依据callee的寄存器和栈内存来康复出caller的寄存器和回来地址,换言之,caller的上下文。康复出来的上下文一般有以下三个用处:
- 调试。某些变量的值或地址会存储在寄存器中,因而想要检查这些变量的信息,必需求康复出这一帧的上下文。
- 反常处理。反常抛出后需求遍历调用栈,然后寻找能够处理该反常的帧,并跳转到相应的处理方法中,因而需求每一帧的回来地址。
- 调用栈打印。不论是崩溃剖析仍是性能剖析,调用栈都是不可或缺的要害信息,因而也需求每一帧的回来地址。
以下是BpBinder::linkToDeath
函数对应的Call Frame Information(CFI) Table。
00004ba0 0000000000000024 00004ba4 FDE cie=00000000 pc=000000000007207c..00000000000722e8
LOC CFA x19 x20 x21 x22 x23 x24 x25 ra
000000000007207c sp+0 u u u u u u u u
0000000000072080 sp+0 u u u u u u u u
0000000000072094 sp+112 c-8 c-16 c-24 c-32 c-40 c-48 c-56 c-64
CFA: Canonical Frame Address,通常被界说为前一帧中调用位置的sp的值。
ra: return address,回来地址寄存器。
u: Undefined rule,标明该寄存器暂时还没有运用,因而无需康复,直接用就行。
c-16: c标明CFA的值。
首要咱们注意到,这个表中的行数并不多,远远少于函数里的汇编指令数量。它们将函数地址分为三个区间([0x7207c, 0x72080); [0x72080, 0x72094); [0x72094, end of function]),pc值落在哪个区间,就选用哪一行的方法来康复上下文。举个比如,当callee的pc=0x72098时,咱们选用第三行的方法来康复上下文。先用sp寄存器的值加上112得到CFA,之后再用CFA减去不同的偏移得到各个寄存器在栈上暂存的地址,终究获取到caller的上下文。这儿的CFA是一个固定的锚点,通常被界说为前一帧中调用位置的sp的值。之所以不用这一帧的sp,是因为sp在函数内部或许会动态改动。
关于绝大多数经过Clang编译的64位函数而言,运用到的核算规矩其实只有一套,也即上述列表的第三行。原因是prologue将寄存器暂存之后,假如sp后续都没有改动的话,那么核算规矩也就无需改动。而这种状况在实在场景中占了绝大多数。那么为何上述表格中还存在三行呢?原因是函数开始位置占一行,PAC机制的引入又增加一行,余下的函数主体再占一行。
Android中运用的Call Frame Information来自ELF文件的eh_frame
(eh: exception handling)段,而并非debug_frame
段。事实上正常运转的库都只会有一个eh_frame
段,原因是运转时需求支持反常处理,但并不需求支持动态调试。二者均遵循DWARF格局,可是内部有些细微的差别。比如,eh_frame
默许反常不会从prologue和epilogue抛出,因而不会为prologue和epilogue生成额外的处理规矩,CFI Table中也不会体现。比如BpBinder::linkToDeath
中的指令地址0x72084,此刻sp现已改动,可是CFI Table中的CFA核算规矩并没有改动。原因便是unwind进程中根本不会存在pc值为0x72084的或许。
如下是详细的解释,感兴趣的能够了解下。
Ideally, eh_frame will be the minimal unwind instructions necessary to unwind the stack when exceptions are thrown/caught. eh_frame will not include unwind instructions for the prologue instructions or epilogue instructions — because we can’t throw an exception there, or have an exception thrown from a called function “below” us on the stack. We call these unwind instructions “synchronous” because they only describe the unwind state from a small set of locations.
debug_frame would describe how to unwind the stack at every instruction location. Every instruction of the prologue and epilogue. If the code is built without a frame pointer, then it would have unwind instructions at every place where the stack pointer is modified. We describe these unwind instructions as “asynchronous” because they describe the unwind state at every instruction location.
其实上面的表格是虚构的,它并不是eh_frame
段实在的内容。实在的内容如下所示,是一系列DWARF指令的集合。运转时依据指令构造出如上的表格,继而找到对应的核算规矩。这种方法关于表格很大,且上下行重复信息较多的场景十分有用(指令只描绘上下差异的信息),能够有用紧缩eh_frame
段的大小。
Program:
DW_CFA_advance_loc: 4
DW_CFA_AARCH64_negate_ra_state:
DW_CFA_advance_loc: 20
DW_CFA_def_cfa_offset: +112
DW_CFA_offset: reg19 -8
DW_CFA_offset: reg20 -16
DW_CFA_offset: reg21 -24
DW_CFA_offset: reg22 -32
DW_CFA_offset: reg23 -40
DW_CFA_offset: reg24 -48
DW_CFA_offset: reg25 -56
DW_CFA_offset: reg30 -64
DW_CFA_nop:
DW_CFA_nop:
稍微了解DWARF格局的朋友或许会猎奇,这篇文章为什么不说一说CIE,FDE这些详细的结构。原因是我认为关于unwind而言,最直观的信息是上面的CFI table,而CIE和FDE仅仅这些信息的紧缩和存储方法,它们关于理解unwind的核心概念无足轻重。何况,这些信息的介绍直接参阅DWARF官方文档就好,没有人能够写得比它更好。
依据DWARF调试信息的这种方法适用范围广,可是性能差强人意,因而在许多需求频频搜集调用栈的场景中并不适用。既然咱们在unwind进程中终究的目标是找到每一帧的回来地址,那么能够在栈中快速定位它么? 事实上这种快速定位的方法早已存在,它被命名为fp-based unwind,依靠于特殊的编译选项-fno-omit-frame-pointer
。
Frame pointer是一个固定的锚点,其效果相当于CFI Table中的CFA。当编译选项-fno-omit-frame-pointer
被置上后,AArch64渠道会将X29寄存器拿来专门存储frame pointer,并且在prologue中将它暂存到栈上,存储位置的地址将成为新一帧X29的值。因而,栈上每一帧的frame pointer构成了一个链表结构,调用栈回溯时能够快速地拿到它们。
可是光拿到这些frame pointer并没有用,咱们想要的仍然是回来地址。假如能让暂存的回来地址和frame pointer有一个固定偏移,那么拿到回来地址也将简单许多。因而,-fno-omit-frame-pointer
还有一个隐含的含义需求提及:Prologue中暂存的并非是单一的frame pointer,而是frame pointer和回来地址的组合,称为frame record。它们严密相连,frame pointer在低地址,回来地址(LR寄存器的值)在高地址。这样一来,unwind进程就能够大大加快了。
现在Android中的系统库默许都打开了-fno-omit-frame-pointer
编译选项,可是一些vendor库和APP的三方库并不能确保。因而fp-based unwind的适用范围仍然很小,通常只在sanitizer敞开的场景中运用(unwind十分频频)。假如APP能够确保自己所用的三方库都敞开了-fno-omit-frame-pointer
,那么fp-based unwind无疑是最好的挑选(不过需求判别JNI函数,然后选用不同的策省略穿越JNI、机器码履行和解释履行)。
退而求其次的计划是优化DWARF-based unwind,微信在这篇文章里叙述了一种优化战略,核心思路是省去unwind进程中关于callee-saved registers的康复,只保存CFA和return address,取得了很好的效果。
Frame pointer的敞开是一个趋势,纵然它会稍稍增大code size,可是带来的优点是更大的,不然Kernel和Android也不会默许将它打开。当下挑选DWARF-based unwind更多是一种无法(Android中的unwind库libunwindstack选用的是DWARF-based unwind),一种考虑兼容性和普适性的挑选。
延伸讨论:
我在写这篇文章的时候参阅了一些资料,发现介绍Call Frame Information Table时,给出的示例表中行数都比较多,其间大多数对应的都是prologue和epilogue的地址,比如DWARF官方文档给出的示例(如下)。正是因为行数比较多,所以才需求紧缩,才需求将表格信息转换为DWARF指令。
可是eh_frame
是不考虑prologue和epilogue的。而且我实践看了几个Android的库,不论是否敞开-fno-omit-frame-pointer
,每个函数的Call Frame Information Table中都只有两行或三行,取决于是否敞开PAC,而其间函数主体对应的核算规矩只有一行。再进一步,假如咱们只考虑CFA和return address,那么每个函数需求保存的信息将会十分十分少。这样一来,咱们是否还需求紧缩和运转时解释呢?
【参阅文档】
【1】Stack Unwind:maskray.me/blog/2020-1…
【2】AARCH64渠道的栈回溯:bbs.kanxue.com/thread-2709…
【3】介绍一种性能较好的Android native unwind技能:mp.weixin.qq.com/s/g4RWAS3vN…