0%

C++从源文件到可执行文件:静态链接

链接器的链接一般分为两步:

  • 空间与地址分配:收集目标文件中所有的段,将他们分类合并。收集目标文件中所有的符号,将他们统一放到一个符号表中。
  • 符号解析与重定位:根据段的重定位表,和上一步中重新调整过的地址,对符号进行重定位。
    关于空间与地址分配符号解析与重定位的问题,可以参考程序员的自我修养中关于静态链接4.1和4.2两节。
    本文主要关注链接中以下几方面问题:
  • 模板去重
  • -ffunction-sections-fdata-sections-gc-sections清除dead code
  • inline函数与inline变量

模板去重

C++编译采用包含模型(inclusion model),大多数编译器在进行模板具现化时采用贪婪实例化(greedy instantiaion)。这将导致一个模板的相同具现化可能出现在多个翻译单元。如果将这些模板具现化产生的符号都认为是GLOBAL类型的符号的话,会导致出现multiple definition 的链接错误。
现在主流编译器采用的策略是:将每个模板的具现化都单独放在一个segment中。当链接器在链接过程中遇到多个相同的因模板具现化而产生的段时,选择其中一个,丢弃其他的。


1
2
3
4
5
6
7
8
9
10
#include <cstdio>
template<typename T>
decltype(auto) add(T a, T b){
const static char s[] = "STRING_LITERAL";
puts(s);
return a + b;
}
int main(){
return add(1, 1);
}

将上面这段程序编译之后,查看其可重定位目标文件,我们会发现该函数模板的实例化被放在一个单独的段.text._Z3addIiEDc中。且其符号_Z3addIiEDcT_S0_BIND为WEAK。并且该实例化所产生的局部static变量也被单独地放置在了一个段中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .group GROUP 0000000000000000 00000040
0000000000000008 0000000000000004 15 13 4
[ 2] .group GROUP 0000000000000000 00000048
000000000000000c 0000000000000004 15 14 4
[ 3] .text PROGBITS 0000000000000000 00000054
000000000000001a 0000000000000000 AX 0 0 1
[ 4] .rela.text RELA 0000000000000000 00000348
0000000000000018 0000000000000018 I 15 3 8
[ 5] .data PROGBITS 0000000000000000 0000006e
0000000000000000 0000000000000000 WA 0 0 1
[ 6] .bss NOBITS 0000000000000000 0000006e
0000000000000000 0000000000000000 WA 0 0 1
[ 7] .rodata._ZZ3addIi PROGBITS 0000000000000000 00000070
000000000000000f 0000000000000000 AG 0 0 8
[ 8] .text._Z3addIiEDc PROGBITS 0000000000000000 0000007f
0000000000000028 0000000000000000 AXG 0 0 1
[ 9] .rela.text._Z3add RELA 0000000000000000 00000360
0000000000000030 0000000000000018 IG 15 8 8
[10] .comment PROGBITS 0000000000000000 000000a7
0000000000000025 0000000000000001 MS 0 0 1
[11] .note.GNU-stack PROGBITS 0000000000000000 000000cc
0000000000000000 0000000000000000 0 0 1
[12] .note.gnu.propert NOTE 0000000000000000 000000d0
0000000000000020 0000000000000000 A 0 0 8
[13] .eh_frame PROGBITS 0000000000000000 000000f0
0000000000000058 0000000000000000 A 0 0 8
[14] .rela.eh_frame RELA 0000000000000000 00000390
0000000000000030 0000000000000018 I 15 13 8
[15] .symtab SYMTAB 0000000000000000 00000148
00000000000001b0 0000000000000018 16 13 8
[16] .strtab STRTAB 0000000000000000 000002f8
000000000000004f 0000000000000000 0 0 1
[17] .shstrtab STRTAB 0000000000000000 000003c0
00000000000000ac 0000000000000000 0 0 1

.text段的上面出现了两个.group段,并且类型为从未见过的GROUP。GROUP类型的段是用来记录哪些段是相互关联的。

比如在本例中,函数模板中的static变量的位置在其专属的.rodata._ZZ3addIi段中,在之后的链接过程中,随着段的合并,其地址必然需要进行重定位。因此,该函数模板的具现化代码所在的段.text._Z3addIiEDcT_S0_也就需要一个与之匹配的重定位段.rela.text._Z3addIiEDcT_S0_。这两个段是密不可分的。如果这两个段中的一个被丢弃,或者两个段中的一个被另一个目标文件中的同名段代替,就会导致不正确的链接。为了避免这种情况的发生,编译器在产生目标文件时,用GROUP将这些段绑定在一起。而链接器将每个GROUP类型的section中记录的段都看作一个整体,要么全部包括,要么全部丢弃,不可分割。
使用readelf -g .group tem.o来查看GROUP section中的内容:

