fp unwind
为了公司内部写一篇软文,所以这篇文章重写了一下~~
正文
今天给大家分享一种 arm64 平台下栈回溯的方式 —— fp(frame pointer) 回溯,同时会介绍一些基础概念和实现原理
首先,我们需要知道栈回溯是做什么的?
其实就是用于获取当前 thread 的调用堆栈的
栈回溯的实现有多种,fp 回溯的优点是:速度极快,相应就会有缺点:占用寄存器 信息少(只能回溯出函数地址) 并且 Android 中回溯会被 libart 中断
同时,因为 fp 回溯不依赖 .eh_frame 中的内容,所以可以裁剪掉 so 中的 .eh_frame 以缩减 so 体积,相应的,因为 .eh_frame 的裁剪,android 系统自带的 unwind 将无法进行堆栈回溯
那何时有栈回溯的需求呢?
其实还是很多的
比如:
- crash 时的堆栈捕获
- 特殊方法的源头追踪,如 malloc pthread_create 等
那我们又是如何获取到函数的调用堆栈的呢?
这就需要我们来看一下 arm64 下 frame pointer 的原理和一些栈帧的原理了(后续会介绍)
需要注意的是:即使同样为 frame pointer,但是各个编译器最终的实现可能也并不一致,但是不影响使用(类比:jvm 有多种实现,但是并不影响上层 java 代码的结果)
什么是栈帧
无论我们使用 c/c++ 还是使用 java 等语言,我们经常会听到栈帧的概念。甚至有关协程的概念,我们也经常听到有栈协程(stackful coroutines)和无栈协程(stackless coroutines)的区别
那么栈帧(stack frame)究竟是什么?
可以查看 wiki
简单来说,栈帧存储了一个方法执行所需要的数据。如果存在方法调用,会在栈中形成多个栈帧,类似 wiki 中的图
但是还是那句话:栈帧只是一套标准,具体实现还是看编译器
其次,我们还要知道一个点:栈是往低地址拓展的
那么一个 c 函数的调用过程究竟是怎样的呢?
如果,要完全的讲通一个 c 函数的调用过程,还是十分复杂的
参数存取部分,就要涉及到调用约定(calling convention),因为各架构下的规则是不一致的,这块没法细说,只能靠熟读 cpu 手册
抛开这些,我们下面可以一起分析一下 arm64 下的简单的 c 函数的调用情况
如果是 c++ 函数,还可能有异常相关的内容,这里就不多说了(所以 c++ 的异常抛出和捕获是如何实现的?java 呢?)
frame pointer 的原理
当我们的编译参数加上 fno-omit-frame-pointer 时,编译器会帮我们保留 frame pointer 相关指令(所以 fp 回溯,依赖链路上的 so 都开启了相应的编译参数)
以下分析也均基于此
arm64 寄存器
了解 frame pointer,也要先了解 arm64 寄存器
如下(copy from learn.microsoft):
AArch64 体系结构支持 32 个整数寄存器(还有 PC 和 SP)
注册 | 波动 | 角色 |
---|---|---|
x0-x8 | 易失的 | 参数/结果临时寄存器 |
x9-x15 | 易失的 | 临时寄存器 |
x16-x17 | 易失的 | 过程内部调用临时寄存器 |
x18 | 不可用 | 保留的平台寄存器:在内核模式下,指向当前处理器的 KPCR;在用户模式下,指向 TEB |
x19-x28 | 非易失性的 | 临时寄存器 |
x29/fp | 非易失性的 | 帧指针 |
x30/lr | 推送、请求和匿名 | 链接寄存器:被调用方函数必须保留它用于其自己的返回,但调用方的值将丢失。 |
这里我们需要记住的是:
- x29 即是帧指针
用于存储 fp 地址 - x30 被称为 lr
用于存储发生函数调用时的下一条指令地址,也就是确定了函数返回后执行的地址(具体查看 bl 指令,bl 指令会修改 x30)。 - pc 寄存器
指向下一条指令的地址 - sp 寄存器
指向栈帧顶部(低地址)
c 函数调用过程
我们可以看一个简单的 c 函数调用
c 代码如下:
int xc_test_call_2(int v) {
//略
}
int xc_test_call_1(void) {
int r = xc_test_call_2(1);
return r;
}
int main(int argc, const char * argv[]) {
int r = xc_test_call_1();
return (0);
}
不开启任何优化,对应汇编代码如下:
main`main:
-> 0x100003b9c <+0>: sub sp, sp, #0x30
0x100003ba0 <+4>: stp x29, x30, [sp, #0x20]
0x100003ba4 <+8>: add x29, sp, #0x20
0x100003ba8 <+12>: mov w8, #0x0
0x100003bac <+16>: str w8, [sp, #0x8]
0x100003bb0 <+20>: stur wzr, [x29, #-0x4]
0x100003bb4 <+24>: stur w0, [x29, #-0x8]
0x100003bb8 <+28>: str x1, [sp, #0x10]
0x100003bbc <+32>: bl 0x100003b74 ; xc_test_call_1 at main.m:51
0x100003bc0 <+36>: mov x8, x0
0x100003bc4 <+40>: ldr w0, [sp, #0x8]
0x100003bc8 <+44>: str w8, [sp, #0xc]
0x100003bcc <+48>: ldp x29, x30, [sp, #0x20]
0x100003bd0 <+52>: add sp, sp, #0x30
0x100003bd4 <+56>: ret
main`xc_test_call_1:
-> 0x100003b74 <+0>: sub sp, sp, #0x20
0x100003b78 <+4>: stp x29, x30, [sp, #0x10]
0x100003b7c <+8>: add x29, sp, #0x10
0x100003b80 <+12>: mov w0, #0x1
0x100003b84 <+16>: bl 0x100003b44 ; xc_test_call_2 at main.m:46
0x100003b88 <+20>: stur w0, [x29, #-0x4]
0x100003b8c <+24>: ldur w0, [x29, #-0x4]
0x100003b90 <+28>: ldp x29, x30, [sp, #0x10]
0x100003b94 <+32>: add sp, sp, #0x20
0x100003b98 <+36>: ret
我们先来看 main 函数的执行
- 将 sp 向下拓展 0x30 48字节
- 将 x29 和 x30 此时的值,存储到 sp 偏移 0x20 32字节 位置(stp 指令就做了这个事情)
- 将 x29 的值更新,更新为 sp 偏移 0x20 的地址值(这个位置刚好是上面存的之前的 x29 的值)
此时,旧的 x29 x30 的值被存储在了 sp + 0x20 的位置,而新的 x29 存储的就是 sp + 0x20 的地址
结合图来看:
当发生函数调用时,bl 指令跳转到 xc_test_call_1(0x100003b74) 处,同时 bl 指令会修改 x30 的值,指向 bl 的下一条指令的地址(0x100003bc0)
之后,我们来看 xc_test_call_1 的执行,开头同 main 一致(其实 frame pointer 就是靠这几条指令实现的)
- 将 sp 向下拓展 0x20 32字节
- 将 x29 和 x30 此时的值,存储到 sp 偏移 0x10 16字节 位置(此时 x30 指向的是 main 函数中 bl 的下一条指令,x29 是 main 函数 stack frame 中的 fp 的地址)
- 将 x29 的值更新,更新为 sp 偏移 0x10 的地址值(同上,刚好就是现在的 xc_test_call_1 fp 的地址)
结合图来看:
所以,你会发现,此时 x29 存储了当前 xc_test_call_1 fp 的地址,我们读出这个地址的值,会发现他其实指向的是 main 的 fp 的地址
fp 就这样可以帮助我们链接两个栈帧,而后续调用皆是如此,只要我们找到当前的 x29,就可以通过 fp 不断向上回溯
回溯原理
上述过程中,我们已经可以不断的拿到 fp 的值了,又因为 lr 永远在 fp 的高8字节,所以我们也就可以获取到 lr 的值
lr 就是我们的函数执行的指令地址,通过 lr 的地址,把地址解析成函数符号,即完成了整个回溯过程(当然,地址解析为符号又是一块内容了)
这就是 fp 回溯的整个的原理了
在 binoic libc 中也有简单的实现
__attribute__((no_sanitize("address", "hwaddress"))) size_t android_unsafe_frame_pointer_chase(
uintptr_t* buf, size_t num_entries) {
// Disable MTE checks for the duration of this function, since we can't be sure that following
// next_frame pointers won't cause us to read from tagged memory. ASAN/HWASAN are disabled here
// for the same reason.
ScopedDisableMTE x;
struct frame_record {
uintptr_t next_frame, return_addr;
};
auto begin = reinterpret_cast<uintptr_t>(__builtin_frame_address(0));
auto end = __get_thread_stack_top();
stack_t ss;
if (sigaltstack(nullptr, &ss) == 0 && (ss.ss_flags & SS_ONSTACK)) {
end = reinterpret_cast<uintptr_t>(ss.ss_sp) + ss.ss_size;
}
size_t num_frames = 0;
while (1) {
#if defined(__riscv)
// Frame addresses seem to have been implemented incorrectly for RISC-V.
// See https://reviews.llvm.org/D87579. We did at least manage to get this
// documented in the RISC-V psABI though:
// https://github.com/riscv-non-isa/riscv-elf-psabi-doc/blob/master/riscv-cc.adoc#frame-pointer-convention
auto* frame = reinterpret_cast<frame_record*>(begin - 16);
#else
auto* frame = reinterpret_cast<frame_record*>(begin);
#endif
if (num_frames < num_entries) {
uintptr_t addr = __bionic_clear_pac_bits(frame->return_addr);
if (addr == 0) {
break;
}
buf[num_frames] = addr;
}
++num_frames;
if (frame->next_frame < begin + sizeof(frame_record) || frame->next_frame >= end ||
frame->next_frame % sizeof(void*) != 0) {
break;
}
begin = frame->next_frame;
}
return num_frames;
}
上述代码,简单使用是可以的,但是有下面几个问题需要注意
signal handler
如果使用 signalhandler 来做 crash 捕获,此时想抓取堆栈的话,别忘记先把当前的 pc 值作为第一层堆栈
然后要使用 signalhandler 的 context 去获取 x29 的值,而不是直接使用 __builtin_frame_address(0)
stack range
我们知道可以通过 fp 的值,不断获取其他 fp 的值,但是终止条件是如何的呢?
这就是 stack 的范围的问题
我们需要确认 stack 的正确范围
pthread_attr_t attr;
pthread_getattr_np(pthread_self(), &attr);
thread_stack_high = (uintptr_t) (attr.stack_base) + attr.stack_size;
thread_stack_low = (uintptr_t) (attr.stack_base);
同时,如果位于 signal handler 中,要考虑 signal stack
no return
我们之前一直说的是,通过 fp 获取 lr,进而通过 lr 解析为对应的函数符号
但是,也有特殊情况需要兼容
比如 bl 指令就是当前函数的最后一条指令,没有 ret 指令。同时,我们知道 bl 会把其下一条指令地址存储到 x30(lr) 中。那此时的 lr 已经到了另一个函数中,最终就会出现函数堆栈的缺失
所以,我们的 lr 获取以后需要 -4(arm64 指令为 4字节),这样才不会出现上述问题
结尾
通过本文,我们一步步窥探了 c 函数的调用过程,同时也明白了 fp unwind 的基本原理
虽然在日常开发中,这些知识可能没什么作用,但是有清晰的基础理论,总会量变产生质变的