手把手实现编程语言(三):从AST到LLVM IR的代码生成

157 阅读14分钟

本文对应LLVM官方教程第三章,将深入讲解如何为Kaleidoscope语言实现LLVM IR代码生成。通过本教程,你将掌握使用LLVM核心库构建编译器中间表示的核心技巧,并理解现代编译器后端的工作机制。完整代码参考LLVM官方示例

3.1 简介

欢迎来到“用LLVM实现一门语言”教程的第三章。本章向您展示如何将第2章构建的抽象语法树转换为LLVM IR。这将教会您一点关于LLVM如何工作的知识,并演示它是如何易于使用的。与生成LLVM IR代码相比,构建词法分析器和解析器的工作量要大得多。:)

请注意:本章和后面的代码需要LLVM 3.7或更高版本。LLVM 3.6及之前版本将无法使用它。还请注意,您需要使用与您的LLVM发行版相匹配的本教程版本:如果您使用的是官方LLVM发行版,请使用发行版中包含的文档版本或llvm.org发布页面上的文档版本。

3.2 代码生成

为了生成LLVM IR,我们需要一些简单的设置。首先,我们在每个AST类中定义虚拟代码生成(codegen)方法:

/// ExprAST - Base class for all expression nodes.
class ExprAST {
public:
  virtual ~ExprAST() = default;
  virtual Value *codegen() = 0;
};

/// NumberExprAST - 数字表达式像 "1.0"
class NumberExprAST : public ExprAST {
  double Val;

public:
  NumberExprAST(double Val) : Val(Val) {}
  Value *codegen() override;
};

codegen()方法表示为AST节点以及它所依赖的所有东西发出IR,并且它们都返回一个LLVM Value对象。“Value”是LLVM中用于表示“静态单赋值(SSA)寄存器”或“SSA值”的类。SSA值最明显的方面是,它们的值是在相关指令执行时计算的,直到(如果)指令重新执行时才获得新值。换句话说,没有办法“更改”SSA值。

注意,不要向ExprAST类层次结构中添加虚拟方法,使用访问者模式或其他方式对其建模也是有意义的。同样,本教程不会详述好的软件工程实践:出于我们的目的,添加虚拟方法是最简单的。

静态变量将在代码生成期间使用。TheContext是一个不透明的对象,它拥有很多核心LLVM数据结构,比如类型表和常量值表。我们不需要详细地理解它,我们只需要一个实例来传递给需要它的api。

Builder对象是一个帮助器对象,它使生成LLVM指令变得容易。IRBuilder类模板的实例跟踪当前插入指令的位置,并具有创建新指令的方法。

TheModule是一个包含函数和全局变量的LLVM结构。在许多方面,它是LLVM IR用来包含代码的顶层结构。它将拥有我们生成的所有IR的内存,这就是为什么codegen()方法返回原始值*,而不是unique_ptr。

NamedValues映射跟踪在当前作用域中定义了哪些值,以及它们的LLVM表示是什么。(换句话说,它是代码的符号表)。在这种形式的Kaleidoscope中,唯一可以引用的是函数参数。因此,在为函数体生成代码时,函数参数将在此映射中。

有了这些基础知识,我们就可以开始讨论如何为每个表达式生成代码。注意,这假定Builder已被设置为生成代码。现在,我们假设这已经完成了,我们只使用它来输出代码。

3.3. 表达式生成

为表达式节点生成LLVM代码非常简单:为所有四个表达式节点生成不到45行注释代码。首先,我们将处理数字字面值:

Value *NumberExprAST::codegen() {
  return ConstantFP::get(*TheContext, APFloat(Val));
}

在LLVM IR中,数值常量用ConstantFP类表示,该类在APFloat内部保存数值(APFloat具有保存任意精度的浮点常量的能力)。这段代码基本上只是创建并返回一个ConstantFP。请注意,在LLVM IR中,所有常量都是唯一的,并且是共享的。

Value *VariableExprAST::codegen() {
  // Look this variable up in the function.
  Value *V = NamedValues[Name];
  if (!V)
    LogErrorV("Unknown variable name");
  return V;
}

使用LLVM对变量的引用也非常简单。在Kaleidoscope的简单版本中,我们假设变量已经在某处它的值是可用的。实际上,NamedValues映射中唯一可以包含的值是函数参数。这段代码只是检查指定的名称是否在映射中(如果没有,则引用了一个未知变量),并返回它的值。在以后的章节中,我们将增加对符号表中的循环变量和局部变量的支持。

