1. 编译链接介绍
一份C代码要编译成为可执行文件,需要经历以下步骤:预编译,编译,汇编,链接[1],如图1。

首先,在预编译阶段,源代码文件被预编译器cpp预编译成一个.i文件。预编译主要处理源代码文件中的以”#”开始的预编译指令。比如:”#include”、”#define”等。经过预编译后的.i文件不包含任何宏定义。
编译阶段就是把预处理完的文件进行一系列的词法分析、语法分析和语义分析以及产生汇编代码。源代码首先被输入到扫描器(Scanner) 中进行词法分析,将源代码的字符序列分割成一系列的记号 (Token) 。接下来语法分析器将对由扫描器产生的记号进行语法分析,从而产生语法树。编译器开发者设置了语法规则对语法书进行解析,检查C语言的代码是否符合语法规范。语法分析仅完成了对表达式的语法层面的分析,但它不了解这个语句是否真的有意义,因此需要对表达式进行语义分析。例如两个指针做乘法运算是没有意义的。语义分析的工作由语义分析器完成,主要用于静态语义的分析。静态语义通常包括生命和类型匹配,类型的转换。在经过语义分析后,整个语法树的表达式都被标识了类型。编译的最后阶段就是将源代码翻译成汇编代码。
汇编阶段将使用汇编器将汇编代码翻译成机器语言。汇编生成的是目标文件。
编译的对象是单个的C文件,我们也称其为编译单元,编译过程关注的是编译单元的翻译,而不处理编译单元之间的变量和函数的引用问题。因此链接的主要内容就是把各个模块之间相互引用的部分处理好,使各个模块之间能够正确的衔接。链接的过程主要包括了符号解析和重定位。
2. 目标文件符号解析
目标文件有三种形式:
- 可重定位目标文件。包含二进制代码和数据,其形式可以在链接阶段和其他重定位目标文件合并起来,创建一个可执行目标文件。目前TDG1项目中编译生成的目标文件都是可重定位目标文件。
- 可执行目标文件。包含二进制代码和数据。其形式可以直接在内存或flash中执行。
- 共享目标文件。一种特殊类型的可重定位目标文件,可以在加载或者运行时被动态的加载到内存并链接。该目标文件一般用于PC中。
编译器和汇编器生成可重定位目标文件(包括共享目标文件) 。链接器生成可执行目标文件。每个可重定位文件中都有一个符号表”.symtab”,它包含目标文件中定义和引用符号的信息,有三种信息:
- 由本目标文件定义的能被其它编译单元引用的全局符号,对应于在该编译单元定义的非static型函数以及全局变量
- 由其它模块定义并且被本目标文件引用的全局符号。这些符号称为外部符号,对应于定义在其它模块中的C函数和变量
- 只被本模块定义和引用的本地符号。对应于在该编译单元定义的static型函数以及全局变量。这些符号在本模块中随处可见,但不能被其它模块引用
在链接过程中,主要对前两种符号进行解析。链接器解析符号的方法是将每个引用于她它输入的可重定位目标文件的符号表中的一个确定的符号定义联系起来。链接器使用两种文件进行链接过程:
- 相关的可重定位目标文件
- 将某些可重定位目标文件打包成一个单独的文件,称为静态库
这两种文件在链接选项中可同时使用和多次调用。GCC在链接时对依赖库可重定位目标文件的顺序是敏感的,被依赖的目标文件和库必须放在后面(为何敏感,将在后续介绍)。由于在链接过程中,各个目标文件的依赖关系错综复杂,因此一般将相关的目标文件打包成一个静态库进行链接。
3. 链接器确定静态库链接顺序
链接器使用直接读取可重定位目标文件和静态库的方法完成链接工作。符号解析阶段,链接器会从左到右按照它们在编译器驱动程序命令行上出现的相同顺序来扫描可重定位目标文件和静态库文件。在扫描的整个周期中,连接器会维护一个可重定位目标文件的集合(集合E中的文件最终会被合并起来形成可执行文件)。一个未解析的符号表U (即集合E中的目标文件中引用了符号,但集合E中所有目标文件都未定义该符号) ,以及已定义的符号集合D (即集合E中的所有目标文件中定义的符号集合) 。在扫描初始阶段,集合E、U和D都是空的[2]。
- 对于命令行上的每个输入文件f,链接器会判断f是目标文件还是静态库。如果f是目标文件,那么链接器把f添加到E,将文件f中的符号定义和引用 (该引用在集合D中不存在) 分别添加到集合D和集合E中,并继续下一个输入文件。
- 如果f是静态库文件,那么链接器就尝试匹配U中未解析的符号和静态库成员定义的符号。静态库匹配顺序就是静态库的打包顺序,可使用nm指令查看静态库顺序。如果静态库文件中某个成员m,定义了一个符号来解析U中的一个引用,那么就将目标文件m加入到E中,并且将m中的符号定义和引用 (该引用在集合D中不存在) 分别添加到集合D和集合E中。对于静态库文件中所有的成员目标文件都依次进行这个过程,并且循环多次直到集合U和D都不再发生变化。此时,静态库文件中任何不包含在E中的成员目标文件都会被直接丢弃,而链接器将继续处理下一个目标文件或库文件。
- 如果当链接器完成对命令行上输入文件的扫描后,U是非空的,那么链接器将会输出一个错误并终止。否则它会合并和重定位E中的目标文件,构建输出的可执行文件。
GCC链接时对依赖库/目标文件的顺序是敏感的,是因为在解析过程中,先被解析的文件中定义的所有符号如果没有被集合E中的目标文件引用到 (即使一个符号被引用,也会添加到E中) ,该文件会被直接丢弃,而在后续文件中即使再有该符号的引用,但已经找不到该符号的定义了,所以链接器会报错。解决这一问题的方法一般是调整好依赖顺序,或者将所有的库文件打包成一个单独的库文件。
例如,test.c文件调用libx.a和libz.a中的函数,而这两个库又调用liby.a中的函数,那么在命令行中,libz.a和libz.a必须处在liby.a之前:
Linux> gcc test.c libx.a libz.a liby.a
或者首先将libx.a和liby.a合并成一个库文件libz.a,然后再进行链接:
Linux> ar x libx.a
Linux> ar x liby.a
Linux> ar cru libz.a *.o
Linux> gcc test.c libz.a
4. 静态库解析符号实例
本节通过实例验证上节中描述的解析方法,实验环境为Ubuntu14.04, GCC版本V4.8.4。代码详见附录。
在该实例中,a.c为主程序,b.c - g.c为a.c依赖的文件,编译阶段生成对应的.o文件(可重定位文件)。源文件之间的依赖关系如图2所示。

