LLVM12-学习手册-二-

125 阅读39分钟

LLVM12 学习手册(二)

原文:zh.annas-archive.org/md5/96A20F7680F39BBAA9B437BF26B65FE2

译者:飞龙

协议:CC BY-NC-SA 4.0

第二部分:从源代码到机器码生成

在本节中,您将学习如何开发自己的编译器。您将首先构建前端,该前端读取源文件并创建其抽象语法树。然后,您将学习如何从源文件生成 LLVM IR。利用 LLVM 的优化能力,您将创建优化的机器码。您还将学习一些高级主题,包括为面向对象语言构造生成 LLVM IR,以及如何添加调试元数据。

本节包括以下章节:

  • 第四章, 将源文件转换为抽象语法树

  • 第五章, IR 生成的基础

  • 第六章, 高级语言构造的 IR 生成

  • 第七章, 高级 IR 生成

  • 第八章, IR 优化

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

编译器通常分为两部分:前端和后端。在本章中,我们将实现编程语言的前端;也就是处理源语言的部分。我们将学习真实世界编译器使用的技术,并将其应用到我们自己的编程语言中。

我们将从定义编程语言的语法开始,以 抽象语法树(AST) 结束,这将成为代码生成的基础。你可以使用这种方法来为你想要实现编译器的每种编程语言。

在本章中,你将学习以下主题:

  • 定义一个真正的编程语言将向你介绍 tinylang 语言,它是一个真正编程语言的子集,你必须为其实现一个编译器前端。

  • 创建项目布局,你将为编译器创建项目布局。

  • 管理源文件和用户消息,这将让你了解如何处理多个输入文件,并以愉快的方式通知用户有关问题。

  • 构建词法分析器,讨论词法分析器如何分解为模块化部分。

  • 构建一个递归下降解析器,将讨论从语法中导出解析器的规则,以执行语法分析。

  • 使用 bison 和 flex 生成解析器和词法分析器,你将使用工具舒适地从规范中生成解析器和词法分析器。

  • 执行语义分析,你将创建 AST 并评估其属性,这将与解析器交织在一起。

通过本章节你将获得的技能,你将能够为任何编程语言构建编译器前端。

技术要求

本章的代码文件可在github.com/PacktPublishing/Learn-LLVM-12/tree/master/Chapter04找到

你可以在bit.ly/3nllhED找到代码演示视频

定义一个真正的编程语言

一个真正的编程语言带来的挑战比简单的 tinylang 更多。

让我们快速浏览一下本章将使用的 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的项目布局遵循我们在第二章中提出的方法,浏览 LLVM 源码。每个组件的源代码都在lib目录的子目录中,而头文件在include/tinylang的子目录中。子目录的名称取决于组件。在第二章中,浏览 LLVM 源码,我们只创建了Basic组件。

从上一章我们知道,我们需要实现词法分析器、解析器、AST 和语义分析器。每个都是自己的组件,称为LexerParserASTSema。在上一章中使用的目录布局如下:

图 4.1 - tinylang 项目的目录布局

图 4.1 - tinylang 项目的目录布局

这些组件有明确定义的依赖关系。在这里,Lexer只依赖于BasicParser依赖于BasicLexerASTSema。最后,Sema只依赖于BasicAST。这些明确定义的依赖关系有助于重用组件。

让我们更仔细地看看它们的实现!

管理源文件和用户消息

一个真正的编译器必须处理许多文件。通常,开发人员使用主编译单元的名称调用编译器。这个编译单元可以引用其他文件,例如,通过 C 中的#include指令或 Python 或 Modula-2 中的import语句。导入的模块可以导入其他模块,依此类推。所有这些文件必须加载到内存中,并通过编译器的分析阶段运行。在开发过程中,开发人员可能会出现语法或语义错误。一旦检测到,应该打印出包括源行和标记的错误消息。在这一点上,显然可以看出这个基本组件并不是简单的。

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

只缺少一个集中定义消息的方法。在大型软件(如编译器)中,您不希望在各个地方散布消息字符串。如果有要求更改消息或将其翻译成另一种语言,那么最好将它们放在一个中心位置!

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

namespace diag {
enum {
#define DIAG(ID, Level, Msg) ID,
#include "tinylang/Basic/Diagnostic.def"
};
} // namespace diag

DiagnosticsEngine类使用SourceMgr实例通过report()方法发出消息。消息可以有参数。为了实现这个功能,必须使用 LLVM 的可变格式支持。消息文本和严重程度级别是通过static方法获取的。作为奖励,发出的错误消息数量也被计算:

class DiagnosticsEngine {
  static const char *getDiagnosticText(unsigned DiagID);
  static SourceMgr::DiagKind
  getDiagnosticKind(unsigned DiagID);

消息字符串由getDiagnosticText()返回,而级别由getDiagnosticKind()返回。这两个方法将在.cpp文件中实现:

  SourceMgr &SrcMgr;
  unsigned NumErrors;
public:
  DiagnosticsEngine(SourceMgr &SrcMgr)
      : SrcMgr(SrcMgr), NumErrors(0) {}
  unsigned nunErrors() { return NumErrors; }

由于消息可以有可变数量的参数,C++中的解决方案是使用可变模板。当然,LLVM 提供的formatv()函数也使用了这个。为了获得格式化的消息,我们只需要转发模板参数:

  template <typename... Args>
  void report(SMLoc Loc, unsigned DiagID,
              Args &&... Arguments) {
    std::string Msg =
        llvm::formatv(getDiagnosticText(DiagID),
                      std::forward<Args>(Arguments)...)
            .str();
    SourceMgr::DiagKind Kind = getDiagnosticKind(DiagID);
    SrcMgr.PrintMessage(Loc, Kind, Msg);
    NumErrors += (Kind == SourceMgr::DK_Error);
  }
};
} // namespace tinylang
#endif

到目前为止,我们已经实现了大部分的类。只有getDiagnosticText()getDiagnosticKind()还没有。它们在lib/Basic/Diagnostic.cpp文件中定义,并且还使用了Diagnostic.def文件:

#include "tinylang/Basic/Diagnostic.h"
using namespace tinylang;
namespace {
const char *DiagnosticText[] = {
#define DIAG(ID, Level, Msg) Msg,
#include "tinylang/Basic/Diagnostic.def"
};

与头文件中一样,DIAG宏被定义为检索所需的部分。在这里,我们将定义一个数组来保存文本消息。因此,DIAG宏只返回Msg部分。我们将使用相同的方法来处理级别:

SourceMgr::DiagKind DiagnosticKind[] = {
#define DIAG(ID, Level, Msg) SourceMgr::DK_##Level,
#include "tinylang/Basic/Diagnostic.def"
};
} // namespace

毫不奇怪,这两个函数只是简单地索引数组以返回所需的数据:

const char *
DiagnosticsEngine::getDiagnosticText(unsigned DiagID) {
  return DiagnosticText[DiagID];
}
SourceMgr::DiagKind
DiagnosticsEngine::getDiagnosticKind(unsigned DiagID) {
  return DiagnosticKind[DiagID];
}

SourceMgrDiagnosticsEngine类的组合为其他组件提供了良好的基础。让我们先在词法分析器中使用它们!

构建词法分析器

正如我们从前一章所知,我们需要一个Token类和一个Lexer类。此外,还需要一个TokenKind枚举,以给每个标记类一个唯一的编号。拥有一个全能的头文件和一个实现文件并不可扩展,所以让我们重新构建一下。TokenKind枚举可以被普遍使用,并放在Basic组件中。TokenLexer类属于Lexer组件,但放在不同的头文件和实现文件中。

有三种不同的标记类:CONST关键字,;分隔符和ident标记,代表源代码中的标识符。每个标记都需要一个枚举的成员名称。关键字和标点符号有自然的显示名称,可以用于消息。

与许多编程语言一样,关键字是标识符的子集。要将标记分类为关键字,我们需要一个关键字过滤器,检查找到的标识符是否确实是关键字。这与 C 或 C++中的行为相同,其中关键字也是标识符的子集。编程语言随着时间的推移而发展,可能会引入新的关键字。例如,最初的 K&R C 语言没有使用enum关键字定义枚举。因此,应该存在一个指示关键字的语言级别的标志。

我们收集了几个信息片段,所有这些信息都属于TokenKind枚举的成员:枚举成员的标签,标点符号的拼写以及关键字的标志。至于诊断消息,我们将信息集中存储在名为include/tinylang/Basic/TokenKinds.def.def文件中,如下所示。需要注意的一点是,关键字以kw_为前缀:

#ifndef TOK
#define TOK(ID)
#endif
#ifndef PUNCTUATOR
#define PUNCTUATOR(ID, SP) TOK(ID)
#endif
#ifndef KEYWORD
#define KEYWORD(ID, FLAG) TOK(kw_ ## ID)
#endif
TOK(unknown)
TOK(eof)
TOK(identifier)
TOK(integer_literal)
PUNCTUATOR(plus,                "+")
PUNCTUATOR(minus,               "-")
// …
KEYWORD(BEGIN                       , KEYALL)
KEYWORD(CONST                       , KEYALL)
// …
#undef KEYWORD
#undef PUNCTUATOR
#undef TOK

有了这些集中定义,很容易在include/tinylang/Basic/TokenKinds.h文件中创建TokenKind枚举。同样,枚举被放入自己的命名空间中,称为tok

#ifndef TINYLANG_BASIC_TOKENKINDS_H
#define TINYLANG_BASIC_TOKENKINDS_H
namespace tinylang {
namespace tok {
enum TokenKind : unsigned short {
#define TOK(ID) ID,
#include "TokenKinds.def"
  NUM_TOKENS
};

现在,您应该熟悉用于填充数组的模式。TOK宏被定义为仅返回枚举标签的ID。作为有用的补充,我们还将NUM_TOKENS定义为枚举的最后一个成员,表示定义的标记数量:

    const char *getTokenName(TokenKind Kind);
    const char *getPunctuatorSpelling(TokenKind Kind);
    const char *getKeywordSpelling(TokenKind Kind);
  }
}
#endif

实现文件lib/Basic/TokenKinds.cpp也使用.def文件来检索名称:

#include "tinylang/Basic/TokenKinds.h"
#include "llvm/Support/ErrorHandling.h"
using namespace tinylang;
static const char * const TokNames[] = {
#define TOK(ID) #ID,
#define KEYWORD(ID, FLAG) #ID,
#include "tinylang/Basic/TokenKinds.def"
  nullptr
};

标记的文本名称是从其枚举标签的ID派生的。有两个特殊之处。首先,我们需要定义TOKKEYWORD宏,因为KEYWORD的默认定义不使用TOK宏。其次,在数组的末尾添加了一个nullptr值,考虑到了添加的NUM_TOKENS枚举成员:

const char *tok::getTokenName(TokenKind Kind) {
  return TokNames[Kind];
}

对于getPunctuatorSpelling()getKeywordSpelling()函数,我们采用了稍微不同的方法。这些函数仅对枚举的子集返回有意义的值。这可以通过switch语句实现,它默认返回nullptr值:

const char *tok::getPunctuatorSpelling(TokenKind Kind) {
  switch (Kind) {
#define PUNCTUATOR(ID, SP) case ID: return SP;
#include "tinylang/Basic/TokenKinds.def"
    default: break;
  }
  return nullptr;
}
const char *tok::getKeywordSpelling(TokenKind Kind) {
  switch (Kind) {
#define KEYWORD(ID, FLAG) case kw_ ## ID: return #ID;
#include "tinylang/Basic/TokenKinds.def"
    default: break;
  }
  return nullptr;
}

提示

请注意如何定义宏以从文件中检索所需的信息。

在上一章中,Token类是在与Lexer类相同的头文件中声明的。为了使其更模块化,我们将Token类放入include/Lexer/Token.h的头文件中。与之前一样,Token存储了指向标记开头的指针,长度和标记的种类,如之前定义的那样:

class Token {
  friend class Lexer;
  const char *Ptr;
  size_t Length;
  tok::TokenKind Kind;
public:
  tok::TokenKind getKind() const { return Kind; }
  size_t getLength() const { return Length; }

SMLoc实例,表示消息中源的位置,是从标记的指针创建的:

  SMLoc getLocation() const {
    return SMLoc::getFromPointer(Ptr);
  }

getIdentifier()getLiteralData()方法允许我们访问标识符和文字数据的文本。对于任何其他标记类型,不需要访问文本,因为这是标记类型所暗示的:

  StringRef getIdentifier() {
    assert(is(tok::identifier) &&
           "Cannot get identfier of non-identifier");
    return StringRef(Ptr, Length);
  }
  StringRef getLiteralData() {
    assert(isOneOf(tok::integer_literal,
                   tok::string_literal) &&
           "Cannot get literal data of non-literal");
    return StringRef(Ptr, Length);
  }
};

我们在include/Lexer/Lexer.h头文件中声明了Lexer类,并将实现放在lib/Lexer/lexer.cpp文件中。结构与上一章的 calc 语言相同。在这里,我们必须仔细看两个细节:

  • 首先,有些运算符共享相同的前缀;例如,<<=。当我们正在查看的当前字符是<时,我们必须先检查下一个字符,然后再决定我们找到了哪个标记。请记住,我们要求输入以空字节结尾。因此,如果当前字符有效,下一个字符总是可以使用的:
    case '<':
      if (*(CurPtr + 1) == '=')
        formTokenWithChars(token, CurPtr + 2, tok::lessequal);
      else
        formTokenWithChars(token, CurPtr + 1, tok::less);
      break;
  • 另一个细节是,在这一点上,关键字要多得多。我们该如何处理?一个简单而快速的解决方案是用关键字填充一个哈希表,这些关键字都存储在TokenKinds.def文件中。这可以在我们实例化Lexer类的同时完成。在这种方法中,也可以支持语言的不同级别,因为关键字可以根据附加的标志进行过滤。在这里,这种灵活性还不需要。在头文件中,关键字过滤器定义如下,使用llvm::StringMap的实例作为哈希表:
class KeywordFilter {
  llvm::StringMap<tok::TokenKind> HashTable;
  void addKeyword(StringRef Keyword,
                  tok::TokenKind TokenCode);
public:
  void addKeywords();

getKeyword()方法返回给定字符串的标记类型,如果字符串不表示关键字,则返回默认值:

  tok::TokenKind getKeyword(
      StringRef Name,
      tok::TokenKind DefaultTokenCode = tok::unknown) {
    auto Result = HashTable.find(Name);
    if (Result != HashTable.end())
      return Result->second;
    return DefaultTokenCode;
  }
};

在实现文件中,关键字表被填充:

void KeywordFilter::addKeyword(StringRef Keyword,
                               tok::TokenKind TokenCode) 
{
  HashTable.insert(std::make_pair(Keyword, TokenCode));
}
void KeywordFilter::addKeywords() {
#define KEYWORD(NAME, FLAGS)                                 
addKeyword(StringRef(#NAME), tok::kw_##NAME);
#include "tinylang/Basic/TokenKinds.def"
}

有了这些技巧,编写一个高效的词法分析器类并不难。由于编译速度很重要,许多编译器使用手写的词法分析器,Clang 就是一个例子。

构建递归下降解析器

正如前一章所示,解析器是从其语法派生出来的。让我们回顾一下所有的构造规则。对于语法的每个规则,你都要创建一个方法,该方法的名称与规则左侧的非终端相同,以便解析规则的右侧。根据右侧的定义,你必须做到以下几点:

  • 对于每个非终端,都会调用相应的方法。

  • 每个标记都被消耗。

  • 对于替代和可选或重复组,会检查先行标记(下一个未消耗的标记)以决定我们可以从哪里继续。

让我们将这些构造规则应用到语法的以下规则上:

ifStatement
  : "IF" expression "THEN" statementSequence
    ( "ELSE" statementSequence )? "END" ;

我们可以很容易地将这个转换成以下的 C++方法:

void Parser::parseIfStatement() {
  consume(tok::kw_IF);
  parseExpression();
  consume(tok::kw_THEN);
  parseStatementSequence();
  if (Tok.is(tok::kw_ELSE)) {
    advance();
    parseStatementSequence();
  }
  consume(tok::kw_END);
}

这样可以将tinylang的整个语法转换为 C++。一般来说,你必须小心并避免一些陷阱。

要注意的一个问题是左递归规则。如果右侧开始的终端与左侧相同,则规则是左递归。一个典型的例子可以在表达式的语法中找到:

expression : expression "+" term ;

如果从语法中还不清楚,那么将其翻译成 C++应该很明显,这会导致无限递归:

Void Parser::parseExpression() {
  parseExpression();
  consume(tok::plus);
  parseTerm();
}

左递归也可能间接发生,并涉及更多的规则,这更难以发现。这就是为什么存在一种算法,可以检测和消除左递归。

在每一步,解析器只需使用先行标记就可以决定如何继续。如果这个决定不能被确定性地做出,那么就说语法存在冲突。为了说明这一点,让我们来看看 C#中的using语句。就像在 C++中一样,using语句可以用来使一个符号在命名空间中可见,比如using Math;。还可以为导入的符号定义别名;也就是说,using M = Math;。在语法中,这可以表示如下:

usingStmt : "using" (ident "=")? ident ";"

显然,这里存在一个问题。在解析器消耗了using关键字之后,先行标记是ident。但这个信息对我们来说不足以决定是否必须跳过或解析可选组。如果可选组的开始标记集与可选组后面的标记集重叠,那么这种情况总是会出现。

让我们用一个替代而不是一个可选组来重写规则:

usingStmt : "using" ( ident "=" ident | ident ) ";" ;

现在,有一个不同的冲突:两种选择都以相同的标记开头。只看先行标记,解析器无法确定哪个选择是正确的。

这些冲突非常常见。因此,了解如何处理它们是很好的。一种方法是以这样的方式重写语法,使冲突消失。在前面的例子中,两种选择都以相同的标记开头。这可以被分解出来,得到以下规则:

usingStmt : "using" ident ("=" ident)? ";" ;

这种表述没有冲突。但是,也应该注意到它的表达力较弱。在另外两种表述中,很明显哪个ident是别名,哪个ident是命名空间名称。在这个无冲突的规则中,最左边的ident改变了它的角色。首先,它是命名空间名称,但如果后面跟着一个等号(=),那么它就变成了别名。

第二种方法是添加一个额外的谓词来区分两种情况。这个谓词通常被称为Token &peek(int n)方法,它返回当前向前看标记之后的第 n 个标记。在这里,等号的存在可以作为决定的一个额外谓词:

if (Tok.is(tok::ident) && Lex.peek(0).is(tok::equal)) {
  advance();
  consume(tok::equal);
}
consume(tok::ident);

现在,让我们加入错误恢复。在上一章中,我介绍了所谓的恐慌模式作为错误恢复的一种技术。基本思想是跳过标记,直到找到适合继续解析的标记。例如,在tinylang中,一个语句后面跟着一个分号(;)。

如果在IF语句中有语法问题,那么你会跳过所有标记,直到找到一个分号为止。然后,你继续下一个语句。不要使用特定的标记集的临时定义,最好使用系统化的方法。

对于每个非终结符,计算可以跟随非终结符的任何地方的标记集(称为ELSEEND标记可以跟随)。因此,在parseStatement()的错误恢复部分中使用这个集合。这种方法假设可以在本地处理语法错误。一般来说,这是不可能的。因为解析器跳过标记,可能会跳过太多标记,导致到达输入的末尾。在这一点上,本地恢复是不可能的。

为了防止无意义的错误消息,调用方法需要被告知错误恢复仍然没有完成。这可以通过bool返回值来实现:true表示错误恢复尚未完成,而false表示解析(包括可能的错误恢复)成功完成。

有许多方法可以扩展这种错误恢复方案。一个流行的方法是还使用活动调用者的 FOLLOW 集。举个简单的例子,假设parseStatement()parseStatementSequence()调用,而后者被parseBlock()调用,而后者又被parseModule()调用。

在这里,每个相应的非终结符都有一个 FOLLOW 集。如果解析器在parseStatement()中检测到语法错误,那么会跳过标记,直到标记至少在活动调用者的 FOLLOW 集中的一个中。如果标记在语句的 FOLLOW 集中,那么错误会在本地恢复,向调用者返回一个false值。否则,返回一个true值,表示错误恢复必须继续。这种扩展的可能实现策略是将一个std::bitsetstd::tuple传递给被调用者,表示当前 FOLLOW 集的并集。

还有一个问题尚未解决:我们如何调用错误恢复?在上一章中,使用了goto来跳转到错误恢复块。这样做虽然有效,但并不是一个令人满意的解决方案。根据之前的讨论,我们可以在一个单独的方法中跳过标记。Clang 有一个名为skipUntil()的方法,可以用于这个目的,我们也可以在tinylang中使用这个方法。

因为接下来要向解析器添加语义动作,如果有必要,最好有一个集中的地方放置清理代码。嵌套函数对此来说是理想的。C++没有嵌套函数。相反,lambda 函数可以起到类似的作用。完整的错误恢复的parseIfStatement()方法如下所示:

bool Parser::parseIfStatement() {
  auto _errorhandler = [this] {
    return SkipUntil(tok::semi, tok::kw_ELSE, tok::kw_END);
  };
  if (consume(tok::kw_IF))
    return _errorhandler();
  if (parseExpression(E))
    return _errorhandler();
  if (consume(tok::kw_THEN))
    return _errorhandler();
  if (parseStatementSequence(IfStmts))
    return _errorhandler();
  if (Tok.is(tok::kw_ELSE)) {
    advance();
    if (parseStatementSequence(ElseStmts))
      return _errorhandler();
  }
  if (expect(tok::kw_END))
    return _errorhandler();
  return false;
}

使用 bison 和 flex 生成解析器和词法分析器

手动构建词法分析器和解析器并不困难,通常会产生快速的组件。缺点是很难引入更改,特别是在解析器中。如果您正在原型设计一种新的编程语言,这可能很重要。使用专门的工具可以缓解这个问题。

有许多可用的工具可以从规范文件生成词法分析器或解析器。在 Linux 世界中,flex (github.com/westes/flex) 和 bison (www.gnu.org/software/bison/) 是最常用的工具。Flex 从一组正则表达式生成词法分析器,而 bison 从语法描述生成解析器。通常,这两个工具一起使用。

Bison 生成的tinylang,存储在tinylang.yy文件中,以以下序言开始:

%require "3.2"
%language "c++"
%defines "Parser.h"
%define api.namespace {tinylang}
%define api.parser.class {Parser}
%define api.token.prefix {T_}
%token
  identifier integer_literal string_literal
  PLUS MINUS STAR SLASH 

我们使用%language指令指示 bison 生成 C++代码。使用%define指令,我们覆盖了一些代码生成的默认值:生成的类应该命名为Parser,并位于tinylang命名空间中。此外,表示标记种类的枚举成员应以T_为前缀。我们要求版本为 3.2 或更高,因为这些变量中的一些是在此版本中引入的。为了能够与 flex 交互,我们告诉 bison 使用%defines指令写入一个Parser.h头文件。最后,我们必须使用%token指令声明所有使用的标记。语法规则在%%之后:

%%
compilationUnit
  : MODULE identifier SEMI imports block identifier PERIOD ;
imports : %empty | import imports ;
import
  : FROM identifier IMPORT identList SEMI
  | IMPORT identList SEMI ;

请将这些规则与本章第一节中显示的语法规范进行比较。Bison 不知道重复组,因此我们需要添加一个称为imports的新规则来模拟这种重复。在import规则中,我们必须引入一个替代方案来模拟可选组。

我们还需要以这种方式重写tinylang语法的其他规则。例如,IF语句的规则变成了以下内容:

ifStatement
  : IF expression THEN statementSequence
    elseStatement END ;
elseStatement : %empty | ELSE statementSequence ;

同样,我们必须引入一个新规则来模拟可选的ELSE语句。%empty指令可以省略,但使用它可以清楚地表明这是一个空的替代分支。

一旦我们以 bison 风格重写了所有语法规则,就可以使用以下命令生成解析器:

$ bison tinylang.yy

这就是创建一个类似于上一节手写的解析器所需的全部内容!

同样,flex 易于使用。Flex 的规范是一系列正则表达式和相关联的操作,如果正则表达式匹配,则执行该操作。tinylang.l文件指定了tinylang的词法分析器。与 bison 规范一样,它以序言开始:

%{
#include "Parser.h"
%}
%option noyywrap nounput noinput batch
id       [a-zA-Z_][a-zA-Z_0-9]*
digit    [0-9]
hexdigit [0-9A-F]
space    [ \t\r]

%{ }%内的文本被复制到 flex 生成的文件中。我们使用这种机制来包含 bison 生成的头文件。使用%option指令,我们控制生成的词法分析器应具有哪些特性。我们只读取一个文件,不希望在到达文件末尾后继续读取另一个文件,因此我们指定noyywrap以禁用此功能。我们也不需要访问底层文件流,并使用nounputnoinout禁用它。最后,因为我们不需要交互式词法分析器,我们要求生成一个batch扫描器。

在序言中,我们还可以定义字符模式以供以后使用。在%%之后是定义部分:

%%
{space}+
{digit}+      return
                   tinylang::Parser::token::T_integer_literal;

在定义部分,您指定一个正则表达式模式和一个要执行的操作,如果模式匹配输入。操作也可以为空。

{space}+模式使用序言中定义的space字符模式。它匹配一个或多个空白字符。我们没有定义操作,因此所有空白将被忽略。

为了匹配一个数字,我们使用{digit}+模式。作为操作,我们只返回相关的标记种类。对于所有标记都是这样做的。例如,我们对算术运算符做如下操作:

"+"             return tinylang::Parser::token::T_PLUS;
"-"             return tinylang::Parser::token::T_MINUS;
"*"             return tinylang::Parser::token::T_STAR;
"/"             return tinylang::Parser::token::T_SLASH;

如果有多个模式匹配输入,则选择最长匹配的模式。如果仍然有多个模式匹配输入,则选择规范文件中按字典顺序排列的第一个模式。这就是为什么首先定义关键字的模式,然后才定义标识符的模式很重要:

"VAR"           return tinylang::Parser::token::T_VAR;
"WHILE"         return tinylang::Parser::token::T_WHILE;
{id}            return tinylang::Parser::token::T_identifier;

这些操作不仅仅限于return语句。如果你的代码需要多于一行,那么你必须用大括号{ }括起你的代码。

扫描器是用以下命令生成的:

$ flex –c++ tinylang.l

你的语言项目应该使用哪种方法?解析器生成器通常生成 LALR(1)解析器。LALR(1)类比 LL(1)类更大,递归下降解析器可以构造。如果无法调整语法使其适合 LL(1)类,则应考虑使用解析器生成器。手动构造这样的自底向上解析器是不可行的。即使你的语法是 LL(1),解析器生成器提供了更多的舒适性,同时生成的代码与手动编写的代码相似。通常,这是受许多因素影响的选择。Clang 使用手写解析器,而 GCC 使用 bison 生成的解析器。

执行语义分析

在前一节中构造的解析器只检查输入的语法。下一步是添加执行语义分析的能力。在上一章的 calc 示例中,解析器构造了一个 AST。在单独的阶段,语义分析器对这棵树进行了处理。这种方法总是可以使用的。在本节中,我们将使用稍微不同的方法,更加交织解析器和语义分析器。

这些是语义分析器必须执行的一些任务:

  • 对于每个声明,语义分析器必须检查所使用的名称是否已经在其他地方声明过。

  • 对于表达式或语句中的每个名称出现,语义分析器必须检查名称是否已声明,并且所需的使用是否符合声明。

  • 对于每个表达式,语义分析器必须计算结果类型。还需要计算表达式是否是常量,如果是,还需要计算它的值。

  • 对于赋值和参数传递,语义分析器必须检查类型是否兼容。此外,我们必须检查IFWHILE语句中的条件是否为BOOLEAN类型。

对于编程语言的这么小的子集来说,这已经是很多要检查的了!

处理名称的作用域

让我们先来看看名称的作用域。名称的作用域是名称可见的范围。像 C 一样,tinylang使用先声明后使用的模型。例如,BX变量在模块级别声明,因此它们是INTEGER类型:

VAR B, X: INTEGER;

在声明之前,变量是未知的,不能使用。只有在声明之后才能使用。在过程内,可以声明更多的变量:

PROCEDURE Proc;
VAR B: BOOLEAN;
BEGIN
  (* Statements *)
END Proc;

在此过程内,在注释所在的位置,使用B指的是局部变量B,而使用X指的是全局变量X。局部变量B的作用域是Proc过程。如果在当前作用域中找不到名称,则在封闭作用域中继续搜索。因此,X变量可以在过程内使用。在tinylang中,只有模块和过程会打开新的作用域。其他语言构造,如structclass通常也会打开作用域。预定义实体,如INTEGER类型或TRUE文字,是在全局作用域中声明的,包围模块的作用域。

tinylang中,只有名称是关键的。因此,作用域可以实现为名称到其声明的映射。只有当名称不存在时才能插入新名称。对于查找,还必须知道封闭或父作用域。接口(在include/tinylang/Sema/Scope.h文件中)如下:

#ifndef TINYLANG_SEMA_SCOPE_H
#define TINYLANG_SEMA_SCOPE_H
#include "tinylang/Basic/LLVM.h"
#include "llvm/ADT/StringMap.h"
#include "llvm/ADT/StringRef.h"
namespace tinylang {
class Decl;
class Scope {
  Scope *Parent;
  StringMap<Decl *> Symbols;
public:
  Scope(Scope *Parent = nullptr) : Parent(Parent) {}
  bool insert(Decl *Declaration);
  Decl *lookup(StringRef Name);
  Scope *getParent() { return Parent; }
};
} // namespace tinylang
#endif

lib/Sema/Scope.cpp文件中的实现如下:

#include "tinylang/Sema/Scope.h"
#include "tinylang/AST/AST.h"
using namespace tinylang;
bool Scope::insert(Decl *Declaration) {
  return Symbols
      .insert(std::pair<StringRef, Decl *>(
          Declaration->getName(), Declaration))
      .second;
}

请注意,StringMap::insert()方法不会覆盖现有条目。结果std::pairsecond成员指示表是否已更新。此信息返回给调用者。

为了实现符号声明的搜索,lookup()方法搜索当前作用域;如果找不到任何内容,则搜索由parent成员链接的作用域:

Decl *Scope::lookup(StringRef Name) {
  Scope *S = this;
  while (S) {
    StringMap<Decl *>::const_iterator I =
        S->Symbols.find(Name);
    if (I != S->Symbols.end())
      return I->second;
    S = S->getParent();
  }
  return nullptr;
}

然后变量声明如下处理:

  • 当前作用域是模块作用域。

  • 查找INTEGER类型声明。如果找不到声明或者它不是类型声明,则会出错。

  • 实例化一个新的 AST 节点VariableDeclaration,重要属性是名称B和类型。

  • 名称B被插入到当前作用域中,映射到声明实例。如果名称已经存在于作用域中,则这是一个错误。在这种情况下,当前作用域的内容不会改变。

  • X变量也是同样的操作。

这里执行了两项任务。就像在 calc 示例中一样,构造了 AST 节点。同时,计算了节点的属性,例如其类型。为什么这是可能的?

语义分析器可以回退到两组不同的属性。作用域从调用者那里继承。类型声明可以通过评估类型声明的名称来计算(或合成)。语言设计成这样的方式,这两组属性足以计算 AST 节点的所有属性。

其中一个重要方面是先声明后使用模型。如果一种语言允许在声明之前使用名称,例如 C++中类内的成员,那么不可能一次计算 AST 节点的所有属性。在这种情况下,AST 节点必须只用部分计算的属性或纯粹的信息(例如在 calc 示例中)构造。

AST 必须被访问一次或多次以确定缺失的信息。在tinylang(和 Modula-2)的情况下,也可以不使用 AST 构造 - AST 是通过parseXXX()方法的调用层次结构间接表示的。从 AST 生成代码更为常见,因此我们也在这里构造了一个 AST。

在我们将所有部分放在一起之前,我们需要了解 LLVM 使用运行时类型信息(RTTI)的风格。

在 AST 中使用 LLVM 风格的 RTTI

当然,AST 节点是类层次结构的一部分。声明总是有一个名称。其他属性取决于正在声明的内容。如果声明了变量,则需要一个类型。常量声明需要一个类型和一个值,依此类推。当然,在运行时,您需要找出正在处理的声明的类型。可以使用dynamic_cast<> C++运算符来实现这一点。问题在于,如果 C++类附有虚表,即使用虚函数,则所需的 RTTI 才可用;另一个缺点是 C++ RTTI 过于臃肿。为了避免这些缺点,LLVM 开发人员引入了一种自制的 RTTI 风格,该风格在整个 LLVM 库中使用。

我们层次结构的(抽象)基类是Decl。为了实现 LLVM 风格的 RTTI,需要添加一个包含每个子类标签的公共枚举。此外,还需要一个私有成员和一个公共 getter 方法。私有成员通常称为Kind。在我们的情况下,看起来是这样的:

class Decl {
public:
  enum DeclKind { DK_Module, DK_Const, DK_Type,
                  DK_Var, DK_Param, DK_Proc };
private:
  const DeclKind Kind;
public:
  DeclKind getKind() const { return Kind; }
};

现在每个子类都需要一个名为classof的特殊函数成员。此函数的目的是确定给定实例是否是请求的类型。对于VariableDeclaration,它的实现如下:

static bool classof(const Decl *D) {
  return D->getKind() == DK_Var;
}

现在,您可以使用llvm::isa<>特殊模板来检查对象是否是请求类型的对象,并使用llvm::dyn_cast<>来动态转换对象。还有更多的模板存在,但这两个是最常用的。有关其他模板,请参见llvm.org/docs/ProgrammersManual.html#the-isa-cast-and-dyn-cast-templates,有关 LLVM 样式的更多信息,包括更高级的用法,请参见llvm.org/docs/HowToSetUpLLVMStyleRTTI.html

创建语义分析器

有了这些知识,我们现在可以实现语义分析器,它操作由解析器创建的 AST 节点。首先,我们将实现存储在include/llvm/tinylang/AST/AST.h文件中的变量的 AST 节点的定义。除了支持 LLVM 样式的 RTTI 外,基类还存储声明的名称、名称的位置和指向封闭声明的指针。后者是为了生成嵌套过程所必需的。Decl基类声明如下:

class Decl {
public:
  enum DeclKind { DK_Module, DK_Const, DK_Type,
                  DK_Var, DK_Param, DK_Proc };
private:
  const DeclKind Kind;
protected:
  Decl *EnclosingDecL;
  SMLoc Loc;
  StringRef Name;
public:
  Decl(DeclKind Kind, Decl *EnclosingDecL, SMLoc Loc,
       StringRef Name)
      : Kind(Kind), EnclosingDecL(EnclosingDecL), Loc(Loc),
        Name(Name) {}
  DeclKind getKind() const { return Kind; }
  SMLoc getLocation() { return Loc; }
  StringRef getName() { return Name; }
  Decl *getEnclosingDecl() { return EnclosingDecL; }
};

变量的声明只是添加了指向类型声明的指针:

class TypeDeclaration;
class VariableDeclaration : public Decl {
  TypeDeclaration *Ty;
public:
  VariableDeclaration(Decl *EnclosingDecL, SMLoc Loc,
                      StringRef Name, TypeDeclaration *Ty)
      : Decl(DK_Var, EnclosingDecL, Loc, Name), Ty(Ty) {}
  TypeDeclaration *getType() { return Ty; }
  static bool classof(const Decl *D) {
    return D->getKind() == DK_Var;
  }
};

解析器中的方法需要扩展语义动作和已收集信息的变量:

bool Parser::parseVariableDeclaration(DeclList &Decls) {
  auto _errorhandler = [this] {
    while (!Tok.is(tok::semi)) {
      advance();
      if (Tok.is(tok::eof)) return true;
    }
    return false;
  };
  Decl *D = nullptr; IdentList Ids;
  if (parseIdentList(Ids)) return _errorhandler();
  if (consume(tok::colon)) return _errorhandler();
  if (parseQualident(D)) return _errorhandler();
  Actions.actOnVariableDeclaration(Decls, Ids, D);
  return false;
}

DeclList是一个称为std::vector<Decl*>的声明列表,而IdentList是一个称为std::vector<std::pair<SMLoc, StringRef>>的位置和标识符列表。

parseQualident()方法返回一个声明,在这种情况下,预期是一个类型声明。

解析器类知道语义分析器类Sema的一个实例,它存储在Actions成员中。调用actOnVariableDeclaration()运行语义分析器和 AST 构造。实现在lib/Sema/Sema.cpp文件中:

void Sema::actOnVariableDeclaration(DeclList &Decls,
                                    IdentList &Ids,
                                    Decl *D) {
  if (TypeDeclaration *Ty = dyn_cast<TypeDeclaration>(D)) {
    for (auto I = Ids.begin(), E = Ids.end(); I != E; ++I) {
      SMLoc Loc = I->first;
      StringRef Name = I->second;
      VariableDeclaration *Decl = new VariableDeclaration(
          CurrentDecl, Loc, Name, Ty);
      if (CurrentScope->insert(Decl))
        Decls.push_back(Decl);
      else
        Diags.report(Loc, diag::err_symbold_declared, Name);
    }
  } else if (!Ids.empty()) {
    SMLoc Loc = Ids.front().first;
    Diags.report(Loc, diag::err_vardecl_requires_type);
  }
}

首先,使用llvm::dyn_cast<TypeDeclaration>检查类型声明。如果它不是类型声明,则打印错误消息。否则,对于Ids列表中的每个名称,实例化一个VariableDeclaration并将其添加到声明列表中。如果将变量添加到当前作用域失败,因为名称已经被声明,则打印错误消息。

大多数其他实体以相同的方式构建,它们的语义分析复杂性是唯一的区别。模块和过程需要更多的工作,因为它们打开了一个新的作用域。打开一个新的作用域很容易:只需实例化一个新的Scope对象。一旦模块或过程被解析,作用域必须被移除。

这必须以可靠的方式完成,因为我们不希望在语法错误的情况下将名称添加到错误的作用域中。这是 C++中资源获取即初始化RAII)习惯用法的经典用法。另一个复杂性来自于过程可以递归调用自身的事实。因此,必须在使用之前将过程的名称添加到当前作用域中。语义分析器有两种方法可以进入和离开作用域。作用域与声明相关联:

void Sema::enterScope(Decl *D) {
  CurrentScope = new Scope(CurrentScope);
  CurrentDecl = D;
}
void Sema::leaveScope() {
  Scope *Parent = CurrentScope->getParent();
  delete CurrentScope;
  CurrentScope = Parent;
  CurrentDecl = CurrentDecl->getEnclosingDecl();
}

一个简单的辅助类用于实现 RAII 习惯用法:

class EnterDeclScope {
  Sema &Semantics;
public:
  EnterDeclScope(Sema &Semantics, Decl *D)
      : Semantics(Semantics) {
    Semantics.enterScope(D);
  }
  ~EnterDeclScope() { Semantics.leaveScope(); }
};

在解析模块或过程时,现在有两种与语义分析器的交互。第一种是在解析名称之后。在这里,(几乎为空的)AST 节点被构造,并建立了一个新的作用域:

bool Parser::parseProcedureDeclaration(/* … */) {
  /* … */
  if (consume(tok::kw_PROCEDURE)) return _errorhandler();
  if (expect(tok::identifier)) return _errorhandler();
  ProcedureDeclaration *D =
      Actions.actOnProcedureDeclaration(
          Tok.getLocation(), Tok.getIdentifier());
  EnterDeclScope S(Actions, D);
  /* … */
}

语义分析器不仅仅是检查当前作用域中的名称并返回 AST 节点:

ProcedureDeclaration *
Sema::actOnProcedureDeclaration(SMLoc Loc, StringRef Name) {
  ProcedureDeclaration *P =
      new ProcedureDeclaration(CurrentDecl, Loc, Name);
  if (!CurrentScope->insert(P))
    Diags.report(Loc, diag::err_symbold_declared, Name);
  return P;
}

一旦所有声明和过程的主体被解析,真正的工作就开始了。基本上,语义分析器只需要检查过程声明末尾的名称是否等于过程的名称,并且用于返回类型的声明是否真的是一个类型声明:

void Sema::actOnProcedureDeclaration(
    ProcedureDeclaration *ProcDecl, SMLoc Loc,
    StringRef Name, FormalParamList &Params, Decl *RetType,
    DeclList &Decls, StmtList &Stmts) {
  if (Name != ProcDecl->getName()) {
    Diags.report(Loc, diag::err_proc_identifier_not_equal);
    Diags.report(ProcDecl->getLocation(),
                 diag::note_proc_identifier_declaration);
  }
  ProcDecl->setDecls(Decls);
  ProcDecl->setStmts(Stmts);
  auto RetTypeDecl =
      dyn_cast_or_null<TypeDeclaration>(RetType);
  if (!RetTypeDecl && RetType)
    Diags.report(Loc, diag::err_returntype_must_be_type,
                 Name);
  else
    ProcDecl->setRetType(RetTypeDecl);
}

一些声明是固有的,无法由开发人员定义。这包括BOOLEANINTEGER类型以及TRUEFALSE字面量。这些声明存在于全局范围,并且必须以编程方式添加。Modula-2 还预定义了一些过程,例如INCDEC,这些过程也应该添加到全局范围。鉴于我们的类,全局范围的初始化很简单:

void Sema::initialize() {
  CurrentScope = new Scope();
  CurrentDecl = nullptr;
  IntegerType =
      new TypeDeclaration(CurrentDecl, SMLoc(), "INTEGER");
  BooleanType =
      new TypeDeclaration(CurrentDecl, SMLoc(), "BOOLEAN");
  TrueLiteral = new BooleanLiteral(true, BooleanType);
  FalseLiteral = new BooleanLiteral(false, BooleanType);
  TrueConst = new ConstantDeclaration(CurrentDecl, SMLoc(),
                                      "TRUE", TrueLiteral);
  FalseConst = new ConstantDeclaration(
      CurrentDecl, SMLoc(), "FALSE", FalseLiteral);
  CurrentScope->insert(IntegerType);
  CurrentScope->insert(BooleanType);
  CurrentScope->insert(TrueConst);
  CurrentScope->insert(FalseConst);
}

有了这个方案,tinylang的所有必需计算都可以完成。例如,要计算表达式是否产生常量值,您必须确保发生以下情况:

  • 字面量或对常量声明的引用是常量。

  • 如果表达式的两侧都是常量,那么应用运算符也会产生一个常量。

这些规则很容易嵌入到语义分析器中,同时为表达式创建 AST 节点。同样,类型和常量值可以被计算。

应该指出,并非所有类型的计算都可以以这种方式完成。例如,要检测未初始化变量的使用,可以使用一种称为符号解释的方法。在其一般形式中,该方法需要通过 AST 的特殊遍历顺序,这在构建时是不可能的。好消息是,所提出的方法创建了一个完全装饰的 AST,可以用于代码生成。当然,可以根据需要打开或关闭昂贵的分析,这个 AST 当然可以用于进一步的分析。

要玩转前端,您还需要更新驱动程序。由于缺少代码生成,正确的tinylang程序不会产生任何输出。但是,它可以用来探索错误恢复并引发语义错误:

#include "tinylang/Basic/Diagnostic.h"
#include "tinylang/Basic/Version.h"
#include "tinylang/Parser/Parser.h"
#include "llvm/Support/InitLLVM.h"
#include "llvm/Support/raw_ostream.h"
using namespace tinylang;
int main(int argc_, const char **argv_) {
  llvm::InitLLVM X(argc_, argv_);
  llvm::SmallVector<const char *, 256> argv(argv_ + 1,
                                            argv_ + argc_);
  llvm::outs() << "Tinylang "
               << tinylang::getTinylangVersion() << "\n";
  for (const char *F : argv) {
    llvm::ErrorOr<std::unique_ptr<llvm::MemoryBuffer>>
        FileOrErr = llvm::MemoryBuffer::getFile(F);
    if (std::error_code BufferError =
            FileOrErr.getError()) {
      llvm::errs() << "Error reading " << F << ": "
                   << BufferError.message() << "\n";
      continue;
    }
    llvm::SourceMgr SrcMgr;
    DiagnosticsEngine Diags(SrcMgr);
    SrcMgr.AddNewSourceBuffer(std::move(*FileOrErr),
                              llvm::SMLoc());
    auto lexer = Lexer(SrcMgr, Diags);
    auto sema = Sema(Diags);
    auto parser = Parser(lexer, sema);
    parser.parse();
  }
}

恭喜!您已经完成了对tinylang前端的实现!

现在,让我们尝试一下我们到目前为止学到的东西。保存以下源代码,这是欧几里德最大公约数算法的实现,保存为Gcd.mod文件:

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 Gcm.mod
Tinylang 0.1

除了打印版本号之外,没有其他输出。这是因为只实现了前端部分。但是,如果更改源代码以包含语法错误,那么将打印错误消息。

我们将在下一章讨论代码生成,继续这个有趣的话题。

总结

在本章中,您了解了现实世界编译器在前端使用的技术。从项目的布局开始,您为词法分析器、解析器和语义分析器创建了单独的库。为了向用户输出消息,您扩展了现有的 LLVM 类,这允许消息被集中存储。词法分析器现在已经分成了几个接口。

然后,您学会了如何从语法描述中构建递归下降解析器,要避免哪些陷阱,以及如何使用生成器来完成工作。您构建的语义分析器执行了语言所需的所有语义检查,同时与解析器和 AST 构造交织在一起。

您的编码工作的结果是一个完全装饰的 AST,将在下一章中用于生成 IR 代码和目标代码。

第五章:IR 代码生成基础

创建了装饰的抽象语法树AST)用于您的编程语言后,下一个任务是从中生成 LLVM IR 代码。LLVM IR 代码类似于三地址代码,具有人类可读的表示。因此,我们需要一个系统化的方法来将语言概念,如控制结构,转换为 LLVM IR 的较低级别。

