内容摘要 本文将以 C 语言为例,介绍 gcc 在接受一个 .c文件的输入以后,其前端是如何进行处理并获得一个中间表示并转交给后端处理。而后,在了解了 gcc[1] 的工做流程后,介绍一下做者尝试在 gcc内部的 RTL 表示层中 hack gcc 的过程,与你们分享一些经验,但愿能给对有兴趣研究和开发 gcc 的读者有所帮助。html
1. GCC 简介
编译器的工做是将源代码(一般使用高级语言编写)翻译成目标代码(一般是低级的目标代码或者机器语言),在现代编译器的实现中,这个工做通常是分为两个阶段来实现的:前端
第一阶段,编译器的前端接受输入的源代码,通过词法、语法和语义分析等等获得源程序的某种中间表示方式。linux
第二阶段,编译器的后端将前端处理生成的中间表示方式进行一些优化,并最终生成在目标机器上可运行的代码。程序员
GCC(GNU Compiler Collection) 是在 UNIX 以及类 UNIX 平台上普遍使用的编译器集合,它可以支持多种语言前端,包括 C, C++, Objective-C, Ada, Fortran, Java 和 treelang 等。编程
GCC 设计中有两个重要的目标,其中一个是在构建支持不一样硬件平台的编译器时,它的代码可以最大程度的被复用,因此 GCC必需要作到必定程度的硬件无关性;另外一个是要生成高质量的可执行代码,这就须要对代码进行集中的优化。为了实现这两个目标,GCC内部使用了一种硬件平台无关的语言,它能对实际的体系结构作一种抽象,这个中间语言就是 RTL(Register TransferLanguage)。后端
虽然关于 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文件是由GNUbison对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 #2 #3 #4 #5 #6 #7 #8 #9 #10 #11 #12 #13 #14 #15 |
main toplev_main do_compile compile_file c_common_parse_file c_parse_file yyparse finish_function cgraph_finalize_function cgraph_assemble_pending_functions cgraph_expand_function c_expand_body c_expand_body_1 tree_rest_of_compilation rest_of_compilation |
main.c toplev.c toplev.c toplev.c c-opts.c c-parse.y c-parse.y c-decl.c cgraphunit.c cgraphunit.c cgraphunit.c c-decl.c c-decl.c tree-optimize.c toplev.c |
表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)); |
这行代码的注释说这个钩子函数用来生成一个被编译函数的RTL表示,接下来还调用了几个函数来进行RTL生成阶段的最后处理(包括调用gcc编译时内部使用的垃圾收集函数),而后就调用了rest_of_compilation了。前面已经提到了,rest_of_compilation的做用是对RTL表示作优化而且生成汇编代码输出,至此咱们能够作出这样的推断:在tree_rest_of_compilation调用了一系列生成RTL表示的函数以后,到调用rest_of_compilation以前,gcc的内部保存了一个原始的、未优化的RTL中间表示。若是咱们但愿对函数的RTL表示作一些修改,在这里插入代码作改动应该是一个不错的选择。
到这里,咱们所关心的gcc编译流程基本已经结束了,也搞清了RTL表示在什么地方生成的,咱们应该有必定的信心在RTL表示层上对gcc进行hack了。
3. RTL简介
咱们的目标是在RTL表示层上hack gcc,因此有必要对RTL作一些介绍。在gcc internals中有专门的一章描述RTL,若是对RTL没有任何了解,那么它很值得您一看;同时,在理解和插入RTL语句的时候,这份文档也能够做为比较详尽的手册来参照。
在gcc的编译过程当中,有三次比较重要的转换:
RTL是gcc内部使用的中间表示语言,为了对其有一个直观点的印象,咱们能够把它dump出来看一看。使用
$ gcc -dr test.c |
就能够获得test.c的RTL表示,文件名通常为test.c.00.rtl。
RTL的设计听说是从LISP语言获得了灵感,因此咱们dump出来的.rtl文件看起来也像是一个LISP程序,每条RTL语句都是用来描述须要输出的指令的,能够对照咱们dump出的.rtl文件以及上面提到的文档来深刻学习RTL。但咱们的要求不只如此,咱们须要插入本身的RTL语句来hackcc,必须阅读gcc源代码提供的RTL操做的接口,这个过程比较繁琐并且没有文档能够参考,惟一有帮助的就是已有的在RTL表示层上对gcc作的补丁,以吸收其余gcc hackers的经验,做者在尝试本身的补丁时曾经参考过StackGuard[8] 的代码,另外能够在gcc的maillist上看到有些hacker提供的patch,这些已有的工做对于gcc hacker newbie来讲是颇有裨益的。
仅仅这么多文字来介绍RTL还远远不够,可是若是但愿把RTL描述得十分清楚,那应该由另一篇文章来完成了,本文就再也不详述了。
4. Let's hack gcc!
下面进入hackgcc的实战阶段了,先说一下个人目的:我但愿使用修改过的gcc编译程序的时候,可以在每一个函数的开始和结束的地方插入一个函数调用语句,也就是说,在每一个函数的第一条指令以前,由编译器强制插入一个函数调用,在函数最后一条指令结束以后,也要插入一个函数调用。下面用两段C语言代码来表达这个补丁的效果:
int foo() { first statement; … … … last statement; } |
int foo() { my_function_begin; first statement; … last statement; my_function_end; } |
左边一列是程序员正常编写的普通函数,我但愿使用修改过的gcc编译该函数后,可以获得至关于编译右边这段函数的结果,就是对程序员透明地在每一个函数的第一条语句以前和最后一条语句以后自动插入两个函数调用:my_function_begin和my_function_end。固然,这两个函数具体实现什么功能能够由程序员来编写,最简单的实现能够仅仅在标准输出上分别打印一句话表示该函数确实被调用了便可。
gcc中生成抽象语法树表示和RTL表示都是以一个完整的函数定义或者toplevel的声明为单位的,这也就意味着在tree_rest_of_compilation这个函数调用了一系列用于生成RTL表示的函数以后,咱们所获得的只是当前正在被编译的函数的RTL表示,而并非整个源程序的RTL表示,这正好方便咱们以函数为单位来进行修改。
咱们在tree_rest_of_compilation函数中调用rest_of_compilation以前插入一条语句,调用一个新函数modify_rtl来对gcc生成的RTL表示作一些处理。函数modify_rtl的定义放在function.c文件中,这是由于gcc在生成RTL表示时须要的相关函数大部分都定义在这个文件中,咱们的补丁也能够看做是gcc生成RTL表示的一部分工做,因此把modify_rtl放到这个文件中定义是最合适的。
接下来工做的关键就集中到如何定义modify_rtl函数了。如今咱们获得了当前编译函数的RTL表示,咱们能够对这个RTL单元进行扫描,找到合适的位置分别调用my_function_begin和my_function_end函数便可。函数的RTL表示是一个双向链接的链表结构,其中每一个节点称为一个insn[9] ,有的insn可能表示一条真实的汇编指令,有的则表示jump指令跳转的标签或者其它各类声明信息。为了简便起见,这里直接给出一个经常使用的gcc所提供的访问insn的宏和函数列表,并给出它们的功能:
宏(函数)名 | 功能 |
INSN_UID(insn) | 获取该insn的id |
PREV_INSN(insn) | 获取insn链表中该insn的前一个insn |
NEXT_INSN(insn) | 获取insn链表中该insn的后一个insn |
GET_CODE(insn) | 获取该insn的code |
NOTE_LINE_NUMBER(insn) | 若是insn的code是NOTE,则返回该insn对应源代码的行号,不然返回一个负数 |
Get_insns() | 获取当前函数RTL表示的第一个insn |
Get_last_insn() | 返回当前函数RTL表示的最后一个insn |
表2. 部分gcc提供的insn操做接口列表
一个函数完整的、未被优化的RTL表示中会有两个noteinsn表示函数的开始和结束,gcc定义了两个全局变量NOTE_INSN_FUNCTION_BEGIN和NOTE_INSN_FUNCTION_END来表示这两个note insn的行数。这样咱们就能够扫描当前RTL单元,当碰到这两个noteinsn的时候,就能够插入相应的函数调用语句了。
gcc提供了emit_library_call函数来插入一个函数调用,这个函数返回的是一个表示函数调用的RTL表达式,并默认地把这个RTL表达式插入到当前RTL单元的最后一个insn以后。因此若是直接调用emit_library_call,就会把函数调用语句插入到RTL单元最后一个insn以后,而不是咱们所但愿的函数开始和结束的地方,咱们可使用start_sequence和end_sequence函数,它们产生一个相对独立的sequence并把函数调用语句保存到一个RTL表达式中以备后用。
咱们已经找到插入函数调用的点,而且也生成了表示函数调用的RTL语句,如今就可使用gcc提供的emit_insn_before和emit_insn_after函数来插入RTL语句了。
到这里,modify_rtl函数的实现基本已经成型了,下面这段示例代码就能够完成在每一个函数的开始处插入RTL语句的功能:
int modify_rtl() { rtx insn; rtx seq; //emit my_function_begin at the beginnig of each function start_sequence(); emit_libarary_call(gen_rtx(SYMBOL_REF, Pmode, my_function_begin), 0, VOIDmode, 0); seq = get_insns(); end_sequence(); for(insn = get_insns(); ; insn = NEXT_INSN(insn)) if((GET_CODE(insn) == NOTE) && (NOTE_LINE_NUMBER(insn) == NOTE_INSN_FUNCTION_BEGIN)) break; emit_insn_after(seq, insn); … } |
这段代码中所使用数据结构、函数的具体功能和用法,属于十分细节的内容,无须在这里描述清楚,请读者参考gcc源代码。
对于在函数结束的地方插入my_function_end函数一样如此,咱们能够用get_last_insn获得RTL单元的最后一个insn,而后使用PREV_INSN(insn)开始向前扫描,遇到行号为NOTE_INSN_FUNCTION_END的noteinsn时,用emit_insn_before把相应的函数调用RTL表达式插入到这个insn以前便可。
如今这个patch的基本功能已经完成了,咱们还能够再作一些工做使得它功能更强大和实用一些,好比加入一个编译选项(好比-finsert-function)来指定是否启用这个patch的,当编译的命令行参数中没有提供这个编译选项时,咱们所做的补丁就不起做用。关于如何增长编译选项,咱们能够参考opts.c中的decode-options函数,在此就不详细分析了。
在modify_rtl中调用current_function_name函数能够获得当前正在被编译的函数名,咱们能够把这些函数名写到一个文件中去,这样能够记录咱们对哪些函数作了修改;还能够实现一个过滤器,在启用了patch的状况下,对于指定的函数,咱们还能够将其过滤掉,不对其作处理,这些功能也是很容易实现的。
咱们还能够再实现一些功能,好比在扫描RTL的时候,若是发现一条call_insn,能够把这条call指令所调用的函数名记录下来,这样咱们甚至能够获得一个程序运行时刻的动态的函数调用关系图,这就能够描绘程序的实际运行轨迹。
最后,还须要把my_function_begin和my_function_end两个函数实现一下,能够把它们的功能扩展一下,不是仅仅输出一条语句到标准输出,而是记录一些信息到文件中,这样就能够获得一个以函数为粒度的运行时刻日志,甚至可使这两个函数与linux内核联系起来,作一些特殊的检查工做等等,这样就使得咱们的patch有一些实用性了。这两个函数咱们能够在mylib.c中实现,编译成一个sharedobject,使用以下命令编译:
$ gcc mylib.c -c -fPIC $ gcc mylib.o -shared -o libmylib.so |
把libmylib.so放到/usr/lib目录下,那么在编译的时候只需加上-lmylib参数就可使用这个shared object中的函数了。
剩下的工做就是进行调试和测试了,当咱们解决了各类问题,使这个修改过的编译器可以完美的运行起来的时候,也许咱们就能体会到gcc hacker的那种成就感和喜悦之情了。
5. 经验总结
先说一下我本身尝试的结果,我是基于gcc version3.4.0工做的,给gcc加入了一个编译选项以选择是否启用添加的补丁,能够在每一个函数的开始和结束的时候插入函数调用,也能够在函数调用以前和返回以后插入函数调用,实现了一个过滤器,能够忽略一些函数不对其作处理,而且能够在运行时将一些信息记录到文件中去留待分析。这个补丁的功能基本上就是这些了,实现方法可能和本文中的方法有所不一样,文中描述的方法是较早的时候我采用的方法,如今则进行了一些改动,这里就不详加介绍了。我已经成功的使用“个人”gcc编译了emacs和lynx等实用软件,运行正常,补丁功能也正常,能够说是取得了一个小小的成功。可是我没有空间能够上载个人补丁,有兴趣的读者能够经过e-mail向我索取。
最后谈谈个人经验:
在理解gcc的编译流程以及试图找到作补丁的思路的时候,须要多阅读文档,包括学习已有的工做是怎么作的。不要贸然尝试,不要奢望能够凭运气达成目的,尽可能找到最合适的实现方法,在确立了一个基本思路以后,能够在gcc的maillist上咨询一下,看看有没有人提供更好的思路,在确信本身思路的可行性以后再开始具体的工做。
在作具体实现的时候,确定会遇到各类各样的问题,好比在编译本身修改过的gcc时会出错,或者用patch过的gcc编译程序时出错,或者是编译经过运行时刻出错等等,这时候须要耐心地检查代码和进行debug,尽可能本身解决问题,不要把一些特别细节地问题拿到maillist上讨论。我记得在maillist上曾经有人严厉地告诫我:“you won't go very far if you ask a question eachtime you get anerror”,本身debug才是解决问题的最好方法,固然若是实在不明白的问题必须拿到maillist上去讨论,这时候要尽可能详细的描述本身的目的和问题,才可以获得有效的帮助。
好了,这就是我本身学习和尝试hack gcc的工做过程,但愿个人一些经验可以给您帮助,若是对本文中的观点有疑问或者在学习gcc的时候碰到困难,欢迎与我探讨。