背景介紹
應用安裝包的體積影響著使用者下載量、安裝時長、使用者磁碟佔用量等多個方面,據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++語言開發中仍具有一定的通用性。
作者:尚紅澤
來源:京東雲開發者社群 轉載請註明來源