编译器的结构

编译器技术是计算机科学中一个经过深入研究的领域。其高级任务是将源语言翻译成机器码。通常,这项任务分为三个部分:前端(frontend)、中端(middle end)和后端(backend)。前端主要处理源语言,中端执行代码改进的转换,后端负责生成机器码。由于LLVM核心库提供了中端和后端,我们将在本章重点关注前端。

在本章中,您将学习以下部分和主题:

  • 编译器的构建块,了解编译器中通常存在的组件
  • 算术表达式语言,介绍一个示例语言,并展示如何使用语法定义语言
  • 词法分析,讨论如何为语言实现词法解析器
  • 句法分析,介绍如何从语法构建解析器
  • 语义分析,学习如何实现语义检查
  • 使用LLVM后端的代码生成,讨论如何与LLVM后端接口并将前面的阶段整合起来,创建一个完整的编译器

编译器的构建块

自从计算机变得普遍可用以来,已经开发了成千上万种编程语言。结果显示,所有编译器都必须解决相同的任务,而且根据这些任务来构建编译器的实现是最佳选择。在高层次上,有三个组件。前端将源代码转换成中间表示IR)。然后中端对IR进行转换,目的是提高性能或减小代码大小。最后,后端从IR产生机器码。LLVM核心库提供了一个包含非常复杂转换的中端,以及所有流行平台的后端。此外,LLVM核心库还定义了一个中间表示,用作中端和后端的输入。这种设计的优势在于,您只需要关心要实现的编程语言的前端。

前端的输入是源代码,通常是文本文件。为了理解它,前端首先需要识别语言的单词,如数字和标识符,通常称为令牌(tokens)。这一步骤由词法解析器(lexer)执行。接下来,分析由令牌组成的句法结构。所谓的解析器(parser)执行这一步骤,其结果是抽象语法树AST)。最后,前端需要检查是否遵守了编程语言的规则,这由语义分析器完成。如果没有检测到错误,则AST被转换成IR并交给中端。

在接下来的部分中,我们将构建一个表达式语言的编译器,它可以将输入转换为LLVM IR。然后可以使用LLVM的llc静态编译器(代表后端)将IR编译成对象代码。一切都始于定义语言。请记住,本章中所有C++实现的文件都将包含在名为src/的目录中。

算术表达式语言

算术表达式是每种编程语言的一部分。以下是一个名为calc的算术表达式计算语言的示例。calc表达式被编译成一个应用程序,用于计算以下表达式:

with a, b: a * (4 + b)

表达式中使用的变量必须用关键字with声明。这个程序被编译成一个应用程序,询问用户ab变量的值并打印结果。

示例总是受欢迎的,但作为编译器编写者,您需要一个比这更详尽的规范来进行实现和测试。用于表示编程语言语法的工具是语法。

指定编程语言语法的形式化方法

语言的元素,例如关键字、标识符、字符串、数字和运算符,被称为令牌(tokens)。从这个意义上说,一个程序是令牌的序列,语法指定了哪些序列是有效的。

通常,语法以扩展巴科斯-诺尔范式EBNF)书写。语法中的一条规则有左右两个部分。左侧只有一个称为非终结符的符号。右侧由非终结符、令牌和用于替代和重复的元符号组成。让我们看看calc语言的语法:

calc : ("with" ident ("," ident)* ":")? expr ;
expr : term (( "+" | "-" ) term)* ;
term : factor (( "*" | "/") factor)* ;
factor : ident | number | "(" expr ")" ;
ident : ([a-zAZ])+ ;
number : ([0-9])+ ; 

在第一行中,calc是一个非终结符。如果没有另外说明,则语法中的第一个非终结符是开始符号。冒号(:)是规则左右两侧的分隔符。这里,"with"","":"是代表这个字符串的令牌。括号用于分组。一个组可以是可选的或重复的。闭括号后的问号(?)表示一个可选组。星号*表示零次或多次重复,加号+表示一次或多次重复。Identexpr是非终结符。对于它们中的每一个,都存在另一条规则。分号(;)标志着一条规则的结束。第二行中的管道|表示替代。最后,最后两行中的方括号[ ]表示字符类。有效字符写在方括号内。例如,字符类[a-zA-Z]匹配一个大写或小写字母,而([a-zA-Z])+匹配一个或多个这样的字母。这对应于一个正则表达式。

语法对编译器编写者有何帮助?

这样的语法可能看起来像一个理论玩具,但它对编译器编写者很有价值。首先,定义了所有的令牌,这对创建词法分析器是必需的。语法的规则可以转换成解析器。当然,如果对解析器是否正确工作有疑问时,语法可以作为一个很好的规范。

然而,语法并没有定义编程语言的所有方面。语法的意义——语义——也必须被定义。为此目的开发了形式化方法,但它们通常以纯文本形式指定,因为它们通常是在语言最初引入时制定的。

配备了这些知识,接下来的两节将展示如何进行词法分析,将输入转换成令牌序列,以及如何在C++中为句法分析编码语法。

词法分析

正如上一节中所见,编程语言由诸如关键字、标识符、数字、运算符等多种元素组成。词法分析器的任务是接受文本输入,并从中创建一个令牌序列。calc语言包含with:+-*/()等令牌,以及正则表达式([a-zA-Z])+(表示标识符)和([0-9])+(表示数字)。为了方便处理令牌,我们为每个令牌分配了一个唯一的数字。

手写词法分析器

词法分析器的实现通常称为Lexer。让我们创建一个名为Lexer.h的头文件,并开始定义Token。它从常见的头文件保护和所需头文件的包含开始:

#ifndef LEXER_H
#define LEXER_H
#include "llvm/ADT/StringRef.h"
#include "llvm/Support/MemoryBuffer.h" 

