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


推荐阅读
  • 本文分析了Wince程序内存和存储内存的分布及作用。Wince内存包括系统内存、对象存储和程序内存,其中系统内存占用了一部分SDRAM,而剩下的30M为程序内存和存储内存。对象存储是嵌入式wince操作系统中的一个新概念,常用于消费电子设备中。此外,文章还介绍了主电源和后备电池在操作系统中的作用。 ... [详细]
  • 本文介绍了数据库的存储结构及其重要性,强调了关系数据库范例中将逻辑存储与物理存储分开的必要性。通过逻辑结构和物理结构的分离,可以实现对物理存储的重新组织和数据库的迁移,而应用程序不会察觉到任何更改。文章还展示了Oracle数据库的逻辑结构和物理结构,并介绍了表空间的概念和作用。 ... [详细]
  • 本文介绍了网页播放视频的三种实现方式,分别是使用html5的video标签、使用flash来播放以及使用object标签。其中,推荐使用html5的video标签来简单播放视频,但有些老的浏览器不支持html5。另外,还可以使用flash来播放视频,需要使用object标签。 ... [详细]
  • 本文概述了JNI的原理以及常用方法。JNI提供了一种Java字节码调用C/C++的解决方案,但引用类型不能直接在Native层使用,需要进行类型转化。多维数组(包括二维数组)都是引用类型,需要使用jobjectArray类型来存取其值。此外,由于Java支持函数重载,根据函数名无法找到对应的JNI函数,因此介绍了JNI函数签名信息的解决方案。 ... [详细]
  • Mono为何能跨平台
    概念JIT编译(JITcompilation),运行时需要代码时,将Microsoft中间语言(MSIL)转换为机器码的编译。CLR(CommonLa ... [详细]
  • 原文地址http://balau82.wordpress.com/2010/02/28/hello-world-for-bare-metal-arm-using-qemu/最开始时 ... [详细]
  • 【技术分享】一个 ELF 蠕虫分析
    【技术分享】一个 ELF 蠕虫分析 ... [详细]
  • 本文介绍了求解gcdexgcd斐蜀定理的迭代法和递归法,并解释了exgcd的概念和应用。exgcd是指对于不完全为0的非负整数a和b,gcd(a,b)表示a和b的最大公约数,必然存在整数对x和y,使得gcd(a,b)=ax+by。此外,本文还给出了相应的代码示例。 ... [详细]
  • 本文介绍了在Python3中如何使用选择文件对话框的格式打开和保存图片的方法。通过使用tkinter库中的filedialog模块的asksaveasfilename和askopenfilename函数,可以方便地选择要打开或保存的图片文件,并进行相关操作。具体的代码示例和操作步骤也被提供。 ... [详细]
  • 本文介绍了在开发Android新闻App时,搭建本地服务器的步骤。通过使用XAMPP软件,可以一键式搭建起开发环境,包括Apache、MySQL、PHP、PERL。在本地服务器上新建数据库和表,并设置相应的属性。最后,给出了创建new表的SQL语句。这个教程适合初学者参考。 ... [详细]
  • 云原生边缘计算之KubeEdge简介及功能特点
    本文介绍了云原生边缘计算中的KubeEdge系统,该系统是一个开源系统,用于将容器化应用程序编排功能扩展到Edge的主机。它基于Kubernetes构建,并为网络应用程序提供基础架构支持。同时,KubeEdge具有离线模式、基于Kubernetes的节点、群集、应用程序和设备管理、资源优化等特点。此外,KubeEdge还支持跨平台工作,在私有、公共和混合云中都可以运行。同时,KubeEdge还提供数据管理和数据分析管道引擎的支持。最后,本文还介绍了KubeEdge系统生成证书的方法。 ... [详细]
  • Windows下配置PHP5.6的方法及注意事项
    本文介绍了在Windows系统下配置PHP5.6的步骤及注意事项,包括下载PHP5.6、解压并配置IIS、添加模块映射、测试等。同时提供了一些常见问题的解决方法,如下载缺失的msvcr110.dll文件等。通过本文的指导,读者可以轻松地在Windows系统下配置PHP5.6,并解决一些常见的配置问题。 ... [详细]
  • 解决Sharepoint 2013运行状况分析出现的“一个或多个服务器未响应”问题的方法
    本文介绍了解决Sharepoint 2013运行状况分析中出现的“一个或多个服务器未响应”问题的方法。对于有高要求的客户来说,系统检测问题的存在是不可接受的。文章详细描述了解决该问题的步骤,包括删除服务器、处理分布式缓存留下的记录以及使用代码等方法。同时还提供了相关关键词和错误提示信息,以帮助读者更好地理解和解决该问题。 ... [详细]
  • 1.脚本功能1)自动替换jar包中的配置文件。2)自动备份老版本的Jar包3)自动判断是初次启动还是更新服务2.脚本准备进入ho ... [详细]
  • uboot与linux驱动1.uboot本身是裸机程序(1)在裸机中本来是没有驱动概念的(狭义的驱动概念是指在操作系统中用来具体操控硬 ... [详细]
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社区 版权所有