二、GCC编译器工作过程

发布时间 2023-07-17 20:47:53作者: SunshineMiles
	从更直观的角度来说,编译器是一种工具,将高级语言转化为机器语言。举个例子,我们可以使用编译器将用C++语言编写的程序转换为机器可执行的指令和数据。之前提到过,用机器指令或汇编语言编写程序非常繁琐和乏味,这导致了低效的程序开发。此外,用机器语言或汇编语言编写的程序依赖于特定的机器,如果在不同的CPU上运行,就需要重新编写程序,这几乎是无法接受的。因此,人们希望能够使用类似自然语言的语言来描述程序,但是自然语言的表达不够准确,所以像数学定义一样的编程语言很快就出现了。在20世纪60年代和70年代,出现了许多高级语言,其中一些至今仍然非常流行,比如FORTRAN和C语言(准确地说,FORTRAN诞生于20世纪50年代的IBM)。高级语言使程序员能够更加专注于程序逻辑本身,而尽量少考虑计算机本身的限制,比如字长、内存大小、通信方式和存储方式等。高级编程语言的出现极大地提高了程序开发的效率,而且高级语言的可移植性使得它能够在多种计算机平台上轻松运行。研究表明,高级语言的开发效率是汇编语言和机器语言的5倍以上。

让我们继续回到编译器本身的职责上来,编译过程一般可以分为6步:扫描、语法分析、语义分析、源代码优化、代码生成和目标代码优化。

																												编译过程

我们将结合图来简单描述从源代码(Source Code)到最终目标代码(Final Target Code)的过程。以一段很简单的C语言的代码为例子来讲述这个过程。比如我们有一行C语言的源代码如下:

array[index]=(index + 4)*(2 + 6) 
CompilerExpression.c

2.1 词法分析

首先,源代码程序会被输入到扫描器(Scanner)进行处理。扫描器的任务相对简单,它执行词法分析的工作。使用类似有限状态机(Finite State Machine)的算法,扫描器能够轻松地将源代码的字符序列分割成一系列记号(Token)。以前面提到的程序为例,它总共包含了28个非空字符。经过扫描器的处理,生成了16个记号。
1.标识符(IDENTIFIER):"array"
2.左方括号(LEFT_BRACKET):"["
3.标识符(IDENTIFIER):"index"
4.右方括号(RIGHT_BRACKET):"]"
5.运算符(ASTERISK):"="
6.左括号(LEFT_PAREN):"("
7.标识符(IDENTIFIER):"index"
8.运算符(PLUS):"+"
9.数字(NUMBER):"4"
10.右括号(RIGHT_PAREN):")"
11.运算符(ASTERISK):"*"
12.左括号(LEFT_PAREN):"("
13.数字(NUMBER):"2"
14.运算符(PLUS):"+"
15.数字(NUMBER):"6"
16.右括号(RIGHT_PAREN):")"
词法分析产生的记号通常可以分为以下几类:关键字(keywords)、标识符(identifiers)、字面量(literals,包括数字、字符串等)和特殊符号(如加号、等号)。在识别记号的同时,扫描器还会完成其他任务,比如将标识符存放到符号表(symbol table),将数字、字符串常量存放到文字表(literal table)等,以便后续步骤使用。

有一个名为lex的程序可以实现词法扫描,它会按照事先定义好的词法规则将输入的字符串分割成一个个记号。由于有这样的程序存在,编译器开发者无需为每个编译器开发独立的词法扫描器,只需根据需要修改词法规则即可。

此外,对于一些具有预处理功能的语言,例如C语言,宏替换和文件包含等任务通常不包含在编译器的范围内,而是交由一个单独的预处理器(preprocessor)处理。预处理器负责在编译之前执行这些额外的任务。

2.2 语法分析

在语法分析阶段,语法分析器(Grammar Parser)将对由扫描器生成的记号进行语法分析,以生成语法树(Syntax Tree)。整个分析过程使用了上下文无关文法(Context-Free Grammar)和推导自动机(Pushdown Automaton)等分析方法。如果你对上下文无关文法和推导自动机非常熟悉,那么应该很容易理解这个过程。语法分析器根据上下文无关文法对记号进行分析,以识别记号之间的语法结构,并构建相应的语法树。语法树以表达式(Expression)为节点,描述了语句的组织结构。在C语言中,一个语句可以是一个表达式,而复杂的语句则由多个表达式组合而成。在你提供的例子中,该语句由赋值表达式、加法表达式、乘法表达式、数组表达式和括号表达式组成。经过语法分析器的处理,它将生成类似下图所示的语法树:

																																		语法树
            *
          /   \
        /       \
     *            +
   /  \         /  \