llvm::MemoryBuffer类提供对一个内存块的只读访问,该内存块填充了文件的内容。根据需求,可以在缓冲区的末尾添加一个尾随零字符('\x00')。我们使用这个特性来读取缓冲区,而无需在每次访问时检查缓冲区的长度。llvm::StringRef类封装了一个C字符串及其长度的指针。因为长度被存储了,所以字符串不需要像普通C字符串那样以零字符('\x00')结尾。这允许StringRef的实例指向由MemoryBuffer管理的内存。

考虑到这一点,我们开始实现Lexer类:

  1. 首先,Token类包含前面提到的唯一令牌编号的枚举定义:
   class Lexer;
   class Token {
     friend class Lexer;
   public:
     enum TokenKind : unsigned short {
       eoi, unknown, ident, number, comma, colon, plus,
       minus, star, slash, l_paren, r_paren, KW_with
     }; 

除了为每个令牌定义一个成员外,我们还添加了两个额外的值:eoiunknowneoi代表输入结束,当输入的所有字符都处理完时返回。unknown用于词法层面的错误事件,例如,#不是该语言的令牌,因此会被映射为unknown

  1. 除了枚举外,该类还有一个Text成员,它指向令牌文本的开始。它使用前面提到的StringRef类:
   private:
     TokenKind Kind;
     llvm::StringRef Text;
   public:
     TokenKind getKind() const { return Kind; }
     llvm::StringRef getText() const { return Text; } 

这对于语义处理很有用,例如,对于一个标识符,知道其名称是有用的。

  1. is()isOneOf()方法用于测试令牌是否属于某种类型。isOneOf()方法使用了变参模板,允许多个参数:
     bool is(TokenKind K) const { return Kind == K; }
     bool isOneOf(TokenKind K1, TokenKind K2) const {
       return is(K1) || is(K2);
     }
     template <typename... Ts>
     bool isOneOf(TokenKind K1, TokenKind K2, Ts... Ks) const {
       return is(K1) || isOneOf(K2, Ks...);
     }
   }; 
  1. Lexer类本身具有类似的简单接口,接下来在头文件中:
   class Lexer {
     const char *BufferStart;
     const char *BufferPtr;
   public:
     Lexer(const llvm::StringRef &Buffer) {
       BufferStart = Buffer.begin();
       BufferPtr = BufferStart;
     }
     void next(Token &token);
   private:
     void formToken(Token &Result, const char *TokEnd,
                    Token::TokenKind Kind);
   };
   #endif 

除了构造函数外,公共接口只有next()方法,它返回下一个令牌。该方法像迭代器一样工作,始终推进到下一个可用令牌。该类的唯一成员是指向输入开始和下一个未处理字符的指针。假定缓冲区以终止0结束(就像C字符串一样)。

  1. 让我们在Lexer.cpp文件中实现Lexer类。它从一些用于分类字符的辅助函数开始:
   #include "Lexer.h"
   namespace charinfo {
     LLVM_READNONE inline bool isWhitespace(char c) {
       return c == ' ' || c == '\t' || c == '\f' ||
              c == '\v' || c == '\r' || c == '\n';
     }
     LLVM_READNONE inline bool isDigit(char c) {
       return c >= '0' && c <= '9';
     }
     LLVM_READNONE inline bool isLetter(char c) {
       return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
     }
   } 

这些函数用于使条件更易于阅读。

注意

我们不使用<cctype>标准库头文件提供的函数有两个原因。首先,这些函数根据环境中定义的区域设置改变行为。例如,如果区域设置是德语区,那么德语重音字母可以被归类为字母。这通常不是编译器所希望的。其次,由于这些函数的参数类型为int,因此需要从char类型转换。这种转换的结果取决于char被视为有符号还是无符号类型,导致可移植性问题。

  1. 从上一节的语法中,我们知道了该语言的所有令牌。但语法没有定义应该忽略的字符。例如,空格或换行符只添加空白,并且通常被忽略。next()方法从忽略这些字符开始:
   void Lexer::next(Token &token) {
     while (*BufferPtr && charinfo::isWhitespace(*BufferPtr)) {
       ++BufferPtr;
     } 
  1. 接下来,确保还有字符要处理:
     if (!*BufferPtr) {
       token.Kind = Token::eoi;
       return;
     } 

至少还有一个字符要处理。

  1. 我们首先检查字符是否是小写或大写。在这种情况下,令牌要么是标识符,要么是with关键字,因为标识符的正则表达式也与关键字匹配。这里最常见的解决方案是收集与正则表达式匹配的字符,并检查字符串是否恰好是关键字:
     if (charinfo::isLetter(*BufferPtr)) {
       const char *end = BufferPtr + 1;
       while (charinfo::isLetter(*end))
         ++end;
       llvm::StringRef Name(BufferPtr, end - BufferPtr);
       Token::TokenKind kind =
           Name == "with" ? Token::KW_with : Token::ident;
       formToken(token, end, kind);
       return;
     } 

formToken()私有方法用于填充令牌。

  1. 接下来,我们检查数字。这部分代码与前面的代码片段非常相似:
     else if (charinfo::isDigit(*BufferPtr)) {
       const char *end = BufferPtr + 1;
       while (charinfo::isDigit(*end))
         ++end;
       formToken(token, end, Token::number);
       return;
     }
   
   

现在,只剩下由固定字符串定义的标记(tokens)了。

  1. 这可以通过一个 switch 语句轻松完成。因为这些标记都只有一个字符,所以使用 CASE 预处理宏来减少输入:
   else {
       switch (*BufferPtr) {
   #define CASE(ch, tok) \
   case ch: formToken(token, BufferPtr + 1, tok); break
CASE('+', Token::plus);
   CASE('-', Token::minus);
CASE('*', Token::star);
   CASE('/', Token::slash);
CASE('(', Token::l_paren);
   CASE(')', Token::r_paren);
   CASE(':', Token::colon);
   CASE(',', Token::comma);
#undef CASE 
  1. 最后,我们需要检查意外字符:
    default:
      formToken(token, BufferPtr + 1, Token::unknown);
    }
    return;
  }
} 

