一、编译过程分解
C语言的经典"Hello World" 程序几乎是每个程序员闭着眼睛就能写出来的,编译运行通过一气呵成,但你知道其中的内部原理吗?
#include <stdio.h>
int main()
{
printf("Hello World\n");
return 0;
}
在 Linux 环境下当我们使用GCC来编译 Hello World 程序时,只需使用最简单的命令(假设源代码文件名为hello.c):
事实上,上述过程可以分解为4个步骤,预处理(Prepressing)、编译(Compilation)、汇编(Assembly)和链接(Linking),如图所示:
预编译命令:
hello.c->hello.i gcc -E hello.c -o hello.i
预编译过程主要是为了处理那些源代码文件中的以 “#” 开始的预编译指令,比如 “#include”、“#define”等,主要处理规则如下:
(1)将所有的 “define” 删除,并且展开所有的宏定义
(2)处理条件预编译指令,比如“#if”,“#ifdef”, “#elif”、“#else”、“#endif”
(3)处理“#include”预编译指令,将被包含的文件插入到该指令的位置
(4)删除注释
(5)添加行号
编译命令:
hello.i->hello.s gcc -S hello.i -o hello.s
hello.c->hello.s gcc -S hello.c -o hello.s
编译过程就是把预处理得到的 hello.i 文件进行词法分析、语法分析、语义分析、优化,生成相应的汇编代码文件hello.s,这部分内容在第二节介绍。
汇编命令:
hello.s->hello.o gcc -c hello.s -o hello.o
hello.c->hello.o gcc -c hello.c -o hello.o
汇编器是将汇编代码转变成机器码,该过程相对于编译过程比较简单。
链接是一个比较然人费解的过程,将在第三节介绍。
二、编译器做了什么
使用机器指令或汇编语言编写程序效率十分低下,并且使用机器指令或 汇编语言编写的嗲吗依赖于特定的CPU,换了另一种CPU则需要重新编写,这几乎令人无法接受,因此,诞生了高级语言。而编译器就是将高级语言翻译成机器语言的一个工具。编译过程可以分为6步:
(1)词法分析
(2)语法分析
(3)语义分析
(4)源代码优化
(5)代码生成
(6)目标代码优化
1. 词法分析
首先,源代码程序被输入扫描器,扫描器对其进行此法分析,将源代码的字符序列分割成一系列的记号(Tokens),比如下面的程序,共包含28个非空字符。
array[index] = (index + 4) * (2 + 6)
记号 | 类型 |
---|---|
array | 标识符 |
[ | 左方括号 |
index | 右方括号 |
= | 赋值 |
… | … |
) | 右圆括号 |
词法分析产生的记号一般分为如下几类:关键字、标识符、(数字、字符串)、特殊符号。识别记号以后,扫面器会将标识符放到符号表,将数字、字符串常量放到文字表等,以备后续使用。
2. 语法分析
接下来,语法分析器将对扫描器产生的记号进行语法分析,从而产生语法树,我们知道,C语言的一个语句是一个表达式,而复杂的语句是很多表达式的组合,上面例子中的语句就是一个由赋值表达式、加法表达式、乘法表达式、数组表达式、括号表达式组成的复杂语句,经过语法分析器后,形成下图的语法树:
3. 语义分析
语法分析仅仅是完成了对表达式语法层面的分析,但它并不了解这个语句是否有真正意义,比如C语言里两个指针做乘法运算是没有意义的,但这个语句在语法上是合法的。编译器能分析的语义是静态语义,是指在编译器可以确定的语义,静态语义通常包括声明和类型的匹配。比如将一个浮点型赋值给一个指针的时候,语义分析程序会发现这个类型不匹配,编译器将会报错。
与之对应的是动态语义,指在运行期间才能确定的语义,比如将0作为除数是一个运行期语义错误。
上面语法中经过语义分析后,如下图所示:
4. 中间语言生成
现代编译器有很多层次的优化,源代码级优化器会在源代码级别进行优化,将整个语法树转换为中间代码。比如在这里,(2+6)这个表达式可以被优化成 8。 中间代码有很多种类型,在不同的编译器中有不同的形式,比较常见的有:三地址码 和 P-代码(P-Code)。我们上面的语法树被翻译成三地址码是这样的:
t1 = 2 + 6
t2 = index + 4
t3 = t2 * t1
array[index] = t3
在三地址码的基础上进行优化,优化程序会将 2+6 的结果计算出来,如下所示:
t2 = index + 4
t3 = t2 * 8
array[index] = t3
中间代码使得编译器可以被 分为前端和后端,编译器前段负责产生与机器无关的中间代码,后端将中间代码转换成目标机器代码,这样,对于一些可以跨平台的编译器而言,他们可以针对不同的平台使用同一个前端和针对不同机器平台的数个后端。
5. 目标代码生成与优化
编译器后端主要包括代码生成器和目标代码优化器。代码生成器将中间代码转换成目标机器代码,这个过程十分依赖于目标机器,因为不同机器有着不同的字长,寄存器,整形数据类型和浮点数数据类型。最后,目标代码优化器对上述目标代码进行优化。比如选择合适的寻址方式、使用位移替代乘法运算、删除多余的指令等。
经过这些步骤以后,源代码终于被编译生成了目标代码。但这个目标代码中有一个问题:index 和 array 的地址还没有确定。如果我们要把目标代码使用汇编器编译成能够在机器上执行的指令,那么 index 和 array 的地址应该从哪得到呢?这就引出了下一个话题:链接。所以,现代的编译器可以将一个源代码文件编译成一个未链接的目标文件,然后由链接器最终将这些目标文件链接起来形成可执行文件。
三、链接器
汇编语言产生以后,软件规模也开始日益庞大,人们要开始考虑将不同功能的代码以一定的方式组织起来,使得更加方便阅读、理解,就把代码按照功能或性质划分,形成不同的功能模块。 这些模块之间相互依赖,右相互独立。这种按照层次化及模块化存储和组织源代码右很多的好处,比如:
(1)代码更容易理解、重用;
(2)每个模块可以单独开发、编译、测试;
(3)改变部分代码不需要编译整个程序。
但是这些模块如何组成一个单一的程序呢?即如何实现模块之间的通信,我们以静态语言的C/C++模块之间的通信方式举例,包含两种方式:
(1)模块间的函数调用;
(2)模块间的变量访问。
模块之间的通信,即为链接。链接的的主要内容就是把各个模块之间相互引用的部分处理好,使得各个模块之间能够正确的衔接。从原理上讲,无非就是把一些指令对其他符号地址的引用加以修正。链接过程主要包括:
(1)地址和空间分配;
(2)符号决议;
(3)重定位。
在这里,我们先只介绍静态链接,最基本的静态链接过程如下图,
库其实就是一组目标文件的包,就是一些最常用的代码编译成目标文件后打包存放。
在我们的程序模块main.c中,如果我们想调用func.c模块中的函数foo(),我们在main.c模块中每一处调用foo的时候都必须知道foo函数的具体位置,但由于每个模块都是单独编译的,所以它暂时把foo函数的目标地址搁置,等到最后链接的时候由链接器将这些目标地址修正。如果没有链接器,就需要手动修正,当func.c模块修改后重新编译,foo函数的地址可能会改变,main.c中使用foo的指令都需要全部调整。而使用链接器的话,会根据所引用的符号foo自动去func.c中查找foo的地址。这就是静态链接最基本的过程。
比如,我们在目标文件 B 中有这么一条指令:
movl $0x2a, var
var是目标文件A中的全局变量,这条指令就是给var变量赋值0x2a.如图所示:
编译目标文件B的时候,编译器并不知道var的目标地址,此时先将目标地址置为0,等待链接器将A和B链接后,变量var的地址确定下来是0x10000。这个地址修正的过程叫做重定位。
本文主要是针对《程序员的自我修养——链接、装载与库》一书的学习、总结,鉴于网上对于这方面的教程和文章都比较分散,因此决定写一个完整、详细的专栏内容,系统总结这方面的相关知识点,以便和大家一起学习。以后会继续更新新的内容,敬请期待!
参考《程序员的自我修养——链接、装载与库》