使用Flex、Bison和LLVM编写自己的Toy Compiler

原文链接:

Writing Your Own Toy Compiler Using Flex, Bison and LLVM (gnuu.org)

以下为笔者翻译+增改的文章。原文是2009年发布的文章,所以请对文中的一些观点保持批判态度。除此之外,本文提到的代码都是旧代码,大概率都不能很好的运行,所以请访问最新的代码,地址如下:

https://github.com/lsegal/my_toy_compiler


1 前言

我一直对编译器和编程语言很有兴趣,但仅仅有兴趣是不够的。编译器设计中有许多初次学习时感到晦涩难懂的概念,即使是最聪明的程序员也会被这些东西所困扰。我曾经尝试写过一个小型的玩具语言/编译器,但总是在语义解析(semantic parsing)阶段遇到一些麻烦。这篇文章主要是受我最近的一次尝试启发,这个尝试到目前为止是比较成功的。

在过去的几年里我有幸参与了一些项目,这些项目帮助我对编译器的实际工作有了更多经验。在这次的Toy Compiler项目中我使用了LLVM,它是一个庞大的工具组件,我无法在一篇文章中详细的谈论它。在这次的Toy Compiler项目中,LLVM帮助我实现了编译器设计中大部分复杂的部分。

1.1 为什么你要阅读这篇文章

很有可能你像我一样对编译器和编程语言感兴趣,但也许你在学习的时候并没有找到真正合适的学习资源,亦或者是你找到了资源但是学习门槛较高、难度较大。本文的目的就是为了给你提供一个门槛较低、容易复现、可以在此基础上继续开发的学习资源,并且尽可能逐步解释如何一步步的创建一个基础功能齐全的编译器。

尽管我不会讲很多理论,但最好你还是能拥有一些关于编译器的基础知识,比如上下文无关文法(BNF)、抽象语法树(AST)和基本的编译器流程(basic compiler pipeline)。如果你的基础较为薄弱,建议先熟悉上述概念,这有助于你更好的理解本文的代码。

1.2 你会得到什么

如果你跟随本文的指示,你可以得到一个可以分析“函数定义(define function)、函数调用(call function)、变量定义(define variables)、为变量分配数据(assign data to variables)、以及执行基本的数学运算(double和int)”的编译器,上述有些功能在本文中并没有被实现的很好,但你可以从这些功能的实现中收获学习的乐趣,并在基础上写出一个自己的编译器。

1.3 让我们先解决一些与编译器设计无关的问题

1.3.1 你需要了解什么编程语言

我们将使用的项目是基于C/C++语言的。LLVM的核心部分就是用C++写的,我们的Toy Compiler也沿袭这个风格,并且OOP(面向对象编程)和STL(C++的标准库 stdlib)也可以让我们的代码更少且更具易读性。所以C/C++语言是一定需要了解的。但是对于Lex和Bison来说,它们的编程语法里不止包含C的语法,还包含它们自己的语法(比如%{%}%%),所以Lex和Bison的语言你也需要有所了解(请不要担心,如果你知道lex和bison这两个工具的作用,它们的语法就会很容易理解),我也会尽可能的文中介绍它们的语法。我们使用的语法代码其实很小,只有约100 LOC(lines of code),因此对你来说应该是容易的。

1.3.2 这真的很难吗

说难也难,说简单也简单。在这个项目里有许多新的信息,也许你在一开始会感到有些畏难并缺乏信心,但是说实话,它其实很简单,我们也会使用很多工具来处理那些很复杂的部分并且让这些复杂的部分变得可以被你操控。

1.3.3 要做完这个项目需要多久

我们将要创建的这个项目,我摸索了大约3天才完成,但是在这过程中有几次尝试失败了,所以我想你应该会花费更少的时间。不过要想完全理解其中的所有内容可能需要更长的时间,但你应该能在一个下午内复现本文提到的所有代码(我希望你能整体理解大部分的内容)

如果你准备好了,那我们就正式进入正文吧。

2.基本的编译器流程

尽管你应该已经对编译器的pipeline非常了解,但实际上,编译器是由三到四个组件(以及一些子组件)组成的,数据以流水线的形式从一个组件传递到下一个组件。在构建这些组件时,我们将分别使用不同的工具来帮助我们。以下是每个步骤及我们将使用的工具的示意图

basic compiler pipeline

你会注意到链接器(linker)部分是灰色的。我们的小型编程语言不支持编译时链接(再说现在大多数编程语言都不再使用编译时链接了)。在词法分析方面,我们将使用开源工具Lex,现在主要是以它的进化版Flex的形式存在。词法分析通常与语义解析密切相关,我们将在Yacc的帮助下进行语义解析,它更为人们所熟知的名称是Bison。最后,在语义解析完成后,我们可以遍历抽象语法树(AST)并生成我们的字节码(bytecode)或机器码(machine code)。为此,我们将使用LLVM,它实际上生成的是中间字节码,但我们将使用LLVM的JIT(Just in time)工具来在我们的计算机上编译执行这些字节码。