唯一还缺少的是 formToken() 这个私有辅助方法。

  1. 它填充 Token 实例的成员,并更新指向下一个未处理字符的指针:
void Lexer::formToken(Token &Tok, const char *TokEnd,
                      Token::TokenKind Kind) {
  Tok.Kind = Kind;
  Tok.Text = llvm::StringRef(BufferPtr,
                             TokEnd - BufferPtr);
  BufferPtr = TokEnd;
} 

在下一部分,我们将探讨如何构建用于语法分析的解析器。

语法分析

语法分析是由解析器完成的,我们接下来将实现这个解析器。其基础是前面部分的语法和词法分析器。解析过程的结果是一个称为抽象语法树AST)的动态数据结构。AST 是输入的高度压缩表示,非常适合进行语义分析。

首先,我们将实现解析器,然后我们将研究 AST 中的解析过程。

手写解析器

解析器的接口定义在头文件 Parser.h 中。它从一些 include 声明开始:

#ifndef PARSER_H
#define PARSER_H
#include "AST.h"
#include "Lexer.h"
#include "llvm/Support/raw_ostream.h" 

AST.h 头文件声明了 AST 的接口,稍后将展示。由于 LLVM 的编码指南禁止使用 <iostream> 库,因此包含了 LLVM 功能的等效头文件。这是为了发出错误信息所必需的:

  1. Parser 类首先声明了一些私有成员:
   class Parser {
     Lexer &Lex;
     Token Tok;
     bool HasError; 

LexTok 是前面部分类的实例。Tok 存储下一个标记(前瞻),而 Lex 用于从输入中检索下一个标记。HasError 标志指示是否检测到错误。

  1. 一些方法处理标记:
     void error() {
       llvm::errs() << "Unexpected: " << Tok.getText()
                    << "\n";
       HasError = true;
     }
     void advance() { Lex.next(Tok); }
     bool expect(Token::TokenKind Kind) {
       if (Tok.getKind() != Kind) {
         error();
         return true;
       }
       return false;
     }
     bool consume(Token::TokenKind Kind) {
       if (expect(Kind))
         return true;
       advance();
       return false;
     } 

advance() 从词法分析器中检索下一个标记。expect() 测试前瞻是否为预期的种类,并在不是的情况下发出错误信息。最后,consume() 在前瞻为预期种类时检索下一个标记。如果发出错误信息,则将 HasError 标志设置为真。

  1. 为语法的每个非终结符声明了一个解析规则的方法:
    AST *parseCalc();
    Expr *parseExpr();
    Expr *parseTerm();
    Expr *parseFactor(); 

注意:

没有 identnumber 的方法。这些规则只返回标记,并被相应的标记所替代。

  1. 接下来是公共接口。构造函数初始化所有成员,并从词法分析器中检索第一个标记:
   public:
     Parser(Lexer &Lex) : Lex(Lex), HasError(false) {
       advance();
     } 
  1. 需要一个函数来获取错误标志的值:
     bool hasError() { return HasError; }
    
  1. 最后,parse() 方法是解析的主入口点:
     AST *parse();
   };
   #endif 

解析器的实现

让我们深入解析器的实现过程!

  1. 我们的实现位于 Parser.cpp 文件中,以 parse() 方法开始:
   #include "Parser.h"
   AST *Parser::parse() {
     AST *Res = parseCalc();
     expect(Token::eoi);
     return Res;
   } 

parse() 方法的主要作用是确保已经消耗了整个输入。你还记得第一部分中的解析示例在输入结束时添加了一个特殊符号吗?我们在这里检查它。

  1. parseCalc() 方法实现了相应的规则。值得仔细研究此方法,因为其他解析方法也遵循相同的模式。让我们回顾一下第一部分的规则:
   calc : ("with" ident ("," ident)* ":")? expr ;
  1. 方法开始时声明了一些局部变量:
   AST *Parser::parseCalc() {
     Expr *E;
     llvm::SmallVector<llvm::StringRef, 8> Vars; 
  1. 需要做的第一个决定是是否解析可选组。可选组以 with 标记开始,所以我们将标记与此值进行比较:
     if (Tok.is(Token::KW_with)) {
       advance(); 
  1. 接下来,我们期望一个标识符:
       if (expect(Token::ident))
         goto _error;
       Vars.push_back(Tok.getText());
       advance(); 

如果有一个标识符,那么我们就将它保存在 Vars 向量中。否则,就是一个语法错误,单独处理。

  1. 接下来的语法是一个重复组,解析更多用逗号分隔的标识符:
       while (Tok.is(Token::comma)) {
         advance();
         if (expect(Token::ident))
           goto _error;
         Vars.push_back(Tok.getText());
         advance();
       } 

目前为止,这应该不足为奇。重复组以标记 (,) 开始。测试标记成为 while 循环的条件,实现零次或多次重复。循环内部的标识符处理方式与之前相同。

  1. 最后,可选组在末尾需要一个冒号:
       if (consume(Token::colon))
         goto _error;
     } 
  1. 最后,必须解析 expr 的规则:
     E = parseExpr();
  1. 通过这个调用,规则的解析成功完成。现在使用收集到的信息来创建这个规则的 AST 节点:
   if (Vars.empty()) return E;
   else return new WithDecl(Vars, E); 

现在只剩下错误处理了。检测语法错误很容易,但从中恢复却出乎意料地复杂。这里采用了一种简单的方法,称为恐慌模式

在恐慌模式下,从标记流中删除标记,直到找到解析器可以继续工作的标记为止。大多数编程语言都有表示结束的符号,例如,在 C++ 中,;(语句结束)或 }(块结束)。这样的标记是寻找的好候选。

