热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

介绍一种性能较好的Androidnativeunwind技术

本文转自:微信终端开发团队的专栏:https:cloud.tencent.comdeveloperarticle1832287介绍一种有点不同于目前An

本文转自:微信终端开发团队的专栏:https://cloud.tencent.com/developer/article/1832287

介绍一种有点不同于目前 Android 平台上常用的 native backtrace 技术,在支持 Android ART unwind
的情况下,通过损失少数可回溯场景换取性能提升。方案有一些优势和局限性,适用于部分场景。


通常如何在 Android native 中进行栈回溯

其实 Android 上实现 native 栈回溯的方式并没有很多,罗列一下大概就两种:一种是基于函数栈帧基地址(fp=frame pointer)寄存器的栈回溯,另一种是基于异常处理(EH=Exception Handling)或调试信息(Dwarf)的回溯。我们先大概看看这两种方式:

1. 基于函数栈帧基地址寄存器的栈回溯(fp-based unwinder)

如果能用 fp 进行栈回溯事情会很轻松。假如你在编译的时候启用了 -fno-omit-frame-pointer 选项(clang 默认启用这个选项),编译器会把某个特定的寄存器当 fp 寄存器,用来保存当前函数调用栈的起始地址。按照 ARM 的调用约定(AAPCS)[1] ,在 fp 寄存器指向的栈空间上紧凑的存着上一层函数的 fp 地址和函数返回地址。大概样子就是下图这样。额外提一下,特定的 fp 寄存器在 64 位上是 x29 寄存器,32 位则是 r7(Thumb Code) 或 r11(ARM Code) [2] 。
在这里插入图片描述

fp 回溯性能是最好的,因为够直接了当,读取的内存也相近。不过它也有些问题,比如在 Arm 32 位上某些情况 fp 会被忽略掉或不准确 [3] ,也不能回溯穿过 JNI 和 OAT (没遵守 fp 的约定)。

[1]. Procedure Call Standard for the Arm: https://developer.arm.com/documentation/ihi0055/latest/
[2]. -fno-omit-frame-pointer: https://developer.arm.com/documentation/dui0774/g/Compiler-Command-line-Options/-fomit-frame-pointer—fno-omit-frame-pointer
[3]. https://gcc.gnu.org/legacy-ml/gcc-help/2018-07/msg00093.html

2. 基于异常处理或调试信息的回溯

这种方式稍微复杂一些。ELF 文件的 .eh_frame 或 .debug_frame section 中存储着一堆结构紧凑的数据,它描绘了很多张“表”(unwind tables),当你的代码执行到某一“行”时,根据此时的 pc 我们可以从这张表中查询到退出当前函数栈时,各个寄存器该怎么进行恢复,比如它可能描述了寄存器的值该在从当前栈的哪个位置上读回来。例如下图:

在这里插入图片描述

P.S. fs = frame stack size, s = same value, u = undefined, rN = register(N)

上图是 Dwarf 标准文档附录里的 example [4] ,方便理解一起贴上对应的汇编代码:
在这里插入图片描述

从前面那张“表”中可以看到 foo 函数每一“行”都写着从 R0 到 R8 寄存器的值该怎么恢复,有些当前没用到或者没变化的寄存器被标记为 u 或 s。而有变化的条目例如 R6 寄存器,在执行到 foo+8 时被它存储到栈基地址偏移 -8 的位置上,所以从 foo+12 这行开始 R6 就变成了 c-8。这个 c 指的是表中 CFA 一列,可以把它理解成一个虚拟的保存着栈帧基地址的 stack pointer 寄存器,所以 c 的值就是当前的栈基地址。

有了 unwind tables 我们能找出来当前函数栈帧起始的位置以及可以计算出寄存器保存的返回地址是多少,经过多轮这样的迭代就可以回溯出整个调用栈。

P.S. ‘.eh_frame’ 和 ‘.debug_frame’ 的区别:.eh_frame 是 Linux 标准规范制定 [5] 用来支持如 C++ 的 Exceptions 能力,它的内容同 Dwarf 的 .debug_frame 基本一样,都能用来回溯。不过 .debug_frame 在编译 release 库的时候通常会被去掉(OAT 和 JIT 会使用 .debug_frame)。

前面快速的了解了 unwind tables 的内容,比较粗糙重在理解,更详细的内容可以参考 Dwarf 文档 6.4 Call Frame Information 章节 [6] 。