简而言之,步骤如下:

  1. 使用Flex进行词法分析:将输入的字符(指代码)切分成很多个token(可以理解为标记)(标识符(identifiers,如myVariablemyFunction()MyClass)、关键字(keyword,如intreturnvoidwhilecase等)、数字括号大括号等)。
  2. 使用Bison进行语义解析:在解析标记时生成AST。Bison将在这里完成大部分工作,我们要做的只是定义我们的AST。
  3. 使用LLVM进行汇编:这是我们遍历AST并为每个节点生成字节码/机器码的过程。尽管听起来有些疯狂,但这可能是最简单的步骤。

在继续深入之前,如果你还没有安装Flex、Bison和LLVM,你应该考虑先安装它们。我们很快就需要它们了。

==================================

以ubuntu22.04为例,介绍如何复现仓库代码

sudo apt-get update
sudo apt-get install flex bison clang llvm
cd my_toy_conpiler
make
./parser example.txt

有几点说明

  • 通过apt管理工具安装llvm,它的头文件应该在/usr/include/下。你可以通过`cpp -v`命令看到预编译器查找头文件的路径

  • /usr/include目录下没有llvm文件夹,只有llvm-14和llvm-c-14两个文件夹。为什么头文件里不写成`#include<llvm-14/llvm/IR/Value.h>`?这是因为 /usr/include/llvm-14 文件夹在 llvm-14 包的安装过程中被链接到了 /usr/include/目录,因此当你写 #include <llvm/IR/Value.h> 时,预处理器可以正确地找到头文件。

  • 你也可以写 #include <llvm-14/llvm/IR/Value.h>,但是这样的话,如果在不同的机器上,llvm的版本不一样,代码就无法正确编译了。所以一般来说,我们会使用 #include <llvm/IR/Value.h> 这种方式,这样代码就可以在不同版本的llvm上编译运行了。

==================================

所谓的编译器其实也是一个可执行文件,写编译器的过程其实就是写若干个cpp和h文件然后用已经有的编译器(如gcc)来编译生成一个我们自己的编译器。但是,我们在写这些cpp文件的过程中,有些文件十分复杂(~2000 LOC)且可被自动化,所以为了减轻程序员的负担,就有了一些工具来帮助程序员生成部分cpp文件,flex和bison就是这样的工具。flex针对词法分析部分,程序员只需要写.l文件(~100 LOC),然后将其输入flex工具,flex就能生成对应的用来处理词法分析的cpp文件,bison工具与之类似,只不过bison用在语法分析环节。最后用来生成Toy Compiler可执行程序的源文件不是.l和.y文件而是被flex和bison对应生成的cpp文件

2.1 定义我们的待翻译语法

我们的编译器是针对一个语言或者说是一个语法去工作的,所以我们要先想好Toy Compiler需要去处理什么语法形式的代码。在这里我们选择一种类C的语法(这个是可以自己定义的,feel free~),比如下面这样的代码

int do_math(int a) 
{ 
    int x = a * 5 + 3 
} 
do_math(10)

可以看到它的语法形式虽然和C语法很像,但是它没有用分号;来分隔语句,函数体里也没有return语句。并且我们在这段代码里也没有设置“结果打印”这样的功能,所以我们甚至无从得知它是不是计算正确了。但是我们可以通过LLVM打印出的中间字节码(bytecode)来检查计算是否正确,关于这一点我们稍后再谈。

2.2 Step 1 使用Flex进行语法分析

我们需要将输入数据分解为一系列已知的token。如前所述,我们的语法具有非常基本的tokens:标识符、数字(整数和浮点数)、数学运算符、括号和大括号。我们的词法文件“tokens.l”会做这个转换操作,tokens.l是flex工具的输入文件,flex会接受.l文件然后生成cpp文件,tokens.l文件的内容如下:

%{
#include <string>
#include "node.h"
#include "parser.hpp"
#define SAVE_TOKEN yylval.string = new std::string(yytext, yyleng)
#define TOKEN(t) (yylval.token = t)
extern "C" int yywrap() { }
%}

%%

[ \t\n]                 ;
[a-zA-Z_][a-zA-Z0-9_]*  SAVE_TOKEN; return TIDENTIFIER;
[0-9]+.[0-9]*           SAVE_TOKEN; return TDOUBLE;
[0-9]+                  SAVE_TOKEN; return TINTEGER;
"="                     return TOKEN(TEQUAL);
"=="                    return TOKEN(TCEQ);
"!="                    return TOKEN(TCNE);
"<"                     return TOKEN(TCLT);
"<="                    return TOKEN(TCLE);
">"                     return TOKEN(TCGT);
">="                    return TOKEN(TCGE);
"("                     return TOKEN(TLPAREN);
")"                     return TOKEN(TRPAREN);
"{"                     return TOKEN(TLBRACE);
"}"                     return TOKEN(TRBRACE);
"."                     return TOKEN(TDOT);
","                     return TOKEN(TCOMMA);
"+"                     return TOKEN(TPLUS);
"-"                     return TOKEN(TMINUS);
"*"                     return TOKEN(TMUL);
"/"                     return TOKEN(TDIV);
.                       printf("Unknown token!n"); yyterminate();

%%

