向工程腐化开炮|动态链接库so治理

1,628 阅读20分钟

作者:刘天宇(谦风)

系列文章回顾《向工程腐化开炮 | 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工具(比较常用的一种)来读取,举一个例子:

Dynamic section at offset 0x2d18 contains 27 entries:
  Tag        Type                         Name/Value
 0x0000000000000001 (NEEDED)             Shared library: [liblog.so]
 0x0000000000000001 (NEEDED)             Shared library: [libm.so]
 0x0000000000000001 (NEEDED)             Shared library: [libc++_shared.so]
 0x0000000000000001 (NEEDED)             Shared library: [libdl.so]
 0x0000000000000001 (NEEDED)             Shared library: [libc.so]
 0x0000000000000001 (NEEDED)             Shared library: [libslimlady_core.so]
 0x000000000000000e (SONAME)             Library soname: [libslimlady.so]

从上述输出可以看到,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、外部依赖模块),示例结果:

com.youku.android:YNativer:1.2.20210119.2
|-- libPdora.so
|   |-- armeabi
|   |-- arm64-v8a
|-- libaua.so
|   |-- armeabi
|   |-- arm64-v8a

com.alient.media:Alier:2.20210202.16
|-- libalier.so
|   |-- armeabi-v7a
|   |-- arm64-v8a

此外,前文基础知识部分,也讲到了so之间可以具备动态依赖关系。一个so依赖哪些其它so,可以通过相关工具直接查看,并不麻烦,但是站在整个apk视角,快速获取一个so,被哪些其它so所依赖,却并不容易。因此,作为辅助工具,还开发了so被依赖关系检测功能,在apk全局范围内,分析所有so之间的这种动态链接(依赖)关系。分析结果中,仅列出一级依赖,即如果A->(依赖)B->(依赖)C,那么列表中,只会包含C<-B,B<-A这两组依赖,示例分析结果:

* libc++_shared.so    # so名称
|-- armeabi-v7a ()    # abi,括号中是目标abi/so,来自哪些模块,如果括号中是空,说明在apk中不存在。
|   |-- libhnd.so (com.youku.arch:Hnd:2.8.15)    # armeabi-v7a/libhnd.so,位于Hnd模块。依赖了armeabi-v7a/libc++_shared.so。
|   |-- libslimlady.so (project.extaar.app:slimlady:1.0)
|-- arm64-v8a ()
|   |-- Hnd.so (project:app:1.0,com.youku.arch:Hnd:2.8.15)
|   |-- libslimlady.so (project.extaar.app:slimlady:1.0)

* libusb100.so
|-- armeabi-v7a (project:library-aar-2:1.0)
|   |-- libUVCCamera.so (project:library-aar-2:1.0)
|   |-- libuvc.so (project:library-aar-2:1.0)
|   |-- libUSBAudioDevice.so (project:library-aar-2:1.0)

接下来,对各个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来自于哪些模块:

libUVCCamera.so
|-- armeabi-v7a ([project:library-aar-2:1.0])

libjpeg-turbo1500.so
|-- armeabi-v7a ([project:library-aar-2:1.0])

优酷在2021年1月上线so abi不兼容卡口至今,累计拦截11次,有效保障对32/64位设备的兼容性。事实上,无论使用哪种模式进行64位apk的支持,这项检测能力和卡口机制,都能够完全一致的发挥预期作用。

abi不兼容治理情况

2.2 重复so

重复so,是指相同abi的不同名so,其文件md5值一致,一般来讲这都是同一个so改文件名之后的结果。在apk构建过程中,重复so均会进入到apk中,导致包大小增加。此外,一旦被全部加载到内存中,会导致多种运行时风险,原因和第一章讲述的STL被多个so静态链接类似。示例检测结果如下:

[armeabi-v7a] md5: c0598ed0b87843147152e14bba2b036f
|-- libmitaec.so (com.youku.android:DQI4Android:1.2.0.10)
|-- libNlsAEC.so (com.youku.android:DQI4Android:1.2.0.10)

[armeabi] md5: 66a9cf3fcd1739ad01d637418e97ebc5
|-- libwxjst.so (com.tb.android:ws:0.26.4.45-youku)
|-- libwxjsb.so (com.tb.android:ws:0.26.4.45-youku)

重复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,来源于哪些模块。示例检测内容如下:

[armeabi-v7a] libaceManager.so
|-- com.youku.arch:Hnd:2.8.16-SNAPSHOT (43392841f299f7b2e35df4bd85703272)
|-- com.youku.android:ALib:1.0.2 (b7f8d6fc7ba25073e8743c061ed9e92a)

由于Android Gradle Plugin默认已经对此类问题,实现了直接的拦截(打包失败),因此本项检测能力,并没有部署上线对应卡口,而是在日常工作中,作为一项辅助功能使用。

2.4 无用导出符号

导出符号(exported symbol),是指在so内定义的对象、方法、全局变量,被设置为可被外部代码引用(导入)。而无用导出符号,正是在apk全局范围内的所有so中,查找是否存在对此符号的导入(引用),如果没有就属于无用导出符号,可以在so构建过程的链接阶段,通过链接选项来进行清理。无用导出符号一定是在apk全局范围内,才能够得到有效的分析,因为在各so编译阶段,除非是调用链最上层的so,否则很难确定到底哪些符号没有被外部使用。分析结果,按照模块、so名称、abi逐级展示,示例内容如下:

* project:library-aar-2:1.0
|-- libuvc.so
|   |-- armeabi-v7a
|   |   |-- _uvc_status_callback
|   |   |-- uvc_print_format_desc_one
|   |   |-- uvc_find_frame_desc_stream
|   |   |-- uvc_any2iyuv420SP
|   |   |-- uvc_print_configuration_desc
|   |   |-- uvc_get_bus_number
|   |   |-- uvc_parse_vc_extension_unit
|   |   |-- uvc_get_stream_ctrl_format_size
|   |   |-- uvc_yuyv2yuv420P
|   |-- arm-v8a
|   |   |-- _uvc_status_callback
|   |   |-- uvc_print_format_desc_one
|   |   |-- uvc_find_frame_desc_stream

对于检测结果,需要注意以下两点:

  • 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领域的开发者,能够扩展自身的语言&技术栈,从而以更全面的视角,写出优秀的代码实现。与工程腐化的斗争,需要不同技术栈开发者,往前迈一小步,打破这种技术边界导致的腐化问题,与诸君共勉。

【参考文档】

关注【阿里巴巴移动技术】微信公众号,每周 3 篇移动技术实践&干货给你思考!