c++包大小的影響因素

背景

c++開發(fā)中經常有一些包大小的訴求,現在做了一些調研和整理。

常說的包大小包括:

1.動態(tài)庫
    動態(tài)庫為Android端的c++庫運行時格式,因為java與c++通信只能通過動態(tài)庫的方式。
    linux服務端一般也是用動態(tài)庫的方式,不過服務端一般對于包大小不是那么敏感。
    windows端一般也使用動態(tài)庫的方式,運行時下載和加載。
2.靜態(tài)庫
    靜態(tài)庫在編譯時使用,具體大小和編譯后產物沒有直接關系,僅會影響編譯速度。
3.可執(zhí)行文件
    iOS的IPA文件內c++會編譯成bin文件,所以c++對于iOS包大小的增量即為目標架構bin文件的增量。

對于動態(tài)庫,包大小要求并不那么嚴格。Android端可以采用動態(tài)下發(fā)的方式,App啟動后下載,一般不隨包安裝,但是也下載的動態(tài)庫文件也不宜過大,增加下載失敗風險。

對于靜態(tài)庫,包大小沒有意義。

對于可執(zhí)行文件,特別在iOS端要求非常嚴格。


下面補充一些基礎知識。



目標文件

目標文件是源代碼編譯但未鏈接的中間文件(Windows的.obj和Linux的.o),Windows的.obj采用 PE 格式,Linux 采用 ELF 格式,兩種格式均是基于通用目標文件格式(COFF,Common Object File Format)變化而來,所以二者大致相同。本文以 Linux 的 ELF 格式的目標文件為例,進行介紹。

目標文件一般包含編譯后的機器指令代碼、數據、調試信息,還有鏈接時所需要的一些信息,比如重定位信息和符號表等,而且一般目標文件會將這些不同的信息按照不同的屬性,以“節(jié)(section)”也叫“段(segment)”的形式進行存儲,本文統稱為“段”。

使用linux下的ELF格式舉例:

readelf -S test.o

There are 13 section headers, starting at offset 0x198:

Section Headers:
  [Nr] Name              Type             Address           Offset      Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000    0000000000000000  0000000000000000           0     0     0
  [ 1] .text             PROGBITS         0000000000000000  00000040    0000000000000056  0000000000000000  AX       0     0     4
  [ 2] .rela.text        RELA             0000000000000000  000006a0    0000000000000078  0000000000000018          11     1     8
  [ 3] .data             PROGBITS         0000000000000000  00000098    0000000000000008  0000000000000000  WA       0     0     4
  [ 4] .bss              NOBITS           0000000000000000  000000a0    0000000000000004  0000000000000000  WA       0     0     4
  [ 5] .rodata           PROGBITS         0000000000000000  000000a0    0000000000000004  0000000000000000   A       0     0     1
  [ 6] .comment          PROGBITS         0000000000000000  000000a4    000000000000002d  0000000000000001  MS       0     0     1
  [ 7] .note.GNU-stack   PROGBITS         0000000000000000  000000d1    0000000000000000  0000000000000000           0     0     1
  [ 8] .eh_frame         PROGBITS         0000000000000000  000000d8    0000000000000058  0000000000000000   A       0     0     8
  [ 9] .rela.eh_frame    RELA             0000000000000000  00000718    0000000000000030  0000000000000018          11     8     8
  [10] .shstrtab         STRTAB           0000000000000000  00000130    0000000000000061  0000000000000000           0     0     1
  [11] .symtab           SYMTAB           0000000000000000  000004d8    0000000000000180  0000000000000018          12    11     8
  [12] .strtab           STRTAB           0000000000000000  00000658    0000000000000045  0000000000000000           0     0     1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), l (large), I (info), L (link order), G (group), T (TLS), E (exclude), x (unknown)
  O (extra OS processing required) o (OS specific), p (processor specific)

從上面的輸出我們可以各個段在文件中的偏移位置,可以推斷出ELF目標文件的結構大致如下。

(1)ELF Header,ELF文件頭描述目標文件整體信息,包含 ELF 文件版本,目標機器型號、程序入口地址等;

(2).text,代碼段存放程序的機器指令;

(3).data,初始化數據段存放已初始化的全局變量與局部靜態(tài)變量;

(4).bss,未初始化數據段存放未初始化的全局變量與局部靜態(tài)變量;

(5).rodata,只讀數據段存放程序中只讀變量,如const修飾的常量和字符串常量;

(6).comment,注釋信息段存放編譯器版本信息,比如字符串"GCC: (GNU) 4.8.5 20150623 (Red Hat 4.8.5-4)"

(7).shstrtab,段表字符串表,用于存放段的名稱字符串;

(8)section header table,段表存放所有段的基本信息,表中的每一項為段頭,即段的基本信息;