在这部分中,首先,我们使用SAVE_TOKEN宏来简化代码,它代表的操作是将标识符和数字的文本保存在yylval这个联合体(union)里的string成员里,具体来说,yylval这个联合体是用来在flex和bison之间传递信息的。在flex文件中,如果识别到一个匹配模式时(即%%之间的内容),flex会自动设置yytextyyleng这两个变量,所以不需要显式赋值,直接new一个string对象即可。举个例子,当flex识别到int x=10这句代码时,flex会检测到标识符x,同时也会返回一个token比如TINTEGER,但是,只给bison一个TINTEGER是不够的,还需要把这个TINTEGER具体是什么(也就是对应的label)也传给bison,也即把x这个文本也传给它。此时yytextx 而yyleng是1,但是由于bison无法直接访问yytext这个变量,所以要转成它们都支持的数据类型进行传输,在这里就是string类型。

其次我们需要重点关注的就是匹配模式。第一个规则表示我们要跳过所有的空白。您还会注意到,我们有一些等式比较标记等。目前这些尚未实现,您可以在自己的Toy Compiler中支持它们!

所以我们在这里所做的就是定义token及其对应的符号名称(也就是对应的文本或者说是label)。这些符号(如 TIDENTIFIER 等)将成为我们语法中的“终结符”。我们只需返回它们,但到目前为止一个很奇怪的事是我们从未定义过这些token。那么它们是在哪里定义的呢?是在 Bison 的文件中。我们包含的 parser.hpp 文件将由 Bison 生成,其中的所有token都将由其生成并可供使用。

我们在这个 tokens.l 文件上运行 Flex 以生成我们的“tokens.cpp”文件,该cpp文件将与我们的其他文件一起编译(正如上文所提到的那样),并提供识别所有这些标记的 yylex() 函数。不过,我们稍后再运行编译命令,因为我们需要先从 Bison 生成那个parser.hpp头文件)

2.3 Step 2 使用Bison进行语义分析

这是我们任务中具有挑战性的部分。生成准确、无歧义的语法并不简单,需要一些实践。幸运的是,我们的语法既简单又基本上已经完成。然而,在我们开始实现语法之前,我们需要先讨论一下设计。

2.3.1 设计抽象语法树(AST)

语义解析的最终产物是一个抽象语法树(AST)。如我们所见,Bison非常优雅地优化了生成AST的过程;我们真正需要做的只是将节点插入语法中而不需要去编写非常复杂的原始cpp文件了。

与“int x”这样的字符串以文本形式表示我们的语言一样,我们的AST表示了我们在内存中的语言(在它被汇编之前)。因此,在我们有机会将它们插入到语法文件中之前,我们需要构建我们将使用的数据结构(一般都是树形)。这个过程相当简单,因为我们基本上是为每个语义都创建一个节点。也就是说,函数调用、函数声明、变量声明、引用等,这些都可以是AST的节点。我们语言中节点的完整图示如下:

AST

关于这棵AST树中的节点类型,解释如下 :

  1. 基类Node:所有节点类型的基类。它包含一个虚拟的codeGen方法,这个方法是用于后续的编译过程中生成目标代码的,现在暂时先不管。所有子类都需要实现这个方法。
  2. NExpression和NStatement:这两个类分别是表达式和语句。表达式用于计算、语句用于执行操作,就像CPU的计算和控制功能。除此之外,由于大多数编程语言都是按照表达式和语句来设计的,所以将expression和statement来表示两个大类是合理的
  3. NInteger和NDouble:分别表示整数和双精度数,它们都是NExpression的子类,因为它们表示的是具体的值
  4. NIdentifier:表示一个标识符,例如变量和函数名,也是NExpression的子类
  5. NMethodCall:表示一个函数调用。它包含一个表示所调用函数名的NIdentifier对象和一个表示传递的参数的ExpressionList,NMethodCall也是NExpression的子类
  6. NBinaryOperator:表示二元操作符,例如加法、减法等。它包含一个表示操作符的整数(例如1表示加法、2表示减法),以及操作数左右两边两个表示操作数的NExpression对象。毕竟操作符两边的操作数不一定只是单纯的数字,也有可能是函数调用,标识符等,所以是NEpression对象。NBinaryOperator也是NExpresson的子类
  7. NAssignment:表示一个赋值操作。它包含一个表示左值的NIdentifier对象和一个表示右值的NExpression对象。它是NExpression的子类。
  8. NBlock:表示一个代码块,它包含一个表示代码块中的语句的StatementList。它是NExpression的子类。
  9. NExpressionStatement:表示一个表达式语句,它包含一个NExpression对象。它是NStatement的子类。
  10. NVariableDeclaration:表示一个变量声明。它包含一个表示变量类型的NIdentifier对象,一个表示变量名的NIdentifier对象,以及一个可选的表示初始值的NExpression对象。它是NStatement的子类。
  11. NFunctionDeclaration:表示一个函数声明。它包含一个表示函数返回类型的NIdentifier对象,一个表示函数名的NIdentifier对象,一个表示函数参数的VariableList对象,以及一个表示函数体的NBlock对象。它是NStatement的子类。

为构建上述这样一棵AST语法树,我们需要先定义每个节点的类型,而这些都在下面的node.h文件中定义

#include <iostream>
#include <vector>
#include <llvm/IR/Value.h>

class CodeGenContext;
class NStatement;
class NExpression;
class NVariableDeclaration;

