对于平常的应用程序开发,我们很少需要关注编译和链接过程,因为通常的开发环境都是流行的集成开发环境(IDE),这样的IDE一般都将编译和链接的过程一步完成,通常将这种编译和链接合并到一起的过程称为构建(Build)。
一、那些被隐藏了的过程
当我们运行一个程序的之前,通常要经过4个步骤,分别是:
1、预处理(Prepressing)
2、编译(Compilation)
3、汇编(Assembly)
4、链接(Linking)
(一)预处理
预处理又称预编译,预编译过程主要处理那些源代码文件中的以"#"
开始的预编译指令。比如#include
、#define
等,主要处理规则如下:
- 将所有的
#define
删除,并且展开所有宏定义。 - 处理所有条件预编译指令,比如
#if
、#ifdef
、#elif
、#else
、#endif
。 - 处理
#include
预编译指令,将被包含的文件插入到该预编译指令的位置。注意,这个过程是递归进行的,也就是说被包含的文件可能还包含其他文件。 - 删除所有的注释。
- 添加行号和文件名标识,以便于编译时编译期产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号。
- 保留所有的
#pragma
编译器指令,因为编译器须要使用它们。
经过预编译后的文件(.i文件)不包含任何宏定义,因为所有的宏已经被展开,并且包含的文件也已经插入到.i
文件中,所以当我们无法判断宏定义是否正确或头文件包含是否正确时,可以查看预编译后的文件来确定问题。
(二)编译
编译过程就是把预处理完的文件进行一系列词法分析、语法分析、语义分析及优化后生产相应的汇编代码文件,这个过程往往是我们所说的整个程序构建的核心部分。
(三)汇编
汇编器是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。所以汇编器的汇编过程相对于编译器来说比较简单,它没有复杂的语法,也没有语义,也不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译就可以了。
(四)链接
链接通常是一个让人比较费解的过程,为什么汇编器不直接输出可执行文件而是输出一个目标文件呢?链接过程到底包含了什么内容?为什么要链接?这是很多人的疑惑,所以我们会在本篇文章具体的分析静态链接。
二、编译器做了什么
从最直观的角度来讲,编译器就是将高级语言翻译成机器语言的一个工具。编译过程一般可以分为6步:扫描、语法分析、语义分析、源代码优化、代码生成和目标代码优化。
我们将结合这个过程来简单描述从源代码到最终目标代码的过程,以一段很简单的C语言的代码为例子来讲述这个过程,比如我们有一行C语言的源代码如下:
array[index] = (index + 4) * (2 + 6)
(一)词法分析
首先,源代码程序被输入到扫描器,通过特定算法轻松地将源代码的字符序列分割成一系列的记号。比如上面的那行代码,总共包含了28个非空字符,经过扫描以后,产生了16个记号,比如:
记号 | 类型 |
---|---|
array | 标识符 |
[ | 左方括号 |
index | 标识符 |
] | 右方括号 |
(二)语法分析
接下来的语法分析器将对由扫描器产生的记号进行语法分析,从而产生语法树。简单的讲,由语法分析器生成的语法树就是以表达式为结点的树,我们知道。C语言的一个语句是一个表达式,而复杂的语句是很多表达式的组合。上面例子中的语句就是一个由赋值表达式、加法表达式、乘法表达式、数组表达式、括号表达式组成的复杂语句。
(三)语义分析
(四)中间语言生成
现代的编译期有着很多层次的优化,往往在源代码级别会有一个优化过程。我们这里所描述的源码级优化器在不同编译器中可能会有不同的定义活有一些其他的差异。源代码级优化器会在源代码级别进行优化,在上面那段代码中,(2+6)
这个表达式可以被优化掉,因为它的值在编译期就可以被确定。
(五)目标代码生成与优化
源代码优化器产生中间代码标志着下面的过程都属于编译器后端。编译器后端主要包括代码生成器和目标代码优化器。
代码生成器:将中间代码转换成目标机器代码。
目标代码优化器:对上述的目标代码进行优化,比如选择合适的寻址方式、使用位移来代替乘法运算、删除多余的指令等。
经过这些扫描、语法分析、语义分析、源代码优化、代码生成和目标代码优化,编译器忙活了这么多个步骤以后,源代码终于被编译成了目标代码,但是这个目标代码中有一个问题是:index和array的地址还没有确定。如果我们要把目标代码使用汇编器编译成真正能够在机器上执行的指令,那么index和array的地址应该从哪得到呢?如果index和array定义在跟上面的源代码同一个编译单元里面,那么编译器可以为index和array分配空间,确定他们的地址,那如果是定义在其他的程序模块呢?
这个问题就涉及到了链接的过程。
三、链接器年龄比编译期长
我们都知道,上古时期没有高级语言,用的都是机器语言,甚至连汇编语言都没有,当程序需要被运行时,程序员人工将他所写的程序写入到存储设备上,最原始的存储设备就是纸带,即在纸带上打孔。
假设有一种计算机,它的每条指令是1个字节,也就是8位,我们假设有一种跳转指令,它的高4位是0001,表示这是一条跳转指令,低4位存放的是跳转目的地的绝对地址。当程序修改的时候,这些位置都要重新计算,绝对地址都会改变,重新计算的过程十分繁琐又耗时,并且很容易出错,这种重新计算各个目标的地址过程被叫做重定位。
四、模块拼装 - 静态链接
程序设计的模块化是人们一直在追求的目标,因为当一个系统十分复杂的时候,我们不得不将一个复杂的系统逐步分割成小的系统以达到各个突破的目的。一个复杂的软件也如此。人们把每个源代码模块独立地编译,然后按照须要将他们“组装”起来,这个组装模块的过程就是链接。链接的主要内容就是把各个模块之间相互引用的部分都处理好,使得各个模块之间能够正确的衔接。链接器所要做的工作其实跟前面所描述的“程序员人工调整地址”本质上没什么两样,只不过现代的高级语言的诸多特性和功能,使得编译器、链接器更为复杂,功能更为强大,但从原理上讲,它的工作无非就是把一些指令对其他符号地址的引用加以修正。链接过程主要包括了地址和空间分配、符号决议和重定位等这些步骤。
对于最基本的静态链接过程,每个模块的源代码文件经过编译器编译成目标文件(Object File,一般扩展名为.o活.obj),目标文件和库(Library)一起链接形成最终可执行文件。而最常见的库就是运行时库(Runtime Library),它是支持程序运行的基本函数的集合。库其实是一组目标文件的包,就是一些最常用的代码编译成目标文件后打包存放。
比如我们在程序模块main.c中使用另外一个func.c中的函数foo(),我们在main.c模块中每一处调用foo的时候都必须确切知道foo这个函数的地址,但是由于每个模块都是单独编译的,在编译器编译main.c的时候它并不知道foo函数的地址,所以它暂时把这些调用foo的指令的目标地址搁置,等待最后链接的时候由链接器去将这些指令的目标地址修正,这个地址修正的过程也被叫做重定位,每个要被修正的地方叫一个重定位入口,重定位所做的就是给程序中每个这样的绝对地址引用的位置“打补丁”,使他们指向正确的地址。