Build.sh为Linux下的批处理文件,如图2所示。其中,注1表示的是将所有的.c文件编译成可重定位目标文件。注2表示将a.c的依赖文件按照指定的顺序打包为静态库文件lib.a,打包顺序为:g.o, f.o, e,o, d.o e.o, d.o c.o b.o。注3表示使用a.o和lib.a进行链接得到可执行目标文件,同时生成test.map文件。map文件中包含了目标文件加载到链接过程的顺序。

第一步,链接命令中第一个文件a.o为可重定位文件,将a.o添加到集合E中,a.o文件中未解析的符号有function_c, c_1, f_1,和b_1(见图4),将这些符号存放至集合U。a.o文件中已定义的符号有a_1和main,存放至集合D,第一步完成后,集合E,U和D的内容详见表1 a.o列。

第二步,链接命令中的第二个文件lib.a为静态库文件,首先匹配g.o文件,g.o文件中定义的符号和集合U中匹配不上,该文件不添加到集合E中,转到下一个文件f.o。g.o文件符号表见图5.

第三步,将集合U和匹配文件f.o,发现f.o文件中存在定义符号f_1和集合U中匹配(见图6),则将f.o文件加入到集合E中,将f_1从集合U中转移到集合D中。该步骤完成后,集合E,U和D的内容详见表1 f.o列。

第四步和第五步分别是对e.o和d.o文件进行匹配,未发现和集合U中匹配的符号。
第六步,将集合U和c.o文件进行匹配,发现c.o中定义的符号function_c和c_1匹配的上,因此将c.o加入集合E,function_c和c_1从集合U转移到集合D中。与此同时,c.o文件中也包含了未解释符号function_e, d_1和f_1。由于f_1已经在集合D中,所以忽略。将未解释符号function_e, d_1加入集合U。该步骤完成后,集合E,U和D的内容详见表1 c.o列。