在本章中,您将学习 LLVM IR 的基础知识,以及如何从 AST 中为控制流结构生成 IR。您还将学习如何使用现代算法以静态单赋值SSA形式为表达式生成 LLVM IR。最后,您将学习如何发出汇编文本和目标代码。

本章将涵盖以下主题:

  • 从 AST 生成 IR

  • 使用 AST 编号以 SSA 形式生成 IR 代码

  • 设置模块和驱动程序

在本章结束时,您将掌握创建自己的编程语言的代码生成器的知识,以及如何将其集成到自己的编译器中。

技术要求

本章的代码文件可在github.com/PacktPublishing/Learn-LLVM-12/tree/master/Chapter05/tinylang找到

您可以在bit.ly/3nllhED找到代码的实际操作视频

从 AST 生成 IR

LLVM 代码生成器将模块作为 IR 的描述输入,并将其转换为目标代码或汇编文本。我们需要将 AST 表示转换为 IR。为了实现 IR 代码生成器,我们将首先查看一个简单的示例,然后开发所需的类:CodeGeneratorCGModuleCGProcedure类。CodeGenerator类是编译器驱动程序使用的通用接口。CGModuleCGProcedure类保存了为编译单元和单个函数生成 IR 代码所需的状态。

我们将从下一节开始查看clang生成的 IR。