1
2
3
4
5
6
7
8
COMDAT group section [    1] `.group' [_ZZ3addIiEDcT_S0_E1s] contains 1 sections:
[Index] Name
[ 7] .rodata._ZZ3addIiEDcT_S0_E1s

COMDAT group section [ 2] `.group' [_Z3addIiEDcT_S0_] contains 2 sections:
[Index] Name
[ 8] .text._Z3addIiEDcT_S0_
[ 9] .rela.text._Z3addIiEDcT_S0_

其中COMDAT是GROUP节的Flag,目前有且仅有一种,也就是COMDAT。COMDAT表示这个group可能和另一个目标文件中的COMDAT group重复。当重复发生时,只有其中一个group可以被留下,其他都会被丢弃。

group当中的section必须有SHF_GROUP作为他们的sh_flags,如果链接器决定丢弃一个group,那么group中的所有成员就也将被丢弃。

当同一个模板的具现化出现在多个目标文件时,连接器就使用GROUP并配合其符号BIND来进行去重。

关于Section Group的更详细解释,见Section Groups - Oracle

函数级别链接

如果想要将所有的函数和数据像模板函数一样,放入自己专属的段中,我们可以使用gcc的编译选项:

  • -ffunction-sections
  • -fdata-sections
    如果目标文件或这静态库在编译时使用了这两个编译选项,链接器再使用--gc-sections选项,就可以进行dead code elimination,去掉没有用到的代码,减少二进制膨胀。

例子1:

1
2
3
4
5
6
7
8
// unused.cpp
int unused_function(){
return 1;
}
// main.cpp
int main(){
return 0;
}

main.cppunused.cpp使用-ffunction-sections-fdata-sections编译得到可重定位文件后,查看其目标文件的段表,发现每个函数确实被放置在了其专属的段中。其中int unused_function()对应的段是.text._Z15unused_int main()对应的段是.text.main

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
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
0000000000000000 0000000000000000 AX 0 0 1
[ 2] .data PROGBITS 0000000000000000 00000040
0000000000000000 0000000000000000 WA 0 0 1
[ 3] .bss NOBITS 0000000000000000 00000040
0000000000000000 0000000000000000 WA 0 0 1
[ 4] .text._Z15unused_ PROGBITS 0000000000000000 00000040
000000000000000f 0000000000000000 AX 0 0 1
[ 5] .comment PROGBITS 0000000000000000 0000004f
0000000000000025 0000000000000001 MS 0 0 1
[ 6] .note.GNU-stack PROGBITS 0000000000000000 00000074
0000000000000000 0000000000000000 0 0 1
[ 7] .note.gnu.propert NOTE 0000000000000000 00000078
0000000000000020 0000000000000000 A 0 0 8
[ 8] .eh_frame PROGBITS 0000000000000000 00000098
0000000000000038 0000000000000000 A 0 0 8
[ 9] .rela.eh_frame RELA 0000000000000000 00000200
0000000000000018 0000000000000018 I 10 8 8
[10] .symtab SYMTAB 0000000000000000 000000d0
0000000000000108 0000000000000018 11 10 8
[11] .strtab STRTAB 0000000000000000 000001d8
0000000000000021 0000000000000000 0 0 1
[12] .shstrtab STRTAB 0000000000000000 00000218
0000000000000082 0000000000000000 0 0 1
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
0000000000000000 0000000000000000 AX 0 0 1
[ 2] .data PROGBITS 0000000000000000 00000040
0000000000000000 0000000000000000 WA 0 0 1
[ 3] .bss NOBITS 0000000000000000 00000040
0000000000000000 0000000000000000 WA 0 0 1
[ 4] .text.main PROGBITS 0000000000000000 00000040
000000000000000f 0000000000000000 AX 0 0 1
[ 5] .comment PROGBITS 0000000000000000 0000004f
0000000000000025 0000000000000001 MS 0 0 1
[ 6] .note.GNU-stack PROGBITS 0000000000000000 00000074
0000000000000000 0000000000000000 0 0 1
[ 7] .note.gnu.propert NOTE 0000000000000000 00000078
0000000000000020 0000000000000000 A 0 0 8
[ 8] .eh_frame PROGBITS 0000000000000000 00000098
0000000000000038 0000000000000000 A 0 0 8
[ 9] .rela.eh_frame RELA 0000000000000000 000001e8
0000000000000018 0000000000000018 I 10 8 8
[10] .symtab SYMTAB 0000000000000000 000000d0
0000000000000108 0000000000000018 11 10 8
[11] .strtab STRTAB 0000000000000000 000001d8
000000000000000f 0000000000000000 0 0 1
[12] .shstrtab STRTAB 0000000000000000 00000200
0000000000000072 0000000000000000 0 0 1