不过事情到这里还没有完全讲完,Android 平台上总会稍微啰嗦一点。

在 ARM 32 位平台上,ARM 提供了一套不同的 Exception Handling 机制(因为比较早),同样可以一帧一帧计算出寄存器的值、栈帧起始以及返回地址。ARM EH 的数据存放在 ELF 的 .ARM.exidx 和 .ARM.extab 中 [7] 。下图是 ARM 定义的回溯指令集合,相比 Dwarf 的实现简单一些。
在这里插入图片描述

用 ndk 命令 arm-linux-androideabi-readelf -u lib.so 可以看到 Android 各种 32 位 so 的 ARM Unwind table。

[4]. Call Frame Information Example: http://www.dwarfstd.org/doc/DWARF4.pdf#page=253
[5]. Ehframe: https://refspecs.linuxfoundation.org/LSB_3.0.0/LSB-Core-generic/LSB-Core-generic/ehframechpt.html
[6]. Dwarf 标准: http://www.dwarfstd.org/doc/DWARF4.pdf#page=140
[7]. ARM Exception Handling: https://developer.arm.com/documentation/ihi0038/b/

如何改进栈回溯的实现方式

假如你使用过基于 EH 的回溯库,能体会到它的性能负担有多大。基本可以确定,在能使用 fp 的情况下从性能角度来说是最好的。不过我们也会遇到某些 frame pointer 无法应对的场景:比如当我们想针对某个 so 库(源码可能也不太容易获取)进行 Hook 或 Tracing 的时候,它可能并没有开启 frame pointer,这在 32 位上比较常遇到,而通常这些 so 会附带着 unwind tables,此时基本只能选像 libunwind 或者 libunwindstack 这类基于 EH 的回溯库。

另外前面也提到过用 fp 回溯没办法穿过 JNI 函数以及系统生成 OAT 代码的,因为这些 Android 平台实现没有遵守 fp 的使用约定。

我们能做哪些改进?

当把目光聚焦在 unwind tables 表格上的时候,可能会留意到这样一个问题:我们为了拿到函数的返回地址,却完整的恢复每一帧所有寄存器的状态。在处理异常或者调试的时候这样做非常有用,但栈回溯的时候貌似有些浪费。

进一步,我们还会看到在 .eh_frame 和 .debug_frame 的情况下还存在更多损耗性能的地方。

扒开 .eh_frame 的内部,unwind tables “表”数据被叫做 Frame Description Entry(FDE) 的结构保存着,FDE 包含了某个范围里的 pc 地址该如何恢复寄存器的一组组具体操作指令集合。每个 FDE 引用一个 Common Information Entry(CIE), CIE 保存共用的操作指令集合用于共享,所以多个 FDE 可以引用相同的 CIE。这些设计节省空间的同时,也增加了 unwind 需要的操作,当需要寻找某个 pc 对应的 CFA(Canonical Frame Address) 计算规则时,在找到 FDE 时可能会需要逐行计算直到找到 pc 所在的“行”。同时 Dwarf 标准为计算每个寄存器提供了计算的规则,其中最复杂的 expression 和 val_expression 规则支持了一套基于栈的完备操作指令,如果遇到这些规则,性能也会被拖累。这样来看也能理解为何 EH unwind 库的性能会比较差。

所以我们也许可以抛弃掉不需要恢复的寄存器,同时把寄存器的计算简化(抛弃可能涉及到多个寄存器参与的计算、同时简化必要寄存器取值规则),性能上应该会有很大改善。

方便理解用 .ARM.exidx 来举个例子(.eh_frame 的命令解释起来有点麻烦):
在这里插入图片描述

上图是一个 .ARM.exidx entry,描述虚拟栈寄存器 vsp 的回溯,pop 命令描述了其后跟随的寄存器将从栈上读取自己的值,同时再次修改 vsp。整个过程下来 vsp 计算了三次,而我们可能只关心 r14 这个存放了返回地址的寄存器具体是多少(r14 是 lr 寄存器)。所以整个计算可以简化 vsp 直接偏移 28 + 256 + (4 * 9) = 320,4 字节乘以 9 个寄存器,r14 则保存在 vsp 计算后偏移 -4 的内存地址上, 写成操作就是:vsp = vsp + 320; r14 = [vsp - 4];