第七步,将集合U和b.o文件进行匹配,发现b.o中定义的符号b_1匹配的上,因此将b.o加入集合E,b_1从集合U转移到集合D中。b.o中没有未解释符号。该步骤完成后,集合E,U和D的内容详见表1 b.o列。
经过第一轮匹配后,集合U中有两个符号,然后进行第二轮匹配。第二轮匹配首先从g.o开始,g.o未匹配上。然由于f.o已经加入到集合E中,直接跳过,开始匹配e.o。
第八步,将集合U和e.o文件进行匹配,发现e.o中定义的符号function_e匹配的上,因此将e.o加入集合E。function_e和e_1添加到集合D中。e.o中存在未定义符号f_1和g_1,其中f_1已经在集合D中,但g_1未在。因此将g_1添加到集合U中。
第九步,将集合U和d.o文件进行匹配,发现d.o中定义的符号d_1匹配的上,因此将d.o加入集合E中,d_1从集合U中转移到集合D中。d.o中没有未解释符号。该步骤完成后,集合E,U和D的内容详见表1 d.o列。
第十步,由于c.o和b.o都已加入集合E中,因此跳过。开始第三轮检索。将集合U和g.o文件进行匹配,g.o中定义的符号g_1匹配的上。因此将g_1添加到集合E中。将g_1从集合U转移到集合D中。
第十一步,检索完第三遍,所有可重定位文件都添加进来,检索结束。
搜索顺序 |
a.o |
g.o |
f.o |
e.o |
d.o |
c.o |
E: |
a.o |
… |
a.o f.o |
… |
… |
a.o f.o c.o |
U: |
function_c c_1 f_1 b_1 |
… |
function_c c_1 b_1 |
… |
… |
b_1 function_e d_1 |
D: |
a_1 main |
… |
a_1 main f_1 |
… |
… |
a_1 main f_1 function_c c_1 |
搜索顺序 |
b.o |
g.o |
e.o |
d.o |
g.o |
|
E: |
a.o f.o c.o b.o |
… |
a.o f.o c.o b.o e.o |
a.o f.o c.o b.o e.o d.o |
a.o f.o c.o b.o e.o d.o g.o |
|
U: |
function_e d_1 |
… |
d_1 g_1 |
g_1 |
NULL |
|
D: |
a_1 main f_1 function_c c_1 b_1 |
… |
a_1 main f_1 function_c c_1 b_1 function_e e_1 |
a_1 main f_1 function_c c_1 b_1 function_e e_1 d_1 |
a_1 main f_1 function_c c_1 b_1 function_e e_1 d_1 g_1 |
|
通过上述的分析,可以得到链接器添加可重定位目标文件的顺序为:a.o à f.o à c.o à b.o àe.o àd.o à g.o。通过map文件可知,链接顺序是一致的,见图8.

5. 总结
通过对链接过程中添加目标文件的过程分析,能够了解为什么GCC在链接时对依赖库的顺序是敏感的,为什么被依赖的目标文件和库必须放在后面,为什么代码升级后,链接顺序可能会变化。链接器正是通过本文介绍的方法自动确定文件顺序。
6. 附录
a.c:
int a_1=5;
extern int b_1;
extern int c_1;
extern int f_1;
extern void function_c(void);
int main()
{
function_c();
a_1=c_1+f_1+b_1;
}
b.c:
int b_1=10;
c.c:
int c_1;
extern int d_1;
extern int f_1;
extern function_e(void);
void function_c(void)
{
function_e();
c_1=d_1+f_1;
}
d.c:
int d_1=20;
e.c:
int e_1;
extern int f_1;
extern int g_1;
void function_e(void)
{
e_1=f_1+g_1;
}
f.c:
int f_1=10;
g.c:
int g_1=20;
build.sh:
gcc -c a.c
gcc -c b.c
gcc -c c.c
gcc -c d.c
gcc -c e.c
gcc -c f.c
gcc -c g.c
ar rs lib.a g.o f.o e.o d.o c.o b.o
gcc -o test.exe a.o -static -L. lib.a -Wl,-Map,test.Map
readelf -a a.o>a.txt
readelf -a test.exe>test.txt
参考文献(References):
[1] 俞甲子, 石帆. 程序员的自我修养——链接、装载与库[M]. 电子工业出版社, 2009. 38-52
[2] 兰德尔, E, 布莱恩特. 深入理解计算机系统[M]. 机械工业出版社, 2016. 477-478