1. GCC 简介
编译器的工作是将源代码(通常使用高级语言编写)翻译成目标代码(通常是低级的目标代码或者机器语言),在现代编译器的实现中,这个工作一般是分为两个阶段来实现的:
第一阶段,编译器的前端接受输入的源代码,经过词法、语法和语义分析等等得到源程序的某种中间表示方式。
第二阶段,编译器的后端将前端处理生成的中间表示方式进行一些优化,并最终生成在目标机器上可运行的代码。
GCC(GNU Compiler Collection) 是在 UNIX 以及类 UNIX 平台上广泛使用的编译器集合,它能够支持多种语言前端,包括 C, C++, Objective-C, Ada, Fortran, Java 和 treelang 等。
GCC 设计中有两个重要的目标,其中一个是在构建支持不同硬件平台的编译器时,它的代码能够最大程度的被复用,所以 GCC 必须要做到一定程度的硬件无关性;另一个是要生成高质量的可执行代码,这就需要对代码进行集中的优化。为了实现这两个目标,GCC 内部使用了一种硬件平台无关的语言,它能对实际的体系结构做一种抽象,这个中间语言就是 RTL(Register Transfer Language)。
虽然关于 GCC 的研究和开发工作侧重于 GCC 后端代码优化方面,但本文中我们关注的目标是在 GCC 的编译过程中前端是如何工作的。
把 GCC 的前端独立出来研究目的在于,在设计新的编译器的时候,我们仅仅需要关注如何设计新编译器的前端,而将代码优化和目标代码的生成留给 GCC 后端去完成,避免了后端设计的重复性劳动。
本文将以 C 语言为例,介绍 gcc[2] 在接受一个 .c 文件的输入之后,其前端是如何进行处理并得到一个中间表示并转交给后端处理。然后,在了解了 gcc 的工作流程后,介绍一下作者尝试在 gcc 内部的RTL表示层中 hack gcc 的过程,与大家分享一些经验,希望能给对有兴趣研究和开发 gcc 的读者有所帮助。
2. gcc 的工作流程
gcc 是一个驱动程序,它接受并解释命令行参数,根据对命令行参数分析的结果决定下一步动作,gcc 提供了多种选项以达到控制 gcc 编译过程的目的,我们可以在 GCC 的手册中查找这些编译选项的详细信息。
gcc 的使用是比较简单的,但是要深入到其内部去了解编译流程,情况就比较复杂了。面对庞大的[3] gcc,我们只能选择感兴趣的部分来分析。但我们无法获得关于 gcc 编译流程的详尽文档[4] ,这主要是由于 gcc 本身过于繁杂,而且它处于不断的变化当中,所以我们只有通过其它途径来了解 gcc。有两个比较好的方法:一是阅读 source,对感兴趣的函数可以跟踪过去看一看,阅读代码看起来可怕,但其实代码中会有很多注释说明它的功能,使得我们的阅读变得更简单一些,这种方法便于从整体上把握 gcc;另外一个是 debug gcc,就是使用调试器来跟踪 gcc 的编译过程,这样可以看清 gcc 编译的实际流程,也可以追踪我们感兴趣的细节部分。我们先从大处着眼,从 source 中看看 gcc 一些比较重要的函数以及它们之间的调用关系,然后在 hack gcc 的时候,对 gcc 进行 debug 来追踪我们关心的细节,并且可以通过调试来发现和修改 patch 中的错误。
在开始阅读 gcc 的代码之前,推荐您阅读一下 GCC internals 中 passes and files of the compiler 一章――如果您以前没有看过的话,这段内容会帮助您对 gcc 的结构建立一个大概的映像。
好了,我们以 gcc 中的函数为单位,希望能够尽量详细地描述 gcc 中自顶向下的函数调用关系。在 gcc 源码目录中,很容易就发现了一个文件 main.c,应该是 gcc 的入口了,这个main.c 文件中只有一个函数 main,而这个 main 函数中也只有一条语句,调用了一下toplev_main 函数。之所以单独用一个 main 函数来调用 toplev_main,是为了让不同的语言前端可以方便设计不同的 main 函数。
toplev_main 函数是在 toplev.c 文件中定义的,从名字中就可以看出这个文件应该是用来控制 gcc 最顶层的编译流程的,在程序开始的注释中也说明了它是用来处理命令行参数、打开文件、以合适的顺序调用各个分析程序 [5] 并记录它们各自所用的处理时间。toplev_main 首先对 gcc 做了一下初始化,主要是设置环境变量和诊断信息等等,然后就开始解析命令行参数,我们对这些并不感兴趣,重要的是接下来调用了 do_compile 函数,这个函数看从名字看就是做编译工作的,而在此之后 toplev_main 函数就返回了。
do_compile 函数也是在 tolev.c 中定义的,它调用了一些函数来做进一步的初始化,比如对编译过程中计时器的初始化、针对特定程序设计语言的初始化以及对后端的初始化等等,同时它还对 toplev_main 函数中解析的命令行参数做了进一步处理。在完成了上述工作后,调用了 compile_file() 函数,这个函数应该是用来进行真正的编译工作了。
compile_file 函数还是在 toplev.c 中定义的,这里提一下 compile_file 函数和上面的do_compile 函数,它们是参数和返回类型都为 void 的函数,在编译的时候需要的各种参数包括编译的文件名、编译参数以及 gcc 内部使用的一些钩子函数等等都是采用全局变量来表示的,当然,这些全局变量在前面各种初始化函数中都已经被适当地初始化了。接着说compile_file 函数,它又做了一些我们并不太关心的初始化工作,之后,它终于调用了一个钩子函数来分析(parse)整个输入文件了:
(*lang_hooks.parse_file)(set_yydebug);
这里的 lang_hooks 是一个全局变量,不同语言的前端对此赋以不同的值,以便调用各自特有的分析程序,关于 lang_hooks 结构的定义和初始化等等可以参见源码中的 langhooks.h、langhooks.c 和 langhooks-def.h 等文件,这里就不详细追究了。对于 C 语言来说,这条语句相当于调用了 c-opts.c 中的 c_common_parse_file 函数。
c_common_parse_file中调用了c-parse.c中的c_parse_file函数,在此函数中又调用了同样位于c-parse.c中的yyparse函数。有必要介绍一下c-parse.c文件,它是由GNU bison [6] 从c-parse.y中得到的一个语法解析器。c-parse.y则是一个YACC文件,它使用BNF(Backus Naur Form)来描述了某种程序设计语言的语法。 [7]
至此,我们对gcc中主要的函数调用关系还是相当清楚的,从main函数层层深入,进入了c-parse.c中的yyparse函数。前面提到过c-parse.c文件是由GNU bison对c-parse.y这个YACC文件作用后自动生成的,这导致这段代码阅读起来比较困难,因为bison生成的c-parse.c文件中有很多条goto语句以及超过500个case的switch语句,如此多的选择和跳转语句无疑给追踪gcc的函数调用带来了极大的困难,我们不可能再继续下去了。
再回过头去看看前面那些代码和注释以及一些文档,注意到多次提到过一个函数??rest_of_compilation,这似乎是一个很重要的函数,我们可以过去看看。
在toplev.c中我们找到了这个函数,注释中说明它的作用是:在对程序中顶层的函数定义或者变量的定义处理以后,接着对这些函数或者变量进行编译并输出相应的汇编代码,在此函数返回后,gcc内部使用的tree结构就消亡了。看来这个函数的功能比较复杂,它已经把源程序对应的汇编代码生成了,并且把对应的tree结构占用的空间已经释放了,而我们所感兴趣的部分是gcc编译过程中内部使用RTL表示的情况,这部分处理应该是在rest_of_compilation这个函数返回之前做的。
前面我们从main函数跟踪到了yyparse函数,这里又发现了一个很重要的rest_of_compilation函数,但中间这段过程gcc做了些什么我们还不清楚,也许我们所关心的有关RTL的处理就在其中。
现在我们只有对gcc进行调试才能确切的看清进入yyparse后函数调用的情况了,这里介绍一下调试gcc的方法:
对gcc进行调试,其实是对编译gcc源代码所得到的cc1程序调试,进入到cc1所在的目录,运行命令:
$ gdb cc1
$ break main
$ run -dr /PATH/test.c
这样就是以-dr为编译参数运行gcc来编译test.c文件了,并且在main函数的入口处设置了一个断点,-dr作为编译参数就是要求在RTL表示生成以后将其dump到一个以.rtl结尾的文件中去。接下来在rest_of_compilation之前再设置一个断点,并用continue命令运行到该断点,用backtrace命令查看此时函数栈帧的情况:
$ break rest_of_compilation
$ continue
$ backtrace
下表1给出了使用gdb调试时显示出的从main到rest_of_compilation的函数调用情况:
表1. 部分函数调用栈帧列表
调试的结果证实我们前面的分析是正确的,从main函数到yyparse函数的调用顺序与我们阅读代码时所分析得到的结果是吻合的。现在我们得到了gcc编译时从yypare到rest_of_compilation之间的一系列函数调用,这些都是值得关注的目标,让我们返回到源码中去看看这些函数的功能。
时刻记得我们的目标:对于gcc如何生成tree结构我们并不关心,也不关心gcc是如何由中间表示层RTL生成汇编代码的,我们感兴趣的是RTL表示是如何生成的,并希望在RTL表示层做一些修改,以达到我们的目的。为了省去一些篇幅,本文中略去了对那些我们不太关心的函数的分析,直接跳转到RTL生成和处理相关的部分。
终于,在tree-optimize.c中的tree_rest_of_compilation中,我们发现了一系列看起来是与RTL生成有关的函数调用,特别引起我们注意的又是一个钩子函数:
(*lang_hooks.rtl_expand.stmt) (DECL_SAVED_TREE (fndecl));
这行代码