向工程腐化开炮|动态链接库 so 治理
作者:刘天宇(谦风)
系列文章回顾《向工程腐化开炮 | proguard治理》《向工程腐化开炮 | manifest治理》《向工程腐化开炮:Java代码治理》《向工程腐化开炮|资源治理》。本文为系列文章第五篇,聚焦于动态链接库 so,这一细分领域。对工程腐化,直接开炮!
在 Android 技术领域,动态链接库 so 一般使用 c/c++开发,近年随着 rust 的“闪耀“,无论在 aosp 系统功能层面,还是 app 应用功能层面,都能看到其身影。但无论使用的开发语言是什么,最终在 apk 和运行时的存在形式,都是符合 ELF 格式的 so 文件。本文聚焦于动态链接库 so 本身,对 abi 不兼容、重复、冲突、无用导出符号,这几种腐化情况,进行工具研发以及治理实践。
基础知识
本章并不会讲解使用 c/c++等语言,编写动态链接库 so 的相关知识,而是站在 app 整体层面,尝试以“外部”(非 c/c++开发者)视角,来讲解近些年在 Android 架构工作中,了解到的一些有趣知识点。
1.1 c++标准模版库(STL)
当使用 c++开发动态链接库 so 时,如果使用到 C++标准模版库,就需要指定具体使用哪一个。有以下几种可供选择:
libc++。LLVM 的 libc++是 STL 规范的一种实现,Android 5.0 及以后版本 os 便开始使用此 STL,更近一步,在 ndkr18 开始成为唯一可用 STL。因此,libc++也是 Android 官方指定 STL;
gnustl&gnustl_port。这两个都是 GNU 项目提供的 STL 规范实现,在旧版本 ndk 中提供了相关支持,正如上述所讲,ndkr18 后已废弃。在当前开发时尽量避免使用此 STL;
system。Android 系统内置 STL 规范实现,仅提供 new 和 delete,一般不使用。同样,也在 ndkr18 后废弃。
在选定具体 STL 后,还有两种链接方式可供选择:
静态链接。静态链接会将使用到的 stl 中代码,链接(拷贝)到 so 中;
动态链接。在链接时,并不会将 stl 代码拷贝到 so 中,而是将使用到的 STL 符号,保存在 so 的动态链接符号表中,在运行时绑定并调用这些 STL 中的符号(位于 STL 的 so 中)。
当 app 只有一个 so 时,建议使用静态链接方式,以减小包尺寸;当 app 包含多个 so 时,全部使用静态链接,stl 代码实现会拷贝多份到不同 so 中,这会极大增加包大小,因此应该选择动态链接。但是需要注意的是,无论是多个 so 静态链接同一个 STL,还是多个 so 动态链接多个不同 STL,都会导致运行时功能异常,甚至引发 crash 的风险,因此,最佳方案是:仅使用一种链接方式,同时,仅使用同一个 STL。
1.2 so 动态链接(依赖)
对于一个 c/c++源码开发的模块,如果需要引用其他模块提供的功能,与对 STL 的使用类似,也有动态链接和静态链接两种方式可供选择。这里需要注意的是,如果依赖的这些模块,已经以动态链接库 so 形式,存在于 apk 中,那么在这里应该选择动态链接形式;否则,应该使用静态链接形式。如果使用动态链接方式,引用了其它 so 中的符号,在最终 so 中会包含这种动态依赖关系的信息。具体来讲,这个信息存在于 so 文件的“.dynamic”段中,我们可以通过 readelf 工具(比较常用的一种)来读取,举一个例子:
从上述输出可以看到,Type 为 SONAME 的条目,记录了 so 名称。注意,这个 so 名称仅用于其它 so 依赖这个 so 时,在搜索路径中进行查找,与 so 文件名称不一定完全一致,但是在 Android 环境下,一般我们会将这二者保持一致。so 之间的动态依赖关系,记录在 Type 为 NEEDED 的条目中,对上述例子,libslimlady.so 动态链接(依赖)了六个 so,我们分别来看下都是什么:
libdl.so。动态链接器,提供动态加载其它 so 能力,Android 平台中的 so,都会包含此项依;
libc.so、libm.so。这个可以认为是 c 语言的基础运行时库,可以认为所有 Android 中使用的 so 都包含;
liblog.so。Android 平台 logcat 日志库,在 c/c++代码中如果需要将信息打印到 logcat 中,就需要动态链接这个库,并在代码中调用相关函数;
libc++_shared.so。这就是上一小节讲到的 LLVM 版本标准模版库 libc++,动态链接形式的 so 名称;
libslimlady_core.so。这是 apk 中另一个已存在的 so,libslimlady.so 通过动态链接方式,依赖这个 so,从而在代码中可以调用其定义的方法。
事实上,支持上述动态链接的系统,还支持另一种更加灵活的 so 加载方式,即显式运行时链接。这种链接方式,不会在 so 文件中记录其依赖的 so,而是在运行时根据需求,动态将其它 so 加载进来(dlopen),获取目标符号的地址(dlsym),然后进行调用,在这里不详细展开。
1.3 so 加载过程分析
接下来,我们看看一个 so 的基本加载过程,是什么样的。
so 加载过程分析
当我们在代码中调用 System.loadLibrary 方法,加载一个 so 时,首先是在 Java API Framework 层查找 so 文件的绝对路径,这个搜索路径存储位置如下:
os 小于 6.0 时,位于 BaseDexClassLoade 对象,DexPathList 实例中的 nativeLibraryDirectories 成员变量;
os 大于等于 6.0 时,位于 DexPathList 实例中的 nativeLibraryPathElements 成员变量。
在找到目标 so 文件的绝对路径后,java 虚拟机会判断此 so 是否已经加载,如果已加载那么直接返回。如果未加载,会继续调用到 nativeloader&linker 层,真正的加载也是在这层中完成。首先,会解析 so 文件头,收集此 so 动态链接的其它 so 集合,如果为空或者均已加载完成,则继续判断目标 so 是否已加载(这里有并发问题,因此在 native 层会再进行判断),如果未加载便直接进行加载。注意,这里的流程是简化过的,这个动态链接 so 集合是否均已加载的判断并不存在,实际上是通过遍历 so,并以广度优先原则,逐一完成各级依赖 so 的加载工作。在这个遍历过程中,同样需要根据 so 名称查找 so 文件绝对路径,这个搜索路径来源如下:
os 小于 7.0 时,就是在 java 层的搜索路径中查找;
os 大于等于 7.0 时,底层 so 的加载引入了 Namespace 概念,每当 BaseDexClassLoader 创建实例时,都会在 nativeloader 层创建一个 Namespace 与之对应,并将 java 层搜索路径拷贝一份。
不同 os 版本的加载流程,并不完全一致,上述 so 加载过程,是一个抽象简化后的示意流程,真实情况要复杂很多。此外,so 加载是线程安全的,因此不会出现一个 so 被加载多份到内存中的问题,也正因为如此,并发加载 so 有可能会导致阻塞等待情况出现,这一点需要特别注意。另外,如果想要加载非 app 内置 so,有一种方案是在 java 层将外置路径添加进去,如果涉及到几个 so 之间的动态链接(依赖)情况,java 层搜索路径和 native 层搜索路径不一致问题,绝不可忽视:如何目标 so 不在 apk 中,那么可能导致 so 找不到,如果目标 so 在 apk 中,可能导致外置 so 和内置 so 都被加载到内存情况发生。
好了,基础知识部分,就讲到这里,仅了解这些还远远不够,作为一名 Android 开发者,即使在实际工作中不需要开发 c/c++代码,多了解一些动态链接库 so 的相关知识,对全面了解 app 运行机制也大有裨益。推荐一本个人非常喜欢的书:程序员的自我修养(链接、装载与库)。
治理实践
随着工程模块 &功能增加,动态链路库 so 腐化逐步积累:对 c++标准模版库的使用五花八门,大量静态链接 STL 导致不必要的包大小增加;新增或者更新 so 时,缺少必须 abi,导致在对应设备上,由于找不到 so 而崩溃;偶有发生的重复 so 问题,也对包大小和稳定性等带来负面影响;无用导出符号逐步积累,同样导致包大小增加。上述这些问题,都是过往优酷与动态链接库 so“腐化”斗争中,遇到的实际问题。通过相关工具建立有效的检测能力,并基于此形成日常研发卡口机制,在确保问题零新增前提下,逐步消化已有存量问题。
在问题定位、排查过程中,快速获取 so 来自哪个模块,是一个很自然的基本诉求。二、三方模块大量引入,以及 app 工程模块化程度提高,都使上述信息获取的成本变得越来越高。为此,首先开发了模块包含 so 列表功能,可以快速查看目标 so,位于哪个模块(app 工程、subproject 工程、flat aar、外部依赖模块),示例结果:
此外,前文基础知识部分,也讲到了 so 之间可以具备动态依赖关系。一个 so 依赖哪些其它 so,可以通过相关工具直接查看,并不麻烦,但是站在整个 apk 视角,快速获取一个 so,被哪些其它 so 所依赖,却并不容易。因此,作为辅助工具,还开发了 so 被依赖关系检测功能,在 apk 全局范围内,分析所有 so 之间的这种动态链接(依赖)关系。分析结果中,仅列出一级依赖,即如果 A->(依赖)B->(依赖)C,那么列表中,只会包含 C<-B,B<-A 这两组依赖,示例分析结果:
接下来,对各个 so“腐化”项的治理实践,逐一讲解。
2.1 abi 不兼容
abi 是 application binary interface 的缩写,代表应用二进制接口。不同 Android 设备使用不同的 CPU,而不同 CPU 支持不同的指令集。CPU 与指令集的每种组合都有专属的应用二进制接口 (ABI),对于 Andriod 平台来说,主要差异部分有以下两个:
可使用的 CPU 指令集,以及扩展指令集;
应用和系统之间传递数据的规范(包括对齐限制),以及系统调用函数时,如何使用堆栈和寄存器。
在当前 Android 生态中,主要是 Arm 指令集 CPU,进一步展开则是 32 位和 64 位 arm 指令集。当前新手机设备,基本都是 64 位 cpu,但是由于历史原因,很多 app 都仅支持 32 位 arm,升级 app 到对 64 位 arm 支持,有多方面优势:
性能。64 位 armCPU 对应的指令集,具有更高效的指令执行速度,充分利用这些指令集,可以有效提高 app 使用体验;
内存。32 位 app 进程,VirtualMemory 最大值为 2^32,即 4GB,由于 os 等占用,实际可用小于 4GB,随着屏幕分辨率、CPU 计算能力等硬件水平的提高,app 承载越来越复杂的功能,因此对虚拟内存的需求也随之提高,进一步,虚拟内存不足导致的 OOM 问题愈发严重。支持 64 位后,在 64 位机型上,VirtualMemory 的限制值将超过 4GB,理论上限可达 2^48,能够极大缓解虚拟内存导致的 OOM 问题。
当然,64 位并非没有一点负面影响,包体积就是其中不可忽视的一项。对于同样的 c/c++/rust 等代码,编译后对应的 64 位 so,由于指令集、数据等占用 Byte 数增加,导致 so 文件也会有明显增大。不出意外的,这并不能阻塞 Android 生态对 64 位 app 支持的步伐:googleplay 于 2019 年 8 月 1 日起,要求所有包含 so 的新增 &升级 app,必须支持 64 位 arm,否则无法通过审核;国内应用商店也相继跟上,例如三星和华为分别在 2020 年启动了相关限制或者推广,时至今日,已有更多应用商店加入到 64 位 app 支持的推进中。
在对 64 位 app 的支持模式上,googleplay 提供了 app bundle 这一组件化技术,将不同 abi 的 so 集合,作为一个 feature module,商店根据设备进行 apk 组装,而在国内,不同应用商店对此的支持情况并不(也很难)统一。除了 app bundle,另一种对 64 位 app 的支持模式是“分包”:一个 32 位 apk,一个 64 位 apk,应用商店根据终端手机 cpu 信息,自动呈现对应 apk,这同样也依赖应用商店支持,也面临支持情况不统一问题。当然,还有第三种支持模式是“合包”:一个 app 内既包含 32 位 apk,又包含 64 位 apk,这种模式不需要额外支持,但是 apk 大小却极速膨胀。
64 位支持方案对比
app bundle&分包两种模式,对于非应用商店渠道,为了保障 apk 可用性,只有两条路可选:使用 32 位 apk,或者 apk 合包。非应用商店渠道,一般都是真金白银换来的流量,过大的包体积会极大降低下载 &安装转化率,所以 apk 合包模式很难满足需求,一般情况下只能牺牲 64 位 apk 带来的用户体验,而不得不使用 32 位 apk。当然,这两年在行业内,对于非应用商店渠道,头部 app 更倾向于使用独立的“极小包”来进行投放,抛开商业和运营等层面不谈,其中的技术实现也是一个比较有意思的话题,但与本文关联较小,在此也不予展开。
优酷在 2020 年就采用分包模式,实现了对 64 位 app 的支持,在改造过程中,有不少存量动态链接库,仅包含 32 位 so,导致 app 整体无法兼容 64 位设备。另一方面,如何在 app 功能迭代过程中,始终保持对 32 和 64 位的兼容性,也是一个不小的挑战:无论是 so 的新增,还是现有 so 的升级迭代,都有可能出现 32 位和 64 位 so 的缺失问题,而不可能每一次工程或者代码的改动,都会全量在 32 位和 64 位 apk 中进行双重验证。
为此,研发了 32/64 位 abi 兼容性检测工具,对于同名 so,当未同时具备 32 位 arm(armeabi 或者 armeabi-v7a)和 64 位 arm(arm64-v8a)时,即判定为 abi 不兼容。更近一步,提供选项,当检测结果不通过时,终止构建过程,形成卡口机制。示例检测结果如下,同时也给出了 so 来自于哪些模块:
优酷在 2021 年 1 月上线 so abi 不兼容卡口至今,累计拦截 11 次,有效保障对 32/64 位设备的兼容性。事实上,无论使用哪种模式进行 64 位 apk 的支持,这项检测能力和卡口机制,都能够完全一致的发挥预期作用。
abi 不兼容治理情况
2.2 重复 so
重复 so,是指相同 abi 的不同名 so,其文件 md5 值一致,一般来讲这都是同一个 so 改文件名之后的结果。在 apk 构建过程中,重复 so 均会进入到 apk 中,导致包大小增加。此外,一旦被全部加载到内存中,会导致多种运行时风险,原因和第一章讲述的 STL 被多个 so 静态链接类似。示例检测结果如下:
重复 so,也同样提供选项,当检测结果不通过时,终止构建过程,形成卡口机制。在实际迭代过程中,这种情况应该出现频率较低,毕竟正常开发过程,不会刻意修改一个 so 的文件名。优酷近 1 年多的实践过程中,仅发现存量的两个重复 so,在 2021 年 2 月卡口上线至今,未出现此类问题导致的拦截记录。之所以还要研发这样的检测能力,并部署上线对应卡口,是因为像这样不常见且没有任何“蛛丝马迹”的问题,一旦出现后很难及时发现,可能会存在很久。而这,也正是“工程腐化”中隐藏较深的一种典型问题,不可不防。
2.3 冲突 so
冲突 so,是指相同 abi 的同名 so,其文件 md5 值不一致。在 apk 构建过程中,相同 abi 下的同名 so 根据构建配置(packaginggptions),会导致构建失败(default,不容易定位同名 so 来自哪一个模块),或选择第一个遇到的(pickFirsts,具有“随机性”,会导致不确定性风险)。研发的本项冲突检测功能,主要是为了方便定位冲突 so,来自于哪些模块,因为 Android Gradle Plugin 在构建失败后,并不会给出这个 so,来源于哪些模块。示例检测内容如下:
由于 Android Gradle Plugin 默认已经对此类问题,实现了直接的拦截(打包失败),因此本项检测能力,并没有部署上线对应卡口,而是在日常工作中,作为一项辅助功能使用。
2.4 无用导出符号
导出符号(exported symbol),是指在 so 内定义的对象、方法、全局变量,被设置为可被外部代码引用(导入)。而无用导出符号,正是在 apk 全局范围内的所有 so 中,查找是否存在对此符号的导入(引用),如果没有就属于无用导出符号,可以在 so 构建过程的链接阶段,通过链接选项来进行清理。无用导出符号一定是在 apk 全局范围内,才能够得到有效的分析,因为在各 so 编译阶段,除非是调用链最上层的 so,否则很难确定到底哪些符号没有被外部使用。分析结果,按照模块、so 名称、abi 逐级展示,示例内容如下:
对于检测结果,需要注意以下两点:
JNI 方法已忽略。os 在绑定 JNI 方法时,会使用到 JNI_OnLoad/JNI_OnUnload,以及所有“Java_”开头的符号,但是在上述检测算法中,会被误检测为无用,因此在检测结果中,专门进行了剔除,避免出现误检情况;
通过 dlsym 方式加载并调用的符号,会被误检为无用,需要结合实际代码功能,进行最终判断。
无用导出符号,考虑到存在理论上的误检问题,以及少量无用导出符号,在短期内存在的合理性,并没有进一步形成卡口,而是作为包大小分析结果中,一个可瘦身项来呈现。2021 年 12 月检测能力开发完成后,优酷这边存量无用导出符号约 3.5 万个,在进行了一轮集中式问题分发后,目前已经降至约 2.8 万个。
2.5 治理全景
至此,对于动态链接库 so,进行了较全面有效的防腐化能力建设和治理。最后,给出一份全景图:
动态链接库 so 治理全景
还能做些什么
事实上,动态链接库 so 作为二进制形式程序代码,包含了很多信息,例如在优酷的包大小分析工具中,将静态链接 STL、链接非标准 STL,做为可瘦身检测项之一,为包瘦身提供有效指导。同时,相对于 java 的 jvm 字节码,so 的分析难度要高很多,后续仍然有广泛的探索空间,例如缺失导出符号、JNI 方法不匹配等。
在 Android 开发领域,java/kotlin 这一上层技术栈,与 c/c++/rust 等底层技术栈,无论从源码编译过程、调试,还是运行时错误定位分析,都有着极大的差异。一方面,so 对应源码很多时候,是同一套代码编译为多端(Android/ios)使用的库,无论是源码还是编译选项,可能都缺少对 Android 的深入优化;另一方面,java/kotlin 代码与 so 互相调用部分,也由于技术栈上的 gap,容易出现“腐坏”的代码。
这需要从事 Android 领域的开发者,能够扩展自身的语言 &技术栈,从而以更全面的视角,写出优秀的代码实现。与工程腐化的斗争,需要不同技术栈开发者,往前迈一小步,打破这种技术边界导致的腐化问题,与诸君共勉。
【参考文档】
【Book】程序员的自我修养(链接、装载与库)
【google】C++ Library Support:https://developer.android.com/ndk/guides/cpp-support#c_runtime_libraries
关注【阿里巴巴移动技术】微信公众号,每周 3 篇移动技术实践 &干货给你思考!
版权声明: 本文为 InfoQ 作者【阿里巴巴移动技术】的原创文章。
原文链接:【http://xie.infoq.cn/article/3905510cf1a21e353a02861b3】。文章转载请联系作者。
评论