平常的程序开发很少关注编译和链接过程,因为通常的开发环境都是流行的集成开发环境(IDE)。这样的IDE一般都将编译和链接的过程合并到一起称为构建(build)。
C语言的经典代码
#include
int main(){
printf("hello world\n");
return 0;
}
在Linux下,使用GCC来编译这个程序时,只需使用最简单的命令
$gcc hello.c
$./a.out
hello world
上述过程可以分为四个步骤:预处理,编译,汇编,链接。
预编译
首先源代码文件hello.c
和相关的头文件,如stdio.h
等被预编译器cpp预编译成一个.i
文件。
//-E表示进行预编译
$gcc -E hello.c -o hello.i
预编译过程主要处理那些源代码文件中以“#”开始的预编译指令,比如“#include”,“#define”等。
主要处理过程如下:
1.将所有的“#define”删除,并且展开所有宏定义。
2.处理所有条件预编译指令比如“#if”,“#ifdef”,“#elif”等。
3.处理“#include”预编译指令,将被包含的文件插入到该预编译指令的位置。
4.删除所有的注释“//”和“/**/”。
5.添加行号和文件名标识,比如#2“hello.c”2,以便于编译时编译器产生调试用的行号信息以及用于编译时产生编译错误或警告时能够显示行号。
经过预编译后的.i
文件不包含任何宏定义,因为所有的宏已经被展开,并且包含的文件也已经被插入到.i
文件中。
编译
编译过程就是把预处理完的文件进行一系列词法分析,语法分析,语义分析以及优化后生产相应的汇编代码文件。
//编译命令
$gcc -S hello.i -o hello.s
汇编
汇编器是将汇编代码转变为机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令,没有什么复杂的语法,只是根据汇编指令的对照表一一翻译就可以了。
$gcc -c hello.s -o hello.o
链接
我们的可执行文件是经过一大堆文件链接起来才可以得到的,在解释链接之前,先要理解编译器的作用。
编译器做了什么
编译器就是将高级语言翻译成机器语言的一个工具。高级语言使得程序员们能够更加关注程序逻辑的本身,而尽量少考虑计算机本身的限制,如字长,内存大小,通信方式,存储方式等。
编译过程一般可以分为六步:扫描,语法分析,语义分析,源代码优化,代码生成和目标代码优化。
简述源代码到最终目标代码的过程比如以下代码:
array[index] = (index+4) * (2 + 6)
词法分析
首先源代码会被输入到扫描器,只是简单的进行词法分析,将源代码的字符序列分割成一系列的记号(Token)。比如以下形式
词法分析产生的记号一般可以分为如下几类:关键字,标识符,字面量(数字,字符串等)和特殊符号(如加号,等号)。在识别记号的同时,扫描器也会同时将标识符存放到符号表,将数字,字符串常量存放到文字表,以备后面步骤使用。
语法分析
接下来语法分析器将对由扫描器产生的记号进行语法分析,从而产生语法树(Syntax Tree)。由语法分析器生成的语法树就是以表达式为节点的树。C语言的一个语句是一个表达式,而复杂的语句是很多表达式的组合,上面的表达式就是一个复杂语句,包含了赋值,加法,乘法,数组,括号表达式。经过语法分析后会形成语法树。
整个语句被看作是一个赋值表达式:赋值表达式的左边是一个数组表达式,它的右边是一个乘法表达式;数组表达式又由两个符号表达式组成。符号和数字是最小的表达式,它们不是由其他的表达式来组成的,所以它们通常作为整个语法树的叶节点。在语法分析的同时,很多运算符号的优先级和含义也被确定下来。比如乘法表达式的优先级比加法高,而小括号的优先级比乘法高。另外有些语法有多重含义,比如“*”在C语言中可以表示乘法,也可以表示对指针取内容的表达式,所以语法分析阶段必须对这些内容进行区分。
语义分析
语义分析由语义分析器来完成语法分析仅仅是完成了对表达式的语法层面的分析,但是他并不了解这个语句是否真正有意义。C语言中两个指针做乘法运算是没有意义的,但是在语法上它是合法的。编译器所能分析的语义是静态语义,也就是编译器可以确定的语义,与之对应的动态语义就是只有在运行期才能确定的语义。
静态语义通常包括声明和类型的匹配,类型的转换,当一个浮点型的表达式赋值给一个整型的表达式时,其中隐含了类型转换的过程,语义分析过程中需要完成这个步骤。如果将一个浮点型赋值给一个指针的时候,语义分析程序会发现这个类型不匹配,编译器将会报错。
经过语义分析后,整个语法树的表达式都被标识了类型,如果有些类型需要做隐式转换,语义分析程序会在语法树中插入相应的转换节点。
每个表达式(包括符号和数字)都被标识了类型。
中间语言生成
现代编译器往往在源代码基本会有一个优化过程。源代码级优化器会在源代码级别进行优化,比如在上例中(2+6)这个表达式可以被优化掉,直接变成8,因为它的值在编译器就可以被确定。
其实直接在语法树上进行优化比较困难,所以源代码优化器往往将整个语法树转换成中间代码,它是语法树的顺序表示,它已经非常接近目标代码了。
中间代码使得编译器可以被分为前端和后端,编译器前端负责产生机器无关的中间代码,编译器后端将中间代码转换为目标机器代码。
目标代码生成与优化
中间代码标志着后面的过程都属于编译器后端,编译器后端主要包括代码生成器和目标代码优化器。代码生成器将中间代码转换成目标机器代码。目标代码优化器对目标代码进行优化,比如合适的寻址方式,删除多余指令等。
经过这些扫描,语法分析,语义分析,源代码优化,代码生成和目标代码优化,源代码终于变成了目标代码。但是index和array的地址还没有确定。
如果index和array的定义和上面的源代码在同一个编译单元里,那么编译器可以为index和array分配空间。如果是在其他的程序模块里面呢?
定义在其他模块的全局变量和函数在最终运行时的绝对地址都要在最后链接的时候才能确定。所以现代的编译器将一个源码文件编译成一个未链接的目标文件,然后由链接器最终将这些目标文件链接起来形成可执行文件。
链接器
我们的程序写好并不是一成不变的,它可能会经常被修改,比如我们有五条指令,第一条和第五条之间插入了一条或者多条指令,那么第五条指令以及后面的指令的位置会相应的往后移动。在以前这就需要程序员重新计算每个子程序或跳转的目标地址。当程序修改的时候,这些位置都要重新计算,这个操作十分繁琐又耗时,这种重新计算各个目标的地址过程叫做重定位。
如果我们的跳转目标地址不在本模块,人工绑定进行指令的修正会更加的繁琐和复杂。
后来出现了汇编语言,使用接近人类的各种符号和标记来帮助记忆,这时我们在进行指令的插入修改时,汇编器在每次汇编程序的时候都会重新计算符号的地址,然后把所有引用这个符号的指令都修正到这个正确的地址。
符号(symbol)这个概念随着汇编语言的普及迅速被使用,它用来表示一个地址,可能是函数的起始地址,也可能是一个变量的起始地址。
汇编的普及使得代码的规模越来越大,人们开始将不同功能的代码以一定的方式组织起来,使得更加容易阅读理解。在C语言中最小的单位是变量和函数,若干个变量和函数组成一个模块,存放在一个“.c”
的源代码文件中。
一个程序被分割成多个模块以后,这些模块之间最后如何组合形成一个单一的程序是重点要解决的问题。模块之间如何组合的问题可以归结为模块之间如何通信的问题。最常见的就是模块间的函数调用,模块间的变量访问。这两种都可以看作是模块间符号的引用。定义符号的模块多出一块区域,引用该符号的模块刚好少了那一块区域,一起拼接刚好完美组合,这个拼接过程就是链接。
静态链接
链接的过程主要包括了:地址和空间的分配,符号决议,重定位这些步骤。符号决议有时也叫符号绑定,决议倾向于静态链接,绑定更倾向于动态链接。
最基本的链接过程如图。每个模块的源码文件经过编译器编译成目标文件,目标文件和库一起链接形成最终的可执行文件,最常见的库就是运行时库,它是支持程序运行的基本函数的集合。
每个模块都是单独进行编译的,如果用到其他模块的函数或者是变量,它会暂时将指令的目标地址搁置,等待最后链接的时候由链接器去将这些指令的目标地址修正。
使用链接器,可以直接引用其他模块的函数和全局变量,无需知道它们的地址,因为链接器在链接的时候会根据引用的符号自动去相应的模块查找地址,然后进行修正。