另一方面,我们正在寻找的符号可能缺失。在这种情况下,在解析器可以继续之前可能会删除很多标记。这听起来并不像它看起来那么糟糕。如今,编译器的速度更为重要。遇到错误时,开发者会查看第一个错误消息,修复它,然后重新启动编译器。这与使用打孔卡片时大不相同,那时获取尽可能多的错误消息很重要,因为下一次编译可能要到第二天。

错误处理

这里没有使用任意的标记进行查找,而是使用了一组特定的标记。对于每个非终结符,有一组可以在规则中跟随这个非终结符的标记:

  1. calc 的情况下,只有输入结束跟随这个非终结符。实现相对简单:
   _error:
     while (!Tok.is(Token::eoi))
       advance();
     return nullptr;
   }
  1. 其他解析方法的构造类似。parseExpr() 是对 expr 规则的翻译:
   Expr *Parser::parseExpr() {
     Expr *Left = parseTerm();
     while (Tok.isOneOf(Token::plus, Token::minus)) {
       BinaryOp::Operator Op =
           Tok.is(Token::plus) ? BinaryOp::Plus :
                                BinaryOp::Minus;
       advance();
       Expr *Right = parseTerm();
       Left = new BinaryOp(Op, Left, Right);
     }
     return Left;
   } 

规则内的重复组被翻译为 while 循环。注意,使用 isOneOf() 方法简化了对几个标记的检查。

  1. term 规则的编码看起来相同:
   Expr *Parser::parseTerm() {
     Expr *Left = parseFactor();
     while (Tok.isOneOf(Token::star, Token::slash)) {
       BinaryOp::Operator Op =
           Tok.is(Token::star) ? BinaryOp::Mul :
                                 BinaryOp::Div;
       advance();
       Expr *Right = parseFactor();
       Left = new BinaryOp(Op, Left, Right);
     }
     return Left;
   } 

这个方法与 parseExpr() 非常相似,你可能会想将它们合并成一个。在语法中,有可能有一个规则处理乘法和加法运算符。但使用两个规则的优势在于,操作符的优先级与数学计算顺序很好地吻合。如果你合并这两个规则,那么你需要在其他地方解决计算顺序的问题。

  1. 最后,你需要实现 factor 规则:
   Expr *Parser::parseFactor() {
     Expr *Res = nullptr;
     switch (Tok.getKind()) {
     case Token::number:
       Res = new Factor(Factor::Number, Tok.getText());
       advance(); break; 

这里使用 switch 语句而不是一连串的 ifelse if 语句,因为每个选项都以一个标记开始。通常,你应该考虑你喜欢使用哪些翻译模式。如果你稍后需要更改解析方法,那么如果不是每种方法都有不同的实现语法规则的方式,这会是一个优势。

  1. 如果使用 switch 语句,那么错误处理发生在 default 情况中:
   case Token::ident:
       Res = new Factor(Factor::Ident, Tok.getText());
       advance(); break;
   case Token::l_paren:
       advance();
       Res = parseExpr();
       if (!consume(Token::r_paren)) break;
   default:
       if (!Res) error(); 

我们在这里保护发出错误消息,因为有可能出现连续错误。

  1. 如果圆括号表达式中有语法错误,则已经发出了错误消息。这个保护措施防止了第二次错误消息:
       while (!Tok.isOneOf(Token::r_paren, Token::star,
                           Token::plus, Token::minus,
                           Token::slash, Token::eoi))
         advance();
     }
     return Res;
   } 

这很简单,不是吗?一旦你记住了使用的模式,根据语法规则编写解析器几乎是一件枯燥的工作。这种类型的解析器被称为递归下降解析器

递归下降解析器并不适用于所有语法

一个语法必须满足某些条件才适合构建递归下降解析器。这类语法被称为 LL(1)。事实上,你在互联网上找到的大多数语法并不属于这个语法类。大多数关于编译器构造理论的书籍都解释了这一点。这方面的经典书籍是由 Aho、Lam、Sethi 和 Ullman 编写的《编译器:原理、技术与工具》(被称为龙书)。

抽象语法树

解析过程的结果是 AST。AST 是输入程序的另一种紧凑表示形式。它捕获了关键信息。许多编程语言中有作为分隔符但不携带进一步含义的符号。例如,在 C++ 中,分号 ; 表示一个单独语句的结束。当然,这对解析器来说是重要的。但一旦我们将语句转换成内存中的表示,分号就不再重要,可以丢弃。

如果你看看示例表达式语言的第一条规则,那么很明显,with 关键词、逗号 (,) 和冒号 (:) 对程序的含义不重要。重要的是声明的变量列表,它们可能在表达式中使用。结果是,只需要几个类就可以记录信息:Factor 保存一个数字或标识符,BinaryOp 保存算术运算符以及表达式的左右两侧,WithDecl 存储声明的变量列表和表达式。ASTExpr 仅用于创建一个共同的类层次结构。

除了解析输入的信息外,还支持使用访问者模式进行树遍历。这一切都在 AST.h 头文件中:

  1. 它以访问者接口开始:
   #ifndef AST_H
   #define AST_H
   #include "llvm/ADT/SmallVector.h"
   #include "llvm/ADT/StringRef.h"
   class AST;
   class Expr;
   class Factor;
   class BinaryOp;
   class WithDecl;
   class ASTVisitor {
   public:
     virtual void visit(AST &){};
     virtual void visit(Expr &){};
     virtual void visit(Factor &) = 0;
     virtual void visit(BinaryOp &) = 0;
     virtual void visit(WithDecl &) = 0;
   }; 

