Android对so体积优化的探索与实践
总第513 篇
2022年 第030篇
-
1. 背景
-
2. so 文件格式分析
-
3. so 可优化内容分析
-
4. 优化方案介绍
-
4.1 精简动态符号表
-
4.2 移除无用代码
-
4.3 优化指令长度
-
4.4 其他措施
-
4.5 整合后的通用方案
-
5. 工程实践
-
支持多种构建工具
-
配置导出符号的注意事项
-
查看优化后 so 的导出符号
-
解析崩溃堆栈
-
6. 方案收益
-
7. 总结与规划
1. 背景
-
针对 dex 的优化,例如 Proguard、dex 的 DebugItem 删除、字节码优化等; -
针对 resource 的优化,例如 AndResGuard、webp 优化等; -
针对 assets 的优化,例如压缩、动态下发等; -
针对 so 的优化,同 assets,另外还有移除调试符号等。
2. so 文件格式分析
readelf -S
命令可以查看一个 so 文件的所有 section 列表,参考 ELF 文件格式说明,这里简要介绍一下本文涉及的 section:
-
.text:存放的是编译后的机器指令,C/C++代码的大部分函数编译后就存放在这里。这里只有机器指令,没有字符串等信息。 -
.data:存放的是初始值不为零的一些可读写变量。 -
.bss:存放的是初始值为零或未初始化的一些可读写变量。该 section 仅指示运行时需要的内存大小,不会占用 so 文件的体积。 -
.rodata:存放的是一些只读常量。 -
.dynsym:动态符号表,给出了该 so 对外提供的符号( 导出符号 )和依赖外部的符号( 导入符号 )的信息。 -
.dynstr:字符串池,不同字符串以 '\0' 分割,供.dynsym和其他部分使用。 -
.gnu.hash和.hash:两种类型的哈希表,用于快速查找.dynsym中的导出符号或全部符号。 -
.gnu.version、.gnu.version_d、.gnu.version_r:这三个 section 用于指定动态符号表中每个符号的版本,其中.gnu.version是一个数组,其元素个数与动态符号表中符号的个数相同,即数组每个元素与动态符号表的每个符号是一一对应的关系。数组每个元素的类型为Elfxx_Half,其意义是索引,指示每个符号的版本。.gnu.version_d描述了该 so 定义的所有符号的版本,供.gnu.version索引。.gnu.version_r描述了该 so 依赖的所有符号的版本,也供.gnu.version索引。因为不同的符号可能具有相同的版本,所以采用这种索引结构,可以减小 so 文件的大小。
.text
中,
.text
中的指令会去读取
.rodata
中的数据,读取或修改
.data
和
.bss
中的数据。看上去 so 中有这些内容也足够了。但是这些函数怎样执行呢?也就是说,只把这些函数和数据加载进内存是不够的,这些函数只有真正去执行,才能发挥作用。
.dynsym
中的符号还可以代表变量等其他类型,与函数类型类似,这里就不再赘述
)。
3. so 可优化内容分析
.debug_
开头的 section,通过这些 section 可以建立 so 每条指令与源码文件的映射关系(
也就是能够对 so 中每条指令找到其对应的源码文件名、文件行号等信息
)。之所以叫 strip 优化,是因为其实际调用的是 NDK 提供的的 strip 命令(
所用参数为--strip-unneeded
)。
注:为什么 AGP 要先编译出带调试信息和符号表的 so,而不直接编译出最终的 so 呢( 通过添加 -s参数是可以做到直接编译出没有调试信息和符号表的 so 的 )?原因就在于需要使用带调试信息和符号表的 so 对崩溃调用栈进行还原。删除了调试信息和符号表的 so 完全可以正常运行,但是当它发生崩溃时,只能保证获取到崩溃调用栈的每个栈帧的相应指令在 so 中的位置,不一定能获取到符号。但是排查崩溃问题时,我们希望得知 so 崩溃在源码的哪个位置。带调试信息和符号表的 so 可以将崩溃调用栈的每个栈帧还原成其对应的源码文件名、文件行号、函数名等,大大方便了崩溃问题的排查。所以说,虽然带调试信息和符号表的 so 不会打包到最终的 apk 中,但它对排查问题来说非常重要。
Unable to strip library 'XXX.so' due to missing strip tool for ABI 'ARMEABI'. Packaging it as is.
-
对于必须保留的内容考虑进行缩减,减小体积占用; -
对于无需保留的内容直接删除。
-
精简动态符号表 :上文已经提到,动态符号表是 so 与外部进行连接的“桥梁”,其中的导出表相当于是 so 对外暴露的接口。哪些接口是必须对外暴露的呢?在 Android 中,大部分 so 是用来实现 Java 的 native 方法的,对于这种 so,只要让应用运行时能够获取到 Java native 方法对应的函数地址即可。要实现这个目标,有两种方法:一种是使用 RegisterNatives 动态注册 Java native 方法,一种是按照 JNI 规范定义 java_***样式的函数并导出其符号。RegisterNatives 方式可以提前检测到方法签名不匹配的问题,并且可以减少导出符号的数量,这也是 Google 推荐的做法。所以在最优情况下只需导出JNI_OnLoad( 在其中使用 RegisterNatives 对 Java native 方法进行动态注册 )和JNI_OnUnload( 可以做一些清理工作 )这两个符号即可。如果不希望改写项目代码,也可以再导出java_***样式的符号。除了上述类型的 so,剩余的 so 通常是被应用的其他 so 动态依赖的,对于这类 so,需要确定所有动态依赖它的 so 依赖了它的哪些符号,仅保留这些被依赖的符号即可。另外,这里应区分符号表项与实现体,符号表项是动态符号表中相应的Elfxx_Sym项( 见上图 ),实现体是其在.text、.data、.bss、.rodata等或其他部分的实体。删除了符号表项,实现体不一定要被删除。结合上文 so 文件结构示意图,可以预估出删除一个符号表项后 so 减小的体积为:符号名字符串长度+ 1 +Elfxx_Sym+Elfxx_Half+Elfxx_Word。 -
移除无用代码 :在实际的项目中,有一些代码在 Release 版中永远不会被使用到( 例如历史遗留代码、用于测试的代码等 ),这些代码被称为 DeadCode。而根据上文分析,只有动态符号表的导出符号直接或间接引用到的所有代码才需要保留,其他剩余的所有代码都是 DeadCode,都是可以删除的( 注:事实上 .init_array等特殊 section 涉及的代码也要保留 )。删除无用代码的潜在收益较大。 -
优化指令长度 :实现某个功能的指令并不是固定的,编译器有可能能用更少的指令完成相同的功能,从而实现优化。由于指令是 so 的主要组成部分,因此优化这一部分的潜在收益也比较大。
.text
),其中 funC、value2、value3、value6 由于分别被需保留部分使用,所以需要保留其实现体,只能删除其符号表项。funD、value1、value4、value5 可删除符号表项及其实现体(
注:因为 value4 的实现体在
.bss
中,而
.bss
实际不占用 so 的体积,所以删除 value4 的实现体不会减小 so 的体积
)。
-
预处理 :将 include 头文件处扩展为实际文件内容并进行宏定义替换。 -
编译 :将预处理后的文件编译成汇编代码。 -
汇编 :将汇编代码汇编成目标文件,目标文件中包含机器指令( 大部分情况下是机器指令,见下文 LTO 一节 )和数据以及其他必要信息。 -
链接 :将输入的所有目标文件以及静态库( .a 文件 )链接成 so 文件。
4. 优化方案介绍
4.1 精简动态符号表
使用 visibility 和 attribute 控制符号可见性
-fvisibility=VALUE
控制全局的符号可见性,VALUE 常取值为 default 和 hidden:
-
default :除非对变量或函数特别指定符号可见性,所有符号都在动态符号表中,这也是不使用 -fvisibility 时的默认值。 -
hidden :除非对变量或函数特别指定符号可见性,所有符号在动态符号表中都不可见。
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fvisibility=hidden")set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fvisibility=hidden")
LOCAL_CFLAGS += -fvisibility=hidden
__attribute__((visibility("hidden")))int hiddenInt=3;
使用 static 关键字控制符号可见性
使用 exclude libs 移除静态库中的符号
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,--exclude-libs,ALL")#使所有静态库中的符号都不被导出set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,--exclude-libs,libabc.a")#使 libabc.a 的符号都不被导出
LOCAL_LDFLAGS += -Wl,--exclude-libs,ALL #使所有静态库中的符号都不被导出LOCAL_LDFLAGS += -Wl,--exclude-libs,libabc.a #使 libabc.a 的符号都不被导出
使用 version script 控制符号可见性
.gnu.version
和
.gnu.version_d
的内容。我们现在只使用它的指定所有导出符号的功能(
即符号版本名使用空字符串
)。开启 version script 需要先编写一个文本文件,用来指定动态库导出哪些符号。示例如下(
只导出 usedFun 这一个函数
):
{ global:usedFun; local:*;};
version_script.txt
)。
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,--version-script=${CMAKE_CURRENT_SOURCE_DIR}/version_script.txt") #version_script.txt 与当前 CMakeLists.txt 同目录
LOCAL_LDFLAGS += -Wl,--version-script=${LOCAL_PATH}/version_script.txt #version_script.txt 与当前 Android.mk 同目录
-
version script 方式可以控制编译进 so 的静态库的符号是否导出,visibility 和 attribute 方式都无法做到这一点。 -
visibility 结合 attribute 方式需要在源码中标明每个需要导出的符号,对于导出符号较多的项目来说是很繁杂的。version script 把需要导出的符号统一地放到了一起,能够直观方便地查看和修改,对导出符号较多的项目也非常友好。 -
version script 支持通配符, *代表0个或者多个字符,?代表单个字符。比如my*;就代表所有以 my 开头的符号。有了通配符的支持,配置 version script 会更加方便。 -
还有非常特殊的一点,version script 方式可以删除 __bss_start这样的一些符号( 这是链接器默认加上的符号 )。
4.2 移除无用代码
开启 LTO
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -flto")set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -flto")set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -O3 -flto")
LOCAL_CFLAGS += -fltoLOCAL_LDFLAGS += -O3 -flto
-
如果使用 Clang,编译参数和链接参数中都要开启 LTO,否则会出现无法识别文件格式的问题( NDK22 之前存在此问题 )。使用 GCC 的话,只需要编译参数中开启 LTO 即可。 -
如果项目工程依赖了静态库,可以使用 LTO 方式重新编译该静态库,那么编译动态库时,就能移除静态库中的 DeadCode,从而减小最终 so 的体积。 -
经过测试,如果使用 Clang,链接器需要开启非 0 级别的优化,LTO 才能真正生效。经过实际测试( NDK 为 r16b ),O1 优化效果较差,O2、O3 优化效果比较接近。 -
由于需要进行更多的分析计算,开启 LTO 后,链接耗时会明显增加。
开启 GC sections
.text
样式的 section 中,一些可读写变量会放到
.data
样式的 section 中,等等。链接器会把所有输入的目标文件的同类型的 section 进行合并,组装出最终的 so 文件。
.init_array
等
)直接或者间接引用到的 section,移除其他无用 section。这样就能减小最终 so 的体积。但开启 GC sections 还需要考虑一个问题:编译器默认会把所有函数放到同一个 section 中,把所有相同特点的数据放到同一个 section 中,如果同一个 section 中既有需要删除的部分又有需要保留的部分,会使得整个 section 都要保留。所以我们需要减小目标文件 section 的粒度,这需要借助另外两个编译参数
-fdata-sections
和
-ffunction-sections
,这两个参数通知编译器,将每个变量和函数分别放到各自独立的 section 中,这样就不会出现上述问题了。实际上 Android 编译目标文件时会自动带上
-fdata-sections
和
-ffunction-sections
参数,这里一并列出来,是为了突出它们的作用。
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fdata-sections -ffunction-sections")set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fdata-sections -ffunction-sections")set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,--gc-sections")
LOCAL_CFLAGS += -fdata-sections -ffunction-sectionsLOCAL_LDFLAGS += -Wl,--gc-sections
4.3 优化指令长度
使用 Oz/Os 优化级别
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Oz")set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Oz")
LOCAL_CFLAGS += -Oz
4.4 其他措施
禁用 C++ 的异常机制
try...catch
等
),可以通过禁用 C++ 的异常机制,来减小 so 的体积。
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fno-exceptions")
禁用 C++ 的 RTTI 机制
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fno-rtti")
合并 so
-
可以删除部分动态符号表项,减小 so 总体积。具体来讲,就是可以删除 liba.so 和 libb.so 的动态符号表中的所有导出符号,以及 libx.so 的动态符号表中从 liba.so 和 libb.so 中导入的符号。 -
可以删除部分 PLT 表项和 GOT 表项,减小 so 总体积。具体来讲,就是可以删除 libx.so 中与 liba.so、libb.so 相关的 PLT 表项和 GOT 表项。 -
可以减轻优化的工作量。如果没有合并 so,对 liba.so 和 libb.so 做体积优化时需要确定 libx.so 依赖了它们的哪些符号,才能对它们进行优化,做了 so 合并后就不需要了。链接器会自动分析引用关系,保留使用到的所有符号的对应内容。 -
由于链接器对原 liba.so 和 libb.so 的导出符号拥有了更全的上下文信息,LTO 优化也能取得更好的效果。
提取多 so 共同依赖库
libc++_shared.so
。
4.5 整合后的通用方案
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Oz -flto -fdata-sections -ffunction-sections")set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Oz -flto -fdata-sections -ffunction-sections")set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -O3 -flto -Wl,--gc-sections -Wl,--version-script=${CMAKE_CURRENT_SOURCE_DIR}/version_script.txt") #version_script.txt 与当前 CMakeLists.txt 同目录
LOCAL_CFLAGS += -Oz -flto -fdata-sections -ffunction-sectionsLOCAL_LDFLAGS += -O3 -flto -Wl,--gc-sections -Wl,--version-script=${LOCAL_PATH}/version_script.txt #version_script.txt 与当前 Android.mk 同目录
version_script.txt
较为通用的配置如下,可根据实际情况添加需要保留的导出符号:
{ global:JNI_OnLoad;JNI_OnUnload;Java_*; local:*;};
5. 工程实践
支持多种构建工具
配置导出符号的注意事项
-
如果一个 so 的某些符号,被其他 so 通过 dlsym 方式使用,那么这些符号也应该保留在该 so 的导出符号中(否则会导致运行时异常)。 -
编写 version_script.txt时需要注意 C++ 等语言对符号的修饰,不能直接把函数名填写进去。符号修饰就是把一个函数的命名空间( 如果有 )、类名( 如果有 )、参数类型等都添加到最终的符号中,这也是 C++ 语言实现重载的基础。有两种方式可以把 C++ 的函数添加到导出符号中:第一种是查看未优化 so 的导出符号表,找到目标函数被修饰后的符号,然后填写到version_script.txt中。例如有一个 MyClass 类:
class MyClass{ void start(int arg); void stop();};
_ZN7MyClass5startEi
。如果想导出该函数,
version_script.txt
相应位置填入
_ZN7MyClass5startEi
即可。
version_script.txt
中使用 extern 语法,如下所示:
{ global: extern "C++" { MyClass::start*; "MyClass::stop()"; }; local:*;};
查看优化后 so 的导出符号
nm -D --defined-only xxx.so
JNI_OnLoad
和
Java_com_example_MainActivity_stringFromJNI
。
解析崩溃堆栈
6. 方案收益
-
删除了大量的非必要导出符号从而提升了 so 的安全性。 -
因为 .data.bss.text等运行时占用内存的 section 减小了,所以也能减小应用运行时的内存占用。 -
如果优化过程中减少了 so 对外依赖的符号,还可以加快 so 的加载速度。
-
提升编译速度。因为使用 LTO、gc sections 等会增加编译耗时,计划调研 ThinLTO 等方案对编译速度进行优化。 -
详细展示保留各个函数/数据的原因。 -
进一步完善平台优化 so 的能力。
8. 参考资料
-
https://www.cs.cmu.edu/afs/cs/academic/class/15213-f00/docs/elf.pdf -
https://llvm.org/docs/LinkTimeOptimization.html -
https://gcc.gnu.org/onlinedocs/gccint/LTO-Overview.html -
https://sourceware.org/binutils/docs/ld/VERSION.html -
https://clang.llvm.org/docs -
https://gcc.gnu.org/onlinedocs/gcc
阅读更多