链接 - CS:APP 第七章

链接

本文将介绍:

  • 编译的过程
  • 三种目标文件
  • 三种链接和链接的过程

1 编译的过程

  1. 源文件(.c /.cpp)经过翻译,形成可重定位目标文件(.o)

    具体过程:

    预处理

    cpp main.c -o main.igcc -E -o main.c main.c

    编译器 :翻译成汇编语言

    cc1 或 cc main.i -o main.sgcc -S -o main.s main.c

    汇编器 :形成可重定位目标文件

    as [args] -o main.o 这中间参数很多,

    如果向直接到这一步可以使用 gcc -c -o main.o main.c

  2. 链接器 链接形成可执行目标文件

    ld -o prog main.o other.ogcc -o prog main.o other.o

如果想要一步一步生成.i .s .o 文件,建议使用gcc 加参数,而不是使用cpp cc1 as ld,这里面水很深,你把握不住。

除了以上的方法,你也可以在使用gcc是,加上-v参数,让gcc显示编译过程。不过,它显示的信息实在太多了,不如一步一步使用 -E -S -c 参数进行编译。

顺带一提,在bash中,可以通过 echo $? 来展示上一次程序退出的返回值

2 三种目标文件

首先,什么是目标文件?

计算机科学中存放目标代码的计算机文件,包含着机器代码,代码在运行时使用的数据,调试信息等,是从源代码文件产生程序文件这一过程的中间产物。

——360百科

目标文件可以分为三类:

  1. 可重定位目标文件 :包含二进制数据和代码,可以在链接时与其他目标文件合并成可执行目标文件。
  2. 可执行目标文件 : 可以被复制到内存中执行。
  3. 共享目标文件 :特殊的可重定位目标文件,可以在加载或运行时被动态地加载进内存并链接

window系统使用PE portable Executable 格式

Linux使用 Executable and Linkable Format, ELF格式

2.1 可重定位目标文件格式

image-20220909120357886

可重定位目标文件 以ELF头开始,通过readelf -a main.o 我们可以看到ELF头的内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ELF 头:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
类别: ELF64
数据: 2 补码,小端序 (little endian)
Version: 1 (current)
OS/ABI: UNIX - System V
ABI 版本: 0
类型: REL (可重定位文件)
系统架构: Advanced Micro Devices X86-64
版本: 0x1
入口点地址: 0x0
程序头起点: 0 (bytes into file)
Start of section headers: 1040 (bytes into file)
标志: 0x0
Size of this header: 64 (bytes)
Size of program headers: 0 (bytes)
Number of program headers: 0
Size of section headers: 64 (bytes)
Number of section headers: 13
Section header string table index: 12

可重定位目标文件的末尾是节头部表,它描述不同节的位置和大小。

我倾向于认为这是节头部表的内容:

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
节头:
[号] 名称 类型 地址 偏移量
大小 全体大小 旗标 链接 信息 对齐
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 0000000000000000 00000040
0000000000000025 0000000000000000 AX 0 0 1
[ 2] .rela.text RELA 0000000000000000 00000358
0000000000000030 0000000000000018 I 10 1 8
[ 3] .data PROGBITS 0000000000000000 00000068
0000000000000010 0000000000000000 WA 0 0 8
[ 4] .bss NOBITS 0000000000000000 00000078
000000000000000c 0000000000000000 WA 0 0 4
[ 5] .comment PROGBITS 0000000000000000 00000078
000000000000002c 0000000000000001 MS 0 0 1
[ 6] .note.GNU-stack PROGBITS 0000000000000000 000000a4
0000000000000000 0000000000000000 0 0 1
[ 7] .note.gnu.propert NOTE 0000000000000000 000000a8
0000000000000020 0000000000000000 A 0 0 8
[ 8] .eh_frame PROGBITS 0000000000000000 000000c8
0000000000000038 0000000000000000 A 0 0 8
[ 9] .rela.eh_frame RELA 0000000000000000 00000388
0000000000000018 0000000000000018 I 10 8 8
[10] .symtab SYMTAB 0000000000000000 00000100
00000000000001c8 0000000000000018 11 12 8
[11] .strtab STRTAB 0000000000000000 000002c8
0000000000000090 0000000000000000 0 0 1
[12] .shstrtab STRTAB 0000000000000000 000003a0
000000000000006c 0000000000000000 0 0 1