访问者模式需要了解每个类。因为每个类也引用了访问者,所以我们在文件顶部声明了所有类。请注意,对于 ASTExprvisit() 方法有一个默认实现,什么也不做。

  1. AST 类是层次结构的根:
   class AST {
   public:
     virtual ~AST() {}
     virtual void accept(ASTVisitor &V) = 0;
   }; 
  1. 类似地,Expr 是与表达式相关的 AST 类的根:
   class Expr : public AST {
   public:
     Expr() {}
   }; 
  1. Factor 类存储数字或变量名:
   class Factor : public Expr {
   public:
     enum ValueKind { Ident, Number };
   private:
     ValueKind Kind;
     llvm::StringRef Val;
   public:
     Factor(ValueKind Kind, llvm::StringRef Val)
         : Kind(Kind), Val(Val) {}
     ValueKind getKind() { return Kind; }
     llvm::StringRef getVal() { return Val; }
     virtual void accept(ASTVisitor &V) override {
       V.visit(*this);
     }
   }; 

在这个示例中,数字和变量几乎被相同地对待,因此我们决定只创建一个 AST 节点类来代表它们。Kind 成员告诉我们实例表示的是哪种情况。在更复杂的语言中,你通常会想要不同的 AST 类,比如表示数字的 NumberLiteral 类和表示对变量的引用的 VariableAccess 类。

  1. BinaryOp 类保存了求值表达式所需的数据:
   class BinaryOp : public Expr {
   public:
     enum Operator { Plus, Minus, Mul, Div };
   private:
     Expr *Left;
     Expr *Right;
     Operator Op;
   public:
     BinaryOp(Operator Op, Expr *L, Expr *R)
         : Op return Left; }
     Expr *getRight() { return Right; }
     Operator getOperator() { return Op; }
     virtual void accept(ASTVisitor &V) override {
       V.visit(*this);
     }
   }; 

与解析器不同,BinaryOp 类不区分乘法和加法运算符。运算符的优先级隐含在树结构中。

  1. 最后,WithDecl 类存储声明的变量和表达式:
   class WithDecl : public AST {
     using VarVector = llvm::SmallVector<llvm::StringRef, 8>;
     VarVector Vars;
     Expr *E;
   public:
     WithDecl(llvm::SmallVector<llvm::StringRef, 8> Vars,
              Expr *E)
         : Vars(Vars), E(E) {}
     VarVector::const_iterator begin()
                                   { return Vars.begin(); }
     VarVector::const_iterator end() { return Vars.end(); }
     Expr *getExpr() { return E; }
     virtual void accept(ASTVisitor &V) override {
       V.visit(*this);
     }
   };
   #endif 

AST 在解析过程中构建。语义分析检查树是否符合语言的含义(例如,使用的变量是否已声明)并可能增强树。之后,该树用于代码生成。

语义分析

语义分析器遍历 AST 并检查语言的各种语义规则,例如,变量必须在使用前声明,或者表达式中变量的类型必须兼容。如果语义分析器发现可以改进的情况,也可以输出警告。对于示例表达式语言,语义分析器必须检查每个使用的变量是否已声明,因为这是语言的要求。一种可能的扩展(这里没有实现)是,如果声明的变量未被使用,则打印警告。

语义分析器在 Sema 类中实现,由 semantic() 方法执行。以下是完整的 Sema.h 头文件:

#ifndef SEMA_H
#define SEMA_H
#include "AST.h"
#include "Lexer.h"
class Sema {
public:
  bool semantic(AST *Tree);
};
#endif 

实现在 Sema.cpp 文件中。有趣的部分是语义分析,它使用访问者模式实现。基本思想是,每个声明的变量名都存储在一个集合中。在创建集合期间,可以检查每个名称的唯一性,稍后可以检查给定名称是否在集合中:

#include "Sema.h"
#include "llvm/ADT/StringSet.h"
namespace {
class DeclCheck : public ASTVisitor {
  llvm::StringSet<> Scope;
  bool HasError;
  enum ErrorType { Twice, Not };
  void error(ErrorType ET, llvm::StringRef V) {
    llvm::errs() << "Variable " << V << " "
                 << (ET == Twice ? "already" : "not")
                 << " declared\n";
    HasError = true;
  }
public:
  DeclCheck() : HasError(false) {}
  bool hasError() { return HasError; } 

Parser 类一样,使用一个标志来指示发生了错误。名称存储在一个名为 Scope 的集合中。对于保存变量名的 Factor 节点,检查变量名是否在集合中:

  virtual void visit(Factor &Node) override {
    if (Node.getKind() == Factor::Ident) {
      if (Scope.find(Node.getVal()) == Scope.end())
        error(Not, Node.getVal());
    }
  }; 

对于 BinaryOp 节点,除了检查两侧是否存在并被访问外,没有其他需要检查的:

  virtual void visit(BinaryOp &Node) override {
    if (Node.getLeft())
      Node.getLeft()->accept(*this);
    else
      HasError = true;
    if (Node.getRight())
      Node.getRight()->accept(*this);
    else
      HasError = true;
  }; 

WithDecl 节点上,填充集合并开始对表达式的遍历:

  virtual void visit(WithDecl &Node) override {
    for (auto I = Node.begin(), E = Node.end(); I != E;
         ++I) {
      if (!Scope.insert(*I).second)
        error(Twice, *I);
    }
    if (Node.getExpr())
      Node.getExpr()->accept(*this);
    else
      HasError = true;
  };
};
} 

