安卓動態連結庫檔案體積最佳化探索實踐

發表於2024-02-11

背景介紹

應用安裝包的體積影響著使用者下載量、安裝時長、使用者磁碟佔用量等多個方面,據Google Play統計,應用體積每增加6MB,安裝的轉化率將下降1%。

安裝包的體積受諸多方面影響,針對dex、資原始檔、so檔案都有不同的最佳化策略,在此不做一一展開,本文主要記錄了在研發時針對動態連結庫的檔案體積裁剪最佳化方案。

我開發的連結庫使用rust語言開發,透過安卓jni介面實現java層和native層之間的相互呼叫。為什麼使用rust主要有以下幾個方面的考慮:

1.穩。安卓的JNI介面呼叫複雜,又涉及到native層的記憶體管理,隨著程式碼量的增加,程式碼的安全穩定性會受到很大的挑戰。使用rust開發,開發者幾乎不需要考慮GC的問題,只要開發的時候按照規範老老實實寫程式碼並且透過了編譯器的檢查,基本上就很難把程式寫崩,這一點在程式碼上線後也確實得到了驗證。

2.安全。傳統使用C、C++開發的程式碼編譯完成以後,如果不加保護,很容易使用反彙編工具破解,市面上比較成熟的工具如IDA、ghidra等都可以將彙編程式碼還原到高階語言。使用rust編譯的產物,內部函式間的呼叫規約和傳統都不一樣,目前市面上還沒有相對完善的反編譯工具,軟體的防破解能力直接上升一個數量級。

但是使用rust有一個非常明顯的缺點就是編譯產物體積過大。在不修改預設的rust編譯選項的情況下,僅開啟strip的情況下,我的動態庫體積達到了495k

最佳化方案

參考網上前人的經驗,依次進行了以下最佳化方式。

調整最佳化等級

預設的編譯最佳化等級是O3,該最佳化的目的提高程式碼的執行速度,但是與此同時會對部分迴圈進行展開,體積造成膨脹。在此我們以縮減體積為目標,將最佳化選項改為z,表示生成最小二進位制體積:

[profile.release]
opt-level = 'z'

最佳化後前後體積變化

| 編譯選項 | 體積 |
| strip | 495k |
| strip + opt-level = 'z' | 437k |

開啟LTO

LTO(Link Time Optimization)可以在連結時消除冗餘程式碼,減小二進位制體積——代價是更長的連結時間。

Cargo.toml
[profile.release]
opt-level = 'z'
lto = true

最佳化後前後體積變化

| 編譯選項 | 體積 |
| strip | 495k |
| strip + opt-level = 'z' | 437k |
| strip + opt-level = 'z' + lto | 436k |

最佳化效果非常不明顯,聊勝於無。

Panic立刻終止

rust預設的panic會在崩潰時進行棧回溯,方便定位問題。然而會帶來額外的體積增加,將這一功能使用abort替代。

[profile.release]
opt-level = 'z'
lto = true
panic = 'abort'

最佳化後前後體積變化

| 編譯選項 | 體積 |
| strip | 495k |
| strip + opt-level = 'z' | 437k |
| strip + opt-level = 'z' + lto | 436k |
| strip + opt-level = 'z' + lto + panic = 'abort' | 366K |

到目前為止,常規的最佳化手段已經用完了,後續最佳化需要配合一些程式碼的額外變動。

使用rust分析工具bloat對產物進行分析,結果如下:

File  .text     Size Crate
4.1%  69.0% 192.7KiB std
1.0%  16.8%  46.9KiB jdmp
0.5%   8.1%  22.7KiB [Unknown]
0.2%   3.8%  10.5KiB jni
0.0%   0.5%   1.5KiB cesu8
0.0%   0.4%   1.1KiB adler32
0.0%   0.3%     904B bytes
0.0%   0.2%     640B aho_corasick
0.0%   0.2%     588B regex_syntax
0.0%   0.2%     572B regex_automata
0.0%   0.2%     440B log
0.0%   0.1%     304B memchr
0.0%   0.0%      52B combine
0.0%   0.0%       8B jni_sys

讓我感到驚訝的是我的核心程式碼jdmp模組只佔了46.9k,為此要額外引入幾百k的額外開銷!

移除一些無用字串