在ELF头和节头部表之间的是节,在上面的节头部表中我们也可以看到那些节。

一个典型的ELF可重定位目标文件包含下面的节:

.text : 已编译的机器代码

.rodata : read only data 只读数据,如printf的格式字符串

.data : 已初始化的全局和静态变量

.bss : 未初始化的静态变量,以及所有被初始化为0的全局或静态变量。这个节只是一个占位符,实际不占空间。(未初始化的全局变量分配到COMMON伪节)

.symtab :符号表

rel.text :.text 节中的位置列表,存放当链接器把这个目标文件和其他文件组合在一起时需要修改的位置。通俗讲就是.text中引用的外部函数或全局变量

.rel.data

.debug :调试信息

.line :调试时的行号

strtab :字符串表,包含符号表中的符号,.debug节的符号表以及节头部表中的节的名字

2.1.1 符号表

符号表的条目格式是这样的

1
2
3
4
5
6
7
8
9
typedef struct {
int name; // 字符串表的字节偏移,指向null结尾的字符串,具体的内容就是变量的名字,函数的名词,文件的名字等 main or main.c
char type:4, // 该符号条目的类型,函数数据或者节 NOTYPE OR OBJECT OR FUNC ...
binding:4; // 全局变量还是本地变量 GLOBAL OR LOCAL
char reserved; // 保留的,未使用
short section; // 在ubuntu 上的名字是Ndx,指明该符号是在那个section的
long value; // 距离节section 起始位置的字节偏移
long size; // 该符号最小的大小
} Elf64_Symbol;

对于section 字段,在ubuntureadelf 命令中,显示为 Ndx。

该字段有三个伪节,他们分别是 UNDEF COMMON ABS

ABS :代表不改被重定位的符号

UNDEF :代表未定义的符号,即在本模块引用却在其他模块定义的符号

COMMON :还未被分配位置的未初始化的数据目标

COMMON.bss 的区别很细微,现在GCC 根据以下规则来讲可重定位目标文件的符号分配到COMMON.bss

COMMON :未初始化的全局变量

.bss :未初始化的静态变量,以及初始化为0的全局或静态变量

下面我们通过一个程序来展示以下ubuntu 中的符号表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int sum(int * a, int n);

int array[2] = {1, 3};

int global_not_init;
int global_init = 1;
int global_init_zero = 0;

int main()
{
static int stat_not_init;
static int stat_init_zero = 0;
static int stat_init = 1;

int val = sum(array, 2);
return val;
}

下面我们生成可重定位的目标文件gcc -c main.c

接着使用readelf -a main.o 读取elf,即可查看符号表

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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
cfla@cfla-virtual-machine:~/code/CSAPP/ch_7$ gcc -c main.c
cfla@cfla-virtual-machine:~/code/CSAPP/ch_7$ readelf -a main.o
ELF 头:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
类别: ELF64
数据: 2 补码,小端序 (little endian)
Version: 1 (current)
OS/ABI: UNIX - System V
ABI 版本: 0
类型: REL (可重定位文件)
系统架构: Advanced Micro Devices X86-64
版本: 0x1
入口点地址: 0x0
程序头起点: 0 (bytes into file)
Start of section headers: 1040 (bytes into file)
标志: 0x0
Size of this header: 64 (bytes)
Size of program headers: 0 (bytes)
Number of program headers: 0
Size of section headers: 64 (bytes)
Number of section headers: 13
Section header string table index: 12

节头:
[号] 名称 类型 地址 偏移量
大小 全体大小 旗标 链接 信息 对齐
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 0000000000000000 00000040
0000000000000025 0000000000000000 AX 0 0 1
[ 2] .rela.text RELA 0000000000000000 00000358
0000000000000030 0000000000000018 I 10 1 8
[ 3] .data PROGBITS 0000000000000000 00000068
0000000000000010 0000000000000000 WA 0 0 8
[ 4] .bss NOBITS 0000000000000000 00000078
000000000000000c 0000000000000000 WA 0 0 4
[ 5] .comment PROGBITS 0000000000000000 00000078
000000000000002c 0000000000000001 MS 0 0 1
[ 6] .note.GNU-stack PROGBITS 0000000000000000 000000a4
0000000000000000 0000000000000000 0 0 1
[ 7] .note.gnu.propert NOTE 0000000000000000 000000a8
0000000000000020 0000000000000000 A 0 0 8
[ 8] .eh_frame PROGBITS 0000000000000000 000000c8
0000000000000038 0000000000000000 A 0 0 8
[ 9] .rela.eh_frame RELA 0000000000000000 00000388
0000000000000018 0000000000000018 I 10 8 8
[10] .symtab SYMTAB 0000000000000000 00000100
00000000000001c8 0000000000000018 11 12 8
[11] .strtab STRTAB 0000000000000000 000002c8
0000000000000090 0000000000000000 0 0 1
[12] .shstrtab STRTAB 0000000000000000 000003a0
000000000000006c 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
l (large), p (processor specific)

