上使用 FP Unwind 学习

时间:2021-04-21
本文章向大家介绍上使用 FP Unwind 学习,主要包括上使用 FP Unwind 学习使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

文章中涉及的 Android 源码以 Android 10 源码为例;讨论的是 64 位 APK 的情况,32 位 FP Unwind 支持的不好(因为使用 FP Unwind ARM/Thumb 指令混合的方法栈不可靠,另外 AArch32 的 ABI 也没有规定 FP 的行为),不在本文讨论范围内

名词

文章中有些名词大家平时可能接触的少,这里先统一介绍下

名词含义
AArch32 Arm 32-bit Architecture
AArch64 Arm 64-bit Architecture
Unwind 栈展开,就是栈回溯的意思
Dwarf Dwarf 是一种编译器、调试器用于源码调试的文件格式;实际上 Android so 文件的很多 section 就是 Dwarf 格式的
Caller 调用程序,也就是父函数
Callee 被调用程序,也就是子函数
APCS64 Procedure Call Standard for AArch64,AArch64 程序调用标准,它描述了调用程序与被调用程序间的协议
gnu_debugdata 它是 so 中的一个名为.gnu_debugdata的 section,包含一些调试信息,采用 LZMA 压缩

背景

我们在开发 Native 内存泄漏监控方案时,遇到一个问题,如何在内存分配时高效的获取堆栈?快手是一个短视频应用,有大量 Native 内存高频分配场景「每秒钟高达近万次内存分配」;我们调研了现有的一些获取堆栈的方式,比如使用 Android 系统的 libunwindstack 库 API、LLVM 提供的 llvm_libunwind 库、libunwind 等;它们都能准确的获取堆栈,但性能「大都是基于 Dwarf 的 unwind,有 IO 及解码 Dwarf 帧指令的开销」都不能满足我们的要求;为了高效的获取堆栈我们最终采用了 FP Unwind,FP Unwind 最大的优势是获取堆栈速度快「只需要简单的回溯下栈内存,速度是其他 unwind 方式的 10X ~ 100X」,但是也有一些缺点:依赖编译器编译参数-fno-omit-frame-pointer等,庆幸的是 Android NDK 工具链编译器默认添加了-fno-omit-frame-pointer。但是在接入 FP Unwind 后,我们又发现了另一问题:它在 Android 上 unwind 到虚拟机的帧时往往会中断,why? 本文将解开这个谜团!

在分析问题前,这里先简单介绍下 FP Unwind 的背景知识: AArch64 通用寄存器以及 AArch64 栈帧布局。

FP(Frame Pointer) Unwind

AArch64 通用寄存器及其在 APCS64 中的使用

RegisterSpecialRole in the procedure call standard
SP   The Stack Pointer
X30 LR The Link Register
X29 FP The Frame Pointer
X19...X28   Callee-saved registers
X18   The Platform Register, if needed; otherwise a temporary register.
X17   The second intra-procedure-call temporary register (can be used by call veneers and PLT code); at other times may be used as a temporary register.
X16   The first intra-procedure-call scratch register (can be used by call veneers and PLT code); at other times may be used as a temporary register.
X9...X15   Temporary registers
X8   Indirect result location register
X0...X7   Parameter/result registers
  • 有两个寄存器需要我们特别关注
    • X30,别名 LR 用于存储当前执行指令(PC)的下一条指令,用于子函数执行完成后恢复 PC(= LR) 继续执行
    • X29,别名 FP,编译器使能-fomit-frame-pointer时 FP 的使用没有规定,本文主要讨论使能 -fno-omit-frame-pointer 的情况,见下文

AArch64 栈帧布局与 Unwind

  • 栈帧布局
    • 需要编译器添加编译参数-fno-omit-frame-pointer,否则 FP 可能不会被保存在栈中;Android NDK clang 默认添加
    • 当编译器添加编译参数-fno-omit-frame-pointer时,ARM Compiler armclang Reference Guide 有如下规定
      • FP 总是指向 FrameRecord 的最低地址
      • FrameRecord 有两部分(FP + LR)组成
        • FP 在 FrameRecord 的低地址
        • LR 在 FrameRecord 的高地址
  • 基于 FP 进行 Stack Unwind 的过程
    1. 获取当前的 FP 寄存器值,判断 FP 是否合法,合法继续;否则中断
    2. 通过 FP 值获取 Caller 栈中保存的 FP 及 LR,LR 保存起来用于计算 backtrace
    3. 回到第一步

FP Unwind 在 Android 中的“坑”

“坑”(FP Unwind 在 Android 上 unwind 到虚拟机的帧时往往中断)