typedef std::vector<NStatement*> StatementList;
typedef std::vector<NExpression*> ExpressionList;
typedef std::vector<NVariableDeclaration*> VariableList;

class Node {
public:
    virtual ~Node() {}
    virtual llvm::Value* codeGen(CodeGenContext& context) { }
};

class NExpression : public Node {
};

class NStatement : public Node {
};

class NInteger : public NExpression {
public:
    long long value;
    NInteger(long long value) : value(value) { }
    virtual llvm::Value* codeGen(CodeGenContext& context);
};

class NDouble : public NExpression {
public:
    double value;
    NDouble(double value) : value(value) { }
    virtual llvm::Value* codeGen(CodeGenContext& context);
};

class NIdentifier : public NExpression {
public:
    std::string name;
    NIdentifier(const std::string& name) : name(name) { }
    virtual llvm::Value* codeGen(CodeGenContext& context);
};

class NMethodCall : public NExpression {
public:
    const NIdentifier& id;
    ExpressionList arguments;
    NMethodCall(const NIdentifier& id, ExpressionList& arguments) :
        id(id), arguments(arguments) { }
    NMethodCall(const NIdentifier& id) : id(id) { }
    virtual llvm::Value* codeGen(CodeGenContext& context);
};

class NBinaryOperator : public NExpression {
public:
    int op;
    NExpression& lhs;
    NExpression& rhs;
    NBinaryOperator(NExpression& lhs, int op, NExpression& rhs) :
        lhs(lhs), rhs(rhs), op(op) { }
    virtual llvm::Value* codeGen(CodeGenContext& context);
};

class NAssignment : public NExpression {
public:
    NIdentifier& lhs;
    NExpression& rhs;
    NAssignment(NIdentifier& lhs, NExpression& rhs) : 
        lhs(lhs), rhs(rhs) { }
    virtual llvm::Value* codeGen(CodeGenContext& context);
};

class NBlock : public NExpression {
public:
    StatementList statements;
    NBlock() { }
    virtual llvm::Value* codeGen(CodeGenContext& context);
};

class NExpressionStatement : public NStatement {
public:
    NExpression& expression;
    NExpressionStatement(NExpression& expression) : 
        expression(expression) { }
    virtual llvm::Value* codeGen(CodeGenContext& context);
};

class NVariableDeclaration : public NStatement {
public:
    const NIdentifier& type;
    NIdentifier& id;
    NExpression *assignmentExpr;
    NVariableDeclaration(const NIdentifier& type, NIdentifier& id) :
        type(type), id(id) { }
    NVariableDeclaration(const NIdentifier& type, NIdentifier& id, NExpression *assignmentExpr) :
        type(type), id(id), assignmentExpr(assignmentExpr) { }
    virtual llvm::Value* codeGen(CodeGenContext& context);
};

class NFunctionDeclaration : public NStatement {
public:
    const NIdentifier& type;
    const NIdentifier& id;
    VariableList arguments;
    NBlock& block;
    NFunctionDeclaration(const NIdentifier& type, const NIdentifier& id, 
            const VariableList& arguments, NBlock& block) :
        type(type), id(id), arguments(arguments), block(block) { }
    virtual llvm::Value* codeGen(CodeGenContext& context);
};

再次强调,这相当简单。我们在上述定义类的时候没有使用任何getset方法,也就是说这些类没有使用数据隐藏,我们在外部调用时只是使用了这些类的公共数据成员;在这个简单的例子中实际上没有必要进行数据隐藏。现在让我们先忽略codeGen方法。它将在我们想要将AST导出为LLVM字节码时派上用场。

2.3.2 回到Bison

总之,接下来是最复杂的部分,也是最难解释的部分。从技术上讲并不复杂,但我需要花一些时间来讨论Bison语法细节。和flex语法里规则定义类似,在bison里总是使用若干个非终结符和终结符来构造一个有效的语句和表达式,语法基本和BNF一样:

if_stmt : IF '(' condition ')' block { /* 当遇到此规则时执行操作 */ }
        | IF '(' condition ')'       { /*...*/) $1 $2 ...}
        ;
condition : number TCLE number {/*...*/$$/*...*/}
          | number TCLT number
          ;

与BNF最大的不同在于,每个语法后都伴随一个在识别后执行的操作(在大括号内)。我们要做的可以看成是不断做替换操作,不断执行语句,将非终结符全部替换为终结符。这种操作一直沿着叶子-根的顺序递归执行。直到最后每一个非终结符都被合并到一棵大树里。

在我们深入讨论 Bison 语法分析器时,你会遇到 $$ 符号,它代表每个子树的当前根节点。此外,$1 代表规则中第一个符号的子树。在上面这个例子,我们有一个以condition开头的规则,其中 condition 的结果被分配给 $$。在回到上一级的 if_stmt 规则中,这个 condition 结果可以作为 $3 使用,因为在 "if_stmt" 规则中,"condition" 是第三个符号。

在实现Toy Compiler中,Bison 是最复杂的部分。可能需要一些时间才能完全理解。而且,你还没有看到具体的代码示例,接下来就让我们一起看看parser.y吧。

%{
    #include "node.h"
    NBlock *programBlock; /* the top level root node of our final AST */

    extern int yylex();
    void yyerror(const char *s) { printf("ERROR: %sn", s); }
%}

