第2章 编译和链接
2.1 被隐藏了的过程:
一个“编译”过程可以分为4个步骤:
- 预处理(Prepressing)
- 编译(Compilation)
- 汇编(Assembly)
- 链接(Linking)
实际上gcc这个命令只是一些后台程序的包装,它会根据不同的参数要求去调用:预编译编译程序 cc1、编译器 cc1、汇编器 as、链接器 ld。
2.1.1 预编译
命令:
gcc -E hello.c -o hello.i
或者:
cpp hello.c > hello.i
使用 “预编译器 cpp”,C语言的 .c
文件被预编译为 .i
文件,C++语言的 .cpp
文件被预编译为 .ii
文件。
预处理的主要规则:
- 将所有的 #define 删除,并且展开所有的宏定义;
- 处理所有条件预编译指令,比如 #if、#ifndef、#elif、#else、#endif 等;
- 处理 #include 预编译指令,将被包含的文件插入到该预编译指令位置。注意,这个过程是递归进行的,也就说被包含的文件可能还包含其他文件;
- 删除所有的注释 “//” 和 “/* */”;
- 添加行号和文件名标识,比如 #2 “hello.c” 2,以便于编译时编译器产生调试用的行号信息以及用于编译时产生编译错误或警告时能够显示行号;
- 保留所有 #pragma 编译器指令,因为编译器须要使用它们。
2.1.2 编译:
编译过程就是把预处理完的文件进行一系列 词法分析、语法分析、语义分析 及 优化后生成相应的汇编代码文件,这个过程往往是我们所说的整个程序构建的核心部分,也是最复杂的部分之一。
命令:
gcc -S hello.i -o hello.s
或者:
/usr/lib/gcc/i486-linux-gnu/4.1/cc1 hello.c
2.1.3 汇编:
汇编器是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。
所以汇编器的汇编过程相对于编译器来说比较简单,它没有复杂的语法,也没有语义,也不需要指令优化,只是根据汇编指令和机器指令的对照表一一翻译就可以了。
命令:
gcc -c hello.s -o hello.o
或者:
as hello.s -o hello.o
源文件经过预编译、编译、汇编后,输出 “目标文件”(Object File),即 .o
文件。
2.1.4 链接:
命令:
ld -static /usr/lib/crt1.o /usr/lib/crti.o /usr/lib/gcc/i486-linux-gnu/4.1.3/crtbeginT.o
-L /usr/lib/gcc/i486-linux-gnu/4.1.3 -L /usr/lib -L /lib hello.o
--start-group -lgcc -lgcc_eh -lc --end-group /usr/lib/gcc/i486-linux-gnu/4.1.3/crtend.o /usr/lib/crtn.o
省略掉路径后的命令:
ld -static crt1.o crti.o crtbeginT.o hello.o -start-group -lgcc -lgcc_eh -lc -end-group crtend.o crtn.o
可以看到,我们要将一大堆文件链接起来才可以得到 a.out
,即最终的“可执行文件”。
2.3 链接器年龄比编译器长:
回顾计算机程序开发的历史:
- 当程序修改的时候,这些(指令的)位置都要重新计算。这种重新计算各个目标的地址过程被叫做“重定位”(Relocation),例如指令1的内容是跳转到指令5,当指令5之前插入了新内容后,指令1跳转的地址就要进行相应的修改;
- 汇编语言中引入了 “符号”(Symbol),符号这个概念随着汇编语言的普及迅速被使用,它用来表示一个地址,这个地址可以是一段子程序(后来发展成函数)的起始地址,也可以是一个变量的起始地址。 例如 “jmp foo”;这种符号的方法使得人们从具体的指令地址中逐步解放出来,程序中开始使用符号来标记地址,不管这个“foo”之前插入或删除了多少条指令导致“foo”的目标地址发生了什么变化,汇编器在每次汇编程序的时候会重新计算“foo”这个符号的地址,然后把所有引用到“foo”的指令修正到这个正确的地址。
2.4 模块拼装 ---- 静态链接:
程序设计的模块化是人们一直在追求的目标,因为当一个系统十分复杂的时候,我们不得不将一个复杂的系统逐步分割成小的系统以达到各个突破的目的。
一个复杂的软件也是如此,人们把每个源代码模块独立的编译,然后按照需要把他们“组装”起来,这个组装的过程就是 “链接”(Linking)。
链接的主要内容就是把各个模块之间相互引用的部分都处理好,使得各个模块之间能够正确的衔接。
从原理上讲,链接器的工作无非就是把一些指令对其他符号地址的引用加以修正。
链接过程主要包括了 “地址和空间分配(Address and Storage Allocatioin)”、“符号决议(Symbol Resolution)” 和 “重定位(Relocation)” 等这些步骤。
举例说明链接的过程:
例如我们在目标文件 main.c 中使用另一个模块 func.c 中的函数 foo()。我们在main.c模块中每一处调用foo()的时候都必须确切知道foo()这个函数的地址,但是由于每个模块都是单独编译的,在编译器编译main.c的时候它并不知道foo()函数的地址,所以它暂时把这些调用foo()的指令目标地址搁置,等待最后链接的时候由链接器去将这些指令的目标地址修正。
如果没有链接器,需要我们手工把每个调用foo()的指令进行修正,填入正确的foo()函数地址。当func.c模块被重新编译,foo()函数的地址有可能改变时,我们在main.c中所有使用到foo()的地址的指令将要全部重新调整。这些繁琐的工作将成为程序员的噩梦。
使用链接器,你可以直接引用其他模块的函数和全局变量而无需知道它们的地址,因为链接器在链接的时候,会根据你所引用的符号foo,自动去相应的func.c模块查找foo()的地址,然后将main.c模块中所有引用foo()的指令重新修正,让它们的目标地址为真正的foo()函数的地址。
这就是静态链接最基本的过程和作用。
例如上图中 目标文件B中有如下一条指令,引用 目标文件A 中的全局变量 var:
movl $0x2a, var
这条指令就是给这个 var变量赋值为 0x2a,相当于C语言中的语句 “var = 42”。
由于在编译目标文件B的时候,编译器并不知道变量var的地址(因为它在目标文件A中,A和B是独立编译的),所以编译器在无法确定地址的情况下,将这条 mov 指令的目标地址置为 0
,等待链接器在将目标文件A和B链接起来的时候再将其修正。
这个地址修正的过程也被叫做 “重定位”(Relocation),每个要被修正的地方叫一个 “重定位入口”(Relocation Entry)。
重定位所做的就是给程序中每个这样的绝地地址引用的位置“打补丁”,使它们指向正确的地址。