-------------- backtrace -------------------------------
libkwaiplayer.so  KP_EGL_create (kpsdl_misc.h:53)      
libkwaiplayer.so  SDL_VoutAndroid_CreateForANativeWindow (kpsdl_vout_android_nativewindow.c:298)      
libkwaiplayer.so  kpmp_android_create (kpplayer_android.c:39)      
libkwaiplayer.so  KpMediaPlayer_native_setup (kpplayer_jni_core.c:1186)      
libkwaiplayer.so  KpMediaPlayer_reset (kpplayer_jni_core.c:536)      
libart.so
-------------- backtrace -------------------------------
  • 在上面的 backtrace 中可以看到 Unwind 到 libart.so 时就终止了,jni 方法的 Caller 通常应该为 Android 虚拟机的跳板/Stub函数,也就是应该在 libart.so 中,但应该能够继续 Unwind 才对,为什么会中断?
  • 这里猜测应该是虚拟机内部实现的跳板/Stub 函数帧布局不满足 FP Unwind,真的是这样吗?下面我们通过一个 NDK 的例子来探讨

一个简单的 NDK 的例子

......
bool __attribute__((oninline)) TestFunc(JNIEnv* env) {
  std::string s = "Hellooooooooooooooo ";
  return s.size() > random();
}

extern "C" JNIEXPORT jstring JNICALL
Java_com_kwai_myapplication_MainActivity_stringFromJNI(
    JNIEnv* env,
    jobject /* this */) {
  std::string hello = "Hello from C++";
  if (TestFunc(env)) {
    hello.append(" TestFunc");
  }
  return env->NewStringUTF(hello.c_str());
}
  • 这个例子很简单就是从 Java Native 方法中调用了 TestFunc 方法,我们把断点设在 TestFunc 的第一行看下此时的 backtrace 如下图,也就是在虚拟机跳板之后应该还有很多解释执行(或者机器码执行)等栈才对
正常的 backtrace 如下:

TestFunc(_JNIEnv*) native-lib.cpp:151
::Java_com_kwai_myapplication_MainActivity_stringFromJNI(JNIEnv *, jobject) native-lib.cpp:160
art_quick_generic_jni_trampoline 0x00000074b4b50354 <---- Android 虚拟机的跳板函数
art_quick_invoke_stub 0x00000074b4b47338
art::ArtMethod::Invoke() 0x00000074b4b561a8
art::interpreter::ArtInterpreterToCompiledCodeBridge() 0x00000074b4cf24a0
bool art::interpreter::DoCall()
......