There are no section groups in this file.

本文件中没有程序头。

There is no dynamic section in this file.

重定位节 '.rela.text' at offset 0x358 contains 2 entries:
偏移量 信息 类型 符号值 符号名称 + 加数
000000000014 000c00000002 R_X86_64_PC32 0000000000000000 array - 4
000000000019 001200000004 R_X86_64_PLT32 0000000000000000 sum - 4

重定位节 '.rela.eh_frame' at offset 0x388 contains 1 entry:
偏移量 信息 类型 符号值 符号名称 + 加数
000000000020 000200000002 R_X86_64_PC32 0000000000000000 .text + 0

The decoding of unwind sections for machine type Advanced Micro Devices X86-64 is not currently supported.

Symbol table '.symtab' contains 19 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS main.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1
3: 0000000000000000 0 SECTION LOCAL DEFAULT 3
4: 0000000000000000 0 SECTION LOCAL DEFAULT 4
5: 000000000000000c 4 OBJECT LOCAL DEFAULT 3 stat_init.1921
6: 0000000000000004 4 OBJECT LOCAL DEFAULT 4 stat_init_zero.1920
7: 0000000000000008 4 OBJECT LOCAL DEFAULT 4 stat_not_init.1919
8: 0000000000000000 0 SECTION LOCAL DEFAULT 6
9: 0000000000000000 0 SECTION LOCAL DEFAULT 7
10: 0000000000000000 0 SECTION LOCAL DEFAULT 8
11: 0000000000000000 0 SECTION LOCAL DEFAULT 5
12: 0000000000000000 8 OBJECT GLOBAL DEFAULT 3 array
13: 0000000000000004 4 OBJECT GLOBAL DEFAULT COM global_not_init
14: 0000000000000008 4 OBJECT GLOBAL DEFAULT 3 global_init
15: 0000000000000000 4 OBJECT GLOBAL DEFAULT 4 global_init_zero
16: 0000000000000000 37 FUNC GLOBAL DEFAULT 1 main
17: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _GLOBAL_OFFSET_TABLE_
18: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND sum

No version information found in this file.

Displaying notes found in: .note.gnu.property
所有者 Data size Description
GNU 0x00000010 NT_GNU_PROPERTY_TYPE_0
Properties: x86 feature: IBT, SHSTK

符号表中的Ndx代表section字段,我们可以看到,只有未初始化的全局变量global_not_init 在COMMON 伪节,已经初始化了的全局变量global_init和静态变量stat_init都在.data 节,而初始化为0的全局变量global_init_zero 和没有初始化的静态变量stat_not_init 和初始化为0的静态变量stat_init_zero.bss

根据这三行的value 字段我们还可以看到这三个变量在.bss 节的存储顺序

1
2
3
  15: 0000000000000000     4 OBJECT  GLOBAL DEFAULT    4 global_init_zero
6: 0000000000000004 4 OBJECT LOCAL DEFAULT 4 stat_init_zero.1920
7: 0000000000000008 4 OBJECT LOCAL DEFAULT 4 stat_not_init.1919

另外我们还看到了一个有趣的现象,对于这个模块,编译器在所有静态变量的名称后面都加上了后辍,而全局变量则没有。

1
2
6: 0000000000000004     4 OBJECT  LOCAL  DEFAULT    4 stat_init_zero.1920
7: 0000000000000008 4 OBJECT LOCAL DEFAULT 4 stat_not_init.1919

注意观察两个静态变量多了两个后辍

这样做其实是为了区分在同一个模块中同名的两个静态变量

如下面程序的这个例子

1
2
3
4
5
6
7
8
9
10
int func_1(int n)
{
static int x = n + 1;
return x;
}
int func_2(int n)
{
static int x = n + 2;
return x;
}