Value *BinaryExprAST::codegen() {
  Value *L = LHS->codegen();
  Value *R = RHS->codegen();
  if (!L || !R)
    return nullptr;

  switch (Op) {
  case '+':
    return Builder->CreateFAdd(L, R, "addtmp");
  case '-':
    return Builder->CreateFSub(L, R, "subtmp");
  case '*':
    return Builder->CreateFMul(L, R, "multmp");
  case '<': 
    L = Builder->CreateFCmpULT(L, R, "cmptmp");
   //Convert bool 0/1 to double 0.0 or 1.0
    return Builder->CreateUIToFP(L, Type::getDoubleTy(*TheContext),
                                 "booltmp");
  default:
    return LogErrorV("invalid binary operator");
  }
}

二元运算符开始变得更有趣了。这里的基本思想是,我们递归地为表达式的左边代码,然后是右边,然后我们计算二元表达式式的结果。在这段代码中,我们在操作码上做了一个简单的开关,以创建正确的LLVM指令。

在上面的例子中,LLVM构建器类开始显示它的值。IRBuilder知道在哪里插入新创建的指令,你所要做的就是指定要创建什么指令(例如使用CreateFAdd),使用哪些操作数(这里是L和R),并为生成的指令提供一个可选的名称。

LLVM的一个优点是它的名字只是一个提示。例如,如果上面的代码发出多个“addtmp”变量,LLVM将自动为每个变量提供递增的唯一数字后缀。指令的本地值名称完全是可选的,但它使读取IR转储更容易.

LLVM指令有严格的规则约束:例如,add指令的左操作数和右操作数必须具有相同的类型,并且add的结果类型必须与操作数类型匹配。因为Kaleidoscope中的所有值都是双精度,这使得add, sub和mul的代码非常简单。

另一方面,LLVM指定fcmp指令总是返回一个‘ i1 ’值(一个位整数)。问题在于,Kaleidoscope希望该值为0.0或1.0值。为了获得这些语义,我们将fcmp指令与uitofp指令结合起来。该指令通过将输入作为无符号值处理,将其输入整数转换为浮点值。相反,如果我们使用sitofp指令,Kaleidoscope中‘ < ’操作符将根据输入值返回0.0和-1.0。

Value *CallExprAST::codegen() {
  // Look up the name in the global module table.
  Function *CalleeF = TheModule->getFunction(Callee);
  if (!CalleeF)
    return LogErrorV("Unknown function referenced");

  // If argument mismatch error.
  if (CalleeF->arg_size() != Args.size())
    return LogErrorV("Incorrect # arguments passed");

  std::vector<Value *> ArgsV;
  for (unsigned i = 0, e = Args.size(); i != e; ++i) {
    ArgsV.push_back(Args[i]->codegen());
    if (!ArgsV.back())
      return nullptr;
  }

  return Builder->CreateCall(CalleeF, ArgsV, "calltmp");
}

用LLVM生成函数调用的代码非常简单。上面的代码首先在LLVM模块的符号表中查找函数名。回想一下,LLVM模块是保存我们要JIT处理的函数的容器。通过为每个函数指定与用户指定相同的名称,我们可以使用LLVM符号表为我们解析函数名称。

一旦有了要调用的函数,我们就递归地对要传入的每个参数进行编码,并创建一个LLVM调用指令。请注意,LLVM在默认情况下使用本机C调用约定,允许这些调用也调用标准库函数,如“sin”和“cos”,而无需额外的开销。

到目前为止,我们已经在Kaleidoscope中处理了四个基本表达式。你可以随意添加更多的内容。例如,通过浏览LLVM语言参考,您将发现其他一些有趣的指令,这些指令非常容易插入到我们的基本框架中

3.4. 函数调用

原型和函数的代码生成必须处理许多细节,这使得它们的代码不如表达式代码生成漂亮,但允许我们说明一些重要的点。首先,让我们谈谈原型的代码生成:它们既用于函数体,也用于外部函数声明。代码开头是