理解 IR 代码

在生成 IR 代码之前,了解 IR 语言的主要元素是很有用的。在[第三章](B15647_03_ePub_RK.xhtml#_idTextAnchor048)编译器的结构中,我们已经简要地看了 IR。了解 IR 的更多知识的简单方法是研究clang的输出。例如,保存这个 C 源代码,它实现了欧几里德算法来计算两个数的最大公约数,命名为gcd.c

unsigned gcd(unsigned a, unsigned b) {
  if (b == 0)
    return a;
  while (b != 0) {
    unsigned t = a % b;
    a = b;
    b = t;
  }
  return a;
}

然后,您可以使用以下命令创建 IR 文件gcd.ll

$ clang --target=aarch64-linux-gnu –O1 -S -emit-llvm gcd.c

IR 代码并非目标无关,即使它看起来经常是这样。前面的命令在 Linux 上为 ARM 64 位 CPU 编译源文件。-S选项指示clang输出一个汇编文件,并通过额外的-emit-llvm规范创建一个 IR 文件。优化级别-O1用于获得易于阅读的 IR 代码。让我们来看看生成的文件,并了解 C 源代码如何映射到 IR。在文件顶部,建立了一些基本属性:

; ModuleID = 'gcd.c'
source_filename = "gcd.c"
target datalayout = "e-m:e-i8:8:32-i16:16:32-i64:64-
                     i128:128-n32:64-S128"
target triple = "aarch64-unknown-linux-gnu"

第一行是一个注释,告诉您使用了哪个模块标识符。在下一行,命名了源文件的文件名。使用clang,两者是相同的。

target datalayout字符串建立了一些基本属性。它的部分由-分隔。包括以下信息:

  • 小写的e表示内存中的字节使用小端模式存储。要指定大端模式,使用大写的E

  • m:指定应用于符号的名称修饰。这里,m:e表示使用了 ELF 名称修饰。

  • iN:A:P形式的条目,例如i8:8:32,指定了以位为单位的数据对齐。第一个数字是 ABI 所需的对齐,第二个数字是首选对齐。对于字节(i8),ABI 对齐是 1 字节(8),首选对齐是 4 字节(32)。

  • n指定了可用的本机寄存器大小。n32:64表示本机支持 32 位和 64 位宽整数。

  • S指定了堆栈的对齐方式,同样是以位为单位。S128表示堆栈保持 16 字节对齐。

注意

目标数据布局可以提供更多的信息。您可以在参考手册中找到完整的信息,网址为llvm.org/docs/LangRef.html#data-layout

最后,target triple字符串指定了我们正在编译的架构。这对于我们在命令行上提供的信息至关重要。您将在第二章中找到对 triple 的更深入讨论,LLVM 源码之旅

接下来,在 IR 文件中定义了gcd函数。

define i32 @gcd(i32 %a, i32 %b) {

这类似于 C 文件中的函数签名。unsigned数据类型被翻译为 32 位整数类型i32。函数名以@为前缀,参数名以%为前缀。函数体用大括号括起来。函数体的代码如下:

entry:
  %cmp = icmp eq i32 %b, 0
  br i1 %cmp, label %return, label %while.body

IR 代码是以所谓的entry组织的。块中的代码很简单:第一条指令将参数%b0进行比较。如果条件为true,第二条指令将分支到标签return,如果条件为false,则分支到标签while.body

IR 代码的另一个特点是它在%cmp中。这个寄存器随后被使用,但再也没有被写入。像常量传播和公共子表达式消除这样的优化在 SSA 形式下工作得非常好,所有现代编译器都在使用它。

下一个基本块是while循环的主体:

while.body:
  %b.addr.010 = phi i32 [ %rem, %while.body ],
                        [ %b, %entry ]
  %a.addr.09 = phi i32 [ %b.addr.010, %while.body ],
                       [ %a, %entry ]
  %rem = urem i32 %a.addr.09, %b.addr.010
  %cmp1 = icmp eq i32 %rem, 0
  br i1 %cmp1, label %return, label %while.body

gcd的循环中,ab参数被分配了新的值。如果一个寄存器只能写一次,那么这是不可能的。解决方案是使用特殊的phi指令。phi指令有一个基本块和值的参数列表。基本块表示来自该基本块的入边,值是来自这些基本块的值。在运行时,phi指令将先前执行的基本块的标签与参数列表中的标签进行比较。

然后指令的值就是与标签相关联的值。对于第一个phi指令,如果先前执行的基本块是while.body,那么值就是寄存器%rem。如果entry是先前执行的基本块,那么值就是%b。这些值是基本块开始时的值。寄存器%b.addr.010从第一个phi指令中获得一个值。同一个寄存器在第二个phi指令的参数列表中使用,但在通过第一个phi指令更改之前,假定值是之前的值。

在循环主体之后,必须选择返回值:

return:
  %retval.0 = phi i32 [ %a, %entry ],
                      [ %b.addr.010, %while.body ]
  ret i32 %retval.0
}

再次,phi指令用于选择所需的值。ret指令不仅结束了这个基本块,还表示了运行时这个函数的结束。它将返回值作为参数。

关于使用phi指令有一些限制。它们必须是基本块的第一条指令。第一个基本块是特殊的:它没有先前执行的块。因此,它不能以phi指令开始。

IR 代码本身看起来很像 C 和汇编语言的混合体。尽管有这种熟悉的风格,但我们不清楚如何轻松地从 AST 生成 IR 代码。特别是phi指令看起来很难生成。但不要害怕。在下一节中,我们将实现一个简单的算法来做到这一点!

了解负载和存储方法

LLVM 中的所有局部优化都是基于这里显示的 SSA 形式。对于全局变量,使用内存引用。IR 语言知道加载和存储指令,用于获取和存储这些值。您也可以用于局部变量。这些指令不是 SSA 形式,LLVM 知道如何将它们转换为所需的 SSA 形式。因此,您可以为每个局部变量分配内存插槽,并使用加载和存储指令来更改它们的值。您只需要记住存储变量的内存插槽的指针。事实上,clang 编译器使用了这种方法。

让我们看一下带有加载和存储的 IR 代码。再次编译gcd.c,这次不启用优化:

$ clang --target=aarch64-linux-gnu -S -emit-llvm gcd.c

gcd函数现在看起来不同了。这是第一个基本块:

define i32 @gcd(i32, i32) {
  %3 = alloca i32, align 4
  %4 = alloca i32, align 4
  %5 = alloca i32, align 4
  %6 = alloca i32, align 4
  store i32 %0, i32* %4, align 4
  store i32 %1, i32* %5, align 4
  %7 = load i32, i32* %5, align 4
  %8 = icmp eq i32 %7, 0
  br i1 %8, label %9, label %11

IR 代码现在传递了寄存器和标签的自动编号。参数的名称没有指定。隐式地,它们是%0%1。基本块没有标签,所以它被分配为2。第一条指令为四个 32 位值分配了内存。之后,参数%0%1被存储在寄存器%4%5指向的内存插槽中。为了执行参数%10的比较,该值被显式地从内存插槽中加载。通过这种方法,您不需要使用phi指令!相反,您从内存插槽中加载一个值,对其进行计算,并将新值存储回内存插槽。下次读取内存插槽时,您将得到最后计算出的值。gcd函数的所有其他基本块都遵循这种模式。

以这种方式使用加载和存储指令的优势在于生成 IR 代码相当容易。缺点是您会生成大量 IR 指令,LLVM 会在将基本块转换为 SSA 形式后的第一个优化步骤中使用mem2reg pass 来删除这些指令。因此,我们直接生成 SSA 形式的 IR 代码。

我们开始通过将控制流映射到基本块来生成 IR 代码。

将控制流映射到基本块

如前一节所述,一个良好形成的基本块只是指令的线性序列。一个基本块可以以phi指令开始,并且必须以分支指令结束。在基本块内部,不允许有phi或分支指令。每个基本块都有一个标签,标记基本块的第一条指令。标签是分支指令的目标。您可以将分支视为两个基本块之间的有向边,从而得到控制流图CFG)。一个基本块可以有前任者后继者。函数的第一个基本块在没有前任者的意义上是特殊的。

由于这些限制,源语言的控制语句,如WHILEIF,会产生多个基本块。让我们看一下WHILE语句。WHILE语句的条件控制着循环体或下一条语句是否执行。条件必须在自己的基本块中生成,因为有两个前任者:

  • WHILE循环之前的语句产生的基本块

  • 从循环体末尾返回到条件的分支

还有两个后继者:

  • 循环体的开始

  • WHILE循环后面的语句产生的基本块

循环体本身至少有一个基本块:

图 5.1 - WHILE 语句的基本块

图 5.1 - WHILE 语句的基本块

IR 代码生成遵循这种结构。我们在CGProcedure类中存储当前基本块的指针,并使用llvm::IRBuilder<>的实例将指令插入基本块。首先,我们创建基本块:

void emitStmt(WhileStatement *Stmt) {
  llvm::BasicBlock *WhileCondBB = llvm::BasicBlock::Create(
      getLLVMCtx(), "while.cond", Fn);
  llvm::BasicBlock *WhileBodyBB = llvm::BasicBlock::Create(
      getLLVMCtx(), "while.body", Fn);
  llvm::BasicBlock *AfterWhileBB = 
    llvm::BasicBlock::Create(
      getLLVMCtx(), "after.while", Fn);

Fn变量表示当前函数,getLLVMCtx()返回 LLVM 上下文。这两者稍后设置。我们以一个分支结束当前基本块,该分支将保持条件:

  Builder.CreateBr(WhileCondBB);

条件的基本块成为新的当前基本块。我们生成条件并以条件分支结束块:

  setCurr(WhileCondBB);
  llvm::Value *Cond = emitExpr(Stmt->getCond());
  Builder.CreateCondBr(Cond, WhileBodyBB, AfterWhileBB);

接下来,我们生成循环体。作为最后一条指令,我们添加一个分支回到条件的基本块:

  setCurr(WhileBodyBB);
  emit(Stmt->getWhileStmts());
  Builder.CreateBr(WhileCondBB);

这结束了WHILE语句的生成。WHILE语句后的空基本块成为新的当前基本块:

  setCurr(AfterWhileBB);
}

按照这个模式,你可以为源语言的每个语句创建一个emit()方法。

使用 AST 编号生成 SSA 形式的 IR 代码

为了从 AST 中生成 SSA 形式的 IR 代码,我们使用一种称为AST 编号的方法。基本思想是对于每个基本块,我们存储在该基本块中写入的本地变量的当前值。

尽管它很简单,我们仍然需要几个步骤。首先我们将介绍所需的数据结构,然后我们将实现读写基本块内的本地值。然后我们将处理在几个基本块中使用的值,并最后优化创建的phi指令。

定义保存值的数据结构

我们使用struct BasicBlockDef来保存单个块的信息:

struct BasicBlockDef {
llvm::DenseMap<Decl *, llvm::TrackingVH<llvm::Value>> Defs;
// ...
};

LLVM 类llvm::Value表示 SSA 形式中的值。Value类就像计算结果的标签。它通常通过 IR 指令创建一次,然后被后续使用。在各种优化过程中可能会发生变化。例如,如果优化器检测到值%1%2始终相同,那么它可以用%1替换%2的使用。基本上,这改变了标签,但不改变计算。为了意识到这样的变化,我们不能直接使用Value类。相反,我们需要一个值句柄。有不同功能的值句柄。为了跟踪替换,我们使用llvm::TrackingVH<>类。因此,Defs成员将 AST 的声明(变量或形式参数)映射到其当前值。现在我们需要为每个基本块存储这些信息:

llvm::DenseMap<llvm::BasicBlock *, BasicBlockDef> 
  CurrentDef;

有了这种数据结构,我们现在能够处理本地值。

读写基本块内的本地值

要在基本块中存储本地变量的当前值,我们只需在映射中创建一个条目:

void writeLocalVariable(llvm::BasicBlock *BB, Decl *Decl,
                        llvm::Value *Val) {
  CurrentDef[BB].Defs[Decl] = Val;
}

查找变量的值有点复杂,因为该值可能不在基本块中。在这种情况下,我们需要扩展搜索到前驱,使用可能的递归搜索:

llvm::Value *
readLocalVariable(llvm::BasicBlock *BB, Decl *Decl) {
  auto Val = CurrentDef[BB].Defs.find(Decl);
  if (Val != CurrentDef[BB].Defs.end())
    return Val->second;
  return readLocalVariableRecursive(BB, Decl);
}

真正的工作是搜索前驱,这在下一节中实现。

搜索前驱块的值

如果我们正在查看的当前基本块只有一个前驱,那么我们在那里搜索变量的值。如果基本块有几个前驱,那么我们需要在所有这些块中搜索该值并组合结果。要说明这种情况,可以看看上一节中WHILE语句的条件的基本块。

这个基本块有两个前驱 - 一个是WHILE循环之前的语句产生的,另一个是WHILE循环体结束的分支产生的。在条件中使用的变量应该有一个初始值,并且很可能在循环体中被改变。因此,我们需要收集这些定义并从中创建一个phi指令。从WHILE语句创建的基本块包含一个循环。

因为我们递归搜索前驱块,我们必须打破这个循环。为此,我们使用一个简单的技巧。我们插入一个空的phi指令并记录这个作为变量的当前值。如果我们在搜索中再次看到这个基本块,那么我们会发现变量有一个值,我们会使用它。搜索在这一点停止。收集到所有的值后,我们必须更新phi指令。

我们仍然会面临一个问题。在查找时,基本块的所有前驱可能并不都已知。这是怎么发生的?看看WHILE语句的基本块的创建。循环条件的 IR 首先生成。但是从主体末尾返回到包含条件的基本块的分支只能在生成主体的 IR 之后添加,因为这个基本块在之前是未知的。如果我们需要在条件中读取变量的值,那么我们就陷入困境,因为并不是所有的前驱都是已知的。

为了解决这种情况,我们必须再做一点:

  1. 首先,我们给基本块附加一个标志。

  2. 然后,如果我们知道基本块的所有前驱,我们将定义基本块为已封装。如果基本块没有被封装,并且我们需要查找尚未在这个基本块中定义的变量的值,那么我们插入一个空的phi指令并将其用作值。

  3. 我们还需要记住这个指令。如果块后来被封装,那么我们需要用真实的值更新指令。为了实现这一点,我们向struct BasicBlockDef添加了两个成员:IncompletePhis 映射记录了我们需要稍后更新的phi指令,Sealed 标志指示基本块是否已封装:

llvm::DenseMap<llvm::PHINode *, Decl *> 
  IncompletePhis;
unsigned Sealed : 1;
  1. 然后,该方法可以按照描述实现:
llvm::Value *readLocalVariableRecursive(
                               llvm::BasicBlock *BB,
                               Decl *Decl) {
  llvm::Value *Val = nullptr;
  if (!CurrentDef[BB].Sealed) {
    llvm::PHINode *Phi = addEmptyPhi(BB, Decl);
    CurrentDef[BB].IncompletePhis[Phi] = Decl;
    Val = Phi;
  } else if (auto *PredBB = BB
                           ->getSinglePredecessor()) {
    Val = readLocalVariable(PredBB, Decl);
  } else {
    llvm::PHINode *Phi = addEmptyPhi(BB, Decl);
    Val = Phi;
    writeLocalVariable(BB, Decl, Val);
    addPhiOperands(BB, Decl, Phi);
  }
  writeLocalVariable(BB, Decl, Val);
  return Val;
}
  1. “addEmptyPhi()”方法在基本块的开头插入一个空的phi指令:
llvm::PHINode *addEmptyPhi(llvm::BasicBlock *BB, Decl *Decl) {
  return BB->empty()
             ? llvm::PHINode::Create(mapType(Decl), 0,
              "", BB)
             : llvm::PHINode::Create(mapType(Decl), 0, 
              "", &BB->front());
}
  1. 为了向phi指令添加缺失的操作数,我们首先搜索基本块的所有前驱,并将操作数对值和基本块添加到phi指令中。然后,我们尝试优化指令:
void addPhiOperands(llvm::BasicBlock *BB, Decl *Decl,
                    llvm::PHINode *Phi) {
  for (auto I = llvm::pred_begin(BB),
            E = llvm::pred_end(BB);
       I != E; ++I) {
    Phi->addIncoming(readLocalVariable(*I, Decl), *I);
  }
  optimizePhi(Phi);
}

这个算法可能会生成不需要的phi指令。优化这些的方法在下一节中实现。

优化生成的 phi 指令

我们如何优化phi指令,为什么要这样做?尽管 SSA 形式对许多优化有利,phi指令通常不被算法解释,从而一般阻碍了优化。因此,我们生成的phi指令越少越好:

  1. 如果指令只有一个操作数或所有操作数都具有相同的值,那么我们将用这个值替换指令。如果指令没有操作数,那么我们将用特殊值Undef替换指令。只有当指令有两个或更多不同的操作数时,我们才必须保留指令:
void optimizePhi(llvm::PHINode *Phi) {
  llvm::Value *Same = nullptr;
  for (llvm::Value *V : Phi->incoming_values()) {
    if (V == Same || V == Phi)
      continue;
    if (Same && V != Same)
      return;
    Same = V;
  }
  if (Same == nullptr)
    Same = llvm::UndefValue::get(Phi->getType());
  1. 删除一个phi指令可能会导致其他phi指令的优化机会。我们搜索其他phi指令中值的所有用法,然后尝试优化这些指令:
  llvm::SmallVector<llvm::PHINode *, 8> CandidatePhis;
  for (llvm::Use &U : Phi->uses()) {
    if (auto *P =
            llvm::dyn_cast<llvm::PHINode>(U.getUser()))
      CandidatePhis.push_back(P);
  }
  Phi->replaceAllUsesWith(Same);
  Phi->eraseFromParent();
  for (auto *P : CandidatePhis)
    optimizePhi(P);
}

如果需要,这个算法可以进一步改进。我们可以选择并记住两个不同的值,而不是总是迭代每个phi指令的值列表。在optimize函数中,我们可以检查这两个值是否仍然在phi指令的列表中。如果是,那么我们知道没有什么可以优化的。但即使没有这种优化,这个算法运行非常快,所以我们现在不打算实现这个。

我们几乎完成了。只有封装基本块的操作还没有实现,我们将在下一节中实现。

封装一个块

一旦我们知道一个块的所有前驱都已知,我们就可以封存该块。如果源语言只包含结构化语句,比如 tinylang,那么很容易确定块可以被封存的位置。再看一下为 WHILE 语句生成的基本块。包含条件的基本块可以在从主体末尾添加分支之后封存,因为这是最后一个缺失的前驱。要封存一个块,我们只需向不完整的 phi 指令添加缺失的操作数并设置标志:

void sealBlock(llvm::BasicBlock *BB) {
  for (auto PhiDecl : CurrentDef[BB].IncompletePhis) {
    addPhiOperands(BB, PhiDecl.second, PhiDecl.first);
  }
  CurrentDef[BB].IncompletePhis.clear();
  CurrentDef[BB].Sealed = true;
}

有了这些方法,我们现在可以准备生成表达式的 IR 代码了。

为表达式创建 IR 代码

一般来说,你可以像在第三章中已经展示的那样翻译表达式,编译器的结构。唯一有趣的部分是如何访问变量。前一节涵盖了局部变量,但还有其他类型的变量。让我们简要讨论一下我们需要做什么:

  • 对于过程的局部变量,我们使用了前一节中的 readLocalVariable()writeLocalVariable() 方法。

  • 对于封闭过程中的局部变量,我们需要一个指向封闭过程框架的指针。这将在后面的部分处理。

  • 对于全局变量,我们生成加载和存储指令。

  • 对于形式参数,我们必须区分按值传递和按引用传递(tinylang 中的 VAR 参数)。按值传递的参数被视为局部变量,按引用传递的参数被视为全局变量。

把所有这些放在一起,我们得到了读取变量或形式参数的以下代码:

llvm::Value *CGProcedure::readVariable(llvm::BasicBlock 
                                       *BB,
                                       Decl *D) {
  if (auto *V = llvm::dyn_cast<VariableDeclaration>(D)) {
    if (V->getEnclosingDecl() == Proc)
      return readLocalVariable(BB, D);
    else if (V->getEnclosingDecl() ==
             CGM.getModuleDeclaration()) {
      return Builder.CreateLoad(mapType(D),
                                CGM.getGlobal(D));
    } else
      llvm::report_fatal_error(
          "Nested procedures not yet supported");
  } else if (auto *FP =
                 llvm::dyn_cast<FormalParameterDeclaration>(
                     D)) {
    if (FP->isVar()) {
      return Builder.CreateLoad(
          mapType(FP)->getPointerElementType(),
          FormalParams[FP]);
    } else
      return readLocalVariable(BB, D);
  } else
    llvm::report_fatal_error("Unsupported declaration");
}

写入变量或形式参数是对称的;我们只需要用写入方法替换读取方法,并使用 store 指令代替 load 指令。

接下来,在生成函数的 IR 代码时应用这些函数,我们将在下一步实现。

发出函数的 IR 代码

大部分 IR 代码将存在于一个函数中。IR 代码中的函数类似于 C 中的函数。它指定了参数和返回值的名称和类型以及其他属性。要在不同的编译单元中调用函数,您需要声明该函数。这类似于 C 中的原型。如果您向函数添加基本块,那么您就定义了该函数。我们将在接下来的部分中完成所有这些工作,首先讨论符号名称的可见性。

使用链接和名称混淆来控制可见性

函数(以及全局变量)都有一个链接样式。通过链接样式,我们定义了符号名称的可见性以及如果有多个符号具有相同名称时应该发生什么。最基本的链接样式是 privateexternal。具有 private 链接的符号只在当前编译单元中可见,而具有 external 链接的符号是全局可用的。

对于没有适当模块概念的语言,比如 C,这当然是足够的。有了模块,我们需要做更多的工作。假设我们有一个名为 Square 的模块,提供一个 Root() 函数,还有一个名为 Cube 的模块,也提供一个 Root() 函数。如果函数是私有的,那么显然没有问题。函数得到名称 Root 和私有链接。如果函数被导出,以便在其他模块中调用,情况就不同了。仅使用函数名称是不够的,因为这个名称不是唯一的。

解决方案是调整名称以使其全局唯一。这称为名称混编。如何做取决于语言的要求和特性。在我们的情况下,基本思想是使用模块和函数名的组合来创建全局唯一的名称。使用Square.Root作为名称看起来是一个明显的解决方案,但可能会导致与汇编器的问题,因为点可能具有特殊含义。我们可以通过在名称组件前面加上它们的长度来获得类似的效果,而不是在名称组件之间使用分隔符:6Square4Root。这对于 LLVM 来说不是合法标识符,但我们可以通过在整个名称前面加上_tt代表tinylang)来解决这个问题:_t6Square4Root。通过这种方式,我们可以为导出的符号创建唯一的名称:

std::string CGModule::mangleName(Decl *D) {
  std::string Mangled;
  llvm::SmallString<16> Tmp;
  while (D) {
    llvm::StringRef Name = D->getName();
    Tmp.clear();
    Tmp.append(llvm::itostr(Name.size()));
    Tmp.append(Name);
    Mangled.insert(0, Tmp.c_str());
    D = D->getEnclosingDecl();
  }
  Mangled.insert(0, "_t");
  return Mangled;
}

如果您的源语言支持类型重载,那么您需要使用类型名称来扩展此方案。例如,为了区分 C++函数int root(int)double root(double),参数的类型和返回值被添加到函数名中。

您还需要考虑生成名称的长度,因为一些链接器对长度有限制。在 C++中有嵌套的命名空间和类,混编的名称可能会很长。在那里,C++定义了一种压缩方案,以避免一遍又一遍地重复名称组件。

接下来,我们将看看如何处理参数类型。

将 AST 描述中的类型转换为 LLVM 类型

函数的参数也需要一些考虑。首先,我们需要将源语言的类型映射到 LLVM 类型。由于tinylang目前只有两种类型,这很容易:

llvm::Type *convertType(TypeDeclaration *Ty) {
  if (Ty->getName() == "INTEGER")
    return Int64Ty;
  if (Ty->getName() == "BOOLEAN")
    return Int1Ty;
  llvm::report_fatal_error("Unsupported type");
}