.eh_frame 也能做类似的简化具体不介绍了,大概就是这样。

如何回溯穿过 JNI、OAT、JIT

接下来我们还要顺便解决另外 3 个问题:回溯穿过 JNI 函数、OAT 代码、JIT 代码。Android 在官方提供的 libunwindstack 库中已经支持了回溯 Android 虚拟机各种特性的能力。为了避免啰嗦,我们直接看具体的做法。

1. 穿过 JNI

Android 的 JNI 函数调用是有保存栈帧基地址到某个特定寄存器的,32 位上是 r10,64 位是 x28,具体可以看 AOSP 代码 [8.1] [8.2] 。我们只要持续恢复 JNI 的特定寄存器就可以回溯穿过 JNI 函数了。

2. 穿过 OAT

Android 生成的 OAT 本质上是一个 ELF 文件。在 Android 8.0 之后的版本的 OAT 都带有 .debug_frame section。在 7.1 ~ 6.0 需要 setprop debug.generate-debug-info true 才行 [9] ,所以一般来说 8.0 之前没法穿过 OAT。Android 8.0 之后的 OAT 不但提供了 .debug_frame,还可以用 OAT 函数地址从符号表中查询到对应的 Java 函数名称。

如果是解释模式下,虚拟机都运行在 libart.so 里,这时 r4 寄存器(32 位)和 x19/x20 寄存(64 位)保存的 dex pc 描述了当前正在执行的 dex 指令地址,具体可查看 AOSP 代码 [10.1] [10.2] [10.3]。所以我们可以顺便恢复 dex pc,这样在打印堆栈的时候可以从 Dex 里找回对应的函数签名。

3. 穿过 JIT

Android 的 JIT 稍微有点繁琐,当 Java 函数被执行足够多次(默认 1 万次)之后就会被 JIT 成机器码,存放在 jit-cache 的内存中。虽然和 OAT 一样都是机器代码,但 jit-cache 不是 ELF 结构的,他的 debug info 被单独存放在 __jit_debug_descriptor [11] ,专门留给调试工具获取 JIT debug info,也是从 Android 8.0 开始出现在 AOSP 中。

[8.1] art_quick_generic_jni_trampoline(arm): https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:art/runtime/arch/arm/quick_entrypoints_arm.S;drc=fa458ac21af98b3bdde2c62ed86b9c192b994372;l=1584
[8.1] art_quick_generic_jni_trampoline(arm64): https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:art/runtime/arch/arm64/quick_entrypoints_arm64.S;drc=fa458ac21af98b3bdde2c62ed86b9c192b994372;l=1870
[9] generate-debug-info: http://androidxref.com/7.1.1_r6/xref/art/dex2oat/dex2oat.cc#328
[10.1] Why r4: https://cs.android.com/android/platform/superproject/+/master:art/runtime/interpreter/mterp/arm/main.S;l=97?q=CFI_DEX
[10.2] Why x20(mterp): https://cs.android.com/android/platform/superproject/+/master:art/runtime/interpreter/mterp/arm64/main.S;l=99?q=CFI_DEX
[10.3] Why x19(switch): https://cs.android.com/android/platform/superproject/+/master:art/runtime/arch/arm64/quick_entrypoints_arm64.S;drc=81a6bd5a05ee3b2bb87ec4a0b471198dbbef3ce3;l=2556
[11] __jit_debug_descriptor: https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:art/runtime/jit/debugger_interface.cc;drc=4d125afe9c92bc1d58da74355de80c4c38377eae;l=39

Quicken Unwind Table

如何精简回溯计算、如何穿过 Android 虚拟机都已弄清楚,然后我们需要的就是一套符合自己诉求的操作指令集,在 unwind 时解释执行。
在这里插入图片描述

模仿 ARM 我们也编辑了一张 QUT 的指令表。可以看到只有我们关心的寄存器才有相应的指令,比如 r7/r11 是 fp 寄存器,r4 可能存放了 dex pc,r10 存放着 JNI 的栈基地址,lr 是返回地址。

性能表现