Function *PrototypeAST::codegen() {
  // Make the function type:  double(double,double) etc.
  std::vector<Type*> Doubles(Args.size(),
                             Type::getDoubleTy(*TheContext));
  FunctionType *FT =
    FunctionType::get(Type::getDoubleTy(*TheContext), Doubles, false);

  Function *F =
    Function::Create(FT, Function::ExternalLinkage, Name, TheModule.get());

这段代码在几行代码中包含了很多功能。首先注意这个函数返回的是“function ”而不是“Value”。因为“原型”实际上谈论的是函数的外部接口(而不是表达式计算的值),所以它在编码时返回它对应的LLVM函数是有意义的。

对FunctionType::get的调用创建了应该用于给定原型的FunctionType。由于Kaleidoscope中的所有函数参数都是双精度类型,因此第一行创建了一个“N”LLVM双精度类型的向量。然后,它使用Functiontype::get方法创建一个函数类型,该函数类型接受“N”双精度体作为参数,返回一个双精度体作为结果,并且它不是可变的(false参数表示这一点)。注意LLVM中的类型是唯一的,就像常量一样,所以你不是“new”一个类型,而是“get”它。

上面的最后一行实际上创建了与原型对应的IR函数。这表明要使用的类型、链接和名称,以及要插入到哪个模块中。“外部链接”意味着该函数可以在当前模块之外定义和/或可以被模块外的函数调用。传入的Name是用户指定的名称:由于指定了“TheModule”,因此该名称注册在“TheModule”的符号表中。

// Set names for all arguments.
unsigned Idx = 0;
for (auto &Arg : F->args())
  Arg.setName(Args[Idx++]);

return F;

最后,根据Prototype中给出的名称设置每个函数参数的名称。这一步并不是严格必要的,但是保持名称的一致性使IR更具可读性,并允许后续代码直接引用其名称的参数,而不必在Prototype AST中查找它们。

此时,我们有了一个没有主体的函数原型。这就是LLVM IR表示函数声明的方式。对于Kaleidoscope中的外部语句,这就是我们需要做的。然而,对于函数定义,我们需要编码并附加函数体。

Function *FunctionAST::codegen() {
// First, check for an existing function from a previous 'extern' declaration.
 Function *TheFunction = TheModule->getFunction(Proto->getName());

 if (!TheFunction)
   TheFunction = Proto->codegen();

 if (!TheFunction)
   return nullptr;

 if (!TheFunction->empty())
   return (Function*)LogErrorV("Function cannot be redefined.");

对于函数定义,我们首先在TheModule的符号表中搜索该函数的现有版本,如果已经使用‘ extern ’语句创建了一个的话。如果Module::getFunction返回null,那么之前的版本不存在,所以我们将从原型中编码一个。在这两种情况下,我们都希望在开始之前断言函数是空的(即还没有函数体)

// Create a new basic block to start insertion into.
BasicBlock *BB = BasicBlock::Create(*TheContext, "entry", TheFunction);
Builder->SetInsertPoint(BB);

// Record the function arguments in the NamedValues map.
NamedValues.clear();
for (auto &Arg : TheFunction->args())
 NamedValues[std::string(Arg.getName())] = &Arg;

现在我们来到了构建器设置的地方。第一行创建了一个新的基本块(名为“entry”),它被插入到TheFunction中。然后,第二行告诉构造器应该将新指令插入到新基本块的末尾。LLVM中的基本块是定义控制流图的函数的重要组成部分。由于我们没有任何控制流,我们的函数此时将只包含一个块。我们将在第5章修复这个问题:)。

接下来,我们将函数参数添加到NamedValues映射(在首先清空之后),以便VariableExprAST节点可以访问它们。

if (Value *RetVal = Body->codegen()) {
 // Finish off the function.
 Builder->CreateRet(RetVal);

 // Validate the generated code, checking for consistency.
 verifyFunction(*TheFunction);

 return TheFunction;
}

一旦设置了插入点并填充了NamedValues映射,我们就为函数的根表达式调用codegen()方法。如果没有错误发生,则发出代码将表达式计算到条目块中,并返回计算得到的值。假设没有错误,然后我们创建一个LLVM ret指令,它完成了这个函数。函数构建完成后,我们调用LLVM提供的verifyFunction。这个函数对生成的代码进行各种一致性检查,以确定编译器是否一切正常。使用这个很重要:它可以捕捉很多BUG。一旦函数完成并验证后,我们返回它。

  // Error reading body, remove function.
  TheFunction->eraseFromParent();
  return nullptr;
}