Int64TyInt1Ty,以及后来的VoidTy是类成员,保存着 LLVM 类型i64i1void的类型表示。

对于通过引用传递的形式参数,这还不够。这个参数的 LLVM 类型是一个指针。我们概括函数并考虑形式参数:

llvm::Type *mapType(Decl *Decl) {
  if (auto *FP = llvm::
    dyn_cast<FormalParameterDeclaration>(
          Decl)) {
    llvm::Type *Ty = convertType(FP->getType());
    if (FP->isVar())
      Ty = Ty->getPointerTo();
    return Ty;
  }
  if (auto *V = llvm::dyn_cast<VariableDeclaration>(Decl))
    return convertType(V->getType());
  return convertType(llvm::cast<TypeDeclaration>(Decl));
}

有了这些帮助,我们接下来创建 LLVM IR 函数。

创建 LLVM IR 函数

要在 LLVM IR 中发出函数,需要一个函数类型,它类似于 C 中的原型。创建函数类型涉及映射类型,然后调用工厂方法创建函数类型:

llvm::FunctionType *createFunctionType(
    ProcedureDeclaration *Proc) {
  llvm::Type *ResultTy = VoidTy;
  if (Proc->getRetType()) {
    ResultTy = mapType(Proc->getRetType());
  }
  auto FormalParams = Proc->getFormalParams();
  llvm::SmallVector<llvm::Type *, 8> ParamTypes;
  for (auto FP : FormalParams) {
    llvm::Type *Ty = mapType(FP);
    ParamTypes.push_back(Ty);
  }
  return llvm::FunctionType::get(ResultTy, ParamTypes,
                                 /* IsVarArgs */ false);
}

根据函数类型,我们还创建 LLVM 函数。这将函数类型与链接和名称混合在一起:

llvm::Function *
createFunction(ProcedureDeclaration *Proc,
               llvm::FunctionType *FTy) {
  llvm::Function *Fn = llvm::Function::Create(
      Fty, llvm::GlobalValue::ExternalLinkage,
      mangleName(Proc), getModule());

getModule()方法返回当前的 LLVM 模块,稍后我们将对其进行设置。

创建函数后,我们可以为其添加更多信息。首先,我们可以给参数命名。这使得 IR 更易读。其次,我们可以向函数和参数添加属性以指定一些特性。例如,我们对通过引用传递的参数这样做。

在 LLVM 级别,这些参数是指针。但是根据源语言设计,这些是非常受限制的指针。类似于 C++中的引用,我们总是需要为VAR参数指定一个变量。因此,我们知道这个指针永远不会为空,并且它总是可以解引用的,这意味着我们可以读取指向的值而不会出现一般保护错误。同样根据设计,这个指针不能被传递。特别是,没有指针的副本会在函数调用之后存在。因此,该指针被称为不被捕获。

llvm::AttributeBuilder类用于构建形式参数的属性集。要获取参数类型的存储大小,我们可以简单地询问数据布局:

  size_t Idx = 0;
  for (auto I = Fn->arg_begin(), E = Fn->arg_end(); I != E;
       ++I, ++Idx) {
    llvm::Argument *Arg = I;
    FormalParameterDeclaration *FP =
        Proc->getFormalParams()[Idx];
    if (FP->isVar()) {
      llvm::AttrBuilder Attr;
      llvm::TypeSize Sz =
          CGM.getModule()
            ->getDataLayout().getTypeStoreSize(
              CGM.convertType(FP->getType()));
      Attr.addDereferenceableAttr(Sz);
      Attr.addAttribute(llvm::Attribute::NoCapture);
      Arg->addAttrs(Attr);
    }
    Arg->setName(FP->getName());
  }
  return Fn;
}

我们现在已经创建了 IR 函数。在下一节中,我们将向函数添加函数体的基本块。

发出函数体

我们几乎完成了为函数发出 IR 代码!我们只需要将各个部分组合在一起以发出函数,包括其函数体:

  1. 给定来自tinylang的过程声明,我们首先创建函数类型和函数:
void run(ProcedureDeclaration *Proc) {
  this->Proc = Proc;
  Fty = createFunctionType(Proc);
  Fn = createFunction(Proc, Fty);
  1. 接下来,我们创建函数的第一个基本块,并将其设置为当前基本块:
  llvm::BasicBlock *BB = llvm::BasicBlock::Create(
      CGM.getLLVMCtx(), "entry", Fn);
  setCurr(BB);
  1. 然后我们遍历所有的形式参数。为了正确处理 VAR 参数,我们需要初始化FormalParams成员(在readVariable()中使用)。与局部变量不同,形式参数在第一个基本块中有一个值,所以我们让这些值知道:
  size_t Idx = 0;
  auto &Defs = CurrentDef[BB];
  for (auto I = Fn->arg_begin(), E = Fn->arg_end(); I !=        E; ++I, ++Idx) {
    llvm::Argument *Arg = I;
    FormalParameterDeclaration *FP = Proc->
      getParams()[Idx];
    FormalParams[FP] = Arg;
    Defs.Defs.insert(
        std::pair<Decl *, llvm::Value *>(FP, Arg));
  }
  1. 在进行下一步之前,我们可以调用emit()方法开始生成语句的 IR 代码:
  auto Block = Proc->getStmts();
  emit(Proc->getStmts());
  1. 在生成 IR 代码之后,最后一个块可能还没有封闭,所以现在我们调用sealBlock()tinylang中的一个过程可能有一个隐式返回,所以我们还要检查最后一个基本块是否有适当的终结符,如果没有,就添加一个:
  sealBlock(Curr);
  if (!Curr->getTerminator()) {
    Builder.CreateRetVoid();
  }
}