我们从 ELF 文件的 .eh_frame、.debug_frame、.ARM.exidx 经过精简生成出对应的 QUT 表,就可以用来在运行时快速的进行栈回溯。通过 Benchmark 进行对比,QUT 回溯速度相较于 EH unwind(基于 libunwindstack 库),60 帧调用栈的回溯速度提升约 15 ~ 30 倍(性能越差的机器提升越明显)。18 帧调用栈不穿过虚拟机时,相较于 fp ,QUT 耗时约是 fp 耗时的 4 ~ 5 倍。
在这里插入图片描述

回溯覆盖率

QUT 精简掉了不支持的寄存器恢复以及基于这些寄存器进行计算的相关规则。为了评估牺牲掉的无法回溯场景情况,我们收集了几个不同厂商 ROM 的 system libraries 及 boot.oat + boot-framework.oat 加上微信进行统计计算,发现不支持的情况非常少。
在这里插入图片描述

生成 QUT 数据

还需要考虑的问题是何时生成 QUT 数据。一个可选的时机是在 APP 编译时顺便生成,但这会对包体积带来负担,并且 OAT 文件要在手机环境下才能获取到。所以运行时生成,是一个综合来说更好的选择。

QUT 数据生成速度通常很快,大多数的库只需要几十毫秒,然而有个例外:boot-framework.oat,这个系统 oat 包含的 entry 以及对应行数非常庞大,慢的情况可能需要数分钟的时间来生成。

QUT 数据占用的空间不大,整个系统库以及 oat 加上微信的 so 全部生成出来的数据大概是 10 ~ 20M。因为最终数据将通过 mmap 到内存,根据实际使用到的情况,可能有大概 10M 左右的内存消耗。

QUT 的优势和局限性

QUT 具有比 unwind tables 高出 15 ~ 30 倍的性能提升,不过对比 fp 的回溯仍然处于劣势( fp 的 4 ~ 5 倍,波动性相比 fp 也更大)。它更适合在 fp 实际使用情况复杂的 ARM 32 位环境下得到出场机会。

QUT 也能同时获取到 Java 堆栈(因为可以回溯 JNI/OAT/JIT)。在需要回溯出 Java 堆栈的情况下,我们也尝试对比了 QUT 和 native 获取 Java 堆栈的性能,总的来说 QUT 有基本不输于 Java 堆栈的获取性能(遇到性能较差的机器可能会稍好一些),而且避免了涉及和改变虚拟机状态,适用性会更广一点。
在这里插入图片描述

还需要留意到 QUT 在运行时生成,会需要一个预热(Warm-up)的过程,并且受限于 ELF 文件是否携带了 Exceptions Handling 信息(有可能没带)。在预热完成前会根据遇到的 entry 生成临时的 QUT 数据。

总结

设计实现 QUT 的初衷是希望在 32 位环境下通过 hook 监控某些资源使用的调用栈,过程中察觉到 libunwindstack 的性能问题也发现基于异常处理的回溯有不少的改善空间。业界也有人提出类似的思路,比如 Reliable and Fast DWARF-Based Stack Unwinding 这篇论文 [12] 就描述了精简回溯过程后直接生成机器码的技术,有很大参考意义。

[12] https://hal.inria.fr/hal-02297690/document

QUT 的兼容性和适应性会比较强,因为实现原理都是依托于业界标准以及 Android 主动提供的调试能力(基础是异常处理和 Android 虚拟机提供的 debug info 及 JNI 约定)。但同时也有一些明显的局限性,得根据实际需要来使用,比如在缺少 fp 寄存器或从 Native 直接穿过 ART 虚拟机(可直接获取 Java 调用栈)等。

实现 QUT 没有想象的复杂,因为标准中涉及到栈基地址计算的指令很有限,除此之外都是可以精简的部分。

生成 QUT 可以基于 libunwindstack 改造实现,源码已经回流到 Matrix v1.0 版本中:

https://github.com/Tencent/matrix


