被隐藏的过程
C语言里面的经典,“Hello World”程序。
#include<stdio.h>
int main()
{
printf("Hello World");
return 0;
}
在linux下,当我们使用GCC来编译Hello World程序时,只需使用最简单的命令(假设源代码文件名为hello.c)
$gcc hello.c
$./a.out
事实上,上述过程可以分解为4个步骤:预处理(Prepressing),编译(Compilation),汇编(Assembly)和链接(Linking)。
预编译
首先是源代码文件hello.c和相关的头文件,如stdio.h等被编译器cpp编译成一个.i文件。对于C++程序员来说,它的源代码文件的扩展名可能是.cpp或.cxx,头文件的扩展名可能是.hpp,而编译后的文件扩展名是.ii。第一步预编译的过程相当于如下命令(-E表示只进行与编译):
$gcc -E hello.c -o hello.i
或者
$gcc -E hello.c > hello.i
预编译过程主要是处理那些源代码文件中以“#”开始的预编译指令。比如“#include”,“#define”等,主要处理规则如下:
(1)将所有的“#define”删除,并且展开所有的宏定义。
(2)处理所有条件预编译指令,比如“#if”,“#ifdef”,“elif”,“#else”,“#endif”。
(3)处理“#include”预编译指令,将被包含的文件插入到该预编译指令的位置。
(4)删除所有的注释“//”和“/**/”。
(5)添加行号和文件名标识,,比如#2 “hello.c” 2,以便编译器产生调试用的行号信息以及用于编译时产生编译错误或警告时能够显示行号。
(6)保留所有的#pragma编译器指令,因为编译器需要使用它们。(#pragma它的作用是设定编译器的状态或者是指示编译器完成一些特定的动作)。
经过预编译后的.i文件不包含任何宏定义,因为所有的宏已经被展开,并且包含的文件也已经被插入到.i文件中。所有我们无法判断宏定义是否正确或头文件包含是否正确时,可以查看预编译后的文件来确定。
编译
编译的过程就是把预处理完的文件进行一系列的词法分析,语法分析,语义分析及优化后生成相应的汇编代码文件。上面的编译过程相当于如下命令:
$gcc -S hello.c -o hello.s
现在版本的GCC把预编译和编译两个步骤合并成一个步骤,使用一个叫做cc1的程序来完成这两个步骤。这个程序位于“/usr/lib/gcc/i486-linux-gnu/4.1”,我们也可以直接调用cc1来完成它:
$ /usr/lib/gcc/i486-linux-gnu/4.1/cc1 hello.c
可以得到汇编输出的文件hello.s。
实际上,gcc这个命令只是这些后台程序的包装,它会根据不同的参数要求去调用预编译编译程序cc1,汇编器as,连接器Id。
汇编
汇编是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。所以汇编器的汇编过程相对于编译器来讲比较简单,它没有复杂的语法,也没有语义,也不需要做指令的优化,只是根据汇编指令和机器指令的对照表一 一翻译就可以了,上面的汇编过程我们可以调用汇编器as来完成:
$as hello.s -o hello.o
或者
$gcc -c hello.s -o hello.o
或者使用gcc命令从C源代码文件开始,经过预编译,编译和汇编直接输出目标文件(Object File):
$gcc -c hello.c -o hello.o
链接
把一大堆文件链接起来才可以得到“a.out”,即最终的可执行文件。
编译器做了什么
从最直观观的角度来讲,编译器就是将高级语言翻译成机器语言的一个工具。编译过程一般可以分为6步:扫描,语法分析,语义分析,源代码优化,代码生成和目标代码优化。
比如我们有一行C语言的源代码如下:
array[index] = (index+4)*(2+6)
CompilerExpressin.c
词法分析
首先源代码程序被输入到扫描器(Scanner),它只是进行词法分析,运行一种类似于有限状态机(Finite State Machine)的算法可以很轻松地将源代码的字符序列分割成一系列的记号(Token)。比如上面的那行程序,总共分包含了28个非空字符,经过扫描产生了16个记号,分别为:array ,[, index, ],=,(,index,+,4,),*,(,2,+,6,)。
词法分析产生的记号一般可以分为如下几类:关键字,标识符,字面量(包含数字,字符串等)和特殊符号(如加号,等号)。在标识记号的同时,扫描器也完成了其他共工作。比如将标识符存放到符号表,将数字,字符串常量存放到文字表,以备后面的步骤使用。
有一个叫做lex的程序可以实现词法扫描,它会按照用户之前描述好的词法规则将输入的字符串分割成一个个记号。编译器的开发者无需为每个编译器开发一个独立的词法扫描器。另外对于一些有预处理的语言,比如C语言,它的宏替换和文件包含等工作一般不归入编译器的范围而交给一个独立的预处理器。
语法分析
接下来就是语法分析器(Grammar Parser)将对由扫描器产生的记号进行语法分析,从而产生语法树(Syntax Tree)。整个分析过程采用了上下文无关语法(Context-free Grammar)的分析手段。简单的讲,由语法分析器生成的语法树就是以表达式(Expression)为节点的树。
从产生的语法树上可以看到:整个语句被看作是一个赋值表达式;赋值表达式的左边是一个数组表达式,它的右边是一个乘法表达式;数组表达式又由两个符号表达式组成,等等。符号和数字是最小的表达式,所以它们通常作为整个语法树的叶节点。如果出现了表达式不合法,比如各种括号不匹配,表达式中缺少操作符等,编译器就会报告语法分析阶段的错误。
语法分析也有一个现成的工具叫做yacc(Yet Another Complier Comoiler)。
语义分析
接下来是语义分析,由语义分析器(Semantic Analyzer)来完成。语法分析仅仅是完成了对表达式的语法层面的分析,但是它并不了解这个语句是否真正有意义。比如C语言里面两个指针做数乘运算是没有意义的,但是这个语句在语法上是合法的;编译器所能分析的语义是静态语义(Static Semantic),所谓静态语义是指在编译期就可以确定的语义,与之对应的动态语义(Dynamic Semantic)就只有在运行期才能确定语义。
静态语义通常包括声明和类型的匹配,类型的转换。动态语义一般指在运行期出现的语义相关的问题,比如将0作为除数是一个运行期语义错误。
中间语言生成
源代码优化器(Source Code Optimizer):会在源代码级别进行优化,在上例中,(2+6)这个表达式可以被优化成8,因为它的值在编译期就可以确定下来。其实直接在语法树上做优化比较困难,所以源代码优化器往往将整个语法树转换成中间代码(Intermediate Code),它是语法树的顺序表示,其实已经非常接近目标代码了。中间代码有很多种类型比较常见的有:三地址代码(Three-address Code)和P-代码(P-Code)。最基本的三地址码是这样的:
x = y op z
三地址码也得名于此,因为一个三地址码语句里面有三个地址变量。上面的例子中的语法树可以被翻译成三地址码后是这样的:
t1 = 2+6
t2 = index + 4
t3 = t2 * t1
array[index] = t3
经过优化后的代码:
t2 = index +4
t2 = t2 * 8
array[index] = t2
中间代码使得编译器可以被分为前端和后端。编译器前端负责产生机器无关的中间代码,编译后端将中间代码转换成目标机器代码。
目标代码生成与优化
源代码优化器产生中间代码标志着下面的过程都属于编译器后端。编译器后端主要包括代码生成器(Code Generator)和目标代码优化器(Target Code Optimizer)。我们先来看看代码生成器:将中间代码转换成目标机器代码,这个过程十分依赖于目标机器,因为不同的机器有着不同的字长,寄存器,整数数据类型和浮点数数据类型等。对于上面的中间代码,代码生成器可能会生成下面的代码序列(用x86的汇编语言来表示)
movl index, %ecx ; value of index to ecx
addl $4, %ecx ;ecx = ecx + 4
mull $8, %ecx ;ecx = ecx * 8
movl index, %eax ;value of index to eax
movl %ecx, array(,eax,4) ;array[index] = ecx
最后目标代码优化器对上述的目标代码进行优化,比如选择合适的寻址方式,使用位移来代替乘法运算,删除多余的指令等。上面的例子中,乘法由一条相对复杂的基址比例变址寻址(Base Index Scale Addressing)的lea指令完成,随后由一条mov指令完成最后的赋值操作。
movl index, %edx
leal 32(,%edx,8), %eax
movl %eax, array(,%edx,4)
编译器忙活了这么多个步骤之后,源代码终于编译成了目标代码。但是整个目标代码中有一个问题是:index和array的地址还没有确定。如果我们要把目标文件使用汇编器编译成真正能够在机器上执行的指令,那么index和array的地址应该从哪里获得呢?如果index和array定义在跟上面的源代码同一个编译单元里面,那么编译器可以为index和array分配地址空间;那如果是定义在其他的程序模块呢?事实上定义其他模块的全局变量和函数在最终运行时的绝对地址都要在最终链接的时候才能确定。所以现代的编译器可以将一个源代码文件编译成一个未链接的目标文件,然后由链接器最终将这些目标文件链接起来形成可执行文件。
模块的拼装——静态链接
人们把每个源代码模块独立的编译,然后按照须要将它们“组装”起来,这个组装模块的过程就是链接(Linking)。链接的主要内容就是把各个模块之间相互引用的部分都处理好,使得各个模块之间能够正确地衔接。链接的过程主要包括了地址和空间分配(Address and Storage Allocation),符号决议(Symbol Resolution)和重定位(Relocation)等这些步骤。
每个模块的源代码文件(如.c)文件经过编译器编译成目标文件(Object File,一般扩展名为.o或。obj),目标文件和库(Library)一起链接形成最终的可执行文件。库其实就是一组目标文件的包,就是一些最常用的代码编译成目标文件后打包存放。
修改地址的过程也被叫做重定位(Relocation),每个要被修正的地方叫一个重定位入口(Relocation Entry)。重定位所做的就是给程序中每个这样的绝对地址引用的位置“打补丁”,使它们指向正确的地址。