semantic() 方法只是开始树遍历并返回错误标志:

bool Sema::semantic(AST *Tree) {
  if (!Tree)
    return false;
  DeclCheck Check;
  Tree->accept(Check);
  return Check.hasError();
} 

如果需要,这里可以做更多的事情。也可以打印警告,如果声明的变量未被使用。我们把这个作为练习留给你来实现。如果语义分析没有错误地完成,那么我们可以从 AST 生成 LLVM IR。这将在下一部分中完成。

使用 LLVM 后端生成代码

后端的任务是从模块的 LLVM IR 创建优化的机器代码。IR 是连接后端的接口,可以使用 C++ 接口或文本形式创建。同样,IR 是从 AST 生成的。

LLVM IR 的文本表示

在尝试生成 LLVM IR 之前,我们应该清楚我们想生成什么。对于我们的示例表达式语言,高层计划如下:

  1. 向用户询问每个变量的值。
  2. 计算表达式的值。
  3. 打印结果。

为了让用户为变量提供值并打印结果,使用了两个库函数:calc_read()calc_write()。对于 with a: 3*a 表达式,生成的 IR 如下:

  1. 必须像在 C 语言中那样声明库函数。语法也类似于 C。函数名前的类型是返回类型。括号中的类型名是参数类型。声明可以出现在文件的任何地方:
   declare i32 @calc_read(ptr)
   declare void @calc_write(i32) 
  1. calc_read() 函数将变量名作为参数。以下构造定义了一个常量,保存 a 和用作 C 中字符串终止符的空字节:
   @a.str = private constant [2 x i8] c"a\00"
  1. 接下来是 main() 函数。参数名被省略了,因为它们没有被使用。就像在 C 语言中一样,函数体用大括号括起来:
   define i32 @main(i32, ptr) {
  1. 每个基本块必须有一个标签。因为这是函数的第一个基本块,我们将其命名为 entry
   entry:
  1. 调用 calc_read() 函数读取 a 变量的值。嵌套的 getelemenptr 指令执行索引计算以计算字符串常量的第一个元素的指针。函数结果赋值给未命名的 %2 变量。
     %2 = call i32 @calc_read(ptr @a.str)
  1. 接下来,变量乘以 3
     %3 = mul nsw i32 3, %2
  1. 通过调用 calc_write() 函数将结果打印到控制台上:
     call void @calc_write(i32 %3)
  1. 最后,main() 函数返回 0 表示成功执行:
     ret i32 0
   } 

LLVM IR 中的每个值都是类型化的,i32 表示 32 位整型类型,ptr 表示指针。

注意

LLVM 的早期版本使用了类型化指针。例如,指向字节的指针在 LLVM 中表示为 i8*。从 LLVM 16 开始,默认使用不透明指针。不透明指针只是一个指向内存的指针,不携带任何关于它的类型信息。在 LLVM IR 中的表示为 ptr

既然现在清楚了 IR 的样子,让我们从 AST 生成它。

从 AST 生成 IR

CodeGen.h 头文件中提供的接口非常简洁:

#ifndef CODEGEN_H
#define CODEGEN_H
#include "AST.h"
class CodeGen
{
public:
 void compile(AST *Tree);
};
#endif 

