1 動態庫裁剪
??庫分為動態庫和靜態庫,動態庫是在程序運行時才加載,靜態庫是在編譯時就加載到程序中。動態庫的大小通常比靜態庫小,因為動態庫只包含了程序需要的函數和數據,而靜態庫則包含了所有的函數和數據。靜態庫可以理解為引入源碼編譯,鏈接器在鏈接過程中會自動分析需要可不需要的代碼進行刪除裁剪。因此靜態庫不存在包大小問題(除了特定平臺生成靜態庫過大導致無法生成庫文件的問題)。
??動態庫裁剪的思路很簡單:
- 通過工具或者編譯選項刪除不必要的數據和代碼;
- 只導出需要的函數和數據;
- 關閉不必要的語言特性,如C++的異常處理等;
- 優化代碼,比如能用
constexpr
實現的盡量用constexpr
實現;
1.1 代碼層面
??首先代碼層面,需要盡可能確保不同模塊之間的耦合度低,避免出現循環依賴的情況。其次,需要盡可能減少代碼的重復,避免出現冗余代碼的情況。最后,需要盡可能減少代碼的復雜度,避免出現復雜的算法和數據結構的情況。對于一些能夠用constexpr
實現的功能,盡量用constexpr
實現,這樣可以減少動態庫的大小。
??C++中容易導致C++膨脹的代碼:
- 模板函數和模板類。模板函數和模板類在實例化時都會有一個對應版本的實例,如果任何函數都通過編譯器的默認推導來實例化很容易導致膨脹。因此模板函數和模板類應該盡量避免使用默認推導,盡可能顯示推導能減少實例化版本。因此可以使用類型擦除和顯示實例化來解決模板膨脹的問題。
- 內聯函數。內聯函數在編譯時會被展開,因此內聯函數的代碼會被復制到調用處,這樣會導致代碼膨脹。因此內聯函數應該盡量避免使用,除非函數的代碼量很小。但是這一條對于現代C++ inline的含義已經發生了變化,inline優化基本完全由C++編譯器自動優化。
- 宏。宏在編譯時會被替換,因此宏的代碼會被復制到調用處,這樣會導致代碼膨脹。因此宏應該盡量避免使用,除非宏的代碼量很小。
- 異常處理。異常處理會導致代碼膨脹,因為異常處理需要在運行時進行,因此異常處理會導致代碼膨脹。因此異常處理應該盡量避免使用,除非異常處理的代碼量很小。異常處理通常需要存儲異常棧回溯相關的信息,因此容易導致代碼膨脹。
- RTTI。RTTI 允許在運行時獲取對象的類型信息。 RTTI 需要在代碼中插入額外的類型信息,這會增加二進制文件的大小。
- 虛函數表。虛函數表是一個指針數組,它包含了虛函數的地址。虛函數表需要在運行時進行查找,這會增加二進制文件的大小。但是一般情況下,虛函數表的大小是固定的,因此虛函數表的大小并不是二進制膨脹的主要原因。
1.2 編譯選項
??通過編譯選項可以控制編譯器的行為,從而控制編譯過程中的優化和裁剪。編譯選項通常是通過編譯器的命令行參數來設置的。常用的降低二進制大小的編譯選項有:
- 優化等級,在編譯動態庫時,使用 -O2 或 -O3 優化級別。 這些優化級別可以使編譯器生成更緊湊的代碼,從而減小動態庫的大小。或者使用
-Os
之類平衡性能和大小的選項。 - 代碼裁剪。
-function-sections
:將每個函數放入單獨的代碼段。-gc-sections
:在鏈接時刪除未使用的代碼段。-Wl,--gc-sections
:在鏈接時刪除未使用的代碼段。
- LTO。使用鏈接時優化(Link-Time Optimization, LTO)可以進一步減小動態庫的大小。 LTO 允許編譯器在鏈接時進行全局優化,從而消除冗余代碼和數據。
-flto
:啟用 LTO 優化。-fwhole-program
:啟用 LTO 優化。
1.3 導出符號
??導出符號是指動態庫中可以被其他模塊(例如可執行文件或其他動態庫)訪問的函數和變量。 換句話說,它們是庫的公共接口。默認情況下,在 Linux 系統中,使用 GCC 或 Clang 編譯動態庫時,所有非 static 的函數和全局變量都會被導出。 這通常會導致導出過多的符號,增加庫的大小。導出符號越多,庫的大小越大。 通過只導出必要的符號,可以顯著減小庫的大小。
??控制導出符號不同編譯器提供的方式不同,但是一般來說,有以下幾種方式:
- 通過導出文件指定導出的符號列表;
- 代碼中通過標記來標記需要導出的函數。
#ifndef MY_LIBRARY_EXPORT_H
#define MY_LIBRARY_EXPORT_H#ifdef _WIN32#ifdef MY_LIBRARY_BUILD#define MY_EXPORT __declspec(dllexport)#else#define MY_EXPORT __declspec(dllimport)#endif
#elif defined(__GNUC__)#define MY_EXPORT __attribute__((visibility("default")))
#else#define MY_EXPORT
#endif#endif // MY_LIBRARY_EXPORT_H
1.4 strip
??通常情況下,二進制產物會包含一些調試信息,比如符號表、調試符號等。這些信息對于調試和分析二進制文件非常有用,但是它們通常不會被用于發布版本。因此,在發布版本中,通常會使用strip
工具來去除這些調試信息,從而減小二進制文件的大小。
- 不可逆操作:
strip
命令會直接修改文件,并且無法恢復。 因此,在運行strip
命令之前,請務必備份文件。 - 影響調試: 移除符號表和調試信息會使調試變得更加困難。 如果需要調試程序,請不要運行
strip
命令。 - 發布版本:
strip
命令通常用于發布最終版本的程序,以減小文件大小并提高安全性。 - 調試信息分離: 可以使用
--only-keep-debug
和--add-gnu-debuglink
選項將調試信息分離到單獨的文件中。 這樣可以在不影響程序運行的情況下進行調試。
2 實驗
2.1 測試代碼和環境
??我們的測試環境是:
Linux DESKTOP-JLHBOB4 4.4.0-19041-Microsoft #4355-Microsoft Thu Apr 12 17:37:00 PST 2024 x86_64 x86_64 x86_64 GNU/Linux
g++ (Ubuntu 7.5.0-3ubuntu1~18.04) 7.5.0
??測試代碼如下,分別是一個頭文件和一個源文件編譯成so庫:
// my_lib.h
#ifndef MY_LARGE_LIBRARY_H
#define MY_LARGE_LIBRARY_H#include <iostream>
#include <vector>// 用于控制導出符號,可以參考之前的通用 EXPORT 宏
#ifdef _WIN32#ifdef MY_LARGE_LIBRARY_BUILD#define MY_LARGE_LIBRARY_API __declspec(dllexport)#else#define MY_LARGE_LIBRARY_API __declspec(dllimport)#endif
#elif defined(__GNUC__)#define MY_LARGE_LIBRARY_API __attribute__((visibility("default")))
#else#define MY_LARGE_LIBRARY_API
#endif// 模板類
template <typename T>
class MY_LARGE_LIBRARY_API MyTemplateClass {
public:MyTemplateClass(T value);T getValue() const;
private:T m_value;
};// 內聯函數
inline int MY_LARGE_LIBRARY_API inlineFunction(int x) {return x * x * x; // 復雜的計算,增加內聯的代價
}// 虛基類
class MY_LARGE_LIBRARY_API BaseClass {
public:BaseClass(int id);virtual ~BaseClass();virtual int calculate() const;int getId() const;
protected:int m_id;
};// 派生類
class MY_LARGE_LIBRARY_API DerivedClass : public BaseClass {
public:DerivedClass(int id, double factor);~DerivedClass() override;int calculate() const override;
private:double m_factor;
};// 一個導出函數,使用了上述的類和函數
MY_LARGE_LIBRARY_API int processData(const std::vector<int>& data);#endif // MY_LARGE_LIBRARY_H
// my_lib.cpp
#include "Mylib.hpp"
#include <numeric> // std::accumulate// 模板類的實現
template <typename T>
MyTemplateClass<T>::MyTemplateClass(T value) : m_value(value) {}template <typename T>
T MyTemplateClass<T>::getValue() const {return m_value;
}// 顯式實例化一些常用的模板類型,減少編譯單元間的重復實例化
template class MY_LARGE_LIBRARY_API MyTemplateClass<int>;
template class MY_LARGE_LIBRARY_API MyTemplateClass<double>;// 基類的實現
BaseClass::BaseClass(int id) : m_id(id) {}BaseClass::~BaseClass() {}int BaseClass::calculate() const {return m_id * 2;
}int BaseClass::getId() const {return m_id;
}// 派生類的實現
DerivedClass::DerivedClass(int id, double factor) : BaseClass(id), m_factor(factor) {}DerivedClass::~DerivedClass() {}int DerivedClass::calculate() const {return static_cast<int>(m_id * m_factor * 3);
}// processData 函數的實現
int processData(const std::vector<int>& data) {int sum = std::accumulate(data.begin(), data.end(), 0);int inlinedResult = inlineFunction(sum);MyTemplateClass<int> templateObject(inlinedResult);BaseClass* baseObject = new DerivedClass(sum, 2.5);int finalResult = templateObject.getValue() + baseObject->calculate();delete baseObject;return finalResult;
}
2.1.2 不同操作對二進制大小的影響
默認 | -O1 | -O2 | -O3 | -Os | 符號 | section | lto | whole | rtti | 異常 | debug | strip | 包大小(Byte) |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
√ | 57400 | ||||||||||||
√ | √ | 53752 | |||||||||||
√ | √ | 53560 | |||||||||||
√ | √ | 54784 | |||||||||||
√ | √ | 53464 | |||||||||||
√ | √ | √ | 53480 | ||||||||||
√ | √ | √ | √ | 53936 | |||||||||
√ | √ | √ | √ | √ | 23120 | ||||||||
√ | √ | √ | √ | √ | √ | 10408 | |||||||
√ | √ | √ | √ | √ | √ | √ | 10016 | ||||||
√ | √ | √ | √ | √ | √ | √ | √ | 10016 | |||||
√ | √ | √ | √ | √ | √ | √ | √ | √ | 9640 | ||||
√ | √ | √ | √ | √ | √ | √ | √ | √ | √ | 6008 |
??下面是不同配置的詳細說明:
- 默認配置:使用默認的編譯選項和編譯方式,不進行任何裁剪和優化。
g++ -fPIC -shared Mylib.cpp -g -DMY_LARGE_LIBRARY_BUILD -o mylib.so
- 使用不同優化選項對比,具體
-O0
、-O1
、-O2
、-O3
。 - 隱藏符號:使用
-fvisibility=hidden
選項隱藏所有符號。g++ -fPIC -shared Mylib.cpp -g -DMY_LARGE_LIBRARY_BUILD -o mylibos_hidden.so -fvisibility=hidden -Os
- 獨立section裁剪:使用
-ffunction-sections
和-fdata-sections
選項將每個函數和數據放入單獨的代碼段和數據段。g++ -fPIC -shared Mylib.cpp -g -DMY_LARGE_LIBRARY_BUILD -o mylibos_sections.so -ffunction-sections -fdata-sections -Os
lto
g++ -fPIC -shared Mylib.cpp -g -DMY_LARGE_LIBRARY_BUILD -o mylibos_sections_lto.so -ffunction-sections -fdata-sections -Os -Wl,--gc-sections -flto
- 更激進的優化:
-fwhole-program
g++ -fPIC -shared Mylib.cpp -g -DMY_LARGE_LIBRARY_BUILD -o mylibos_sections_lto_whole.so -ffunction-sections -fdata-sections -Os -Wl,--gc-sections -flto -fwhole-program
- 禁用RTTI:
-fno-rtti
g++ -fPIC -shared Mylib.cpp -g -DMY_LARGE_LIBRARY_BUILD -o mylibos_sections_lto_whole_nortti.so -ffunction-sections -fdata-sections -Os -Wl,--gc-sections -flto -fwhole-program -fno-rtti
- 禁用異常
-fno-exceptions
g++ -fPIC -shared Mylib.cpp -g -DMY_LARGE_LIBRARY_BUILD -o mylibos_sections_lto_whole_nortti_noex.so -ffunction-sections -fdata-sections -Os -Wl,--gc-sections -flto -fwhole-program -fno-rtti -fno-exceptions
- 分離調試信息:
-gsplit-dwarf
g++ -fPIC -shared Mylib.cpp -g -DMY_LARGE_LIBRARY_BUILD -o mylibos_sections_lto_whole_nortti_noex_debuginfo.so -ffunction-sections -fdata-sections -Os -Wl,--gc-sections -flto -fwhole-program -fno-rtti -fno-exceptions -gsplit-dwarf
- 刪除無用的信息:
strip
strip -g -x -s mylib.so
??從上面的結果來看我們上面大部分操作都可以減少二進制,而且效果明顯,我們的庫從最開始的57400Byte減少到了6008Byte。能夠看到成效是非常明顯的。但是本來預期能夠降低包大小的操作沒有降低包大小的同時,反而增加了包大小這是為什么。
??實際工程中往往限制導出符號比較能夠降低包大小,上面的實驗沒有降低包大小的原因是因為我們的測試代碼非常簡單函數太少,因此包大小的優化效果不是很明顯。以及一些其他參數沒有降低包大小的原因也是因為我們的測試代碼比較簡單。
2.1 包大小排查思路
??下面我們就簡單排查下。
??根據上面的數據我們能夠看到有兩個選項導致了包大小變大,分別是-O3
和gc-sections
,前者是因為該選項更傾向于優化性能而犧牲存儲空間,因此已經有明確的結論不需要我們去排查。但是我們期望gc-sections
等選項帶來的是包大小優化,但是事實卻不是如此。
??首先,對于一個二進制動態庫,其有不同的section組成,為了確認包大小變大的原因我們首先要做的是確認是哪個section變大了。因此我們使用objdump -h
工具拆分二進制包來確認哪個部分增大了。下面是拆分得到的結果:
27 .debug_aranges 00000080 0000000000000000 DEBUG
30 .debug_line 000005f1 0000000000000000 DEBUG
31 .debug_str 00003bbe 0000000000000000 DEBUG
33 .debug_ranges 00000180 0000000000000000 DEBUG27 .debug_aranges 00000110 0000000000000000 DEBUG
30 .debug_line 0000055f 0000000000000000 DEBUG
31 .debug_str 00003bae 0000000000000000 DEBUG
33 .debug_ranges 000000f0 0000000000000000 DEBUG
??從上面的拆包能夠看到增加的主要是調試信息。而這部分調試信息在后續的strip
中已經被刪除了,因此影響我們最終產物大小的額外因素已經被排除了。如果希望知道具體增大了什么可以通過相關的提取對應section的信息來確認哪一部分增大了。
??上面的排查路徑其實不是很典型,因為一般情況下包大小都是因為代碼引起的
??下面簡單描述下如何排查包大小問題:
- 首先,對比的產物一定是相同編譯參數下的最終產物,使用兩個帶調試信息的不同編譯參數的包對比沒有意義(因此排查的前提是代碼相同編譯參數不同或者編譯參數相同代碼更改);
- 準備好后,使用
objdump -h
分析不同section的大小,來確認方向:- 不同section對應不同的數據,一般情況下比較容易出現增大的是data和text段
.text
: 代碼段,包含可執行指令。 如果包大小增加主要是 .text section 變大,則需要關注代碼優化。.rodata
: 只讀數據段,包含字符串常量、只讀變量等。 大量的字符串常量或嵌入式資源會增加此 section 的大小。.data
: 已初始化數據段,包含已初始化的全局變量和靜態變量。 大的靜態數組或全局變量會增加此 section 的大小。
- 明確具體包大小變化比較大的section后,可以嘗試對比代碼變動來初步確定變大的根本原因,如果無法確定則繼續;
- 使用命令
nm -CS <your_binary> | sort -rnk1
對代碼段和數據段進行排序,然后對比不同版本之間的差異。 - 找到差異的具體部分之后再使用
objdump -d
反匯編并對比源碼來確認最終原因。
emsp;?需要注意的是,有些博客會推薦使用
bloaty
,個人建議如果能夠通過該工具排查發現數據異常,推薦直接使用linux native的工具鏈。(在實際項目中發現bloaty
似乎統計的不是很準確。)