编写 GCC 前端
GCC,首屈一指的自由软件编译器套件,在过去几年中经历了许多变化。其中一个特别的变化是 tree-ssa 分支的合并,这使得编写新的 GCC 前端变得更加简单。
GCC 始终有两种不同的内部表示形式:树(trees)和 RTL。RTL,即寄存器传输语言,是 GCC 在生成机器代码时使用的低级表示形式。传统上,所有优化都在 RTL 中完成。树是一种更高级别的表示形式;传统上,它们不如 RTL 那样文档完善且广为人知。
tree-ssa 项目,由 Diego Novillo 牵头对 GCC 内部结构进行的长期改造,改变了这一切。现在,树变得更好了,尽管文档仍然不够完善,并且许多优化都在树级别完成。这项关于树的工作的一个副作用是明确指定了一种名为 GENERIC 的基于树的语言。所有 GCC 前端都生成 GENERIC,然后将其降级为另一种名为 GIMPLE 的基于树的表示形式,并从那里转到 RTL。
这对您来说意味着,为 GCC 编写新的前端要简单得多。事实上,现在编写一个 GCC 前端,而无需任何 RTL 知识是可行的。本文概述了如何将您自己的编译器前端连接到 GCC。本文中的信息特定于 GCC 4.0,该版本计划于 2005 年发布。
就我们的目的而言,编译分两个阶段完成:解析和语义分析,然后是代码生成。GCC 为您处理第二个阶段,因此问题是,实现第一个阶段的最佳方法是什么?
传统的 GCC 前端,例如 C 和 C++ 前端,在解析期间生成树。这些前端通常为特定于语言的构造添加自己的树代码。然后,在语义分析完成后,通过用更低级别的等价物替换高级的、特定于语言的树,将这些树降级为 GENERIC。这种方法的一个优点是,特定于语言的树通常已经接近 GENERIC。降级阶段通常可以防止生成过多的垃圾。
这种方法的主要缺点是树是动态类型的。理论上,这似乎并没有那么糟糕——存在许多动态类型的环境,开发人员可以有效地使用它们,包括 Lisp 和 Python。然而,这些是完整的环境,而 GCC 高度宏化的 C 代码并没有带来相同的优势。
我编写前端的首选方法是拥有一个强类型的、特定于语言的程序表示形式,称为抽象语法树 (AST)。Ada 前端和 gcjx(Java 编程语言前端的重写版本)都使用了这种方法。
例如,gcjx 是用 C++ 编写的,并且具有一个类层次结构,该层次结构对 Java 编程语言的元素进行建模。这段代码实际上独立于 GCC,可以用于其他目的。在 gcjx 的情况下,该模型可以降级为 GENERIC,但也可以用于生成字节码或 JNI 头文件。此外,它还可以用于各种类型的代码内省;实际上,前端是一个可重用的库。
这种方法提供了强类型设计的所有常见优势,并且在 GCC 环境中,它使程序更容易理解和调试。由此产生的前端相对于 GCC 其余部分的相对独立性也是一个优势,因为 GCC 变化迅速,这种松耦合最大限度地减少了您的风险。
这种方法的潜在缺点是您的编译器可能会做更多不严格需要的工作,或者使用更多内存。实际上,这似乎不太重要。
在我们讨论将您的前端与 GCC 连接的一些细节之前,让我们先看一下您需要了解的一些文档和源文件。由于使编写前端更简单并不是 GCC 社区的优先事项,因此您需要了解的一些内容仅在源代码中记录。这里的文档引用指的是 info 页面而不是 URL,因为 GCC 4.0 尚未发布。因此,网页反映了早期版本。您最好的选择是从 CVS 检出 GCC 的副本,并在源代码中挖掘。
gcc/c.opt:描述 C 系列前端使用的命令行选项。更重要的是,它描述了 .opt 文件的格式。您将编写其中一个。
gcc info 页面,节点 Spec Files(源文件 gcc/doc/invoke.texi):描述 GCC 驱动程序使用的 spec 小语言。您将编写一些 spec 来告诉 GCC 如何调用您的前端。
gccint info 页面,节点 Front End(源文件 gcc/doc/sourcebuild.texi):描述如何将您的前端集成到 GCC 构建过程中。
gccint info 页面,节点 Tree SSA(源文件 gcc/doc/tree-ssa.texi):描述 GENERIC。
gcc/tree.def, gcc/tree.h:树的一些属性似乎没有文档记录,阅读这些文件可以提供帮助。tree.def 定义了所有树代码,并且在很大程度上是解释性注释。tree.h 定义了树节点结构、许多访问器宏,并声明了在构建各种类型的树时有用的函数。
libcpp/include/line-map.h:行映射用于表示 GCC 中的源代码位置。您可能在前端中使用它们,也可能不使用它们——gcjx 没有使用。即使您不使用它们,在降级到 GENERIC 时也需要构建它们,因为行映射中的信息在生成调试信息时使用。
gcc/errors.h, gcc/diagnostic.h:定义了 GCC 错误格式化函数的接口,您可以选择使用它们。
gcc/gdbinit.in:定义了一些在调试 GCC 时很方便的 GDB 命令。例如,pt命令打印树的文本表示形式。文件 .gdbinit 也在 GCC 构建目录中创建;如果您在那里调试,宏会立即可用。
gcc/langhooks.h:lang hooks 是 GCC 使用的一种机制,允许前端控制 GCC 行为的某些方面。每个前端都必须定义自己的 langhooks 结构副本;这些结构主要由函数指针组成。GCC 的中间端和后端调用这些函数,以便在编译期间做出特定于语言的决策。langhooks 结构会不时发生变化,但由于 GCC 期望前端初始化这些结构的方式,您在很大程度上与源代码级别的这些变化隔离。其中一些 lang hooks 是非可选的,因此您的前端将要实现它们。其他的是针对特定问题的临时添加。例如,can_use_bit_fields_p hook 的引入仅仅是为了解决当前 gcj 前端的优化问题。
目前,GCC 要求您的前端在构建时可见——没有办法编写一个单独构建并链接到已安装的 GCC 的前端。对于此步骤,请通读 GCC 手册的相应部分,以了解如何编写前端所需的构建基础设施。通常,最简单的方法是复制另一个前端的文件并修改它们以适合。
接下来,编写两个文件以帮助将您的前端集成到 GCC 驱动程序中。lang-specs.h 文件向 GCC 驱动程序描述您的前端。它告诉驱动程序,当在命令行上看到哪些文件扩展名时,应导致 GCC 调用您的前端。它还为驱动程序提供了一些关于必须运行哪些其他程序的指令,例如是否应在您的前端之后运行汇编器,以及如何传递或修改某些命令行选项。编写此文件可能需要一段时间,因为 specs 是它们自己奇怪的语言。但是,其他前端的示例可以提供帮助。
lang.opt 文件描述了特定于您的前端的任何命令行选项。这是一个以直接格式编写的纯文本文件。简单的选项,例如警告标志,可以放在 lang.opt 中,并且不需要您方面的任何其他代码。其他参数必须由您必须编写的 lang hook 处理。
接下来,实现驱动编译过程所需的 lang hooks。此类别中重要的有:
init_options:对您的前端的第一个调用,在完成任何选项处理之前。
handle_option:调用以处理单个命令行选项。
post_options:在完成所有命令行处理后调用。此 lang hook 也是确定要解析的输入文件名称的便捷位置。
init:在 post_options 之后调用,以初始化您的前端。
finish:在完成所有编译后调用。如有必要,您可以使用它来清理您的前端。
parse_file:一个 lang hook,它执行输入文件所需的所有解析、语义分析和代码生成。它完成编译的所有实际工作。
GCC 需要您的前端进行一些初始化。GCC 的大部分内容是自初始化的,但为了适应不同前端的需求,可以以非典型的方式初始化一些与树相关的全局变量。我建议不要试图深入研究这一点。以标准方式定义标准树节点,并为表示语言中标准类型的树想出您自己的名称,这样更简单。
在初始化期间,您需要调用 build_common_tree_nodes、set_sizetype 和 build_common_tree_nodes_2。set_sizetype 用于设置 size_t 内部等价物的类型;始终将其设置为 long_unsigned_type_node 是最简单的。
其他设置步骤可以在此阶段完成。例如,在 gcjx 的初始化代码中,我们构建表示各种结构的类型,我们需要这些结构来描述 Java 类和方法。
您的 parse_file lang hook 调用您的编译器来生成您的内部数据结构。假设这在没有错误的情况下完成,您的前端现在已准备好从您的 AST 生成 GENERIC 树。在 gcjx 中,这是通过使用特殊的访问者 API 遍历类的 AST 来完成的。此 API 的 GENERIC 特定实现增量构建表示代码的树,然后将其交给 GCC。
生成树的所有细节都超出了本文的范围。但是,下面是一些示例,显示了三种主要的树类型,以便您可以了解每种树类型的样子。
一种树表示类型。以下是 gcjx 中 Java char 类型的示例
tree type_jchar = make_node (CHAR_TYPE); TYPE_PRECISION (type_jchar) = 16; fixup_unsigned_type (type_jchar);
您可以使用树表示任何类型。特别是,有树类型表示记录、联合、指针和各种大小的整数。
Decl 表示声明,或者换句话说,是赋予某些对象的名称。例如,源代码中的局部变量由 decl 表示
tree local = build_decl (VAR_DECL, get_identifier ("variable_name"), type_jchar);
有 decls 表示程序中各种命名的对象:翻译单元、函数、字段、变量、参数、常量、标签和类型。类型 decl 表示类型的声明,而不是类型本身。
有许多种 expr 树可用于表示程序中各种类型的表达式。这些树类似于 C 表达式,但在某些方面更通用。例如,树不区分 if 语句和条件表达式——两者都由 COND_EXPR 表示,唯一的区别是 if 语句具有 void 类型。以下是如何构建一个将我们的变量添加到自身的表达式
tree addition = build2 (PLUS_EXPR, type_jchar, local, local);
表示语句的树使用特殊的迭代器 API 链接在一起。以下是如何将两个语句 s1 和 s2 链接在一起
tree result = alloc_stmt_list (); tree_stmt_iterator out = tsi_start (result); tsi_link_after (&out, s1, TSI_CONTINUE_LINKING); tsi_link_after (&out, s2, TSI_CONTINUE_LINKING); // Now `result' holds the list of statements.
还存在其他类型的树节点;阅读 tree.def 和手册以获得更全面的了解。前端也可以定义自己的树代码;但是,如果您有自己的 AST,则不需要这样做。
您生成的程序的总体结构可能类似于翻译单元 decl,其中将包含类型、变量和函数。
一旦您构建了表示函数、全局变量或要为其生成调试信息的类型的树,您需要将树传递给适当的函数以处理编译的其余部分。目前有三个这样的函数可用:rest_of_decl_compilation 处理 decl 节点的编译,cgraph_finalize_function 处理函数的编译,rest_of_type_compilation 处理类型的编译。
尽管 GCC 有相当多的内部一致性检查,但仍然很容易在与您的前端无关的代码中引发崩溃。在许多情况下,您可以向上移动堆栈,打印正在操作的任何树,直到找到由不正确的树生成引起的某些差异。为了有效地调试您的代码,此技术只需要令人惊讶的少量通用 GCC 知识。
GCC 具有一些方便的调试功能。在调试器中,您可以调用 debug_tree 函数来打印树。您还可以使用 -fdump-tree 系列命令行选项在各种 pass 运行后打印树。
我编写 gcjx 的经验是,将其强类型中间表示降级为树非常简单。gcjx 的树后端(多个后端之一)约占编译器总代码的 10%。虽然尚未完成,但目前的代码量为 6,000 行(原始wc -l计数)——与字节码后端的大小大致相同。由此可以得出的一个推论是,如果您已经有一个编译器,那么将其附加到 GCC 的任务可以轻松完成。
由于树是高级的,因此我在编写此前端时没有查看任何 RTL。我根本没有花任何时间思考或处理特定于处理器的问题。除非您的语言有一些深奥的要求,否则这种情况也应该适用于您。
gcjx 中的静态类型 AST 很容易重用。目前,有四个后端,我希望以后编写更多。例如,构建一个将程序的交叉引用表示写入数据库的后端将很简单。或者,可以编写一个后端来遍历 AST 以搜索典型错误,类似于 FindBugs 程序。对于那些与 Java 不同,尚未拥有大量分析工具的语言来说,这种考虑将更具说服力。
编写前端的过程肯定可以变得更简单。例如,没有必要要求 lang-specs.h。相反,前端可以安装一个描述文件,GCC 驱动程序会在启动时读取该文件。类似地,lang.opt 也可能被取消。通过更多的工作,甚至可以从 GCC 的其余部分单独编译前端。
本文的资源: /article/8138。
Tom Tromey 自 1991 年以来一直参与自由软件,并参与了许多不同的程序。他目前在 Red Hat 担任工程师,从事 GCJ 工作。