之后使用链接器,并加入-gc-setions选项来清除未用到的函数,使用-print-gc-sections来具体打印出哪些段被舍弃。
1
$ gcc -Wl,--gc-sections -Wl,--print-gc-sections -o unused_clean unused.o main.o

其中使用-Wl的目的是将后面的参数只传给链接器。运行后可以看到一些运行库的数据段和unused.o中的unused_function被舍弃了。
1
2
3
/usr/bin/ld: removing unused section '.rodata.cst4' in file '/usr/lib/gcc/x86_64-linux-gnu/9/../../../x86_64-linux-gnu/Scrt1.o'
/usr/bin/ld: removing unused section '.data' in file '/usr/lib/gcc/x86_64-linux-gnu/9/../../../x86_64-linux-gnu/Scrt1.o'
/usr/bin/ld: removing unused section '.text._Z15unused_functionv' in file 'unused.o'


例子二:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// unused.hpp
void print1();
void print2();

// unused.cpp
#include <cstdio>
void print1(){
puts("USED");
}
void print2(){
puts("UNUSED");
}

// main.cpp
#include "unused.hpp"
int main(){
print1();
return 0;
}

使用下面的编译选项编译后:
1
2
$ gcc -c -ffunction-sections -fdata-sections unused.cpp main.cpp
$ gcc -Wl, -gc-sections -Wl, -print-gc-sections -o unused_literal unused.o main.o

查看生成的共项目标文件的.rodata,发现字符串字面量"UNUSED"依然存在,并没有被丢弃:
1
2
3
String dump of section '.rodata':
[ 0] USED
[ 5] UNUSED

这是因为-fdata-sections[[gnu::section("name")]](__attribute__(section("name")))一样,只能用在某个有名字的标识符上。而字符串字面量则统一被放入.rodata段中。在本例中.rodata段中的字符串"USED"因为被函数print1()使用,因此.rodata没有被链接器丢弃,进而就使得"UNUSED"这个字符串保留了下来。为了修复这个问题,我们将这个两个字符串字面量初始化为某一个具名字符串即可。
1
2
3
4
5
6
7
8
9
#include <cstdio>
void print1(){
const static char str[] = "USED";
puts(str);
}
void print2(){
const static char str[] = "UNUSED";
puts(str);
}

检查编译后产生的目标文件,发现这两个字符串的确被放在了自己专属的段中了:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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
0000000000000000 0000000000000000 AX 0 0 1
[ 2] .data PROGBITS 0000000000000000 00000040
0000000000000000 0000000000000000 WA 0 0 1
[ 3] .bss NOBITS 0000000000000000 00000040
0000000000000000 0000000000000000 WA 0 0 1
[ 4] .rodata._ZZ6print PROGBITS 0000000000000000 00000040
0000000000000005 0000000000000000 A 0 0 1
[ 5] .text._Z6print1v PROGBITS 0000000000000000 00000045
0000000000000017 0000000000000000 AX 0 0 1
[ 6] .rela.text._Z6pri RELA 0000000000000000 00000348
0000000000000030 0000000000000018 I 15 5 8
[ 7] .rodata._ZZ6print PROGBITS 0000000000000000 0000005c
0000000000000007 0000000000000000 A 0 0 1
[ 8] .text._Z6print2v PROGBITS 0000000000000000 00000063
0000000000000017 0000000000000000 AX 0 0 1
[ 9] .rela.text._Z6pri RELA 0000000000000000 00000378
0000000000000030 0000000000000018 I 15 8 8
[10] .comment PROGBITS 0000000000000000 0000007a
0000000000000025 0000000000000001 MS 0 0 1
[11] .note.GNU-stack PROGBITS 0000000000000000 0000009f
0000000000000000 0000000000000000 0 0 1
[12] .note.gnu.propert NOTE 0000000000000000 000000a0
0000000000000020 0000000000000000 A 0 0 8
[13] .eh_frame PROGBITS 0000000000000000 000000c0
0000000000000058 0000000000000000 A 0 0 8
[14] .rela.eh_frame RELA 0000000000000000 000003a8
0000000000000030 0000000000000018 I 15 13 8
[15] .symtab SYMTAB 0000000000000000 00000118
00000000000001c8 0000000000000018 16 15 8
[16] .strtab STRTAB 0000000000000000 000002e0
0000000000000067 0000000000000000 0 0 1
[17] .shstrtab STRTAB 0000000000000000 000003d8
00000000000000c5 0000000000000000 0 0 1