array index   index  4
                |
               +
              / \
             2   6

从图中我们可以观察到整个语句被看作是一个赋值表达式。赋值表达式的左侧是一个数组表达式,右侧是一个乘法表达式。数组表达式由两个符号表达式组成,而符号和数字则是最基本的表达式,它们不由其他表达式组成,通常作为语法树的叶节点存在。在语法分析的过程中,许多运算符的优先级和含义也被确定。例如,乘法表达式的优先级高于加法表达式,圆括号表达式的优先级高于乘法表达式等。此外,一些符号具有多重含义,例如在C语言中,星号 * 可以表示乘法表达式,也可以表示对指针取内容的操作。因此,在语法分析阶段必须对这些符号进行区分。如果出现了不合法的表达式,例如括号不匹配、缺少操作符等,编译器会在语法分析阶段报告错误。就像词法分析器有Lex一样,语法分析器也有一个常用工具称为Yacc(Yet Another Compiler Compiler)。类似于Lex,Yacc可以根据用户提供的语法规则解析输入的记号序列,并构建出相应的语法树。对于不同的编程语言,编译器开发者只需修改语法规则,而无需为每个编译器编写一个独立的语法分析器,因此Yacc也被称为"编译器编译器"。使用Yacc或类似的工具,开发者可以根据语法规则定义语言的语法结构,实现自定义的语法分析器,从而支持编译器的开发和语法分析阶段的错误检测。

2.3 语义分析

接下来是语义分析阶段,由语义分析器(Semantic Analyzer)完成。语法分析仅仅是对表达式进行语法层面的分析,而不了解这个语句是否具有实际意义。例如,在C语言中,两个指针进行乘法运算是没有意义的,但在语法上是合法的;又如一个指针和一个浮点数进行乘法运算是否合法等。因此,编译器需要进行语义分析来判断语句的语义是否正确。编译器能够分析的语义称为静态语义(Static Semantic),即在编译期可以确定的语义。与之相对应的是动态语义(Dynamic Semantic),它只能在运行期确定。静态语义通常涉及声明和类型匹配、类型转换等。例如,当一个浮点表达式赋值给一个整型表达式时,涉及到浮点型到整型的隐式类型转换,语义分析阶段需要完成这个转换过程。又如将一个浮点数赋值给一个指针时,语义分析程序会发现类型不匹配,编译器将报告错误。动态语义一般指与运行时相关的语义问题,例如将0作为除数是一个运行时语义错误。经过语义分析阶段后,整个语法树中的表达式都被标记了类型信息。如果存在需要隐式类型转换的情况,语义分析程序会在语法树中插入相应的转换节点。

可以看到,每个表达式(包括符号和数字)都被标识了类型。我们的例子中几乎所有的表达式都是整型的,所以无须做转换,整个分析过程很顺利。语义分析器还对符号表里的符号类型也做了更新。


2.4 中间语言生成

现代编译器通常包含多个层次的优化,其中源代码级优化器(Source Code Optimizer)是在源代码级别进行的一种优化过程。尽管不同编译器可能对源代码级优化器有不同的定义或存在一些差异,但其基本目标是在源代码级别对代码进行优化。在前面的例子中,大家可能已经注意到,表达式(2+6)的值在编译期就可以确定,因此可以进行优化。类似地,还有许多其他复杂的优化过程可以应用。在这里,我们不会详细描述所有这些优化过程。通过源代码级优化器的优化,(2+6)这个表达式被简化成了常数10,并且被直接赋值给了数组。这种优化可以提高程序的执行效率,并且减少不必要的计算。这只是一个简单的示例,实际的优化过程可能涉及更多的复杂情况和技术。源代码级优化器的作用是在保持程序语义不变的前提下,尽可能地提高程序的性能和效率。它为后续的代码生成阶段提供了更优化的源代码,以便生成高效的目标代码。

<img src="2b84210dd43dc1bb30ceafc409941568.jpg" alt="语义.jpg" style="zoom:50%;" />

