c++包大小的影响因素

背景

c++开发中经常有一些包大小的诉求,现在做了一些调研和整理。

常说的包大小包括:

1.动态库
    动态库为Android端的c++库运行时格式,因为java与c++通信只能通过动态库的方式。
    linux服务端一般也是用动态库的方式,不过服务端一般对于包大小不是那么敏感。
    windows端一般也使用动态库的方式,运行时下载和加载。
2.静态库
    静态库在编译时使用,具体大小和编译后产物没有直接关系,仅会影响编译速度。
3.可执行文件
    iOS的IPA文件内c++会编译成bin文件,所以c++对于iOS包大小的增量即为目标架构bin文件的增量。

对于动态库,包大小要求并不那么严格。Android端可以采用动态下发的方式,App启动后下载,一般不随包安装,但是也下载的动态库文件也不宜过大,增加下载失败风险。

对于静态库,包大小没有意义。

对于可执行文件,特别在iOS端要求非常严格。


下面补充一些基础知识。



目标文件

目标文件是源代码编译但未链接的中间文件(Windows的.obj和Linux的.o),Windows的.obj采用 PE 格式,Linux 采用 ELF 格式,两种格式均是基于通用目标文件格式(COFF,Common Object File Format)变化而来,所以二者大致相同。本文以 Linux 的 ELF 格式的目标文件为例,进行介绍。

目标文件一般包含编译后的机器指令代码、数据、调试信息,还有链接时所需要的一些信息,比如重定位信息和符号表等,而且一般目标文件会将这些不同的信息按照不同的属性,以“节(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,初始化数据段存放已初始化的全局变量与局部静态变量;

(4).bss,未初始化数据段存放未初始化的全局变量与局部静态变量;

(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 动态链接信息
.debug 调试信息
.comment 存放编译器版本信息,比如 “GCC:(GNU)4.2.0”
.plt和.got 动态链接的跳转表和全局入口表
.init 和 .fini 程序初始化和终结代码段
.rodata1 Read Only Data,只读数据段,存放字符串常量,全局 const 变量,该段和 .rodata 一样



静态库

静态库可以简单看成是一组目标文件(.o/.obj文件)的集合,即多个目标文件经过打包后形成的一个归档文件。

linux下ar -r的默认行为仅仅是将目标文件合并归档,记录目标文件的次序,不对目标文件内容做任何改变。使用readelf命令读取静态库的内容,发现它和目标文件的内容完全一样。



动态库

动态库将所有目标文件编译编译成一个可以运行时加载的库文件,隐藏内部符号,为链接器暴露导出符号。

动态库为多任务而生,本意是为了共享代码区来解决运行时的内存。编译时加入-fPIC来实现共享的目的。

相比目标文件,动态库多了动态加载相关描述,另外符号表仅包含导出符号。

关于导出符号:

在ELF(Linux下动态库的格式),共享库中所有的全局函数和变量在默认情况下都可以被其他模块使用,即ELF默认导出所有的全局符号。

DLL则需要显式地“告诉”编译器需要导出某个符号,否则编译器默认所有的符号都不导出。
    __declspec(dllexport) 表示该符号是从本DLL导出的符号
    __declspec(dllimport) 表示该符号是从别的DLL中导入的



如何减小包大小?




代码阶段

最重要的因素实现一个功能的代码逻辑,切中要害,避免过度设计。

其实是谨慎引入三方库,三方库一般是包大小的主要来源。

除此之外,开发者还需要知道,以下的编程方式会让包大小增加:

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字节递增,经过测试clang与g++行为一致。

思考:一下情况增加命名空间的名字长度,会导致内部所有变量的符号长度增加吗?

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

4)隐藏符号:

c语言没有作用域和命名空间,只能使用static来隐藏符号,因为static修饰符的变量将编译为内部符号。
对于c++,我们推荐:

1、尽可能使用命名空间来管理符号,尤其是使用匿名命名空间来隐藏符号。
    namespace {
        // ...
    }
2、尽可能多的使用private关键字。



编译阶段:

-g

编译时增加debug符号,将会显著增加包大小。

-Wl,-gc-sections

不链接未用函数,减小可执行文件大小。

在链接生成最终可执行文件时,如果带有-Wl,--gc-sections参数,并且之前编译目标文件时带有-ffunction-sections、-fdata-sections参数,则链接器ld不会链接未使用的函数,从而减小可执行文件大小

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

循环展开开启后,将增加包大小。

循环展开可以减少循环的次数,对程序的性能带了两方面的提高。一是减少了对循环没有直接贡献的计算,比如循环计数变量的计算,分支跳转指令的执行等。二是提供了进一步利用机器特性进行的优化的机会。

// 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命令来清除目标文件的符号表,从而减小体积。

对于动态库:

strip -s xxx
strip只清除普通符号表,会保留动态符号表,即dynsym、dynstr段,而动态链接依靠的就是动态符号表。
清除后的动态库依然可以链接成功。



对于静态库

strip -s xxx

通过--strip-unneeded即清除了部分符号的信息,还能保证库可用,减少程序体积


-o:

常用的优化手段





思考

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
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容