编译和链接合并到一起的过程称为构建
在Linux 下,当我们用编译Hello World 程序时,只需要使用简单的命令。
#include <stdio.h>
int main()
{
printf("Hello World\n");
return 0;
}
$ gcc hello.c
$ ./a.out
Hello World
上述过程可以分为4个步骤,分别是预处理(Prepressing)、编译(Compilation)、汇编(Assembly)、链接(Linking)。
预编译
源代码文件hello.c 和相关头文件被编译成.i 文件。预编译过程相当于如下命令(-E 表示只进行预编译)
$gcc -E hello.c -o hello.i
预编译过程主要处理源代码中以“#” 开始的预编译指令。比如”#include“、”#define“ 等,主要规则如下:
-
将”#define“ 删除,并展开宏定义。
-
处理条件预编译指令,比如”#if“、”#ifdef“、”#elif“、”#else“、”#endif“
-
处理”#include“ 预编译指令,将被包含的文件插入预编译指令的位置。这个过程是递归的,也就是说包含的文件可能还包含其他文件。
-
删除所有的注释
-
添加行号和文件名标识。以便编译时产生行号信息和产生错误时显示行号。
-
保留#pragma 编译器指令,因为编译器需要使用。
编译
编译过程就是把预处理完的文件进行一系列的词法分析、语法分析、语义分析以及优化后产生的汇编代码文件。编译过程相当于如下命令:
$gcc -S hello.i -o hello.s
比如我们有一行C语言源代码,我们将简单描述从源代码到最终目标代码的过程。源代码如下:
array[index] = (index + 4) * (2 + 6)
词法分析
首先源代码被输入到扫描器(Scanner) 将源代码的字符序列分割成一系列的记号(Token)。
| 记号 | 类型 |
|---|---|
| array | 标识符 |
| [ | 左方括号 |
| index | 标识符 |
| ] | 右方括号 |
| = | 赋值 |
| ( | 左圆括号 |
| index | 标识符 |
| + | 加号 |
| 4 | 数字 |
| ) | 右圆括号 |
| * | 乘号 |
| ( | 左圆括号 |
| 2 | 数字 |
| + | 加号 |
| 6 | 数字 |
| ) | 右圆括号 |
词法分析产生的记号可以分为几类:关键字、标识符、字面量和特殊符号。同时扫描器也完成了其他工作,比如将标识符存放到符号表,将数字、字符常量存放到文字表等。
语法分析
接下来语法分析器(Grammer Parser) 将对由扫描器产生的记号进行语法分析,从而产生语法树(Syntax Tree)。语法树就是以表达式(Expression) 为节点的树。上面例子中的语句就是由赋值表达式、加法表达式、乘法表达式、数组表达式、括号表达式组成的复杂语句。它经过语法分析器后形成语法树。
符号和数字是最小的表达式,所以它们通常作为语法树的叶子节点。在语法分析的同时,很多运算符号的优先级和含义也被确定了下来,比如乘法优先级比加法高,括号优先级比乘法高等等。另外如果括号不匹配、表达式中缺少操作符等,编译器会报告语法分析阶段错误。
语义分析
语义分析由语义分析器(Semantic Analyzer) 来完成。语法分析只是完成了对表达式的语法层面的分析,但是它并不了解这个语句是否真正有意义。比如C语言里对两个指针做乘法是没有意义的,但是这个语句在语法上是合法的。编译器所能分析的语义是静态语义(Static Semantic)。所谓静态语义是指能在编译器确定的语义,与之对应的动态语义(Dynamic Semantic) 就是在运行时才能确定的语义。
静态语义通常包括声明和类型匹配,类型的转换。比如当一个浮点型表达式赋值给一个整型表达式时,其中隐含了一个浮点型到整型的转换过程,语义分析需要完成这个步骤。当发现类型不匹配时编译器会报错。
经过语义分析后整个语法树表达式都被标识了类型。如果需要隐式转换会在语法树中插入相应的转化节点。
中间语言生成
现代编译器有着很多层次的优化,往往源代码级别会有一个优化过程。比如(2 + 6)这个表达式就可以被优化,因为它的值在编译期就可以确定。
我们可以看到(2 + 6)这个表达式被优化成了8。其实直接在语法树上优化比较困难,所以源代码优化器(Source Code Optimizer) 将整个语法树转换成中间代码(Intermediate Code)。中间代码有着很多种类型,比较常见的有:三地址码(Tree-Address Code) 和P-代码(P-Code)。我们将例子中的语法树翻译成三地址码是这样的:
t1 = 2 + 6
t2 = index + 4
t3 = t2 * t1
array[index] = t3
进行优化之后
t2 = index + 4
t2 = t2 * 6
array[index] = t2
中间代码使得编译器可以被分为前端和后端。前端负责生产机器无关的中间代码,后端将中间代码转换成目标机器代码。这样对于可跨平台的编译器而言,可以针对不同的平台使用同一个前端和针对不同机器平台的数个后端。
汇编
汇编器是将汇编代码转换成机器可执行的指令,每一条汇编指令几乎都对应着一条机器指令。
$gcc -c hello.s -o hello.o
或者直接从C 源码开始经过预编译、编译、汇编输出目标文件(Object File)
$gcc -c hello.c -o hello.o
目标代码生成与优化
编译器后端主要包括代码生成器(Code Generator) 和目标代码优化器(Target Code Optimizer)。代码生成器将中间代码转换成目标机器代码,这个过程十分依赖于目标机器。不同的机器有着不同的字长、寄存器、整数数据类型、浮点数据类型等。我们用x86汇编语言来表示,可能会生成下面的代码:
movl index, %ecx
addl $4, %ecx
mull $8, %ecx
movl index, %eax
movl %ecx, array(,eax,4)
最后目标代码优化器对上述目标代码进行优化,比如选择合适的寻址方式、使用位移代替乘法运算等等。
movl index, %edx
leal 32(,$edx,8), %eax
movl %eax, array(,%edx,4)
经历过扫描、语法分析、语义分析、源代码优化、代码生成和目标代码优化,源代码终于被编译成了目标代码。但是目标代码中有一个问题是:index 和array 的地址还没有确定。事实上定义在其他模块的全局变量和函数最终运行时的绝对地址都是要在链接时才能确定。所以现代编译器可以将一个源代码文件编译成一个未链接的目标文件,然后链接器最终将这些目标文件链接起来形成可执行文件。
链接
程序模块化是我们一直追求的目标,当一个软件十分复杂时,我们将软件分割成小的UI源代码模块独立的编译,然后按照要求”组装“起来,这个组装模块的过程就是链接(Linking)。链接的过程主要包括地址和空间分配(Address and Storage Allocation)、符号决议(Symbol Resolution) 和重定位(Relocation)。
比如我们在程序模块main.c 中调用另一个模块func.c 中的函数foo()。我们在调用foo 的时候必须知道foo 这个函数的地址,但是由于每个模块都是单独编译的,编译器在编译main.c 的时候我们并不知道foo 的地址。所以编译器暂时将调用foo 的指令的目标地址暂时搁置,等到链接时由链接器将这些目标地址修正。链接器在连接时会根据引用的符号foo ,自动的去相应的func.c 模块查找foo 的地址。然后将main.c 模块中所有引用到foo 的指令重新修正,让它的目标地址为真正的foo 函数地址。这就是静态链接最基本的过程和作用。