这里剩下的唯一部分是处理错误情况。为简单起见,我们仅通过删除使用eraseFromParent方法生成的函数来处理此问题。这允许用户重新定义他们之前输入错误的函数:如果我们不删除它,它将存在于符号表中,并带有一个主体,以防止将来重新定义。

但是,这段代码确实有一个错误:如果FunctionAST::codegen()方法找到了一个现有的IR函数,它不会根据定义自己的原型验证其签名。这意味着先前的‘ extern ’声明将优先于函数定义的签名,这可能导致代码生成失败,例如,如果函数参数的命名不同。有很多方法可以修复这个bug,看看你能想出什么!下面是一个测试用例:

extern foo(a);     # ok, defines foo.
def foo(b) b;      # Error: Unknown variable name. (decl using 'a' takes precedence).

3.5. 驱动程序更改和关闭

现在,生成到LLVM的代码并没有给我们带来太多好处,除了我们可以看看漂亮的IR调用。示例代码将对codegen的调用插入到“HandleDefinition”,“HandleExtern”等函数中,然后转储LLVM IR。这提供了一种很好的方式来查看LLVM IR中的简单函数。例如:

ready> 4+5;
Read top-level expression:
define double @0() {
entry:
 ret double 9.000000e+00
}

注意解析器是如何为我们将顶级表达式转换为匿名函数的。当我们在下一章中添加JIT支持时,这将非常方便。还要注意的是,代码是逐字转录的,除了IRBuilder完成的简单常量折叠外,没有进行任何优化。我们将在下一章中明确地添加优化。

ready> def foo(a b) a*a + 2*a*b + b*b;
Read function definition:
define double @foo(double %a, double %b) {
entry:
  %multmp = fmul double %a, %a
  %multmp1 = fmul double 2.000000e+00, %a
  %multmp2 = fmul double %multmp1, %b
  %addtmp = fadd double %multmp, %multmp2
  %multmp3 = fmul double %b, %b
  %addtmp4 = fadd double %addtmp, %multmp3
  ret double %addtmp4
}

这显示了一些简单的算术。请注意,它与我们用来创建指令的LLVM构建器调用惊人地相似。

ready> def bar(a) foo(a, 4.0) + bar(31337);
Read function definition:
define double @bar(double %a) {
entry:
  %calltmp = call double @foo(double %a, double 4.000000e+00)
  %calltmp1 = call double @bar(double 3.133700e+04)
  %addtmp = fadd double %calltmp, %calltmp1
  ret double %addtmp
}

这显示了一些函数调用。注意,如果调用这个函数,它将花费很长时间来执行。在未来,我们将添加条件控制流,以使递归真正有用:)。

ready> extern cos(x);
Read extern:
declare double @cos(double)

ready> cos(1.234);
Read top-level expression:
define double @1() {
entry:
  %calltmp = call double @cos(double 1.234000e+00)
  ret double %calltmp
}

这显示了libm“cos”函数的外部函数,以及对它的调用。

ready> ^D
; ModuleID = 'my cool jit'

define double @0() {
entry:
  %addtmp = fadd double 4.000000e+00, 5.000000e+00
  ret double %addtmp
}

define double @foo(double %a, double %b) {
entry:
  %multmp = fmul double %a, %a
  %multmp1 = fmul double 2.000000e+00, %a
  %multmp2 = fmul double %multmp1, %b
  %addtmp = fadd double %multmp, %multmp2
  %multmp3 = fmul double %b, %b
  %addtmp4 = fadd double %addtmp, %multmp3
  ret double %addtmp4
}

define double @bar(double %a) {
entry:
  %calltmp = call double @foo(double %a, double 4.000000e+00)
  %calltmp1 = call double @bar(double 3.133700e+04)
  %addtmp = fadd double %calltmp, %calltmp1
  ret double %addtmp
}

declare double @cos(double)

define double @1() {
entry:
  %calltmp = call double @cos(double 1.234000e+00)
  ret double %calltmp
}

当您退出当前演示时(在Linux上通过CTRL+D或在Windows上通过CTRL+Z和ENTER发送EOF),它会转储生成的整个模块的IR。在这里,您可以看到所有函数相互引用的大图。

这是Kaleidoscope教程的第三章。接下来,我们将介绍如何在此基础上添加JIT代码源和优化器支持,以便我们可以真正开始运行代码!

完整代码见llvm.org/docs/tutori…