这两个静态变量虽然都是x,但显然他们不是同一个变量,因此编译器会在符号表中通过加上一个后辍的形式来区分他们。

2.2 可执行目标文件格式

下面是典型的ELF 可执行目标文件

image-20220910192419982

在ELF 头和节之间,有一个特殊的段头部表,接下来我们通过readelf 来看一下这个段头部表

1
2
3
4
5
6
7
8
LOAD           0x0000000000000000 0x0000000000400000 0x0000000000400000
0x0000000000000498 0x0000000000000498 R 0x1000
LOAD 0x0000000000001000 0x0000000000401000 0x0000000000401000
0x0000000000000205 0x0000000000000205 R E 0x1000
LOAD 0x0000000000002000 0x0000000000402000 0x0000000000402000
0x0000000000000120 0x0000000000000120 R 0x1000
LOAD 0x0000000000002e50 0x0000000000403e50 0x0000000000403e50
0x00000000000001e0 0x00000000000001e8 RW 0x1000

我们知道,程序的代码从0x0000000000400000开始,从上面这个程序头部表中我们看到它将文件中0x00000000000000000处的内容映射到了虚拟内存0x0000000000400000处,这正是程序开始运行的地方。

ELF可执行文件被设计为很容易加载到存储器,连续的可执行文件的组块(cuks)被映射到连续的存储器段。段头表(segment header table)描述了这种映射关系。

加载可执行文件:

在shell中输入 ./prog 后:

  1. shell调用fork() 函数,创建子进程
  2. 子进程调用execve(),execve调用加载器,加载prog程序
  3. 加载器讲可执行文件加载到内存后(在段头部表的引导下),跳转到程序的入口点(_start函数地址)
  4. _start 调用系统函数 __libc_start_main,初始化执行环境,调用用户层的main函数

2.3 可共享目标文件格式

3 三种链接和链接的过程

三种链接:

  1. 静态链接
  2. 动态链接库
  3. 程序运行时链接共享库

3.1 静态链接

静态链接的两个过程

  1. 符号解析
  2. 重定位

3.1.1 符号解析

链接器解析符号引用的方法是,讲每个引用与它输入的可重定位目标文件的符号表中的一个确定的符号定义关联起来。

对于局部符号,它不会出现在符号表中。

对于本地静态变量,编译器会确保它们有唯一的名字 (回忆一下,编译器会通过给名称相同的静态变量加后缀来区分他们),因此也很好解析。

唯一难处理的是对全局符号(全局变量,非static 的函数声明)的引用。

对于一个不在当前模块定义的符号,编译器会假定它定义在其他模块,并生成一条符号表条目,将它交给链接器处理。

而编译器向链接器输出的这些符号,都会被划分为强符号或弱符号。

强符号:函数和已初始化的全局变量

弱符号:未初始化的全局变量 (在COMMON伪节)

接着使用以下规则来处理这些符号:

  1. 不允许有多个同名的强符号
  2. 如果一个强符号与多个弱符号同名,选择强符号
  3. 若有多个弱符号同名,从这些弱符号中任选一个。

这三个规则很容易造成一些不易察觉的运行时错误。

为什么会有.COMMON伪节?

如果有一个未初始化的全局变量x,编译器不知道这是一个extern 声明还是一个定义,不知道其他模块是否还有一个x,因此它把这个决定权留给链接器。

而如果是一个初始化为0的全局变量,根据强符号的规则,它是唯一的,因此编译器可以把他放到.bss节

以上讲的是几个.o 文件的链接,他们都是目标文件

接下来我们讲与静态库的链接,其中会有存档文件 这个概念,注意区别

静态库,封装了很多函数编译出来的目标文件的文件,一般是一个一个的函数。静态库即存档文件,后辍是 .a

在链接时,链接器指挥复制静态库里被使用的存档文件,从而节省空间。

生成静态库:

首先编译:gcc -c addvec.c multvec.c

接着生成静态库:ar rcs libvector.a addvec.o multvec.o

得到静态库 libvector.a,存档文件

image-20220910174348618

(重要) 链接器如何使用静态库来解析引用:

