1.7 C语言程序的编译流程
本节以C语言程序的编译流程为例,介绍实际的C语言编译器是如何运作的。通常把整个代码的编译流程分为编译过程和链接过程。
1.编译过程
编译过程可分为编译预处理、编译与优化、汇编等阶段。
(1)编译预处理
编译预处理即读取C源程序,对其中的伪指令(以#开头的指令)和特殊符号进行处理。主要包括以下几个方面:
1)宏定义指令,如# define Name TokenString、# undef等。对于前一个伪指令,预编译所要做的是将程序中的所有Name用TokenString替换,但作为字符串常量的Name则不被替换。对于后一个伪指令,则将取消对某个宏的定义,使以后该串的出现不再被替换。
2)条件编译指令,如# ifdef、# ifndef、# else、# elif、# endif等。这些伪指令的引入使得程序员可以通过定义不同的宏来决定编译程序对哪些代码进行处理。预编译程序将根据有关的文件,将那些不必要的代码过滤掉。
3)头文件包含指令,如# include "FileName"或者# include <FileName>等。在头文件中一般用伪指令# define定义了大量的宏,还有对各种外部符号的声明。采用头文件的目的是使某些定义可以供多个不同的C源程序使用。因为在需要用到这些定义的C源程序中,只需加上一条# include语句,而不必再在此文件中将这些定义重复一遍。预编译程序将把头文件中的定义统统都加入它所产生的输出文件中,以供编译程序对之进行处理。注意,这个过程是递归进行的,也就是说,被包含的文件可能还包含其他文件。包含到C源程序中的头文件可以是系统提供的,这些头文件一般放在/usr/include目录下,在# include中使用它们要用尖括号(< >)。另外开发人员也可以定义自己的头文件,这些文件一般与C源程序放在同一目录下,此时在# include中要用双引号(" ")。
4)特殊符号。例如在源程序中出现的LINE标识将被解释为当前行号(十进制数),FILE则被解释为当前被编译的C源程序的名称。预编译程序对于在源程序中出现的这些串将用合适的值进行替换。预编译程序所完成的基本上是对源程序的“替代”工作。经过此种替代,生成一个没有宏定义、没有条件编译指令、没有特殊符号的输出文件。这个文件的含义与没有经过预处理的源文件是相同的,但内容有所不同。下一步,此输出文件将作为编译程序的输入而被翻译成为机器指令序列。
5)删除注释。删除所有的注释“//…”和“/*…*/”。
6)保留所有的#pragma编译器指令。以#pragma开始的编译器指令必须保留,因为编译器需要使用它们。
经过预编译后的.i文件不包含任何宏定义,因为所有的宏已经被展开,并且包含的文件也已经被插入.i文件中。所以,当无法判断宏定义是否正确或头文件包含是否正确时,可以查看预编译后的文件来确定。
(2)编译与优化
经过预编译得到的输出文件中只有常量、变量的定义,以及C语言的关键字,如main、if、else、for、while、{、}、+、-、*、\等。编译程序所要做的工作就是通过词法分析和语法分析,在确认所有的指令都符合语法规则之后,将其翻译成等价的中间代码表示或汇编代码。优化处理涉及的问题不仅同编译技术本身有关,而且同机器的硬件环境也有关。优化中的一种是对中间代码的优化。另一种优化则主要是针对目标代码的生成而进行的。对于前一种优化,主要的工作是删除公共表达式、循环优化(代码外提、强度削弱、变换循环控制条件、已知量的合并等)、复写传播,以及无用赋值的删除等。后一种类型的优化同机器的硬件结构密切相关,最主要的是考虑如何充分利用机器的各个硬件寄存器存放有关变量的值,以减少对内存的访问次数。另外,如何根据机器硬件执行指令的特点(如流水线、RISC、CISC、VLIW等)而对指令进行一些调整使目标代码比较短,执行的效率比较高,也是优化的一个重要任务。经过优化得到的汇编代码序列必须经过汇编程序的汇编转换成相应的机器指令序列,方能被机器执行。
(3)汇编
汇编过程是把汇编语言代码翻译成目标机器指令的过程。对于待编译处理的每一个C语言源程序,都将经过这一处理过程而得到相应的目标文件。目标文件中所存放的也就是与源程序等效的机器语言代码。目标文件由段组成,通常一个目标文件中至少有两个段:①代码段。该段中所包含的主要是程序的机器指令,一般是可读和可执行的,但却不可写。②数据段。主要存放程序中要用到的各种全局变量或静态的数据,一般是可读、可写、可执行的。
UNIX环境下主要有三种类型的目标文件:①可重定位文件,其中包含适合于其他目标文件链接以创建一个可执行的或者共享的目标文件的代码和数据。②共享的目标文件,这种文件存放了适合于在两种上下文里链接的代码和数据。第一种是静态链接程序,可把它与其他可重定位文件共享的目标文件一起处理来创建另一个目标文件;第二种是动态链接程序,将它与另一个可执行文件及其他共享目标文件结合到一起,创建一个进程映像。③可执行文件,它包含了一个可以被操作系统通过创建一个进程来执行的文件。汇编程序生成的实际上是第一种类型的目标文件。对于后两种还需要其他的一些处理方能得到,这就是链接程序的工作了。
2.链接过程
由汇编程序生成的目标文件并不能立即被执行,其中可能还有许多没有解决的问题。例如,某个源文件中的函数可能引用了另一个源文件中定义的某个符号(如变量或者函数调用等);在程序中可能调用了某个库文件中的函数,等等。所有的这些问题,都需要经过链接程序的处理方能得以解决。链接程序的主要工作就是将有关的目标文件彼此相连接,亦即将在一个文件中引用的符号同该符号在另外一个文件中的定义连接起来,使得所有的这些目标文件成为一个能够被操作系统装入执行的统一整体。根据开发人员指定的与库函数的链接方式的不同,链接处理通常可分为两种:①静态链接。在该方式下,函数的代码将从其所在的静态链接库中被复制到可执行程序中。这样当该程序被执行时,这些代码将被装入该进程的虚拟地址空间中。静态链接库实际上是一个目标文件的集合,其中的每个文件含有库中的一个或者一组相关函数的代码。②动态链接。在该方式下,函数的代码被放到称作动态链接库或共享对象的某个目标文件中。链接程序此时所做的只是在最终的可执行程序中记录下共享对象的名字以及一些少量的登记信息。在该可执行文件被执行时,动态链接库的全部内容将被映射到运行时相应进程的虚地址空间。动态链接程序将根据可执行程序中记录的信息找到相应的函数代码。对于可执行文件中的函数调用,可分别采用动态链接或静态链接的方法。使用动态链接能够使最终的可执行文件比较短小,并且当共享对象被多个进程使用时能节约一些内存,因为在内存中只需要保存一份此共享对象的代码。但并不是使用动态链接就一定比使用静态链接要优越,在某些情况下动态链接可能带来一些性能上的
损失。
3. GCC的编译链接
在Linux中使用的GCC编译器是把以上几个过程进行了捆绑,使用户只使用一次命令就完成编译工作,这确实很方便,但对于初学者了解编译过程却很不利。GCC代理的编译流程如下:①预编译,将.c文件转化成.i文件,使用的GCC命令是gcc –E (对应于预处理命令cpp);②编译,将.c/.h文件转换成.s文件,使用的gcc命令是gcc –S(对应于编译命令cc1,实际上,现在版本的GCC使用cc1将预编译和编译两个步骤合成为一个步骤;③汇编,将.s文件转化成.o文件,使用的GCC命令是gcc –c (对应于汇编命令as);④链接,将.o文件转化成可执行程序,使用的GCC命令是gcc(对应于链接命令ld)。
以名为hello.c的程序为例,编译流程主要经历如图1-2所示的四个过程。
图1-2 C语言程序编译流程图
例如,hello.c为:
#include <stdio.h>
Int main(int argc,char *argv[])
{
printf("hello world\n");
return 0;
}
运行gcc –S hello.c可以得到hello.s文件,其内容为:
.file "hello.c"
.def ___main; .scl 2; .type 32; .endef
.section .rdata,"dr"
LC0:
.ascii "hello world\0"
.text
.globl _main
.def _main; .scl 2; .type 32; .endef
_main:
LFB6:
.cfi_startproc
pushl %ebp
.cfi_def_cfa_offset 8
...
所有以字符“.”开头的行都是指导汇编器和链接器的命令,其他行则是被翻译成汇编语言的代码。
C语言编译的整个过程是比较复杂的,涉及的编译器知识、硬件知识、工具链知识非常多。一般情况下,只需要知道其分成编译和链接两个阶段,编译阶段是将源程序(*.c)转换成为目标代码(一般是obj文件),链接阶段是将源程序转换成的目标代码(obj文件)与程序里面调用的库函数对应的代码链接起来形成对应的可执行文件(exe文件),其他的都需要在实践中多多体会才能有更加深入的理解。