/* Represents the many different ways we can access our data */
%union {
    Node *node;
    NBlock *block;
    NExpression *expr;
    NStatement *stmt;
    NIdentifier *ident;
    NVariableDeclaration *var_decl;
    std::vector<NVariableDeclaration*> *varvec;
    std::vector<NExpression*> *exprvec;
    std::string *string;
    int token;
}

/* Define our terminal symbols (tokens). This should
   match our tokens.l lex file. We also define the node type
   they represent.
 */
%token <string> TIDENTIFIER TINTEGER TDOUBLE
%token <token> TCEQ TCNE TCLT TCLE TCGT TCGE TEQUAL
%token <token> TLPAREN TRPAREN TLBRACE TRBRACE TCOMMA TDOT
%token <token> TPLUS TMINUS TMUL TDIV

/* Define the type of node our nonterminal symbols represent.
   The types refer to the %union declaration above. Ex: when
   we call an ident (defined by union type ident) we are really
   calling an (NIdentifier*). It makes the compiler happy.
 */
%type <ident> ident
%type <expr> numeric expr 
%type <varvec> func_decl_args
%type <exprvec> call_args
%type <block> program stmts block
%type <stmt> stmt var_decl func_decl
%type <token> comparison

/* Operator precedence for mathematical operators */
%left TPLUS TMINUS
%left TMUL TDIV

%start program

%%

program : stmts { programBlock = $1; }
        ;

stmts : stmt { $$ = new NBlock(); $$->statements.push_back($<stmt>1); }
      | stmts stmt { $1->statements.push_back($<stmt>2); }
      ;

stmt : var_decl | func_decl
     | expr { $$ = new NExpressionStatement(*$1); }
     ;

block : TLBRACE stmts TRBRACE { $$ = $2; }
      | TLBRACE TRBRACE { $$ = new NBlock(); }
      ;

var_decl : ident ident { $$ = new NVariableDeclaration(*$1, *$2); }
         | ident ident TEQUAL expr { $$ = new NVariableDeclaration(*$1, *$2, $4); }
         ;

func_decl : ident ident TLPAREN func_decl_args TRPAREN block 
            { $$ = new NFunctionDeclaration(*$1, *$2, *$4, *$6); delete $4; }
          ;

func_decl_args : /*blank*/  { $$ = new VariableList(); }
          | var_decl { $$ = new VariableList(); $$->push_back($<var_decl>1); }
          | func_decl_args TCOMMA var_decl { $1->push_back($<var_decl>3); }
          ;

ident : TIDENTIFIER { $$ = new NIdentifier(*$1); delete $1; }
      ;

numeric : TINTEGER { $$ = new NInteger(atol($1->c_str())); delete $1; }
        | TDOUBLE { $$ = new NDouble(atof($1->c_str())); delete $1; }
        ;

expr : ident TEQUAL expr { $$ = new NAssignment(*$<ident>1, *$3); }
     | ident TLPAREN call_args TRPAREN { $$ = new NMethodCall(*$1, *$3); delete $3; }
     | ident { $<ident>$ = $1; }
     | numeric
     | expr comparison expr { $$ = new NBinaryOperator(*$1, $2, *$3); }
     | TLPAREN expr TRPAREN { $$ = $2; }
     ;

call_args : /*blank*/  { $$ = new ExpressionList(); }
          | expr { $$ = new ExpressionList(); $$->push_back($1); }
          | call_args TCOMMA expr  { $1->push_back($3); }
          ;

comparison : TCEQ | TCNE | TCLT | TCLE | TCGT | TCGE 
           | TPLUS | TMINUS | TMUL | TDIV
           ;

%%

2.3.3 生成我们的代码

现在,我们已经有了用于 Flex 的 tokens.l 文件和用于 Bison 的 parser.y 文件。要从这些定义文件生成 C++ 源文件,我们需要将它们传递给相应的工具。注意,由于使用了 -d 选项,Bison 还将为 Flex 创建一个 "parser.hpp" 头文件;这样做是为了将我们关于token定义的声明与源代码分开,以便我们可以在其他地方包含和使用这些token。以下命令应该创建我们的 parser.cpp、parser.hpp 和 tokens.cpp 源文件:

bison -d -o parser.cpp parser.y
flex -o tokens.cpp tokens.l

如果一切顺利,我们现在应该已经完成了Toy Compiler三个步骤的两个部分。如果你想测试一下,可以创建一个main.cpp 文件中创建一个简短的 main 函数:

main.cpp 文件内容:

#include <iostream>
#include "node.h"
extern NBlock* programBlock;
extern int yyparse();

int main(int argc, char **argv)
{
    yyparse();
    std::cout << programBlock << std::endl;
    return 0;
}

然后,你可以编译源文件:

g++ -o parser parser.cpp tokens.cpp main.cpp

此时,你需要已经安装了 LLVM,以便在 "node.h" 文件中引用 。如果你还没有安装 LLVM,也可以暂时注释掉 node.h 文件中的 codeGen() 方法,仅测试词法分析器和语法分析器的组合。

2.4 Step 3 使用LLVM对AST做汇编操作

2.4.1 关于codeGen和CodeGenContext

