C++ 编译优化指北

1,397 阅读6分钟

背景

so 即动态库,本质上是 ELF(Executable and Linkable Format)文件。可以从两个维度查看 so 文件的内部结构:链接视图(Linking View)和执行视图(Execution View)。链接视图将 so 主体看作多个 section 的组合,该视图体现的是 so 是如何组装的,是编译链接的视角。而执行视图将 so 主体看作多个 segment 的组合,该视图告诉动态链接器如何加载和执行该 so,是运行时的视角。鉴于对 so 优化更侧重于编译链接角度,并且通常一个 segment 包含多个 section(即链接视图对 so 的分解粒度更小),因此我们这里只讨论 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 文件的大小。

在各大厂对安全意识的不断重视下,核心逻辑为了防止反编译后被窃取,更多的下沉到了 C++ 层实现,不仅能够实现双端统一,更能够有效防止产物反编译后重要信息被竞对窃取。但针对动态库 ,为了加快 loadso 速度,缩减包体积,一般有以下几种手段进行治理。

工具介绍

先分析,再治理

首先我们要对动态库进行分析,看看优化的方向,这时候就要用到 bloaty 了,这是 google 开源的一款小工具,会显示二进制文件的大小概况,以便了解其中占用空间的内容。

image.png

通过section 表中的信息,我们来进行针对性的治理,因为这些东西最终都会 load 到内存中,在 Andorid 内,具体的加载逻辑是这样的

  1. 用 mmap 预留一块足够大的内存,用于后续映射 ELF。(MAP_PRIVATE 方式)
  2. 读 ELF 的 PHT,用 mmap 把所有类型为 PT_LOAD 的 segment 依次映射到内存中。
  3. 从 .dynamic segment 中读取各信息项,主要是各个 section 的虚拟内存相对地址,然后计算并保存各个 section 的虚拟内存绝对地址。
  4. 执行重定位操作(relocate),这是最关键的一步。重定位信息可能存在于下面的一个或多个 secion 中:.rel.plt, .rela.plt, .rel.dyn, .rela.dyn, .rel.android, .rela.android。动态链接器需要逐个处理这些 .relxxx section 中的重定位诉求。根据已加载的 ELF 的信息,动态链接器查找所需符号的地址(比如 libtest.so 的符号 malloc),找到后,将地址值填入 .relxxx 中指明的目标地址中,这些 “目标地址” 一般存在于.got 或 .data 中。
  5. ELF 的引用计数加一。

那么我们可以知道,影响到loadso 速度主要是导出符号表的大小,影响包体积大小的则是so中的各类数据。

治理手段

治理的手段多种多样,但最终的导向都是一致的,也即 so 中 各 section 的大小越小越好,首先我们来看看.text,这部分如果占比超过50%,意味着你的动态库中可能躺着非常多无用的代码,这个时候需要做的有以下几点

  • 手动移除无用的deadcode,主动缩减包体积
  • 通过增加 -flto 编译选项来进行 体积缩减,因为 lto 技术能够有效利用编译链接时的上下文信息,更好进行优化操作,能够大幅降低 deadcode 对代码产生的影响
  • 通过 -fdata-sections -ffunction-sections 来移除不可达的code
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fvisibility=hidden")
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -flto")
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -O3 -flto")

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")
  • 动态加载 libc++
  • 禁用 rtti
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fno-rtti")
  • 禁用 excptions
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fno-exceptions")
  • 使用 exclude libs 移除静态库中的符号,因为我们的这些编译优化都只对源码进行了限制,如果有静态库引入的话,导出符号表也会膨胀,会影响到对应的so体积。

可以通过bloaty 再来查看下 .dynsym 对应的体积占用,如果占比很高,说明导出符号表有非常大的缩减空间,这里可以使用 nm -D xxx.so来具体查看导出的符号,优化的手段有:

  • 上面提到的 lto 技术,可以缩减一些无用符号
  • AGP 编译 so 时,首先产生的是带调试信息和符号表的 so(任务名为 externalNativeBuildRelease),之后对刚产生的带调试信息和符号表的 so 进行 strip,就得到了最终打包到 apk 或 aar 中的 so(任务名为 stripReleaseDebugSymbols)。在打Release包时,AGP就已经帮我们做了这些工作,一般情况下,不需要我们再去关心这部分的问题了。
  • 如果无需暴露给外部使用的 类与函数,我们给他们加上一个属性,这样,它们就不会出现在导出符号表中,从而极大的缩减包体积。
__attribute__((visibility("hidden")))

另外就是黑盒的一些手段了,比如 加大优化力度,让编译器大力出奇迹

set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Oz")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Oz")

总结

其实C++ 编译优化的手段主要还是要结合实际,但万变不离其宗,最重要的就是缩减 so 体积。 主要从以下三个方向上来考虑

  • 精简动态符号表
  • 缩减无用代码
  • 指令优化

利用好 编译器的优势,不论是gcc/clang,针对性的进行调优,收益会非常可观。