为什么 FP Unwind 会出现中断

  • 先看下TestFunc开始执行时的寄存器信息

    (lldb) re r
    General Purpose Registers:
          x0 = 0x00000074b50f3980
          x1 = 0x0000007440a00900  
          x2 = 0x000000000000000e
          x3 = 0x75002b2b43206d6f
          x4 = 0x0000007440a0090e  
          x5 = 0x0000007fd58ffebf
          x6 = 0x7266206f6c6c6548
          x7 = 0x2b2b43206d6f7266
          ......
         x28 = 0x0000007fd58fff00
          fp = 0x0000007fd58ffe60
          lr = 0x0000007440a097e4  libnative-lib.so`::Java_com_kwai_myapplication_MainActivity_stringFromJNI(JNIEnv *, jobject) + 56 at native-lib.cpp:160
          sp = 0x0000007fd58ffe00
  • 有了 FP 和 SP 寄存器之后,我们 dump 下栈内容

    (lldb) x --force -s8 -fx -c512 0x0000007fd58ffe00
    0x7fd58ffe00: 0x0000000000000000 0x00000074b5010800
    0x7fd58ffe10: 0x0000007fd5900120 0x000000000000000e
    0x7fd58ffe20: 0x0000007440a00900 0x0000007fd58ffeb0
    0x7fd58ffe30: 0x000000753aae8020 0x00000074b50f3980
    0x7fd58ffe40: 0x0000007fd58ffeb0 0xc1da7c077a586644
    0x7fd58ffe50: 0x0000000000000007 0xc1da7c077a586644
    0x7fd58ffe60: 0x0000007fd58ffed0(FP’) 0x0000007440a097e4(LR’) <--------- Java_com_kwai_myapplication_MainActivity_stringFromJNI
    
    0x7fd58ffe70: 0x0000007fd5900020 0x00000074b4cb0988
    0x7fd58ffe80: 0xc1da7c077a586644 0x0000007fd5900020
    0x7fd58ffe90: 0x000000753aae8020 0x00000074b50b88a0
    0x7fd58ffea0: 0x0000007fd58fff04 0x00000074b50f3980
    0x7fd58ffeb0: 0x66206f6c6c65481c 0x002b2b43206d6f72
    0x7fd58ffec0: 0x0000007fd5900154 0xc1da7c077a586644
    0x7fd58ffed0: 0x0000007fd58fff00(FP”) 0x00000074b4b50354(LR”) <--------- art_quick_generic_jni_trampoline
    
    0x7fd58ffee0: 0x00000074b50b8600 0x00000007d5900128
    0x7fd58ffef0: 0x00000075354df1d0 0x0000007fd5904038
    0x7fd58fff00: 0x130c9d1000000001(错误的 FP'") 0x0000000012c85440(错误的 LR'") <----- 这里会出现中断因为 FP'" 保存的已经不再是 Caller 栈的 FP 了
    
    0x7fd58fff10: 0x0000000000000000 0x00494c020045474c
    0x7fd58fff20: 0x52415242494c0d00 0x0000040000000001
    0x7fd58fff30: 0x0000000000100000 0x4010040140100401
    0x7fd58fff40: 0x0000001000040401 0x8020080280200802
    0x7fd58fff50: 0x00000000130c9d10 0x0000000000000000
    0x7fd58fff60: 0x00000074b5010800 0x0000007fd5900460
    0x7fd58fff70: 0x000000744522150e 0x0000000000000040
    0x7fd58fff80: 0x636d604b1fff3a76 0x0000000000000000
    0x7fd58fff90: 0x00000074b5010800 0x0000007fd5900170
    0x7fd58fffa0: 0x000000744522150e 0x0000000000000004
    0x7fd58fffb0: 0x000000753aae8020 0x00000074b50108b0
    0x7fd58fffc0: 0x0000000000000001 0x0000000000000002
    0x7fd58fffd0: 0x0000007fd58ffff0(FP'") 0x00000074b4b47338(LR'") <--------- art_quick_invoke_stub
    
    ==> 如果我们把 FP" 中的 0x7fd58fff00 调整为 0x7fd58fffd0 就可继续 FP Unwind。
    ==> 而 0x7fd58fffd0 - 0x7fd58fff00 = 208,后面我们会看到为什么调整 208 个字节就可以继续了
    ......
  • 二进制内容比较枯燥我们用图来表示下栈的情况

  • 从上面的栈帧内容可以发现 0x7fd58ffed0 中保存的 FP 已经是错误的地址,从而导致 FP Unwind 中断;也就是 Java_com_kwai_myapplication_MainActivity_stringFromJNI 帧中保存的 art_quick_generic_jni_trampoline 方法的 FP 是错误的「这里的错误指的是 FP 指向的地址并没有保存 Caller 的 FP 寄存器」,下面我们看下 art_quick_generic_jni_trampoline 方法的栈帧布局究竟是什么样的?

art_quick_generic_jni_trampoline 是执行 JNI 方法时虚拟机需要执行的跳板函数,负责准备参数,解决 JNI 方法的地址等
代码路径:https://cs.android.com/android/platform/superproject/+/android-10.0.0_r30:art/runtime/arch/arm64/quick_entrypoints_arm64.S

// 宏定义,该宏的作用是保存 d0 ~ d7、x1 ~ x7、x20 ~ x30 寄存器的内容到栈中
.macro SETUP_SAVE_REFS_AND_ARGS_FRAME_INTERNAL
    INCREASE_FRAME 224

    // Ugly compile-time check, but we only have the preprocessor.
#if (FRAME_SIZE_SAVE_REFS_AND_ARGS != 224)
#error "FRAME_SIZE_SAVE_REFS_AND_ARGS(ARM64) size not as expected."
#endif

    // Stack alignment filler [sp, #8].
    // FP args.
    stp d0, d1, [sp, #16]
    stp d2, d3, [sp, #32]
    stp d4, d5, [sp, #48]
    stp d6, d7, [sp, #64]

    // Core args.
    SAVE_TWO_REGS x1, x2, 80
    SAVE_TWO_REGS x3, x4, 96
    SAVE_TWO_REGS x5, x6, 112

    // x7, Callee-saves.
    // Note: We could avoid saving X20 in the case of Baker read
    // barriers, as it is overwritten by REFRESH_MARKING_REGISTER
    // later; but it's not worth handling this special case.
    SAVE_TWO_REGS x7, x20, 128
    SAVE_TWO_REGS x21, x22, 144
    SAVE_TWO_REGS x23, x24, 160
    SAVE_TWO_REGS x25, x26, 176
    SAVE_TWO_REGS x27, x28, 192

    // x29(callee-save) and LR.
    SAVE_TWO_REGS x29, xLR, 208  <-------- sp 距离 x29 208 个字节

.endm

// 该宏的作用是保存 x0 到栈中,并且设置 sp 到 Thread::Current()->managed_stack->tagged_top_quick_frame_ 成员中
.macro SETUP_SAVE_REFS_AND_ARGS_FRAME_WITH_METHOD_IN_X0
    SETUP_SAVE_REFS_AND_ARGS_FRAME_INTERNAL
    str x0, [sp, #0]  // Store ArtMethod* to bottom of stack.
    // Place sp in Thread::Current()->top_quick_frame.
    mov xIP0, sp
    str xIP0, [xSELF, # THREAD_TOP_QUICK_FRAME_OFFSET]
.endm

art_quick_generic_jni_trampoline 方法的实现是平台相关的汇编实现
ENTRY art_quick_generic_jni_trampoline
    SETUP_SAVE_REFS_AND_ARGS_FRAME_WITH_METHOD_IN_X0 // 定义见上面的宏

    // Save SP , so we can have static CFI info.

    mov x28, sp
    .cfi_def_cfa_register x28

    // This looks the same, but is different: this will be updated to point to the bottom
    // of the frame when the handle scope is inserted.
    // 这里 xFP(x29) 寄存器设置为 sp
    mov xFP, sp
    // 我们看下此时栈帧布局
    // * #-------------------#
    // * |                   |
    // * | caller method...  |
    // * #-------------------#    
    // * | Return X30/LR     |
    // * | X29/FP            |    callee save  <----------- 如果 xFP 指向这里,FP Unwind 才能正确进行
    // * | X28               |    callee save
    // * | X27               |    callee save
    // * | X26               |    callee save
    // * | X25               |    callee save
    // * | X24               |    callee save
    // * | X23               |    callee save
    // * | X22               |    callee save
    // * | X21               |    callee save
    // * | X20               |    callee save
    // * | X19               |    callee save
    // * | X7                |    arg7
    // * | X6                |    arg6
    // * | X5                |    arg5
    // * | X4                |    arg4
    // * | X3                |    arg3
    // * | X2                |    arg2
    // * | X1                |    arg1
    // * | D7                |    float arg 8
    // * | D6                |    float arg 7
    // * | D5                |    float arg 6
    // * | D4                |    float arg 5
    // * | D3                |    float arg 4
    // * | D2                |    float arg 3
    // * | D1                |    float arg 2
    // * | D0                |    float arg 1
    // * | Method*           | <- X0     <--------------- xFP 指向这里,距离上面保存 x29 的地址 208 个字节
    // * #-------------------#

    ......

    blr xIP0        // native call. 调用Native方法或者先查找再调用;注意 JNI 方法中保存的 x29 也就是该方法中的 xFP
    // 不管是直接调用 Native 方法或者调用查找跳板, Native 方法的保存的 FP 是相同的,因为跳板会 Clear 自己的栈

    ......
END art_quick_generic_jni_trampoline
  • art_quick_generic_jni_trampoline 的实现是平台相关的汇编代码,从汇编上看在调用 Native 方法时,它的 FP 确实没有指向保存 Caller FP 的位置,而是偏差了 208 字节,这导致 FP Unwind 的中断,至此我们就找到了 FP Unwind 在 Android 上发生中断的原因。

小结

  • Android 虚拟机内部的跳板/Stub 函数的实现导致 FP Unwind 在 Android 运行时发生中断,这可能并不是 Android 的 BUG;编译器参数不同 FP 的意义可能不同,这也提醒我们在 Android 上使用 FP Unwind 时要小心,可能会有想不到的问题

思考及后续文章

一些思考

  • FP Unwind 在 Android 上有“坑”我们还能用吗?
    • 如果我们只关心 Native 层的栈,在性能要求高的场景下 FP Unwind 依然是最佳选择
  • Android 系统进行 Stack Unwind 时,它是如何做的?
    • 基于 Dwarf 的 Stack Unwind
    • 基于 gnu_debugdata 的 Unwind
  • Android 上 Java 和 Native Stack 是同一个栈,Android 系统 Unwind 时如何识别出 Java Frame 并计算出方法名行号?
    • 解释执行时如何并识别出 Java Frame?
    • 机器码执行时如何识别出 Java Frame ?
    • 执行 JIT Code Cache 中的代码时又会怎样?

后续文章

  • 对 Android 系统的 Stack Unwind 做一个全面的介绍;上面的问题都会从文章中找到答案

参考

原文地址:https://www.cnblogs.com/liunx1109/p/14685608.html