浅谈链接器
编译过程简介
C语言的编译过程由五个阶段组成:
- 步骤1:预处理:主要是处理以
#
开头的语句,主要工作如下:1)将#include
包含的头文件直接拷贝到.c
文件中;2)将#define
定义的宏进行替换;3)处理条件编译指令#ifdef
;4)将代码中的注释删除;5)添加行号和文件标示,这样的在调试和编译出错的时候才知道是是哪个文件的哪一行 ;6)保留#pragma编译器指令,因为编译器需要使用它们。
gcc -E helloworld.c -o helloworld_pre.c
- 步骤2: 编译:将C语言翻译成汇编,主要工作如下:1)词法分析;2)语法分析;3)语义分析 4)优化后生成相应的汇编;
gcc -S helloworld.c -o helloworld.s
- 步骤3: 汇编:将上一步的汇编代码转换成机器码(machine code),这一步产生的文件叫做目标文件;
gcc -c helloworld.c -o helloworld.o
- 步骤4:链接:将多个目标文以及所需的库文件(.so等)链接成最终的可执行文件(executable file)。
gcc helloworld.c -o helloworld
什么是链接器?
链接器是一个将编译器产生的目标文件打包成可执行文件或者库文件或者目标文件的程序。
链接器的作用有点类似于我们经常使用的压缩软WinRAR(Linux下是tar),压缩软件将一堆文件打包压缩成一个压缩文件,而链接器和压缩软件的区别在于链接器是将多个目标文件打包成一个文件而不进行压缩。
写C或者C++的u同学经常遇到这样一个错误:
undefined reference to function ABC.
链接器可操作的元素:目标文件
链接器可操作的最小元素是一个简单的目标文件。
从广义上来讲,目标文件与可执行文件的格式几乎是一模一样的,在Linux下,我们把它们统称为ELF文件。
ELF文件标准里面把系统中采用ELF格式的文件归为以下四类:
-
可重定位文件(Relocatable File):Linux的.o文件,这类文件包含了代码和数据,可以被用来链接成可执行文件或共享目标文件,静态链接库也归属于这一类;
-
可执行文件(Executable File):比如bin/bash文件,这类文件包含了可以直接执行的程序,它的代表就是ELF文件,他们一般都没有扩展名;
-
共享目标文件(shared Object File): 比如Linux的.so文件,这种文件包含了代码和数据,可以在以下两种情况下使用,一种是链接器可以直接使用这种文件跟其他的可重定位文件和共享目标文件链接,产生新的目标文件。第二种是动态链接器可以将几个这样的共享目标文件与可执行文件结合,作为进程映射的一部分来运行。
-
核心转储文件(Core Dump File): Linux下面的core dump,当进程意外终止时,系统可以将该进程的地址空间的内容及终止时的一些其他信息转储到核心转储文件中。
符号表(Symbol table)
编译器在遇到外部定义的全局变量或者函数时只要能在当前文件找到其声明,编译器就认为编译正确。而寻找使用变量定义的这项任务就被留给了链接器。链接器的其中一项任务就是要确定所使用的变量要有其唯一的定义。虽然编译器给链接器留了一项任务,但为了让链接器工作的轻松一点编译器还是多做了一点工作的,这部分工作就是符号表(Symbol table)。
符号表中保存的信息有两个部分:
- 该目标文件中引用的全局变量以及函数;
- 该目标文件中定义的全局变量以及函数。
编译器在编译过程中每次遇到一个全局变量或者函数名都会在符号表中添加一项,最终编译器会统计一张符号表。
假设C语言源码如下:
// 定义未初始化的全局变量
int g_x_uninit;
// 定义初始化的全局变量
int g_x_init = 1;
// 定义未初始化的全局私有变量,只能在当前文件中使用
static int g_y_uninit;
// 定义初始化的全局私有变量
static int g_y_init = 2;
// 声明全局变量,该变量的定义在其它文件
extern int g_z;
// 函数声明,该函数的定义在其它文件
int fn_a(int x, int y);
// 私有函数定义,该函数只能在当前文件中使用
static int fn_b(int x)
{
return x + 1;
}
// 函数定义
int fn_c(int local_x)
{
int local_y_uninit;
int local_y_init = 3;
// 对全局变量,局部变量以及函数的使用
g_x_uninit = fn_a(local_x, g_x_init);
g_y_uninit = fn_a(local_x, local_y_init);
local_y_uninit += fn_b(g_z);
return (g_y_uninit + local_y_uninit);
}
编译器将为此文件统计出如下一张符号表:
名字 | 类型 | 是否可被外部引用 | 区域 |
---|---|---|---|
g_z | 引用,未定义 | ||
fn_a | 引用,未定义 | ||
fn_b | 定义 | 否 | 代码段 |
fn_c | 定义 | 是 | 代码段 |
g_x_init | 定义 | 是 | 数据段 |
g_y_uninit | 定义 | 否 | 数据段 |
g_x_uninit | 定义 | 是 | 数据段 |
g_y_init | 定义 | 否 | 数据段 |
g_z
以及fn_a
是未定义的,因为在当前文件中,这两个变量仅仅是声明,编译器并没有找到其定义。剩余的变量编译器都可以在当前文件中找到其定义。
本质上整个符号表主要表达两件事:1)我能提供给其它文件使用的符号; 2)我需要其它文件提供给我使用的符号。
目标文件 |
---|
数据段 |
代码段 |
符号表 |
符号决议
有了符号表,链接器就可以进行符号决议了。如图所示,假设链接器需要链接三个目标文件,如下:
链接器会依次扫描每一个给定的目标文件,同时链接器还维护了两个集合,一个是已定义符号集合D,另一个是未定义符合集合U,下面是链接器进行符合决议的过程:
- 对于当前目标文件,查找其符号表,并将已定义的符号并添加到已定义符号集合D中。
- 对于当前目标文件,查找其符号表,将每一个当前目标文件引用的符号与已定义符号集合D进行对比,如果该符号不在集合D中则将其添加到未定义符合集合U中。
- 当所有文件都扫描完成后,如果为定义符号集合U不为空,则说明当前输入的目标文件集合中有未定义错误,链接器报错,整个编译过程终止。
链接过程中,只要每个目标文件所引用变量都能在其它目标文件中找到唯一的定义,整个链接过程就是正确的。
若链接器在查找了所有目标文件的符号表后都没有找到函数,因此链接器停止工作并报出错误undefined reference to function A
。
库与可执行文件
链接器根据目标文件构建出库(动态库、静态库)或可执行文件。
给定目标文件以及链接选项,链接器可以生成两种库,分别是静态库以及动态库,如下图所示,给定同样的目标文件,链接器可以生成两种不同类型的库。
静态库
静态库在Windows下是以.lib
为后缀的文件,Linux下是以.a
为后缀的文件。
静态库是链接器通过静态链接将其和其它目标文件合并生成可执行文件的,而静态库只不过是将多个目标文件进行了打包,在链接时只取静态库中所用到的目标文件。
目标文件分为三段:代码段、数据段、符号表,在静态链接时可执行文件的生成过程如下图所示:
可执行文件的特点如下:
- 可执行文件和目标文件一样,也是由代码段和数据段组成。
- 每个目标文件中的数据段都合并到了可执行文件的数据段,每个目标文件当中的代码段都合并到了可执行文件的代码段。
- 目标文件当中的符号表并没有合并到可执行文件当中,因为可执行文件不需要这些字段。
可执行文件和目标文件没有什么本质的不同,可执行文件区别于目标文件的地方在于,可执行文件有一个入口函数,这个函数也就是我们在C语言当中定义的main函数,main函数在执行过程中会用到所有可执行文件当中的代码和数据。main函数是被操作系统调用。
动态库
静态库在编译链接期间就被打包copy到了可执行文件,也就是说静态库其实是在编译期间(Compile time)链接使用的。
动态链接可以在两种情况下被链接使用,分别是加载时动态链接(load-time dynamic linking) 与 运行时动态链接 (run-time dynamic linking)。
-
加载时动态链接:在这里我们只需要简单的把加载理解为程序从磁盘复制到内存的过程,加载时动态链接就出现在这个过程。操作系统会查找可执行文件依赖的动态库信息(主要是动态库的名字以及存放路径),找到该动态库后就将该动态库从磁盘搬到内存,并进行符号决议,如果这个过程没有问题,那么一切准备工作就绪,程序就可以开始执行了,如果找不到相应的动态库或者符号决议失败,那么会有相应的错误信息报告为用户,程序运行失败。
-
运行时动态链接:run-time dynamic linking 运行时动态链接则不需要在编译链接时提供动态库信息,也就是说,在可执行文件被启动运行之前,可执行文件对所依赖的动态库信息一无所知,只有当程序运行到需要调用动态库所提供的代码时才会启动动态链接过程。
可以使用特定的API来运行时加载动态库,在Windows下通过LoadLibrary或者LoadLibraryEx,在Linux下通过使用dlopen、dlsym、dlclose这样一组函数在运行时链接动态库。当这些API被调用后,同样是首先去找这些动态库,将其从磁盘copy到内存,然后查找程序依赖的函数是否在动态库中定义。这些过程完成后动态库中的代码就可以被正常使用了。
在动态链接下,可执行文件当中会新增两段,即dynamic
段以及GOT(Global offset table)
段,这两段内容就是是我们之前所说的必要信息。
dynamic
段中保存了可执行文件依赖哪些动态库,动态链接符号表的位置以及重定位表的位置等信息。
当加载可执行文件时,操作系统根据dynamic段中的信息即可找到使用的动态库,从而完成动态链接。
参考
微信公共号
NFVschool,关注最前沿的网络技术。