在引入的第三方依賴裡,開發者自己新增了很多字串資訊,大部分是用來完善提供執行時報錯資訊。透過修改、精簡這些依賴庫,刪除無用程式碼,又可以省出一部分空間來。

同時,上面的最佳化儘管使用abort替代了panic,rust編譯器仍然會生出一些格式化的字串,使用panic\_immediate\_abort這個編譯選項禁用這個行為。

.cargo/config.toml
[unstable]
build-std-features = ["panic_immediate_abort"]
build-std = ["std","panic_abort"]

最佳化後前後體積變化

| 編譯選項 | 體積 |
| strip | 495k |
| strip + opt-level = 'z' | 437k |
| strip + opt-level = 'z' + lto | 436k |
| strip + opt-level = 'z' + lto + panic = 'abort' + 程式碼裁減 + panic\_immediate\_abort | 135k |

再次分析,整個檔案的體積已經降到了135k,自己開發的核心程式碼佔總程式碼量的52%,基本符合預期。

 File  .text    Size Crate
14.2%  52.0% 41.3KiB jdmp
 3.2%  11.7%  9.3KiB core
 3.1%  11.4%  9.1KiB jni
 3.0%  11.0%  8.8KiB [Unknown]
 1.9%   6.8%  5.4KiB std
 0.9%   3.3%  2.6KiB alloc
 0.3%   1.1%    936B cesu8
 0.3%   1.0%    792B adler32
 0.1%   0.5%    372B aho_corasick
 0.1%   0.4%    316B regex_automata
 0.1%   0.3%    220B log
 0.1%   0.3%    216B hashbrown
 0.0%   0.1%    108B bytes
 0.0%   0.1%     44B combine
 0.0%   0.1%     44B rustc_demangle
 0.0%   0.0%      8B compiler_builtins
 0.0%   0.0%      8B jni_sys

最佳化linker script

儘管目前檔案體積已經相比一開始最佳化了不少,但是還沒有達到接入要求。透過readelf進一步分析ELF檔案的各個section,我找到了一些額外的最佳化空間。

$ aarch64-linux-gnu-readelf -S target/aarch64-linux-android/release/libjdmp.so
There are 24 section headers, starting at offset 0x21738:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .note.android.ide NOTE             0000000000000270  00000270
       0000000000000098  0000000000000000   A       0     0     4
  [ 2] .dynsym           DYNSYM           0000000000000308  00000308
       00000000000002e8  0000000000000018   A       7     1     8
  [ 3] .gnu.version      VERSYM           00000000000005f0  000005f0
       000000000000003e  0000000000000002   A       2     0     2
  [ 4] .gnu.version_r    VERNEED          0000000000000630  00000630
       0000000000000040  0000000000000000   A       7     2     4
  [ 5] .gnu.hash         GNU_HASH         0000000000000670  00000670
       0000000000000024  0000000000000000   A       2     0     8
  [ 6] .hash             HASH             0000000000000694  00000694
       0000000000000100  0000000000000004   A       2     0     4
  [ 7] .dynstr           STRTAB           0000000000000794  00000794
       000000000000014d  0000000000000000   A       0     0     1
  [ 8] .rela.dyn         RELA             00000000000008e8  000008e8
       00000000000007f8  0000000000000018   A       2     0     8
  [ 9] .rela.plt         RELA             00000000000010e0  000010e0
       00000000000002a0  0000000000000018  AI       2    19     8
  [10] .rodata           PROGBITS         0000000000001380  00001380
       0000000000001d83  0000000000000000  AM       0     0     8
  [11] .eh_frame_hdr     PROGBITS         0000000000003104  00003104
       0000000000002494  0000000000000000   A       0     0     4
  [12] .eh_frame         PROGBITS         0000000000005598  00005598
       00000000000078cc  0000000000000000   A       0     0     8
  [13] .text             PROGBITS         000000000000de64  0000ce64
       0000000000013e0c  0000000000000000  AX       0     0     4
  [14] .plt              PROGBITS         0000000000021c70  00020c70
       00000000000001e0  0000000000000000  AX       0     0     16
  [15] .data.rel.ro      PROGBITS         0000000000022e50  00020e50
       0000000000000430  0000000000000000  WA       0     0     8
  [16] .fini_array       FINI_ARRAY       0000000000023280  00021280
       0000000000000010  0000000000000008  WA       0     0     8
  [17] .dynamic          DYNAMIC          0000000000023290  00021290
       0000000000000180  0000000000000010  WA       7     0     8
  [18] .got              PROGBITS         0000000000023410  00021410
       0000000000000048  0000000000000000  WA       0     0     8
  [19] .got.plt          PROGBITS         0000000000023458  00021458
       00000000000000f8  0000000000000000  WA       0     0     8
  [20] .data             PROGBITS         0000000000024550  00021550
       0000000000000060  0000000000000000  WA       0     0     8
  [21] .bss              NOBITS           00000000000245b0  000215b0
       0000000000000101  0000000000000000  WA       0     0     8
  [22] .comment          PROGBITS         0000000000000000  000215b0
       00000000000000b2  0000000000000001  MS       0     0     1
  [23] .shstrtab         STRTAB           0000000000000000  00021662
       00000000000000d3  0000000000000000           0     0     1