编译器的下一步自然是将 AST 转换为机器代码。这意味着将AST里的每一个Node转换为等效的机器指令。然而幸运的是LLVM 为我们简化了这个过程,因为它将实际要生成的机器指令抽象为类似于AST这样的形式。这意味着我们实际上只是在将一个AST转换为另一个AST。

略微思考就会知道这个过程是这样的:我们从根节点开始遍历 AST,对于每个节点,我们都会生成与之对应的字节码。这就是我们为每一个节点定义的 codeGen 方法的作用。例如,当我们遍历到一个 NBlock 节点(代表一组语句的列表集合)时,我们会在NBlock包含的语句列表中的每个语句上调用 codeGen 方法。它看起来是这样的:

Value* NBlock::codeGen(CodeGenContext& context)
{
    StatementList::const_iterator it;
    Value *last = NULL;
    for (it = statements.begin(); it != statements.end(); it++) {
        std::cout << "Generating code for " << typeid(**it).name() << std::endl;
        last = (**it).codeGen(context);
    }
    std::cout << "Creating block" << std::endl;
    return last;
}

我们整体感知了每个节点的codeGen方法,但是事实上还有一个很重要的事,那就是上下文信息,如果我们非常简单的把每个语义节点翻译成机器执行的机器指令,而不考虑变量的作用域等这些上下文信息的话,这样的编译器的功能会很low。所以我们在这里定义一个CodeGenContext类来存储每个节点的上下文信息。举例如下:

假设我们有以下的高级语言代码:

int x = 10; 
{ 
    int y = x * 2; 
}

这段代码的AST大致如下

NBlock
  ├─ NVariableDeclaration (x)
  │    └─ NInteger (10)
  └─ NBlock
       └─ NVariableDeclaration (y)
            └─ NBinaryOperator (*)
                 ├─ NIdentifier (x)
                 └─ NInteger (2)

为了生成这段代码的 LLVM IR,我们需要遍历整个 AST 并为每个节点生成相应的指令。在这个过程中,CodeGenContext 类扮演着关键角色,它可以帮助我们:

  1. 管理当前作用域中的变量:在遍历 AST 时,我们需要知道当前作用域中存在哪些变量,以便正确地生成指令。例如,当我们遇到 NIdentifier (x) 节点时,我们需要知道 x 已经在当前作用域中声明,并获取其值。CodeGenContext 类可以存储这些信息,使我们能够在遍历过程中轻松地访问它们。
  2. 管理生成的指令:CodeGenContext 类可以跟踪和存储生成的 LLVM IR 指令。这使我们能够在整个代码生成过程中管理和组织指令。
  3. 处理嵌套作用域:在本例中,我们有一个嵌套的块(NBlock 节点)。这意味着我们需要正确处理作用域的嵌套。CodeGenContext 类可以帮助我们管理这些嵌套的作用域,确保变量和指令在正确的上下文中生成。

2.4.2 关于 LLVM 的一个重要警告

LLVM 的一个缺点是很难找到有用的文档。他们的在线教程和其他文档都非常过时,对于 C++ API 的信息也几乎没有,除非你真的仔细寻找。如果你自己安装了 LLVM,请查看 "docs",因为它有 "更" 最新的文档。

我发现学习 LLVM 的最佳方法是通过示例。在 LLVM 归档文件的 "examples" 目录中,还有一些编程生成字节码的快速示例。此外,还有 LLVM 的在线演示站点,它可以为输入的 C 程序生成 C++ API 代码。这是发现像 "int x = 5;" 这样的指令将生成什么样的指令的好方法。我使用演示工具实现了大多数节点。

(由于原作者的博客是2009年的,所以这条警告放在2023年是否合适还有待商榷)

下面,我将列出 codegen.h 和 codegen.cpp 文件 codegen.h

#include <stack>
#include <typeinfo>
#include <llvm/IR/Module.h>
#include <llvm/IR/Function.h>
#include <llvm/IR/Type.h>
#include <llvm/IR/DerivedTypes.h>
#include <llvm/IR/LLVMContext.h>
#include <llvm/IR/LegacyPassManager.h>
#include "llvm/Pass.h"
#include <llvm/IR/Instructions.h>
#include <llvm/IR/CallingConv.h>
#include <llvm/IR/IRPrintingPasses.h>
#include <llvm/IR/IRBuilder.h>
#include <llvm/Bitstream/BitstreamReader.h>
#include <llvm/Bitstream/BitstreamWriter.h>
#include <llvm/Support/TargetSelect.h>
#include <llvm/ExecutionEngine/ExecutionEngine.h>
#include <llvm/ExecutionEngine/MCJIT.h>
#include <llvm/ExecutionEngine/GenericValue.h>
#include <llvm/Support/raw_ostream.h>

using namespace llvm;

class NBlock;

static LLVMContext MyContext;

class CodeGenBlock {
public:
    BasicBlock *block;
    Value *returnValue;
    std::map<std::string, Value*> locals;
};

class CodeGenContext {
    std::stack<CodeGenBlock *> blocks;
    Function *mainFunction;

public:

    Module *module;
    CodeGenContext() { module = new Module("main", MyContext); }