这完成了函数的 IR 代码生成。我们仍然需要创建 LLVM 模块,其中包含所有的 IR 代码。我们将在下一节中完成这个工作。

设置模块和驱动程序

我们在 LLVM 模块中收集编译单元的所有函数和全局变量。为了简化 IR 生成,我们将前面章节中的所有函数封装在一个代码生成器类中。为了获得一个可工作的编译器,我们还需要定义要生成代码的目标架构,并添加生成代码的传递。我们将在接下来的章节中实现所有这些,从代码生成器开始。

将所有内容包装在代码生成器中

IR 模块是我们为编译单元生成的所有元素的大括号。在全局级别,我们遍历模块级别的声明,并创建全局变量,并调用过程的代码生成。在tinylang中,全局变量映射到llvm::GobalValue类的实例。这个映射保存在Globals中,并且可以在过程的代码生成中使用:

void CGModule::run(ModuleDeclaration *Mod) {
  for (auto *Decl : Mod->getDecls()) {
    if (auto *Var =
            llvm::dyn_cast<VariableDeclaration>(Decl)) {
      llvm::GlobalVariable *V = new llvm::GlobalVariable(
          *M, convertType(Var->getType()),
          /*isConstant=*/false,
          llvm::GlobalValue::PrivateLinkage, nullptr,
          mangleName(Var));
      Globals[Var] = V;
    } else if (auto *Proc =
                   llvm::dyn_cast<ProcedureDeclaration>(
                       Decl)) {
      CGProcedure CGP(*this);
      CGP.run(Proc);
    }
  }
}

模块还包含LLVMContext类,并缓存了最常用的 LLVM 类型。后者需要初始化,例如,64 位整数类型:

Int64Ty = llvm::Type::getInt64Ty(getLLVMCtx());

CodeGenerator类初始化 LLVM IR 模块,并调用模块的代码生成。最重要的是,这个类必须知道我们想要为哪个目标架构生成代码。这个信息传递给llvm::TargetMachine类,在驱动程序中设置:

void CodeGenerator::run(ModuleDeclaration *Mod, std::string FileName) {
  llvm::Module *M = new llvm::Module(FileName, Ctx);
  M->setTargetTriple(TM->getTargetTriple().getTriple());
  M->setDataLayout(TM->createDataLayout());
  CGModule CGM(M);
  CGM.run(Mod);
}

为了方便使用,我们还引入了一个代码生成器的工厂方法:

CodeGenerator *CodeGenerator::create(llvm::TargetMachine *TM) {
  return new CodeGenerator(TM);
}

CodeGenerator类提供了一个小接口来创建 IR 代码,这对于在编译器驱动程序中使用是理想的。在集成之前,我们需要实现对机器代码生成的支持。

初始化目标机器类

现在,只剩下创建目标机器了。有了目标机器,我们可以定义要生成代码的 CPU 架构。对于每个 CPU,还有一些可用的特性,可以用来影响代码生成。例如,CPU 架构系列的新 CPU 可以支持矢量指令。有了特性,我们可以切换矢量指令的使用。为了支持从命令行设置所有这些选项,LLVM 提供了一些支持代码。在Driver类中,我们添加了以下include变量:

#include "llvm/CodeGen/CommandFlags.h"

这个include变量将常见的命令行选项添加到我们的编译器驱动程序中。许多 LLVM 工具也使用这些命令行选项,这样做的好处是为用户提供了一个共同的接口。只是缺少指定目标三元组的选项。由于这非常有用,我们自己添加这个选项:

static cl::opt<std::string>
    MTriple("mtriple",
            cl::desc("Override target triple for module"));

让我们创建目标机器:

  1. 为了显示错误消息,应用程序的名称必须传递给函数:
llvm::TargetMachine *
createTargetMachine(const char *Argv0) {
  1. 首先收集命令行提供的所有信息。这些是代码生成器的选项,CPU 的名称,可能要激活或停用的特性,以及目标的三元组:
  llvm::Triple = llvm::Triple(
      !MTriple.empty()
          ? llvm::Triple::normalize(MTriple)
          : llvm::sys::getDefaultTargetTriple());
  llvm::TargetOptions =
      codegen::InitTargetOptionsFromCodeGenFlags(Triple);
  std::string CPUStr = codegen::getCPUStr();
  std::string FeatureStr = codegen::getFeaturesStr();
  1. 然后我们在目标注册表中查找目标。如果发生错误,我们会显示错误消息并退出。用户指定的可能错误是不支持的三元组:
  std::string Error;
  const llvm::Target *Target =
      llvm::TargetRegistry::lookupTarget(
                     codegen::getMArch(), Triple, 
                     Error);
  if (!Target) {
    llvm::WithColor::error(llvm::errs(), Argv0) << 
                     Error;
    return nullptr;
  }
  1. 借助 Target 类的帮助,我们使用用户请求的所有已知选项配置目标机器:
  llvm::TargetMachine *TM = Target->
    createTargetMachine(
      Triple.getTriple(), CPUStr, FeatureStr, 
      TargetOptions, 
      llvm::Optional<llvm::Reloc::Model>(
                           codegen::getRelocModel()));
  return TM;
}

有了目标机器实例,我们可以生成针对我们选择的 CPU 架构的 IR 代码。缺少的是将其转换为汇编文本或生成目标代码文件。我们将在下一节中添加这个支持。

发出汇编文本和目标代码

在 LLVM 中,IR 代码通过一系列 passes 运行。每个 pass 执行一个任务,例如删除死代码。我们将在 第八章 中了解更多关于 passes 的知识,优化 IR。输出汇编代码或目标文件也被实现为一个 pass。让我们为此添加基本支持!

我们需要包含更多的 LLVM 头文件。我们需要 llvm::legacy::PassManager 类来保存发出代码到文件的 passes。我们还希望能够输出 LLVM IR 代码,因此我们还需要一个 pass 来发出这个。最后,我们使用 llvm:: ToolOutputFile 类进行文件操作:

#include "llvm/IR/IRPrintingPasses.h"
#include "llvm/IR/LegacyPassManager.h"
#include "llvm/Support/ToolOutputFile.h"

还需要另一个用于输出 LLVM IR 的命令行选项:

static cl::opt<bool>
    EmitLLVM("emit-llvm",
             cl::desc("Emit IR code instead of assembler"),
             cl::init(false));

emit() 方法中的第一个任务是处理输出文件的名称。如果输入是从 stdin 读取的,表示为减号 -,那么我们将结果输出到 stdoutToolOutputFile 类知道如何处理特殊文件名 -

bool emit(StringRef Argv0, llvm::Module *M,
          llvm::TargetMachine *TM,
          StringRef InputFilename) {
  CodeGenFileType FileType = codegen::getFileType();
  std::string OutputFilename;
  if (InputFilename == "-") {
    OutputFilename = "-";
  }

否则,我们会删除输入文件名的可能扩展,并根据用户给出的命令行选项附加.ll.s.o作为扩展名。FileType 选项在 llvm/CodeGen/CommandFlags.inc 头文件中定义,我们之前已经包含了这个选项。这个选项不支持发出 IR 代码,所以我们添加了新选项 –emit-llvm,只有在与汇编文件类型一起使用时才会生效:

  else {
    if (InputFilename.endswith(".mod"))
      OutputFilename = InputFilename.drop_back(4).str();
    else
      OutputFilename = InputFilename.str();
    switch (FileType) {
    case CGFT_AssemblyFile:
      OutputFilename.append(EmitLLVM ? ".ll" : ".s");
      break;
    case CGFT_ObjectFile:
      OutputFilename.append(".o");
      break;
    case CGFT_Null:
      OutputFilename.append(".null");
      break;
    }
  }

一些平台区分文本和二进制文件,因此在打开输出文件时我们必须提供正确的打开标志:

  std::error_code EC;
  sys::fs::OpenFlags = sys::fs::OF_None;
  if (FileType == CGFT_AssemblyFile)
    OpenFlags |= sys::fs::OF_Text;
  auto Out = std::make_unique<llvm::ToolOutputFile>(
      OutputFilename, EC, OpenFlags);
  if (EC) {
    WithColor::error(errs(), Argv0) << EC.message() << 
      '\n';
    return false;
  }

现在我们可以向 PassManager 添加所需的 passes。TargetMachine 类有一个实用方法,用于添加请求的类。因此,我们只需要检查用户是否要求输出 LLVM IR 代码:

  legacy::PassManager PM;
  if (FileType == CGFT_AssemblyFile && EmitLLVM) {
    PM.add(createPrintModulePass(Out->os()));
  } else {
    if (TM->addPassesToEmitFile(PM, Out->os(), nullptr,
                                FileType)) {
      WithColor::error() << "No support for file type\n";
      return false;
    }
  }

准备工作都做好了,发出文件归结为一个函数调用:

  PM.run(*M);

ToolOutputFile 类会在我们没有明确要求保留文件时自动删除文件。这样做可以使错误处理更容易,因为可能有很多地方需要处理错误,但只有一个地方在一切顺利的情况下被调用。我们成功地发出了代码,所以我们想要保留这个文件:

  Out->keep();

最后,我们向调用者报告成功:

  return true;
}

使用我们创建的 llvm::Module 调用 CodeGenerator 类的 emit() 方法,按照请求发出代码。

假设您在 tinylang 中有最大公约数算法存储在 gcd.mod 文件中。要将其转换为 gcd.os 目标文件,您可以输入以下内容:

$ tinylang –filetype=obj gcd.mod

如果您想直接在屏幕上检查生成的 IR 代码,那么可以输入以下内容:

$ tinylang –filetype=asm –emit-llvm –o – gcd.mod

让我们庆祝一下!到目前为止,我们已经创建了一个完整的编译器,从读取源语言到发出汇编代码或目标文件。

总结

在本章中,您学习了如何为 LLVM IR 代码实现自己的代码生成器。基本块是一个重要的数据结构,包含所有指令并表示分支。您学习了如何为源语言的控制语句创建基本块,以及如何向基本块添加指令。您应用了一种现代算法来处理函数中的局部变量,从而减少了 IR 代码。编译器的目标是为输入生成汇编文本或目标文件,因此您还添加了一个简单的编译流水线。有了这些知识,您将能够为自己的语言编译器生成 LLVM IR,随后生成汇编文本或目标代码。

在下一章中,您将学习如何处理聚合数据结构,以及如何确保函数调用符合您平台的规则。