2.1 被隐藏了的过程
我们将源代码变成可执行文件的过程实际包含4个步骤,分别是预处理、编译、汇编和链接。
(1)预处理过程主要处理源代码中以“#”开头的预编译指令,主要的处理规则如下:
- 去除#define,展开所有宏定义
- 处理所有的预编译指令,如#if #ifdef #elif #else #endif
- 处理#include预编译指令,将被包含的文件插入到该编译指令的位置,这个过程是递归进行的。
- 删除所有的注释
- 添加行号和文件名标示,如#2”hello.c”2,以便于编译器编译时能产生调试时使用的行号信息,以及显示错误警告时带行号。
- 保留所有的#progma编译器指令,因为编译器要使用它们。
(2)编译的过程就是把预处理完的文件进行一系列的词法分析、语法分析、语义分析及优化后生产相应的汇编代码文件。下面两个命令都可以产生汇编文件。
(3)汇编:是将汇编代码转变成机器可执行的指令,每条汇编语句都几乎对应一条机器指令。下面两个命令都可以产生目标文件。
(4)链接:链接是一个复杂的过程,要将一大堆文件链接起来才可以得到“a.out”可执行文件。下面细讲。
2.2 编译器做了什么
我们想了解链接的内容,首先还是得看看编译器做了什么工作。编译过程一般可以分为六步:扫描、语法分析、语义分析、源代码优化、代码生成和目标代码优化。
以下面的一行代码为例:
(1)扫描并进行词法分析:源代码被输入到扫描器中,扫描器运用一种类似于有限状态机的算法可以很轻松地将源代码的字符分割成一系列的记号。如上面的一行代码,产生了16个记号。
词法分析产生的记号一般可以分为如下几类:关键字、标识符、字面量(包含数字、字符串等)和特殊符号(如加好、等号)。
(2)语法分析:语法分析器对扫描器产生的记号进行语法分析,产生语法树。采用上下文无关语法的分析方法。下图是所生成的语法树。
(3)语义分析:语法分析只完成了对表达式语法层面的分析,但并不了解这个语句是否真正有意义,比如两个指针做乘法是无意义的。但编译器所能分析的语义是静态语义(可以在编译器确定的语义),与之对应的是动态语义(只有在运行时才能确定的语义)。
(4)中间语言生成:现代编译器有很多层优化,往往在源码级别就有一个优化过程。比如,上述例子中,2+6的值就可以被优化,因为它的值在编译时就可以确定。优化后的语法树如下图所示:
实际上,直接在语法树上做优化比较困难,所以源代码优化器往往将整个语法树转换成了中间代码,它已经很接近目标代码了,但是与机器运行时的环境无关,不包含数据的大小,变量的地址和寄存器的名字等信息。中间代码有很多种类型,比较常见的是三地址码。
(5)目标代码的生成与优化:编译器后端主要包括代码生成器和目标代码优化器。
代码生成器将中间代码转换成目标机器代码,这个过程十分依赖于目标机器,因为不同机器有着不同的字长、寄存器、整数数据类型和浮点数数据类型等。最后目标代码优化器对上述的目标代码进行优化,比如选择合适的寻址方式、使用位移来代替乘法运算、删除多余的指令等。
经过上述6个步骤,源代码终于被编译成目标代码,但是这个目标代码的index和array的地址还没确定。如果目标代码需要再机器上执行,那么地址从哪来?假如index和array和上述源代码定义在同一个编译单元中,那编译器可以为其分配空间,但如果不在同一个编译单元呢?
这就是链接器的作用。事实上,定义在其它模块的全局变量和函数在最终运行时的绝对地址都要在最终链接的时候才能确定。
2.3 链接器比编译器年龄长
早在高级语言发明之前就存在链接的概念。
(1) 最早的时候,人们通过在纸带上是否穿孔来表示0和1,用来存储数据。假设高四位0001 表示跳转指令,第四位表示所要跳转的目的地址。但问题是,程序常常需要被更改,所以跳转指令的低四位就要进行相应的调整。这个过程中,需要人工计算新的目标地址。重新计算目的地址的过程叫做重定位。
(2) 后来人们发明了汇编语言,使用jmp就表示跳转,比如jmp foo,就表示跳转到foo这个函数。这时候,不论程序如何修改,汇编器每次重新汇编都计算foo这个符号的地址。符号这个概念随着汇编语言的普及迅速被使用。
(3) 汇编语言的方便使得代码量日益增大,人们开始将代码按照功能或性质分,形成不同的功能模块,比如在C语言中,最小的单位是变量和函数。若干变量和函数组成一个模块,存放在.c文件中。
(4) 程序被分割成多个模块之后,这些模块最后如何组成一个整体是需要解决的问题。这个问题可以归结到模块之间的通信上。
(5) C/C++模块的通信有两种方式,模块间的函数调用和模块间的变量访问。函数调用需要知道函数地址,变量访问也需知道变量的地址。所以就可以归为一种方式,即模块间符号的引用。
(6) 通过符号引用来拼接各个模块,这个过程就是链接。
2.4 模块拼装 -- 静态链接
(1)链接的过程主要包括了地址和空间分配、符号决议和重定位。
(2)基本的静态链接就由目标文件(.O文件)和库链接而成。最常见的库就是运行时库。
(3)链接的过程:比如main.c要调用另一个.c文件中的foo函数,但因为两个文件是单独编译的,所以并不知道main.c并不知道foo的具体地址。因此在编译时只能讲foo函数的地址先空出来,等待连接器根据foo这个符号找打foo函数的确切地址,然后填进去。
如果foo函数进行了修改,其目标地址也会发生改变,调用foo的地方也都要做相应的改变。这部分工作依旧由链接器来做。
(4)在不知道调用函数或变量的确切地址时,通常填入0,等待链接器进行修正。这个过程被叫做重定位,每个被修正的地方叫做一个重定位入口。