    void generateCode(NBlock& root);
    GenericValue runCode();
    std::map<std::string, Value*>& locals() { return blocks.top()->locals; }
    BasicBlock *currentBlock() { return blocks.top()->block; }
    void pushBlock(BasicBlock *block) { blocks.push(new CodeGenBlock()); blocks.top()->returnValue = NULL; blocks.top()->block = block; }
    void popBlock() { CodeGenBlock *top = blocks.top(); blocks.pop(); delete top; }
    void setCurrentReturnValue(Value *value) { blocks.top()->returnValue = value; }
    Value* getCurrentReturnValue() { return blocks.top()->returnValue; }
};

codegen.cpp

#include "node.h"
#include "codegen.h"
#include "parser.hpp"

using namespace std;

/* Compile the AST into a module */
void CodeGenContext::generateCode(NBlock& root)
{
    std::cout << "Generating code...\n";

    /* Create the top level interpreter function to call as entry */
    vector<Type*> argTypes;
    FunctionType *ftype = FunctionType::get(Type::getVoidTy(MyContext), makeArrayRef(argTypes), false);
    mainFunction = Function::Create(ftype, GlobalValue::InternalLinkage, "main", module);
    BasicBlock *bblock = BasicBlock::Create(MyContext, "entry", mainFunction, 0);

    /* Push a new variable/block context */
    pushBlock(bblock);
    root.codeGen(*this); /* emit bytecode for the toplevel block */
    ReturnInst::Create(MyContext, bblock);
    popBlock();

    /* Print the bytecode in a human-readable format 
       to see if our program compiled properly
     */
    std::cout << "Code is generated.\n";
    // module->dump();

    legacy::PassManager pm;
    // TODO:
    pm.add(createPrintModulePass(outs()));
    pm.run(*module);
}

/* Executes the AST by running the main function */
GenericValue CodeGenContext::runCode() {
    std::cout << "Running code...\n";
    ExecutionEngine *ee = EngineBuilder( unique_ptr<Module>(module) ).create();
    ee->finalizeObject();
    vector<GenericValue> noargs;
    GenericValue v = ee->runFunction(mainFunction, noargs);
    std::cout << "Code was run.\n";
    return v;
}

/* Returns an LLVM type based on the identifier */
static Type *typeOf(const NIdentifier& type) 
{
    if (type.name.compare("int") == 0) {
        return Type::getInt64Ty(MyContext);
    }
    else if (type.name.compare("double") == 0) {
        return Type::getDoubleTy(MyContext);
    }
    return Type::getVoidTy(MyContext);
}

/* -- Code Generation -- */

Value* NInteger::codeGen(CodeGenContext& context)
{
    std::cout << "Creating integer: " << value << endl;
    return ConstantInt::get(Type::getInt64Ty(MyContext), value, true);
}

Value* NDouble::codeGen(CodeGenContext& context)
{
    std::cout << "Creating double: " << value << endl;
    return ConstantFP::get(Type::getDoubleTy(MyContext), value);
}

Value* NIdentifier::codeGen(CodeGenContext& context)
{
    std::cout << "Creating identifier reference: " << name << endl;
    if (context.locals().find(name) == context.locals().end()) {
        std::cerr << "undeclared variable " << name << endl;
        return NULL;
    }

    // return nullptr;  
    return new LoadInst(context.locals()[name]->getType(),context.locals()[name], name, false, context.currentBlock());
}

Value* NMethodCall::codeGen(CodeGenContext& context)
{
    Function *function = context.module->getFunction(id.name.c_str());
    if (function == NULL) {
        std::cerr << "no such function " << id.name << endl;
    }
    std::vector<Value*> args;
    ExpressionList::const_iterator it;
    for (it = arguments.begin(); it != arguments.end(); it++) {
        args.push_back((**it).codeGen(context));
    }
    CallInst *call = CallInst::Create(function, makeArrayRef(args), "", context.currentBlock());
    std::cout << "Creating method call: " << id.name << endl;
    return call;
}

Value* NBinaryOperator::codeGen(CodeGenContext& context)
{

    std::cout << "Creating binary operation " << op << endl;
    Instruction::BinaryOps instr;
    switch (op) {
        case TPLUS:     instr = Instruction::Add; goto math;
        case TMINUS:    instr = Instruction::Sub; goto math;
        case TMUL:      instr = Instruction::Mul; goto math;
        case TDIV:      instr = Instruction::SDiv; goto math;

        /* TODO comparison */
    }
    return NULL;
math:
    return BinaryOperator::Create(instr, lhs.codeGen(context), 
        rhs.codeGen(context), "", context.currentBlock());
}

Value* NAssignment::codeGen(CodeGenContext& context)
{
    std::cout << "Creating assignment for " << lhs.name << endl;
    if (context.locals().find(lhs.name) == context.locals().end()) {
        std::cerr << "undeclared variable " << lhs.name << endl;
        return NULL;
    }
    return new StoreInst(rhs.codeGen(context), context.locals()[lhs.name], false, context.currentBlock());
}

Value* NBlock::codeGen(CodeGenContext& context)
{
    StatementList::const_iterator it;
    Value *last = NULL;
    for (it = statements.begin(); it != statements.end(); it++) {
        std::cout << "Generating code for " << typeid(**it).name() << endl;
        last = (**it).codeGen(context);
    }
    std::cout << "Creating block" << endl;
    return last;
}