因为 AST 包含了信息,基本的想法是使用访问者模式遍历 AST。CodeGen.cpp 文件的实现如下:

  1. 文件顶部有必需的包含:
   #include "CodeGen.h"
   #include "llvm/ADT/StringMap.h"
   #include "llvm/IR/IRBuilder.h"
   #include "llvm/IR/LLVMContext.h"
   #include "llvm/Support/raw_ostream.h" 
  1. 使用 LLVM 库的命名空间进行名称查找:
   using namespace llvm;
    
  1. 首先,在访问者中声明了一些私有成员。每个编译单元在 LLVM 中由 Module 类表示,访问者有一个指向模块的指针 M。为了简化 IR 生成,使用 IRBuilder<> 类型的 Builder。LLVM 有一个类层次结构来在 IR 中表示类型。你可以从 LLVM 上下文中查找基本类型如 i32 的实例。

    这些基本类型经常被使用。为了避免重复查找,我们缓存了所需类型实例:VoidTyInt32TyPtrTyInt32ZeroV 成员是当前计算的值,通过树遍历更新。最后,nameMap 将变量名映射到 calc_read() 函数返回的值:

   namespace {
   class ToIRVisitor : public ASTVisitor {
     Module *M;
     IRBuilder<> Builder;
     Type *VoidTy;
     Type *Int32Ty;
     PointerType *PtrTy;
     Constant *Int32Zero;
     Value *V;
     StringMap<Value *> nameMap; 
  1. 构造函数初始化所有成员:
   public:
     ToIRVisitor(Module *M) : M(M), Builder(M->getContext())
     {
       VoidTy = Type::getVoidTy(M->getContext());
       Int32Ty = Type::getInt32Ty(M->getContext());
       PtrTy = PointerType::getUnqual(M->getContext());
       Int32Zero = ConstantInt::get(Int32Ty, 0, true);
     } 
  1. 对于每个函数,必须创建一个 FunctionType 实例。在 C++ 术语中,这是一个函数原型。函数本身是用 Function 实例定义的。run() 方法首先在 LLVM IR 中定义 main() 函数:
     void run(AST *Tree) {
       FunctionType *MainFty = FunctionType::get(
           Int32Ty, {Int32Ty, PtrTy}, false);
       Function *MainFn = Function::Create(
           MainFty, GlobalValue::ExternalLinkage,
           "main", M); 
  1. 然后我们创建带有 entry 标签的 BB 基本块,并将其附加到 IR 构建器上:
       BasicBlock *BB = BasicBlock::Create(M->getContext(),
                                           "entry", MainFn);
       Builder.SetInsertPoint(BB); 
  1. 完成这些准备工作后,可以开始树遍历:
       Tree->accept(*this);
    
  1. 树遍历结束后,通过调用 calc_write() 函数打印计算的值。再次,必须创建一个函数原型(FunctionType 的实例)。唯一的参数是当前值 V
       FunctionType *CalcWriteFnTy =
           FunctionType::get(VoidTy, {Int32Ty}, false);
       Function *CalcWriteFn = Function::Create(
           CalcWriteFnTy, GlobalValue::ExternalLinkage,
           "calc_write", M);
       Builder.CreateCall(CalcWriteFnTy, CalcWriteFn, {V}); 
  1. 生成结束,main() 函数返回 0 表示成功执行:
       Builder.CreateRet(Int32Zero);
     } 
  1. WithDecl 节点持有声明变量的名称。首先,我们为 calc_read() 函数创建一个函数原型:

    virtual void visit(WithDecl &Node) override {
      FunctionType *ReadFty =
          FunctionType::get(Int32Ty, {PtrTy}, false);
      Function *ReadFn = Function::Create(
          ReadFty, GlobalValue::ExternalLinkage,
          "calc_read", M); 
  2. 方法遍历变量名:

      for (auto I = Node.begin(), E = Node.end(); I != E;
           ++I) { 
  3. 为每个变量创建一个变量名的字符串:

        StringRef Var = *I;
        Constant *StrText = ConstantDataArray::getString(
            M->getContext(), Var);
        GlobalVariable *Str = new GlobalVariable(
            *M, StrText->getType(),
            /*isConstant=*/true,
            GlobalValue::PrivateLinkage,
            StrText, Twine(Var).concat(".str")); 
  4. 然后创建 IR 代码调用 calc_read() 函数。上一步创建的字符串作为参数传递:

        CallInst *Call =
            Builder.CreateCall(ReadFty, ReadFn, {Str}); 
  5. 返回的值存储在 nameMap 映射中以供后用:

        nameMap[Var] = Call;
      } 
  6. 树遍历继续进行表达式处理:

      Node.getExpr()->accept(*this);
    }; 
  7. Factor 节点要么是变量名,要么是数字。对于变量名,值在 nameMap 映射中查找。对于数字,值被转换为整数并转换为常量值:

    virtual void visit(Factor &Node) override {
      if (Node.getKind() == Factor::Ident) {
        V = nameMap[Node.getVal()];
      } else {
        int intval;
        Node.getVal().getAsInteger(10, intval);
        V = ConstantInt::get(Int32Ty, intval, true);
      }
    }; 
  8. 最后,对于 BinaryOp 节点,必须使用正确的计算操作:

    virtual void visit(BinaryOp &Node) override {
      Node.getLeft()->accept(*this);
      Value *Left = V;
      Node.getRight()->accept(*this);
      Value *Right = V;
      switch (Node.getOperator()) {
      case BinaryOp::Plus:
        V = Builder.CreateNSWAdd(Left, Right); break;
      case BinaryOp::Minus:
        V = Builder.CreateNSWSub(Left, Right); break;
      case BinaryOp::Mul:
        V = Builder.CreateNSWMul(Left, Right); break;
      case BinaryOp::Div:
        V = Builder.CreateSDiv(Left, Right); break;
      }
    };
      };
      } 
  9. 这样,访问者类就完成了。compile() 方法创建全局上下文和模块,运行树遍历,并将生成的 IR 输出到控制台:

      void CodeGen::compile(AST *Tree) {
    LLVMContext Ctx;
    Module *M = new Module("calc.expr", Ctx);
    ToIRVisitor ToIR(M);
    ToIR.run(Tree);
    M->print(outs(), nullptr);
      } 

我们现在已经实现了编译器前端,从读取源代码到生成 IR。当然,所有这些组件必须在用户输入上一起工作,这是编译器驱动程序的任务。我们还需要实现运行时需要的函数。这些都是下一节的主题。

编译器驱动程序和运行时库 —— 缺失的部分

前几节中的所有阶段都通过 Calc.cpp 驱动程序结合在一起,我们按照如下方式实现:声明一个输入表达式的参数,初始化 LLVM,并调用前几节中的所有阶段:

  1. 首先,我们包含必要的头文件:
   #include "CodeGen.h"
   #include "Parser.h"
   #include "Sema.h"
   #include "llvm/Support/CommandLine.h"
   #include "llvm/Support/InitLLVM.h"
   #include "llvm/Support/raw_ostream.h" 
  1. LLVM 自带了自己的命令行选项声明系统。你只需要为每个需要的选项声明一个静态变量。这样做的好处是,每个组件可以根据需要添加命令行选项。我们声明了一个输入表达式的选项:
   static llvm::cl::opt<std::string>
       Input(llvm::cl::Positional,
             llvm::cl::desc("<input expression>"),
             llvm::cl::init("")); 
  1. main() 函数内部,首先初始化 LLVM 库。你需要调用 ParseCommandLineOptions() 函数来处理命令行上给定的选项。这也处理帮助信息的打印。如果出现错误,此方法会退出应用程序:
   int main(int argc, const char **argv) {
     llvm::InitLLVM X(argc, argv);
     llvm::cl::ParseCommandLineOptions(
         argc, argv, "calc - the expression compiler\n"); 
  1. 接下来,我们调用词法分析器和解析器。在语法分析之后,我们检查是否发生了任何错误。如果是这种情况,我们会退出编译器并返回指示失败的返回代码:
     Lexer Lex(Input);
     Parser Parser(Lex);
     AST *Tree = Parser.parse();
     if (!Tree || Parser.hasError()) {
       llvm::errs() << "Syntax errors occured\n";
       return 1;
     } 
  1. 如果发生了语义错误,我们也会执行相同的操作:
    Sema Semantic;
     if (Semantic.semantic(Tree)) {
    llvm::errs() << "Semantic errors occured\n";
       return 1;
    } 
  1. 作为驱动程序的最后一步,调用代码生成器:
    CodeGen CodeGenerator;
     CodeGenerator.compile(Tree);
    return 0;
   } 