推荐阅读
  • EST:西湖大学鞠峰组污水厂病原菌与土著反硝化细菌是多重抗生素耐药基因的活跃表达者...
    点击蓝字关注我们编译:祝新宇校稿:鞠峰、袁凌论文ID原名:PathogenicandIndigenousDenitrifyingBacte ... [详细]
  • Ihavetwomethodsofgeneratingmdistinctrandomnumbersintherange[0..n-1]我有两种方法在范围[0.n-1]中生 ... [详细]
  • 在机器学习领域,深入探讨了概率论与数理统计的基础知识,特别是这些理论在数据挖掘中的应用。文章重点分析了偏差(Bias)与方差(Variance)之间的平衡问题,强调了方差反映了不同训练模型之间的差异,例如在K折交叉验证中,不同模型之间的性能差异显著。此外,还讨论了如何通过优化模型选择和参数调整来有效控制这一平衡,以提高模型的泛化能力。 ... [详细]
  • 在C#编程中,数值结果的格式化展示是提高代码可读性和用户体验的重要手段。本文探讨了多种格式化方法和技巧,如使用格式说明符、自定义格式字符串等,以实现对数值结果的精确控制。通过实例演示,展示了如何灵活运用这些技术来满足不同的展示需求。 ... [详细]
  • 线程能否先以安全方式获取对象,再进行非安全发布? ... [详细]
  • 本文详细介绍了Java反射机制的基本概念、获取Class对象的方法、反射的主要功能及其在实际开发中的应用。通过具体示例,帮助读者更好地理解和使用Java反射。 ... [详细]
  • 本文详细介绍了在 CentOS 7 系统中配置 fstab 文件以实现开机自动挂载 NFS 共享目录的方法,并解决了常见的配置失败问题。 ... [详细]
  • 本文详细介绍了 PHP 中对象的生命周期、内存管理和魔术方法的使用,包括对象的自动销毁、析构函数的作用以及各种魔术方法的具体应用场景。 ... [详细]
  • 基于Linux开源VOIP系统LinPhone[四]
    ****************************************************************************************** ... [详细]
  • 在处理大规模数据数组时,优化分页组件对于提高页面加载速度和用户体验至关重要。本文探讨了如何通过高效的分页策略,减少数据渲染的负担,提升应用性能。具体方法包括懒加载、虚拟滚动和数据预取等技术,这些技术能够显著降低内存占用和提升响应速度。通过实际案例分析,展示了这些优化措施的有效性和可行性。 ... [详细]
  • 在 CentOS 6.7 系统维护中,常用的巡检命令包括:`uname -a` 用于查看内核、操作系统和 CPU 信息;`head -n 1 /etc/issue` 用于查看操作系统的版本;`cat /proc/cpuinfo` 用于获取详细的 CPU 信息;`hostname` 用于显示当前主机名;`ls` 命令则用于列出目录内容。这些命令可以帮助系统管理员快速了解系统的运行状态和配置信息,确保系统的稳定性和安全性。 ... [详细]
  • 深入解析 OpenSSL 生成 SM2 证书:非对称加密技术与数字证书、数字签名的关联分析
    本文深入探讨了 OpenSSL 在生成 SM2 证书过程中的技术细节,重点分析了非对称加密技术在数字证书和数字签名中的应用。非对称加密通过使用公钥和私钥对数据进行加解密,确保了信息传输的安全性。公钥可以公开分发,用于加密数据或验证签名,而私钥则需严格保密,用于解密数据或生成签名。文章详细介绍了 OpenSSL 如何利用这些原理生成 SM2 证书,并讨论了其在实际应用中的安全性和有效性。 ... [详细]
  • Android中将独立SO库封装进JAR包并实现SO库的加载与调用
    在Android开发中,将独立的SO库封装进JAR包并实现其加载与调用是一个常见的需求。本文详细介绍了如何将SO库嵌入到JAR包中,并确保在外部应用调用该JAR包时能够正确加载和使用这些SO库。通过这种方式,开发者可以更方便地管理和分发包含原生代码的库文件,提高开发效率和代码复用性。文章还探讨了常见的问题及其解决方案,帮助开发者避免在实际应用中遇到的坑。 ... [详细]
  • 计算机视觉领域介绍 | 自然语言驱动的跨模态行人重识别前沿技术综述(上篇)
    本文介绍了计算机视觉领域的最新进展,特别是自然语言驱动的跨模态行人重识别技术。上篇内容详细探讨了该领域的基础理论、关键技术及当前的研究热点,为读者提供了全面的概述。 ... [详细]
  • 在Python多进程编程中,`multiprocessing`模块是不可或缺的工具。本文详细探讨了该模块在多进程管理中的核心原理,并通过实际代码示例进行了深入分析。文章不仅总结了常见的多进程编程技巧,还提供了解决常见问题的实用方法,帮助读者更好地理解和应用多进程编程技术。 ... [详细]
author-avatar
潮爆啊--_317
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有