(9).symtab,符號表記錄了目標文件中使用的所有符號,比如變量和函數名,對于變量和函數而言,符號對應的值為它們所在的地址。符號用于鏈接器鏈接時找到符號地址;

(10).strtab,字符串表用于存放目標文件中用到的字符串,比如變量名等。因為字符串的長度往往是不定的,所以用固定的結構來表示比較困難。常見的做
法就是把字符串集中起來存放到一個表。然后使用字符串在表中的偏移來引用字符串;

(11).rela.text,代碼段重定位表存放目標文件未定義的指令在鏈接時所需的重定位信息。

除了上面提到的段外,ELF文件也有可能包含其他的段,用來保存與程序相關的其他信息。

段名 說明
.hash 符號哈希表
.line 調試時的行號表,即源代碼行號與編譯后指令的對應表
.dynamic 動態(tài)鏈接信息
.debug 調試信息
.comment 存放編譯器版本信息,比如 “GCC:(GNU)4.2.0”
.plt和.got 動態(tài)鏈接的跳轉表和全局入口表
.init 和 .fini 程序初始化和終結代碼段
.rodata1 Read Only Data,只讀數據段,存放字符串常量,全局 const 變量,該段和 .rodata 一樣



靜態(tài)庫

靜態(tài)庫可以簡單看成是一組目標文件(.o/.obj文件)的集合,即多個目標文件經過打包后形成的一個歸檔文件。

linux下ar -r的默認行為僅僅是將目標文件合并歸檔,記錄目標文件的次序,不對目標文件內容做任何改變。使用readelf命令讀取靜態(tài)庫的內容,發(fā)現它和目標文件的內容完全一樣。



動態(tài)庫

動態(tài)庫將所有目標文件編譯編譯成一個可以運行時加載的庫文件,隱藏內部符號,為鏈接器暴露導出符號。

動態(tài)庫為多任務而生,本意是為了共享代碼區(qū)來解決運行時的內存。編譯時加入-fPIC來實現共享的目的。

相比目標文件,動態(tài)庫多了動態(tài)加載相關描述,另外符號表僅包含導出符號。

關于導出符號:

在ELF(Linux下動態(tài)庫的格式),共享庫中所有的全局函數和變量在默認情況下都可以被其他模塊使用,即ELF默認導出所有的全局符號。

DLL則需要顯式地“告訴”編譯器需要導出某個符號,否則編譯器默認所有的符號都不導出。
    __declspec(dllexport) 表示該符號是從本DLL導出的符號
    __declspec(dllimport) 表示該符號是從別的DLL中導入的



如何減小包大???




代碼階段

最重要的因素實現一個功能的代碼邏輯,切中要害,避免過度設計。

其實是謹慎引入三方庫,三方庫一般是包大小的主要來源。

除此之外,開發(fā)者還需要知道,以下的編程方式會讓包大小增加:

1)內聯函數

空間換時間,增加包大小,減少函數調用堆棧。

2)模板

模板默認inline

模板函數中的內容如果比較多, 即使并不需要復用, 也盡量抽取為 extern function, 避免被 inline, 減少模板函數中的代碼量

對于同一個模板, 實例化不同類型時, 會為每個類型生成一份完整的代碼

盡量減少實例化類型, 例如, vector<MyType> 和 vector<MyType *> 就是不一樣的類型, 盡量考慮只留其中一種
又如 MyClass<A, B> 盡量拆分為 MyClass0<A> 和 MyClass1<B>, 否則模板參數排列組合特化容易造成模板實例化個數明顯增長

3)變量、函數名稱、命名空間

#include "example.h"

#define A 
#define MIN(A,B) A < B ? A : B  // 宏定義指令:將所有的#define刪除,并且展開所有的宏定義



 int a = 0;

/**
 * @brief 特殊符號指令:預編譯器可研識別一些特殊的符號,例如:刪除所有注釋
 * 
 * @param argc 
 * @param argv 
 * @return int 
 */
int main(int argc, const char** argv) {
    
    #ifdef A  
    static int a = MIN(1 , 2);
    #endif // A

    #ifdef B   // 條件編譯指令:處理所有的條件預編譯指令,比如#if #ifdef #elif #else #endif等
    int a = MIN(1 , 2);
    #endif // B
    
    
    return 0;
}

int a = 0;

-rwxr-xr-x 1 root root   8448 Oct 25 10:00 example*
-rwxr-xr-x 1 root root   7720 Oct 25 10:00 example.so*

int a1234 = 0;

-rwxr-xr-x 1 root root   8448 Oct 25 10:13 example*
-rwxr-xr-x 1 root root   7720 Oct 25 10:13 example.so*

int a12345 = 0;

-rwxr-xr-x 1 root root   8456 Oct 25 10:14 example*
-rwxr-xr-x 1 root root   7720 Oct 25 10:14 example.so*