在對這些section進行最佳化時,有必要搞清楚每個section在程式執行的作用。

| section | 作用 |
| .text | 程式碼段 |
| .data .rodata .bss | 資料段 |
| .plt .got .dynamic .dynsym .rela.dyn .rela.plt .shstrtab | 執行時被動態連結庫解析,用於動態連結。 |
| .eh\_frame .eh\_frame_hdr | 用於儲存函式的棧幀偏移,方便棧回溯 |
| .gnu.hash .gnu.version .gnu.version_r .hash | 儲存編譯檔案元資訊 |

程式在正常執行時,程式碼段、資料段必不可少,同時需要保留動態連結需要的section。剩餘的section可以移除,可以進一步最佳化檔案體積。值得注意到是,刪除.eh\_frame .eh\_frame_hdr後,在程式崩潰時只能得到一個崩潰地址,無法進行棧回溯。

建立一個linker script,只保留程式執行最小依賴的section。

PHDRS
{
  headers PT_PHDR PHDRS ;
  text PT_LOAD FILEHDR PHDRS ;
  data PT_LOAD ;
  dynamic PT_DYNAMIC ;
}
ENTRY(Reset);
EXTERN(RESET_VECTOR); 
SECTIONS
{
  . = SIZEOF_HEADERS;
  .text : { *(.text .text.*) } :text
  .rodata : { *(.rodata .rodata.*) } :text

  . = . + 0x1000;
  .data : { *(.data .data.*) *(.fini_array .fini_array.*) *(.got .got.*) *(.got.plt .got.plt.*) } : data
  .bss : {*(.bss .bss.*)} : data
  .dynamic : { *(.dynamic .dynamic.*)  } :data :dynamic

  /DISCARD/ :
  {
    *(.ARM.exidx .ARM.exidx.*);
    *(.gnu.version .gnu.version.*);
    *(.gnu.version_r .gnu.version_r.*);
    *(.eh_frame_hdr .eh_frame .eh_frame_hdr.* .eh_frame.* );
    *(.note.android.ident .note.android.ident.*);
    *(.comment .comment.*);
  }
}

修改編譯引數,替換預設的linker script

.cargo/config.toml

[build]
target = ["aarch64-linux-android","armv7-linux-androideabi"]

[unstable]
build-std-features = ["panic_immediate_abort"]
build-std = ["std","panic_abort"]

[target.aarch64-linux-android]
rustflags = ["-C", "link-arg=-Tlinker.lds"]

[target.armv7-linux-androideabi]
rustflags = ["-C", "link-arg=-Tlinker.lds"]

經過一番操作,程式的體積最終裁減到了95k!完美符合要求。

總結

| 編譯選項 | 體積 |
| strip | 495k |
| strip + opt-level = 'z' | 437k |
| strip + opt-level = 'z' + lto | 436k |
| strip + opt-level = 'z' + lto + panic = 'abort' + 程式碼裁減 + panic\_immediate\_abort | 135k |
| strip + opt-level = 'z' + lto + panic = 'abort' + 程式碼裁減 + panic\_immediate\_abort + 移除section | 95k |

本文記錄了我進行編譯體積最佳化的各種操作,其中的一些策略在使用C、C++語言開發中仍具有一定的通用性。

作者:尚紅澤

來源:京東雲開發者社群 轉載請註明來源