我们看到(2+6)这个表达式被优化成8。其实直接在语法树上作优化比较困难,所以源代码优化器往往将整个语法树转换成中间代码(Intermediate Code),它是语法树的顺序表示,其实它已经非常接近目标代码了。但是它一般跟目标机器和运行时环境是无关的,比如它不包含数据的尺寸、变量地址和寄存器的名字等。中间代码有很多种类型,在不同的编译器中有着不同的形式,比较常见的有:三地址码(Three-address Code)和P-代码(P-Code)。我们就拿最常见的三地址码来作为例子,最基本的三地址码是这样的:`x=y op z`

这个三地址码表示将变量y和z进行op操作以后,赋值给x。这里op操作可以是算数运算,比如加减乘除等,也可以是其他任何可以应用到y和z的操作。三地址码也得名于此,因为一个三地址码语句里面有三个变量地址。我们上面的例子中的语法树可以被翻译成三地址码后是这样的:
t1=2+6
t2 =index+4
t3=t2*t1 
array [index]=t3
我们可以看到,为了使所有的操作都符合三地址码形式,这里利用了几个临时变量:t1、 t2和t3。在三地址码的基础上进行优化时,优化程序会将2+6的结果计算出来,得到t1=8。然后将后面代码中的t1替换成数字8。还可以省去一个临时变量t3,因为2可以重复利用。经过优化以后的代码如下:
t2=index+4
t2=t2*8
array [index]=t2

中间代码使得编译器可以被分为前端和后端。编译器前端负责产生机器无关的中间代码,编译器后端将中间代码转换成目标机器代码。这样对于一些可以跨平台的编译器而言,它们可以针对不同的平台使用同一个前端和针对不同机器平台的数个后端

2.5目标代码生成与优化

源代码级优化器产生中间代码标志着下面的过程都属于编辑器后端。编译器后端主要包括代码生成器(Code Generator)和目标代码优化器(Target Code Optimizer)。让我们先来看看代码生成器。**代码生成器将中间代码转换成目标机器代码**,这个过程十分依赖于目标机器,因为不同的机器有着不同的字长、寄存器、整数数据类型和浮点数数据类型等。对于上面例子中的中间代码,代码生成器可能会生成下面的代码序列(我们用x86的汇编语言来表示,并且假设index的类型为int型,array的类型为int型数组):
movl index,%ecx          ;     value of index to ecx
addl $S4,  %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指令完成最后的赋值操作,这条mov指令的寻址方式与lea是一样的。
movl index, %edx        ;    Move the value of index to the edx register
leal 32(,%edx,8), %eax  ;    Calculate the address of the array element and store it in the eax register
movl %eax, array(,%edx,4)  ; Store the value in eax to the memory location array[edx*4]

现代编译器的复杂结构是由于现代高级编程语言本身的复杂性所致。例如,C++语言的定义非常复杂,迄今为止还没有一个编译器能够完全支持C++标准中规定的所有语言特性。此外,现代计算机的CPU也非常复杂,具备流水线、多发射、超标量等复杂特性。为了支持这些特性,编译器的机器指令优化过程也变得十分复杂。编译器需要对代码进行各种优化,以充分利用CPU的特性,提高程序的执行效率。编译过程中的另一个复杂因素是编译器需要支持多种硬件平台。这意味着编译器需要能够生成适用于不同CPU架构的目标代码。例如,著名的GCC编译器几乎支持所有的CPU平台,这导致了编译器的指令生成过程更加复杂。在经历了扫描、语法分析、语义分析、源代码优化、代码生成和目标代码优化等多个步骤后,源代码最终被编译成目标代码。然而,目标代码中存在一个问题,即index和array的地址还没有确定。如果index和array的定义与上面的源代码在同一个编译单元中,编译器可以为它们分配空间并确定它们的地址。但是,如果它们是在其他程序模块中定义的呢?这个问题引出了链接的概念。当目标代码中的变量在其他模块中定义时,其地址需要在最终链接阶段确定。现代编译器可以将一个源代码文件编译成一个未链接的目标文件,然后由链接器将这些目标文件链接在一起,形成可执行文件。因此,链接器的任务是解决变量和函数在最终运行时的绝对地址问题。它将不同模块中的目标代码组合在一起,解析符号引用,为变量和函数分配最终的地址,并创建可执行文件。因此,链接是编译过程的最后一步,它解决了目标代码中变量定义在其他模块中的地址问题,确保程序能够正确地执行。


参考文献《程序员的自我修养》俞甲子