Value* NExpressionStatement::codeGen(CodeGenContext& context)
{
    std::cout << "Generating code for " << typeid(expression).name() << endl;
    return expression.codeGen(context);
}

Value* NReturnStatement::codeGen(CodeGenContext& context)
{
    std::cout << "Generating return code for " << typeid(expression).name() << endl;
    Value *returnValue = expression.codeGen(context);
    context.setCurrentReturnValue(returnValue);
    return returnValue;
}

Value* NVariableDeclaration::codeGen(CodeGenContext& context)
{
    std::cout << "Creating variable declaration " << type.name << " " << id.name << endl;
    AllocaInst *alloc = new AllocaInst(typeOf(type),4, id.name.c_str(), context.currentBlock());
    context.locals()[id.name] = alloc;
    if (assignmentExpr != NULL) {
        NAssignment assn(id, *assignmentExpr);
        assn.codeGen(context);
    }
    return alloc;
}

Value* NExternDeclaration::codeGen(CodeGenContext& context)
{
    vector<Type*> argTypes;
    VariableList::const_iterator it;
    for (it = arguments.begin(); it != arguments.end(); it++) {
        argTypes.push_back(typeOf((**it).type));
    }
    FunctionType *ftype = FunctionType::get(typeOf(type), makeArrayRef(argTypes), false);
    Function *function = Function::Create(ftype, GlobalValue::ExternalLinkage, id.name.c_str(), context.module);
    return function;
}

Value* NFunctionDeclaration::codeGen(CodeGenContext& context)
{
    vector<Type*> argTypes;
    VariableList::const_iterator it;
    for (it = arguments.begin(); it != arguments.end(); it++) {
        argTypes.push_back(typeOf((**it).type));
    }
    FunctionType *ftype = FunctionType::get(typeOf(type), makeArrayRef(argTypes), false);
    Function *function = Function::Create(ftype, GlobalValue::InternalLinkage, id.name.c_str(), context.module);
    BasicBlock *bblock = BasicBlock::Create(MyContext, "entry", function, 0);

    context.pushBlock(bblock);

    Function::arg_iterator argsValues = function->arg_begin();
    Value* argumentValue;

    for (it = arguments.begin(); it != arguments.end(); it++) {
        (**it).codeGen(context);

        argumentValue = &*argsValues++;
        argumentValue->setName((*it)->id.name.c_str());
        StoreInst *inst = new StoreInst(argumentValue, context.locals()[(*it)->id.name], false, bblock);
    }

    block.codeGen(context);
    ReturnInst::Create(MyContext, context.getCurrentReturnValue(), bblock);

    context.popBlock();
    std::cout << "Creating function: " << id.name << endl;
    return function;
}

这里确实有很多内容需要理解,但现在是时候你开始自己探索了。我只有几点需要说明: 1. 在我们的 CodeGenContext 类中,我们使用一个“堆栈”来保存最后进入的块(因为指令会被添加到块中)。 2. 我们还使用这个堆栈来保存每个块中的局部变量的符号表。 3. 我们的Toy Compiler只知道其自身作用域中的变量。要支持“全局”上下文的概念,你需要在堆栈中的每个块中向上搜索,直到找到与符号匹配的项(而不仅仅是在顶部符号表中进行搜索) 4. 在进入一个块之前,我们应该将其压入堆栈,离开时再弹出。

其余的细节都与 LLVM 相关,虽然我对这方面的了解并不深入。但在这一点上,我们已经拥有了编译和运行Toy Compiler所需的所有代码。

2.5 构建Toy Compiler

我们已经有了代码,但如何构建它呢?链接 LLVM 可能会很复杂,幸运的是,如果你已经安装了 LLVM,你还会得到一个 llvm-config 工具(一个可执行文件),它会返回你所需的所有编译器/链接器标志。

此外,我们还需要更新之前的 main.cpp 文件,以便实际编译和运行我们的代码:

main.cpp 文件内容:

#include < iostream>
#include "codegen.h"
#include "node.h"

using namespace std;

extern int yyparse();
extern NBlock* programBlock;

int main(int argc, char **argv)
{
    yyparse();
    std::cout << programBlock << std::endl;

    CodeGenContext context;
    context.generateCode(*programBlock);
    context.runCode();

    return 0;
}

然后运行如下命令

g++ -o parser `llvm-config --libs core jit native --cxxflags --ldflags` *.cpp

也可以使用Makefile来是整个过程更加容易

all: parser

clean:
    rm parser.cpp parser.hpp parser tokens.cpp

parser.cpp: parser.y
    bison -d -o $@ $^

parser.hpp: parser.cpp

tokens.cpp: tokens.l parser.hpp
    lex -o $@ $^

parser: parser.cpp codegen.cpp main.cpp tokens.cpp
    g++ -o $@ `llvm-config --libs core jit native --cxxflags --ldflags` *.cpp

最后可以生成一个parser的可执行文件,它就是我们的Toy Compiler

为了测试它,我们创建一个example.txt如下

extern void printi(int val)

int do_math(int a) {
  int x = a * 5
  return x + 3
}


echo(do_math(11))
echo(do_math(12))
printi(10)

执行

./parser example.txt

至此,项目基本完成。

--2023-5-1-2:09--

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值