目录

fp unwind

为了公司内部写一篇软文,所以这篇文章重写了一下~~

正文

今天给大家分享一种 arm64 平台下栈回溯的方式 —— fp(frame pointer) 回溯,同时会介绍一些基础概念和实现原理

首先,我们需要知道栈回溯是做什么的?
其实就是用于获取当前 thread 的调用堆栈的

栈回溯的实现有多种,fp 回溯的优点是:速度极快,相应就会有缺点:占用寄存器 信息少(只能回溯出函数地址) 并且 Android 中回溯会被 libart 中断

同时,因为 fp 回溯不依赖 .eh_frame 中的内容,所以可以裁剪掉 so 中的 .eh_frame 以缩减 so 体积,相应的,因为 .eh_frame 的裁剪,android 系统自带的 unwind 将无法进行堆栈回溯

那何时有栈回溯的需求呢?
其实还是很多的
比如:

  1. crash 时的堆栈捕获
  2. 特殊方法的源头追踪,如 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 推送、请求和匿名 链接寄存器:被调用方函数必须保留它用于其自己的返回,但调用方的值将丢失。

这里我们需要记住的是:

  1. x29 即是帧指针
    用于存储 fp 地址
  2. x30 被称为 lr
    用于存储发生函数调用时的下一条指令地址,也就是确定了函数返回后执行的地址(具体查看 bl 指令,bl 指令会修改 x30)。
  3. pc 寄存器
    指向下一条指令的地址
  4. 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 函数的执行

  1. 将 sp 向下拓展 0x30 48字节
  2. 将 x29 和 x30 此时的值,存储到 sp 偏移 0x20 32字节 位置(stp 指令就做了这个事情)
  3. 将 x29 的值更新,更新为 sp 偏移 0x20 的地址值(这个位置刚好是上面存的之前的 x29 的值)

此时,旧的 x29 x30 的值被存储在了 sp + 0x20 的位置,而新的 x29 存储的就是 sp + 0x20 的地址

结合图来看: /img/in-post/main_stack.png

当发生函数调用时,bl 指令跳转到 xc_test_call_1(0x100003b74) 处,同时 bl 指令会修改 x30 的值,指向 bl 的下一条指令的地址(0x100003bc0)

之后,我们来看 xc_test_call_1 的执行,开头同 main 一致(其实 frame pointer 就是靠这几条指令实现的)

  1. 将 sp 向下拓展 0x20 32字节
  2. 将 x29 和 x30 此时的值,存储到 sp 偏移 0x10 16字节 位置(此时 x30 指向的是 main 函数中 bl 的下一条指令,x29 是 main 函数 stack frame 中的 fp 的地址)
  3. 将 x29 的值更新,更新为 sp 偏移 0x10 的地址值(同上,刚好就是现在的 xc_test_call_1 fp 的地址)

结合图来看: /img/in-post/call_1_stack_frame.png

所以,你会发现,此时 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 的基本原理

虽然在日常开发中,这些知识可能没什么作用,但是有清晰的基础理论,总会量变产生质变的