第 3 章:将源文件转换为抽象语法树

如前一章所学,编译器通常分为前端和后端两部分。在本章中,我们将实现一种编程语言的前端,即主要处理源语言的部分。我们将学习现实世界中编译器使用的技术,并将其应用于我们的编程语言。

我们的旅程将从定义我们的编程语言的语法开始,结束于抽象语法树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非常相似。只支持INTEGERBOOLEAN数据类型。

另外,使用了identifierinteger_literal令牌。标识符是以字母或下划线开头,后跟字母、数字和下划线的名称。整数字面量是十进制数字序列或十六进制数字序列,后跟字母H

这些规则已经很多了,我们只涵盖了Modula-2的一部分!尽管如此,仍然可以在这个子集中编写小型应用程序。让我们为tinylang实现一个编译器!

创建项目布局

tinylang的项目布局遵循了我们在第1章安装LLVM中所描述的方法。每个组件的源代码位于lib目录的子目录中,头文件位于include/tinylang的子目录中。子目录以组件的名称命名。在第1章安装LLVM中,我们只创建了Basic组件。

从前一章我们知道,我们需要实现一个词法分析器、一个解析器、一个AST和一个语义分析器。每个都是独立的组件,分别叫做LexerParserASTSema。本章将使用的目录布局如下图所示:

图3.1 - tinylang项目的目录布局

图3.1 - tinylang项目的目录布局

组件有明确定义的依赖关系。Lexer仅依赖于BasicParser依赖于BasicLexerASTSemaSema仅依赖于BasicAST。明确的依赖关系有助于我们重用组件。

让我们更仔细地看看实现吧!

管理编译器的输入文件

一个真实的编译器需要处理许多文件。通常,开发者调用编译器时会指定主编译单元的名称。这个编译单元可以引用其他文件——例如,通过C语言中的#include指令或Python或Modula-2中的import语句。一个导入的模块可以导入其他模块,以此类推。所有这些文件必须被加载到内存中,并通过编译器的分析阶段运行。在开发过程中,开发者可能会犯语法或语义错误。当检测到错误时,应该打印包括源代码行和标记的错误消息。这个重要组件并非微不足道。

幸运的是,LLVM提供了一个解决方案:llvm::SourceMgr类。使用AddNewSourceBuffer()方法可以向SourceMgr添加新的源文件。或者,可以使用AddIncludeFile()方法加载文件。这两种方法都返回一个ID来识别缓冲区。你可以使用这个ID来检索与关联文件的内存缓冲区指针。要定义文件中的位置,你可以使用llvm::SMLoc类。这个类封装了一个指向缓冲区的指针。各种PrintMessage()方法允许你向用户发出错误和其他信息性消息。

处理用户消息

现在唯一缺少的是消息的集中定义。在大型软件中(例如编译器),你不希望在所有地方撒播消息字符串。如果有更改消息或将其翻译成另一种语言的请求,那么最好将它们放在一个中心位置!

一个简单的方法是,每个消息都有一个ID(一个enum成员)、一个严重级别(如ErrorWarning)和一个包含消息的字符串。在你的代码中,你只引用消息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来定义枚举。为了不污染全局命名空间,使用了一个叫做<

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值