如前一章所学,编译器通常分为前端和后端两部分。在本章中,我们将实现一种编程语言的前端,即主要处理源语言的部分。我们将学习现实世界中编译器使用的技术,并将其应用于我们的编程语言。
我们的旅程将从定义我们的编程语言的语法开始,结束于抽象语法树(AST),它将成为代码生成的基础。你可以将这种方法应用于你想要实现编译器的任何编程语言。
在本章中,你将学到以下内容:
- 定义一个真实的编程语言,你将了解
tinylang
语言,这是一个真实编程语言的子集,你将为其实现编译器前端 - 组织编译器项目的目录结构
- 知道如何处理编译器的多个输入文件
- 处理用户消息的技能,并以愉快的方式告知他们问题
- 使用模块化部件构建词法分析器
- 根据从语法中派生的规则构建递归下降解析器,进行语法分析
- 通过创建AST并分析其特性来进行语义分析
通过本章获得的技能,你将能够为任何编程语言构建编译器前端。
定义一个真实的编程语言
与前一章的简单计算语言相比,真实的编程带来了更多的挑战。为了深入了解细节,我们将在本章及后续章节中使用Modula-2的一个小子集。Modula-2设计精良,可选择支持泛型和面向对象编程(OOP)。然而,我们不打算在本书中创建一个完整的Modula-2编译器。因此,我们将这个子集命名为tinylang
。
让我们从一个tinylang
程序的例子开始。以下函数使用欧几里得算法计算最大公约数:
MODULE Gcd;
PROCEDURE GCD(a, b: INTEGER) : INTEGER;
VAR t: INTEGER;
BEGIN
IF b = 0 THEN
RETURN a;
END;
WHILE b # 0 DO
t := a MOD b;
a := b;
b := t;
END;
RETURN a;
END GCD;
END Gcd;
现在我们对这种语言的程序有了一定的感觉,让我们快速浏览一下本章中使用的tinylang
子集的语法。在接下来的几节中,我们将使用这个语法来派生词法分析器和解析器:
compilationUnit
: "MODULE" identifier ";" ( import )* block identifier "." ;
Import : ( "FROM" identifier )? "IMPORT" identList ";" ;
Block
: ( declaration )* ( "BEGIN" statementSequence )? "END" ;
Modula-2中的编译单元以MODULE
关键字开始,后跟模块名称。模块的内容可以包含导入的模块列表、声明和包含初始化时运行的语句的块:
declaration
: "CONST" ( constantDeclaration ";" )*
| "VAR" ( variableDeclaration ";" )*
| procedureDeclaration ";" ;
声明用于引入常量、变量和过程。常量的声明以CONST
关键字为前缀。类似地,变量声明以VAR
关键字开始。常量的声明非常简单:
constantDeclaration : identifier "=" expression ;
标识符是常量的名称。值来自于一个表达式,该表达式必须在编译时可计算。变量的声明稍微复杂一些:
variableDeclaration : identList ":" qualident ;
qualident : identifier ( "." identifier )* ;
identList : identifier ( "," identifier)* ;
为了能够一次声明多个变量,使用了标识符列表。类型名称可能来自另一个模块,在这种情况下,前缀是模块名称。这称为合格标识符。过程需要最多的细节:
procedureDeclaration
: "PROCEDURE" identifier ( formalParameters )? ";"
block identifier ;
formalParameters
: "(" ( formalParameterList )? ")" ( ":" qualident )? ;
formalParameterList
: formalParameter (";" formalParameter )* ;
formalParameter : ( "VAR" )? identList ":" qualident ;
前面的代码展示了如何声明常量、变量和过程。过程可以有参数和返回类型。普通参数作为值传递,VAR
参数通过引用传递。从block
规则中缺少的部分是statementSequence
,它是单个语句的列表:
statementSequence
: statement ( ";" statement )* ;
如果语句后面跟着另一个语句,则用分号分隔。再次强调,只支持Modula-2语句的一个子集:
statement
: qualident ( ":=" expression | ( "(" ( expList )? ")" )? )
| ifStatement | whileStatement | "RETURN" ( expression )? ;
这条规则的第一部分描述了赋值或过程调用。后跟:=
的合格标识符是一个赋值。如果其后跟着(
,那么它是一个过程调用。其他语句是常规的控制语句:
ifStatement
: "IF" expression "THEN" statementSequence
( "ELSE" statementSequence )? "END" ;
IF
语句也具有简化的语法,因为它只能有一个ELSE
块。有了这个语句,我们可以有条件地保护一个语句:
whileStatement
: "WHILE" expression "DO" statementSequence "END" ;
WHILE
语句描述了一个由条件保护的循环。与IF
语句一起,这使我们能够在tinylang
中编写简单的算法。最后,缺少表达式的定义:
expList
: expression ( "," expression )* ;
expression
: simpleExpression ( relation simpleExpression )? ;
relation
: "=" | "#" | "<" | "<=" | ">" | ">=" ;
simpleExpression
: ( "+" | "-" )? term ( addOperator term )* ;
addOperator
: "+" | "-" | "OR" ;
term
: factor ( mulOperator factor )* ;
mulOperator
: "*" | "/" | "DIV" | "MOD" | "AND" ;
factor
: integer_literal | "(" expression ")" | "NOT" factor
| qualident ( "(" ( expList )? ")" )? ;
表达式语法与前一章的calc非常相似。只支持INTEGER
和BOOLEAN
数据类型。
另外,使用了identifier
和integer_literal
令牌。标识符是以字母或下划线开头,后跟字母、数字和下划线的名称。整数字面量是十进制数字序列或十六进制数字序列,后跟字母H
。
这些规则已经很多了,我们只涵盖了Modula-2的一部分!尽管如此,仍然可以在这个子集中编写小型应用程序。让我们为tinylang
实现一个编译器!
创建项目布局
tinylang
的项目布局遵循了我们在第1章安装LLVM中所描述的方法。每个组件的源代码位于lib
目录的子目录中,头文件位于include/tinylang
的子目录中。子目录以组件的名称命名。在第1章安装LLVM中,我们只创建了Basic
组件。
从前一章我们知道,我们需要实现一个词法分析器、一个解析器、一个AST和一个语义分析器。每个都是独立的组件,分别叫做Lexer
、Parser
、AST
和Sema
。本章将使用的目录布局如下图所示:
图3.1 - tinylang项目的目录布局
组件有明确定义的依赖关系。Lexer
仅依赖于Basic
。Parser
依赖于Basic
、Lexer
、AST
和Sema
。Sema
仅依赖于Basic
和AST
。明确的依赖关系有助于我们重用组件。
让我们更仔细地看看实现吧!
管理编译器的输入文件
一个真实的编译器需要处理许多文件。通常,开发者调用编译器时会指定主编译单元的名称。这个编译单元可以引用其他文件——例如,通过C语言中的#include
指令或Python或Modula-2中的import
语句。一个导入的模块可以导入其他模块,以此类推。所有这些文件必须被加载到内存中,并通过编译器的分析阶段运行。在开发过程中,开发者可能会犯语法或语义错误。当检测到错误时,应该打印包括源代码行和标记的错误消息。这个重要组件并非微不足道。
幸运的是,LLVM提供了一个解决方案:llvm::SourceMgr
类。使用AddNewSourceBuffer()
方法可以向SourceMgr
添加新的源文件。或者,可以使用AddIncludeFile()
方法加载文件。这两种方法都返回一个ID来识别缓冲区。你可以使用这个ID来检索与关联文件的内存缓冲区指针。要定义文件中的位置,你可以使用llvm::SMLoc
类。这个类封装了一个指向缓冲区的指针。各种PrintMessage()
方法允许你向用户发出错误和其他信息性消息。
处理用户消息
现在唯一缺少的是消息的集中定义。在大型软件中(例如编译器),你不希望在所有地方撒播消息字符串。如果有更改消息或将其翻译成另一种语言的请求,那么最好将它们放在一个中心位置!
一个简单的方法是,每个消息都有一个ID(一个enum
成员)、一个严重级别(如Error
或Warning
)和一个包含消息的字符串。在你的代码中,你只引用消息ID。严重级别和消息字符串仅在打印消息时使用。这三个项目(ID、安全级别和消息)必须一致管理。LLVM库使用预处理器来解决这个问题。数据存储在一个带有.def
后缀的文件中,并被包装在一个宏名称中。通常,这个文件会被多次包含,每次都有不同的宏定义。定义在include/tinylang/Basic/Diagnostic.def
文件路径中,如下所示:
#ifndef DIAG
#define DIAG(ID, Level, Msg)
#endif
DIAG(err_sym_declared, Error, "symbol {0} already declared")
#undef DIAG
第一个宏参数ID
是枚举标签,第二个参数Level
是严重级别,第三个参数Msg
是消息文本。有了这个定义,我们可以定义一个DiagnosticsEngine
类来发出错误消息。接口在include/tinylang/Basic/Diagnostic.h
文件中:
#ifndef TINYLANG_BASIC_DIAGNOSTIC_H
#define TINYLANG_BASIC_DIAGNOSTIC_H
#include "tinylang/Basic/LLVM.h"
#include "llvm/ADT/StringRef.h"
#include "llvm/Support/FormatVariadic.h"
#include "llvm/Support/SMLoc.h"
#include "llvm/Support/SourceMgr.h"
#include "llvm/Support/raw_ostream.h"
#include <utility>
namespace tinylang {
在包含必要的头文件后,可以使用Diagnostic.def
来定义枚举。为了不污染全局命名空间,使用了一个叫做<