现在我们已经成功为用户输入创建了一些 IR 代码。我们将对象代码生成委托给 LLVM 的 llc 静态编译器,这样就完成了我们编译器的实现。我们将所有组件链接在一起,创建 calc 应用程序。

运行时库由单个文件 rtcalc.c 组成。它以 C 语言编写,实现了 calc_read()calc_write() 函数:

#include <stdio.h>
#include <stdlib.h>
void calc_write(int v)
{
  printf("The result is: %d\n", v);
} 

calc_write() 仅将结果值写入终端:

int calc_read(char *s)
{
  char buf[64];
  int val;
  printf("Enter a value for %s: ", s);
  fgets(buf, sizeof(buf), stdin);
  if (EOF == sscanf(buf, "%d", &val))
  {
    printf("Value %s is invalid\n", buf);
    exit(1);
  }
  return val;
} 

calc_read() 从终端读取一个整数。用户输入字母或其他字符不会阻止程序,因此我们必须仔细检查输入。如果输入不是数字,我们将退出应用程序。更复杂的方法是使用户意识到问题并再次要求输入数字。

下一步是构建并尝试我们的编译器 calc,这是一个从表达式创建 IR 的应用程序。

构建和测试 calc 应用程序

为了构建 calc,我们首先需要在包含所有源文件实现的原始 src 目录外创建一个新的 CMakeLists.txt 文件:

  1. 首先,我们设置了 LLVM 所需的最低 CMake 版本,并给项目命名为 calc
   cmake_minimum_required (VERSION 3.20.0)
   project ("calc") 
  1. 接下来,需要加载 LLVM 包,并将 LLVM 提供的 CMake 模块目录添加到搜索路径中:
   find_package(LLVM REQUIRED CONFIG)
   message("Found LLVM ${LLVM_PACKAGE_VERSION}, build type ${LLVM_BUILD_TYPE}")
   list(APPEND CMAKE_MODULE_PATH ${LLVM_DIR}) 
  1. 我们还需要添加 LLVM 的定义和包含路径。使用的 LLVM 组件通过函数调用映射到库名:
   separate_arguments(LLVM_DEFINITIONS_LIST NATIVE_COMMAND ${LLVM_DEFINITIONS})
   add_definitions(${LLVM_DEFINITIONS_LIST})
   include_directories(SYSTEM ${LLVM_INCLUDE_DIRS})
   llvm_map_components_to_libnames(llvm_libs Core) 
  1. 最后,我们表示需要在构建中包含 src 子目录,因为本章中完成的所有 C++ 实现都位于此处:
   add_subdirectory ("src")

src 子目录内也需要一个新的 CMakeLists.txt 文件。src 目录内的 CMake 描述如下。我们只需定义可执行文件的名称(称为 calc),然后列出要编译的源文件和要链接的库:

add_executable (calc
  Calc.cpp CodeGen.cpp Lexer.cpp Parser.cpp Sema.cpp)
target_link_libraries(calc PRIVATE ${llvm_libs}) 

最后,我们可以开始构建 calc 应用程序。在 src 目录外,我们创建一个新的构建目录并切换到该目录。之后,我们可以按如下方式运行 CMake 和构建调用:

$ cmake -GNinja -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DLLVM_DIR=<path to llvm installation configuration> ../
$ ninja 

现在我们应该拥有一个新构建的、功能完备的 calc 应用程序,能够生成 LLVM IR 代码。这可以进一步用于 llc,这是 LLVM 的静态后端编译器,将 IR 代码编译成对象文件。

然后,您可以使用您喜欢的 C 编译器链接到小型运行时库。在 Unix 的 X86 上,你可以输入以下命令:

$ calc "with a: a*3" | llc –filetype=obj \
  -relocation-model=pic  –o=expr.o
$ clang –o expr expr.o rtcalc.c
$ expr
Enter a value for a: 4
The result is: 12 

在其他 Unix 平台如 AArch64 或 PowerPC 上,您需要移除 -relocation-model=pic 选项。

在 Windows 上,你需要按如下方式使用 cl 编译器:

$ calc "with a: a*3" | llc –filetype=obj –o=expr.obj
$ cl expr.obj rtcalc.c
$ expr
Enter a value for a: 4
The result is: 12 

您现在已经创建了您的第一个基于 LLVM 的编译器!请花点时间尝试各种表达式。特别要检查乘法运算符是否在加法运算符之前计算,以及使用括号是否改变计算顺序,正如我们从基本计算器中所期望的那样。

完整代码下载

https://download.csdn.net/download/cppclub/88767974
https://share.xueplus.com/s/9fXDVv-Cfijh.html

总结

在本章中,您了解了编译器的典型组件。为了向您介绍编程语言的语法,我们使用了算术表达式语言。您学习了如何为这种语言开发典型的前端组件:词法分析器、解析器、语义分析器和代码生成器。代码生成器仅产生 LLVM IR,并且使用 LLVM llc 静态编译器从中创建对象文件。您现在已经开发了您的第一个基于 LLVM 的编译器!

在下一章中,您将深化这些知识,构建一个编程语言的前端。


AI段舸
1 声望0 粉丝