int a123456 = 0;

-rwxr-xr-x 1 root root   8456 Oct 25 10:02 example*
-rwxr-xr-x 1 root root   7728 Oct 25 10:02 example.so*

int a123456789123 = 0;

-rwxr-xr-x 1 root root   8456 Oct 25 10:06 example*
-rwxr-xr-x 1 root root   7728 Oct 25 10:06 example.so*

int a1234567891234 = 0;

-rwxr-xr-x 1 root root   8464 Oct 25 10:08 example*
-rwxr-xr-x 1 root root   7728 Oct 25 10:08 example.so*

int a12345678912345 = 0;

-rwxr-xr-x 1 root root   8464 Oct 25 10:19 example*
-rwxr-xr-x 1 root root   7736 Oct 25 10:19 example.so*

8字節(jié)遞增,經過測試clang與g++行為一致。

思考:一下情況增加命名空間的名字長度,會導致內部所有變量的符號長度增加嗎?

namespace tal
{
    int a = 0;
    int b = 0;
}

4)隱藏符號:

c語言沒有作用域和命名空間,只能使用static來隱藏符號,因為static修飾符的變量將編譯為內部符號。
對于c++,我們推薦:

1、盡可能使用命名空間來管理符號,尤其是使用匿名命名空間來隱藏符號。
    namespace {
        // ...
    }
2、盡可能多的使用private關鍵字。



編譯階段:

-g

編譯時增加debug符號,將會顯著增加包大小。

-Wl,-gc-sections

不鏈接未用函數,減小可執(zhí)行文件大小。

在鏈接生成最終可執(zhí)行文件時,如果帶有-Wl,--gc-sections參數,并且之前編譯目標文件時帶有-ffunction-sections、-fdata-sections參數,則鏈接器ld不會鏈接未使用的函數,從而減小可執(zhí)行文件大小

6.3.3.2 Compilation options
The operation of eliminating the unused code and data from the final executable is directly performed by the linker.

In order to do this, it has to work with objects compiled with the following options: -ffunction-sections -fdata-sections.

These options are usable with C and Ada files. They will place respectively each function or data in a separate section in the resulting object file.

Once the objects and static libraries are created with these options, the linker can perform the dead code elimination. You can do this by setting the -Wl,–gc-sections option to gcc command or in the -largs section of gnatmake. This will perform a garbage collection of code and data never referenced.

If the linker performs a partial link (-r linker option), then you will need to provide the entry point using the -e / --entry linker option.

Note that objects compiled without the -ffunction-sections and -fdata-sections options can still be linked with the executable. However, no dead code elimination will be performed on those objects (they will be linked as is).

The GNAT static library is now compiled with -ffunction-sections and -fdata-sections on some platforms. This allows you to eliminate the unused code and data of the GNAT library from your executable.

-funroll-loops

循環(huán)展開開啟后,將增加包大小。

循環(huán)展開可以減少循環(huán)的次數,對程序的性能帶了兩方面的提高。一是減少了對循環(huán)沒有直接貢獻的計算,比如循環(huán)計數變量的計算,分支跳轉指令的執(zhí)行等。二是提供了進一步利用機器特性進行的優(yōu)化的機會。

// before
for(;i<len;++i)
    acc+=data[i];
    
// after
for(i=0;i<limit;i+=4){
    acc=acc+data[i]+data[i+1];
    acc=acc+data[i+2]+data[i+3];
}

-fno-rtti

-fno-exceptions

關閉rtti,關閉exeption,實際測試效果并不顯著


strip:

linux下提供了strip命令來清除目標文件的符號表,從而減小體積。

對于動態(tài)庫:

strip -s xxx
strip只清除普通符號表,會保留動態(tài)符號表,即dynsym、dynstr段,而動態(tài)鏈接依靠的就是動態(tài)符號表。
清除后的動態(tài)庫依然可以鏈接成功。



對于靜態(tài)庫

strip -s xxx

通過--strip-unneeded即清除了部分符號的信息,還能保證庫可用,減少程序體積


-o:

常用的優(yōu)化手段





思考

1)程序引入了一個頭文件如下,假設此頭文件中的所有函數、變量均為被其他地方使用,那么引入此頭文件后會增加包大小嗎?哪些行會增加?


#pragma once
#pragma warning(disable : 4521) // 保留#pragma編譯器指令,因為編譯器需要使用它們

int a1;
int a2;
const int a3 = 0;
static int a4;
int a5 = 1;
int a6 = 2;
int a7;

struct s_example1{
    int e1;
    int e2;
} s1;

struct s_example2{
    int e1;
    int e2;
};

class Example
{
public:
    int e1;
    int e2;
};

void func_a();

#define ABC1
#define ABC2
#define ABC3
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

友情鏈接更多精彩內容