链接器维持一个可重定位目标文件的集合E,这个集合中的文件会被合并起来形成可执行文件,和一个未解析的符号(也就是,引用了但是尚未定义的符号)集合U,以及一个在前面输入文件中已定义的符号集合D。初始地,E、U和D都是空的。

  • 对于命令行上的每个输入文件f,链接器会判断 f 是一个目标文件还是一个存档文件。如果f是一个目标文件,那么链接器把f添加到E,修改U和D来反映 f 中的符号定义和引用,并继续下一个输入文件。
  • 如果f是一个存档文件,那么链接器就尝试匹配U中未解析的符号和由存档文件成员定义
    的符号。如果某个存档文件成员m,定义了一个符号来解析U中的一个引用,那么就将m
    加到E中,并且链接器修改U和D来反映m中的符号定义和引用。对存档文件中所有的成
    员目标文件都反复进行这个过程,直到U和D都不再发生变化。在此时,任何不包含在E
    中的成员目标文件都被丢弃,而链接器将继续到下一个输入文件。
  • 如果当链接器完成对命令行上输入文件的扫描后,U是非空的,那么链接器就会输出一个错
    误并终止。否则,它会合并和重定位E中的目标文件,从而构建输出的可执行文件。

注意:链接器对待存档文件和目标文件是有区别的,对于目标文件,他会解析所有的符号,而对待存档文件,如果U中没有这个符号,该存档文件就会被抛弃。

因此,如果有几个相互依赖的目标文件,他们在命令行中出现的顺序是无关紧要的。

但如果几个存档文件相互依赖,那么他们在命令行中出现的顺序就是需要特别关注的

如果文件A的符号定义在文件B中,我们就说文件A依赖文件B A→B

假如有这样的依赖关系:

image-20220910181532038

我们要写成gcc A.o B.a C.a B.a -o prog 因为A.o 是目标文件,它的所有符号都被解析了,所以它只需要出现一次,而B.a则需要出现两次。

3.1.2 重定位

《深入理解计算机系统》原书第三版 P 478 7.7

3.2 动态链接库

静态库的代码被嵌入到链接的程序,如果一个静态库被几乎所有的程序使用,就会造成大量的空间浪费,因此出现了动态库。

所有引用一个动态库的可执行目标文件共享一个动态库,而不是像静态库一样,代码被嵌入进程序中。

创建动态库 gcc -shared -fpic -o libvector.so addvec.c multvec.c

与动态库链接:

gcc -o prog main.c ./libvector.so

与动态库链接的时候,只会讲重定位和符号表信息复制到可执行文件中,而不会嵌入其他数据。

下面是动态链接库的过程:

image-20220911205952800

一个使用动态库的例子:(详见异常控制流 - CS:APP 第八章)

我们将csapp.c 库编译成动态库

使用gcc -shared -fpic csapp.c -o libcsapp.so -lphread

得到 libcsapp.so 将它移动到/lib

接着将csapp.h 移动到 /usr/local/include

编译问使用CSAPP动态库时,只需要使用 gcc main.c -o prog -lcsapp

其中编译选项-lxxx 代表告诉GCC去/lib等文件夹下寻找 libxxx.so 与其链接

我们在编译csapp.c的时候,用的编译选项,-lphread 就是告诉编译器与libphread.so库链接,这个库存放与线程相关的代码

以后打包静态库时,我们也要记住,动态库的命名规则是libxxx.so

3.3 程序运行时链接共享库

可以在运行时,从动态库中寻找该符号,动态加载到程序中。示例程序:

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
// 7.11 load and link shared library from an application
// to compile this file : "gcc -rdynamic -o prog_runtime_load dll.c -ldl"
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>

int x[2] = {1, 2};
int y[2] = {3, 4};
int z[2];

int main(void)
{
void * handle; // shared lib handle
void (*addvec)(int *, int *, int *, int); // point to a function
void (*multvec)(int *, int *, int *, int); // point to a function
char * error; // point to error massages string

// load shared library
handle = dlopen("./libvector.so", RTLD_LAZY);
if (!handle)
{
fprintf(stderr, "%s\n", dlerror());
exit(1);
}

// search the symbol "multvec" from the shared library
multvec = dlsym(handle, "multvec");
if ((error = (dlerror())) != NULL)
{
fprintf(stderr, "%s\n", error);
exit(1);
}

// execute the function
multvec(x, y, z, 2);
printf("z = [%d %d]\n", z[0], z[1]);

// unload the shared library
if (dlclose(handle) < 0)
{
fprintf(stderr, "%s\n", dlerror());
exit(1);
}
return 0;
}

名词索引

ELF-64 目标文件格式

PIC(Position-Independent Code) 位置无关代码