链接后,可以发现.rodata这个section已经不存在了。

inline关键字

inline关键字的本意是提示编译器采用函数的内联替换而非进行函数调用。inline的该含义是非强制的,编译器可以对标注inline的函数不进行内联替换,也可以对不标注inline的函数进行内联替换。

inline关键字的另一个含义是

  • 将函数声明为内联函数
  • 将具有静态存储期的变量(即所有声明于命名空间作用域的对象和声明时带有staticextern关键字的对象)声明为内联变量
    内联函数内联变量有以下特点:
  • 它们的定义必须在访问它们的翻译单元中可见
  • 带有外部链接的内联函数内联变量(比如不生命为static的)拥有下列属性:
    • 他们可以在程序中拥有多于一次定义。但要求所有的定义都出现的不同的翻译单元且定义等同。(例如声明并定义在头文件中的inline函数可能被#include进多个翻译单元,进而使得其定义出现在多个翻译单元中。)
    • 他们必须在每个翻译单元中都被声明为inline
    • 他们在每个翻译单元中都有相同的地址

inline 变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// iv.hpp
inline int x = 1;

// iv.cpp
#include <cstdio>
#include "iv.hpp"
void print_addr(){
printf("%p\n", &x);
}

// main.cpp
#include <cstdio>
#include "iv.hpp"
int main(){
printf("%p\n", &x);
}

编译上述文件,检查得到的可重定位文件的符号表,发现x的BIND类型为UNIQUE:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Symbol table '.symtab' contains 16 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS iv.cpp
2: 0000000000000000 0 SECTION LOCAL DEFAULT 2
3: 0000000000000000 0 SECTION LOCAL DEFAULT 4
4: 0000000000000000 0 SECTION LOCAL DEFAULT 5
5: 0000000000000000 0 SECTION LOCAL DEFAULT 6
6: 0000000000000000 0 SECTION LOCAL DEFAULT 7
7: 0000000000000000 0 SECTION LOCAL DEFAULT 9
8: 0000000000000000 0 SECTION LOCAL DEFAULT 10
9: 0000000000000000 0 SECTION LOCAL DEFAULT 11
10: 0000000000000000 0 SECTION LOCAL DEFAULT 8
11: 0000000000000000 0 SECTION LOCAL DEFAULT 1
12: 0000000000000000 4 OBJECT UNIQUE DEFAULT 6 x
13: 0000000000000000 35 FUNC GLOBAL DEFAULT 2 _Z10print_addrv
14: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _GLOBAL_OFFSET_TABLE_
15: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND printf

man nm:
The symbol is a unique global symbol. This is a GNU extension to the standard set of ELF symbol bindings. he symbol is a unique global symbol. This is a GNU extension to the standard set of ELF symbol bindings. For such a symbol the dynamic linker will make sure that in the entire process there is just one symbol with this name and type in use.

即链接器会保证在链接过程中,保留一个且仅有一个(因为是GLOBAL符号,不能舍弃)该符号。GNU正是依靠这个扩展实现了内联变量的特性。

inline关键字在变量上的引用使得将拥有全局变量的库打包成header only成为了可能。

inline 函数

1
2
3
4
5
6
7
8
9
__attribute__((noinline)) inline auto ilf(){
const int i = 1;
return i;
}

int main(){
ilf();
return 0;
}

编译上述文件,检查得到的可重定位文件的符号表,发现函数ilf()的符号类型为WEAK:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Num:    Value          Size Type    Bind   Vis      Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS inline_function.cc
2: 0000000000000000 0 SECTION LOCAL DEFAULT 2
3: 0000000000000000 0 SECTION LOCAL DEFAULT 4
4: 0000000000000000 0 SECTION LOCAL DEFAULT 5
5: 0000000000000000 0 SECTION LOCAL DEFAULT 6
6: 0000000000000000 0 SECTION LOCAL DEFAULT 8
7: 0000000000000000 0 SECTION LOCAL DEFAULT 9
8: 0000000000000000 0 SECTION LOCAL DEFAULT 10
9: 0000000000000000 0 SECTION LOCAL DEFAULT 7
10: 0000000000000000 0 SECTION LOCAL DEFAULT 1
11: 0000000000000000 22 FUNC WEAK DEFAULT 6 _Z3ilfv
12: 0000000000000000 20 FUNC GLOBAL DEFAULT 2 main

根据WEAK符号的特点,链接器会在所有的翻译单元生成的_Z3ilfv当中选择一个,来满足inline函数在每个翻译单元中拥有相同的地址的特性。