热门标签 | 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


推荐阅读
  • 【shell】网络处理:判断IP是否在网段、两个ip是否同网段、IP地址范围、网段包含关系
    本文介绍了使用shell脚本判断IP是否在同一网段、判断IP地址是否在某个范围内、计算IP地址范围、判断网段之间的包含关系的方法和原理。通过对IP和掩码进行与计算,可以判断两个IP是否在同一网段。同时,还提供了一段用于验证IP地址的正则表达式和判断特殊IP地址的方法。 ... [详细]
  • 本文介绍了网页播放视频的三种实现方式,分别是使用html5的video标签、使用flash来播放以及使用object标签。其中,推荐使用html5的video标签来简单播放视频,但有些老的浏览器不支持html5。另外,还可以使用flash来播放视频,需要使用object标签。 ... [详细]
  • tcpdump 4.5.1 crash 深入分析
    tcpdump 4.5.1 crash 深入分析 ... [详细]
  • 本文总结了Java中日期格式化的常用方法,并给出了示例代码。通过使用SimpleDateFormat类和jstl fmt标签库,可以实现日期的格式化和显示。在页面中添加相应的标签库引用后,可以使用不同的日期格式化样式来显示当前年份和月份。该文提供了详细的代码示例和说明。 ... [详细]
  • VScode格式化文档换行或不换行的设置方法
    本文介绍了在VScode中设置格式化文档换行或不换行的方法,包括使用插件和修改settings.json文件的内容。详细步骤为:找到settings.json文件,将其中的代码替换为指定的代码。 ... [详细]
  • 云原生边缘计算之KubeEdge简介及功能特点
    本文介绍了云原生边缘计算中的KubeEdge系统,该系统是一个开源系统,用于将容器化应用程序编排功能扩展到Edge的主机。它基于Kubernetes构建,并为网络应用程序提供基础架构支持。同时,KubeEdge具有离线模式、基于Kubernetes的节点、群集、应用程序和设备管理、资源优化等特点。此外,KubeEdge还支持跨平台工作,在私有、公共和混合云中都可以运行。同时,KubeEdge还提供数据管理和数据分析管道引擎的支持。最后,本文还介绍了KubeEdge系统生成证书的方法。 ... [详细]
  • 向QTextEdit拖放文件的方法及实现步骤
    本文介绍了在使用QTextEdit时如何实现拖放文件的功能,包括相关的方法和实现步骤。通过重写dragEnterEvent和dropEvent函数,并结合QMimeData和QUrl等类,可以轻松实现向QTextEdit拖放文件的功能。详细的代码实现和说明可以参考本文提供的示例代码。 ... [详细]
  • 本文介绍了数据库的存储结构及其重要性,强调了关系数据库范例中将逻辑存储与物理存储分开的必要性。通过逻辑结构和物理结构的分离,可以实现对物理存储的重新组织和数据库的迁移,而应用程序不会察觉到任何更改。文章还展示了Oracle数据库的逻辑结构和物理结构,并介绍了表空间的概念和作用。 ... [详细]
  • 本文分享了一个关于在C#中使用异步代码的问题,作者在控制台中运行时代码正常工作,但在Windows窗体中却无法正常工作。作者尝试搜索局域网上的主机,但在窗体中计数器没有减少。文章提供了相关的代码和解决思路。 ... [详细]
  • android listview OnItemClickListener失效原因
    最近在做listview时发现OnItemClickListener失效的问题,经过查找发现是因为button的原因。不仅listitem中存在button会影响OnItemClickListener事件的失效,还会导致单击后listview每个item的背景改变,使得item中的所有有关焦点的事件都失效。本文给出了一个范例来说明这种情况,并提供了解决方法。 ... [详细]
  • Metasploit攻击渗透实践
    本文介绍了Metasploit攻击渗透实践的内容和要求,包括主动攻击、针对浏览器和客户端的攻击,以及成功应用辅助模块的实践过程。其中涉及使用Hydra在不知道密码的情况下攻击metsploit2靶机获取密码,以及攻击浏览器中的tomcat服务的具体步骤。同时还讲解了爆破密码的方法和设置攻击目标主机的相关参数。 ... [详细]
  • Voicewo在线语音识别转换jQuery插件的特点和示例
    本文介绍了一款名为Voicewo的在线语音识别转换jQuery插件,该插件具有快速、架构、风格、扩展和兼容等特点,适合在互联网应用中使用。同时还提供了一个快速示例供开发人员参考。 ... [详细]
  • 如何在服务器主机上实现文件共享的方法和工具
    本文介绍了在服务器主机上实现文件共享的方法和工具,包括Linux主机和Windows主机的文件传输方式,Web运维和FTP/SFTP客户端运维两种方式,以及使用WinSCP工具将文件上传至Linux云服务器的操作方法。此外,还介绍了在迁移过程中需要安装迁移Agent并输入目的端服务器所在华为云的AK/SK,以及主机迁移服务会收集的源端服务器信息。 ... [详细]
  • 解决github访问慢的问题的方法集锦
    本文总结了国内用户在访问github网站时可能遇到的加载慢的问题,并提供了解决方法,其中包括修改hosts文件来加速访问。 ... [详细]
  • Vagrant虚拟化工具的安装和使用教程
    本文介绍了Vagrant虚拟化工具的安装和使用教程。首先介绍了安装virtualBox和Vagrant的步骤。然后详细说明了Vagrant的安装和使用方法,包括如何检查安装是否成功。最后介绍了下载虚拟机镜像的步骤,以及Vagrant镜像网站的相关信息。 ... [详细]
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社区 版权所有