我们知道,一个典型的程序写好之后,会经历如下几个过程最终成为可执行的程序: 编译
,汇编
,链接
。其中,编译和汇编都是以单个文件为单位独立进行的。 而链接是将各个目标文件组织在一起的过程。在源程序层面,各个文件之间是以符号(即变量名和函数名)相关联的,而在二进制的目标文件层面,各个目标文件以确定的地址相引用。链接过程就是分配程序的虚拟地址空间,并进行符号解析对符号的引用进行重定向。
编译
编译过程是指编译器通过 词法分析
,语法分析
,语义分析
等将高级语言编译成汇编代码的过程。
汇编
汇编指汇编器将汇编代码转变成机器可以执行的指令,每一条汇编代码几乎都对应一条机器指令,所以整个过程是简单的。我们把得到的包含机器指令的程序叫做 目标文件(Object File)
。
链接
我们知道,CPU 最终执行的都是一条条的二进制机器码,并且通过汇编已经得到了程序的二进制目标文件,为什么还会有 链接
这一过程呢?
一个完整的程序是分很多文件的,各个文件之间通过函数和变量来访问。我们在编译和汇编时都是以文件为单位来分别处理的,但最终执行时各个目标文件必然是有关联的,它们必然要知道所引用的外部文件的函数和变量的地址。所以,链接过程主要是来进行符号地址的确定。
链接分为 静态链接
和 动态链接
,本文主要关注 静态链接
。
静态链接
我们以一个实例来一步步的阐述一个 C 语言编写的程序如何成为一个可执行文件的。
//p1.c
#include <stdio.h>
extern int ex_var; //外部变量.
int glo_var; //全局变量
int main(){
fun(ex_var, glo_var);
ex_fun(); //外部函数调用
}
int fun(int a1, int a2){
printf("arg1=%d, arg2=%d\n", a1, a2);
}
//p2.c
#include <stdio.h>
int ex_var = 123;
int ex_fun(){
printf("lalala");
}
我们分别用 gcc -c xx.c
将 p1.c
和 p2.c
编译并汇编为目标代码文件 p1.o
和 p2.o
。两个独立的文件各自存储着源程序的二进制信息。并且在 linux
下以一种 ELF
的数据结构组织着。到目前为止(链接前),两个目标文件还没有任何地址的关联。我们可以通过 objdump
工具来查看 p1 文件中 main
函数关于四种不同的符号的引用情况: 内部函数符号:func
, 内部全局变量符号: glo_var
, 外部函数符号: ex_func
, 外部全局变量符号: ex_var
。
$ objdump -d p1.o
0000000000000000 <main>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
//rip是程序计数器,指向下一条执行指令的地址.
//下面的指令是用来为调用 fun(ex_var,global)作准备的.
//x86-64采用寄存器传参,
//下面的前两条指令是用来读取两个全局变量到寄存器的.
4: 8b 15 00 00 00 00 mov 0x0(%rip),%edx
a: 8b 05 00 00 00 00 mov 0x0(%rip),%eax
10: 89 d6 mov %edx,%esi
12: 89 c7 mov %eax,%edi
14: b8 00 00 00 00 mov $0x0,%eax
//关于方法调用.
19: e8 00 00 00 00 callq 1e <main+0x1e>
1e: b8 00 00 00 00 mov $0x0,%eax
23: e8 00 00 00 00 callq 28 <main+0x28>
28: 5d pop %rbp
29: c3 retq
我们从 mov 0x0(%rip)
以及关于函数调用指令e8 00 00 00 00
分析到在目标代码文件中对于符号变量的访问是不做地址解析的,只是用一个临时零值占位。所以,很显然目标文件是无法运行的。
下面,我们通过 gcc -o p p1.c p2.c
将两个目标文件链接为一个可执行文件。得到可执行文件 p 之后,使用 objdump
工具重新查看 main
函数关于符号的引用地址的情况。
$ objdump -d p
0000000000400530 <main>:
400530: 55 push %rbp
400531: 48 89 e5 mov %rsp,%rbp
400534: 8b 15 02 0b 20 00 mov 0x200b02(%rip),%edx #60103c <glo_var>
40053a: 8b 05 f4 0a 20 00 mov 0x200af4(%rip),%eax #601034 <ex_var>
400540: 89 d6 mov %edx,%esi
400542: 89 c7 mov %eax,%edi
400544: b8 00 00 00 00 mov $0x0,%eax
400549: e8 0c 00 00 00 callq 40055a <fun>
40054e: b8 00 00 00 00 mov $0x0,%eax
400553: e8 2c 00 00 00 callq 400584 <ex_fun>
400558: 5d pop %rbp
400559: c3 retq
下面,我们来看看一个机器级指令是如何访问内存变量和进行方法调用的。
我们来分析如下两条汇编指令,是将存储器中的值复制到 edx 和 eax 寄存器中,并采用存储器 (基址 + 偏移量)寻址。 %rip
是程序计数器寄存器,用来存放CUP下一条执行指令的地址。所以,我们可以得到 glo_var
和 ex_var
的内存地址为 0x40053a + 0x200b02
和 0x400540 + 0x200af4
。
400534: 8b 15 02 0b 20 00 mov 0x200b02(%rip),%edx # 60103c <glo_var>
40053a: 8b 05 f4 0a 20 00 mov 0x200af4(%rip),%eax # 601034 <ex_var>
我们在来看看函数调用指令, e8 表示这是一个函数调用指令,后面的 0c 00 00 00
值表示跳转地址相对于下一条指令的偏移量,即 0x40054e + 0x40055a
。
400549: e8 0c 00 00 00 callq 40055a <fun>
我们也把 call 指令叫做: 近址相对位移调用指令(Call near, relative, displacement relative to next instruction)
。
ok,这个时候所有的符号引用已经有了确定的地址。我们把这个确定符号引用地址的过程叫做 重定向
或者叫做 符号解析
。
符号表
链接过程的本质就是要把多个不同的目标文件之间相互 “粘” 到一起。在链接中,目标文件之间相互拼合实际上是目标文件之间对地址的引用,即对函数和变量的地址的引用。如上例中,目标文件 p1.o
引用了 p2.o
的 ex_var
变量 和 ex_fun
函数。这两个目标文件也是通过这些 函数名和变量名 来相关联的。在这里,我们将函数和变量统称为 符号(symbol)
。整个链接过程正是基于符号完成的。
每一个目标文件都可能定义一些符号,也可能引用一些外部目标文件的符号,所以每一个目标文件都有一个符号表(Symbol Table)
,这个表里记录了目标文件中所用到的所有符号。我们可以通过 readelf
工具来查看目标文件中的符号表。下面列举了对链接
很重要的符号表项:
$readelf -s p1.o
...
9: 0000000000000004 4 OBJECT GLOBAL DEFAULT COM glo_var
10: 0000000000000000 42 FUNC GLOBAL DEFAULT 1 main
11: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND ex_var
12: 000000000000002a 39 FUNC GLOBAL DEFAULT 1 fun
13: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND ex_fun
14: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND printf
$ readelf -s p2.o
9: 0000000000000000 4 OBJECT GLOBAL DEFAULT 3 ex_var
10: 0000000000000000 21 FUNC GLOBAL DEFAULT 1 ex_fun
11: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND printf
我们可以观察到 p1.o
的符号表中关于 ex_fun
, ex_var
, printf
的符号类型为 UND
,表示該符號在本目標文件被引用到,但是定義在其他目標文件中。
靜態鏈接過程
通過上面的分析,我們知道鏈接過程就是將所有相關聯的目標文件組合起來,並將進行 符號解析
以真實的地址替換在目標文件中未知的符號引用。整個鏈接過程大致可以分爲兩部:
- 空間與地址分配 掃描所有的輸入目標文件,獲取它們的各個段的長度,屬性和位置,並且將輸入目標文件中的符號表中所有的符號定義和符號引用收集起來,統一放到一個全局符號表。
- 符號解析與重定位 使用上面第一步中收集到的所有信息,讀取輸入文件中段的數據,衝定位信息,並且進行符號解析與重定位,調整代碼中的地址等。