实现第一个语言前端LLVM教程(九)增加错误调试信息

9.1.简介

欢迎来到"用LLVM实现一门语言"教程的第9章。在第1章到第8章中,我们用函数和变量构建了一个不错的小型编程语言。如果出现问题会发生什么,你如何调试你的程序?

源级调试使用格式化的数据,帮助调试器从二进制和机器状态转换回程序员编写的源代码。在LLVM中,我们通常使用一种称为DWARF的格式。DWARF是一种表示类型、源位置和变量位置的紧凑编码

本章的简短总结是,我们将讨论必须添加到编程语言中以支持调试信息的各种东西,以及如何将其转换为DWARF。

警告:目前我们还不能通过JIT进行调试,所以我们需要将程序编译成一个小而独立的程序。作为其中的一部分,我们将对语言的运行和程序的编译方式进行一些修改。这意味着我们将拥有一个源文件,其中包含一个用Kaleidoscope编写的简单程序,而不是交互式JIT。它确实涉及到一个限制,即我们一次只能有一个"顶级"命令,以减少必要的更改数量。

下面是我们将要编译的示例程序:

scss 复制代码
def fib(x)
  if x < 3 then
    1
  else
    fib(x-1)+fib(x-2);

fib(10)

9.2. 为什么这是一个难题?

由于几个不同的原因,调试信息是一个难题------主要集中在优化的代码上。首先,优化使得保持源位置变得更加困难。在LLVM IR中,我们在指令上保留每个IR级指令的原始源位置。优化pass应该为新创建的指令保留源位置,但是合并的指令只能保留一个位置------这可能导致在逐步执行优化程序时来回跳转。其次,其次,优化可以以优化的方式移动变量,与其他变量共享内存,或者难以跟踪,出于本教程的目的,我们将避免优化(正如您将在下一组代码中看到的那样)。

9.3. 提前编译模式

为了只突出向源语言中添加调试信息的各个方面,而不需要担心JIT调试的复杂性,我们将对Kaleidoscope进行一些更改,以支持将前端发出的IR编译成一个简单的独立程序,您可以执行、调试和查看结果。首先,我们将包含顶层语句的匿名函数设为"main"

c 复制代码
-    auto Proto = std::make_unique<PrototypeAST>("", std::vector<std::string>());
+    auto Proto = std::make_unique<PrototypeAST>("main", std::vector<std::string>());

只是简单地改了个名字,

然后我们将删除命令行代码,无论它是否存在:

c 复制代码
@@ -1129,7 +1129,6 @@ static void HandleTopLevelExpression() {
 /// top ::= definition | external | expression | ';'
 static void MainLoop() {
   while (true) {
-    fprintf(stderr, "ready> ");
     switch (CurTok) {
     case tok_eof:
       return;
@@ -1184,7 +1183,6 @@ int main() {
   BinopPrecedence['*'] = 40; // highest.

   // Prime the first token.
-  fprintf(stderr, "ready> ");
   getNextToken();

最后,我们将禁用所有的优化pass和JIT,以便在我们完成解析和生成代码后唯一发生的事情是LLVM IR进入标准错误:

scss 复制代码
@@ -1108,17 +1108,8 @@ static void HandleExtern() {
 static void HandleTopLevelExpression() {
   // Evaluate a top-level expression into an anonymous function.
   if (auto FnAST = ParseTopLevelExpr()) {
-    if (auto *FnIR = FnAST->codegen()) {
-      // We're just doing this to make sure it executes.
-      TheExecutionEngine->finalizeObject();
-      // JIT the function, returning a function pointer.
-      void *FPtr = TheExecutionEngine->getPointerToFunction(FnIR);
-
-      // Cast it to the right type (takes no arguments, returns a double) so we
-      // can call it as a native function.
-      double (*FP)() = (double (*)())(intptr_t)FPtr;
-      // Ignore the return value for this.
-      (void)FP;
+    if (!FnAST->codegen()) {
+      fprintf(stderr, "Error generating code for top level expr");
     }
   } else {
     // Skip token for error recovery.
@@ -1439,11 +1459,11 @@ int main() {
   // target lays out data structures.
   TheModule->setDataLayout(TheExecutionEngine->getDataLayout());
   OurFPM.add(new DataLayoutPass());
+#if 0
   OurFPM.add(createBasicAliasAnalysisPass());
   // Promote allocas to registers.
   OurFPM.add(createPromoteMemoryToRegisterPass());
@@ -1218,7 +1210,7 @@ int main() {
   OurFPM.add(createGVNPass());
   // Simplify the control flow graph (deleting unreachable blocks, etc).
   OurFPM.add(createCFGSimplificationPass());
-
+  #endif
   OurFPM.doInitialization();

这组相对较小的更改使我们可以通过以下命令行将Kaleidoscope语言编译成可执行程序

Kaleidoscope-Ch9 < fib.ks | & clang -x ir -

它生成了当前工作目录下的a.out/a.exe。

9.4. 编译单元

DWARF中一段代码的顶层容器是一个编译单元。这包含了单个翻译单元的类型和函数数据(读取:一个源代码文件)。我们要做的第一件事就是为fib.ks文件构造一个编译单元。

9.5. DWARF安装

与IRBuilder类类似,我们有一个DIBuilder类来帮助构建LLVM IR文件的调试元数据。它与IRBuilder和LLVM IR对应1:1,但有更好的名称。使用它确实需要您比熟悉IRBuilder和指令名称更熟悉DWARF术语,但是如果您通读了元数据格式的通用文档,应该会更清楚一些。我们将使用这个类来构建我们所有的IR级别描述。它的构造需要一个模块,所以我们需要在构造模块后不久构造它。我们将其保留为全局静态变量,以使其更易于使用。

接下来,我们将创建一个小容器来缓存一些常用数据。第一个将是我们的编译单元,但我们也将为我们的单一类型编写一些代码,因为我们不必担心多类型表达式:

ini 复制代码
static std::unique_ptr<DIBuilder> DBuilder;

struct DebugInfo {
  DICompileUnit *TheCU;
  DIType *DblTy;

  DIType *getDoubleTy();
} KSDbgInfo;

DIType *DebugInfo::getDoubleTy() {
  if (DblTy)
    return DblTy;

  DblTy = DBuilder->createBasicType("double", 64, dwarf::DW_ATE_float);
  return DblTy;
}

然后在main中,当我们构造模块的时候

rust 复制代码
DBuilder = std::make_unique<DIBuilder>(*TheModule);

KSDbgInfo.TheCU = DBuilder->createCompileUnit(
    dwarf::DW_LANG_C, DBuilder->createFile("fib.ks", "."),
    "Kaleidoscope Compiler", false, "", 0);

这里有几件事需要注意。首先,当我们为名为Kaleidoscope的语言生成编译单元时,我们使用了C语言常量。这是因为调试器不一定理解它不识别的语言的调用约定或默认ABI,而我们在LLVM代码生成中遵循C ABI,所以它是最接近准确的。这确保了我们可以从调试器中调用函数并执行它们。其次,你会看到"fib.ks"。在createCompileUnit的调用中。这是一个默认的硬编码值,因为我们使用shell重定向将源代码放入Kaleidoscope编译器中。在通常的前端,你会有一个输入文件名。

通过DIBuilder发送调试信息的最后一件事是,我们需要"最终确定"调试信息。原因是DIBuilder的底层API的一部分,但请确保在main末尾执行此操作:

scss 复制代码
DBuilder->finalize();

在转储模块之前。

9.6. 函数

现在我们有了编译单元和源代码位置,我们可以将函数定义添加到调试信息中。因此,在FunctionAST::codegen()中,我们添加了几行代码来描述子程序的上下文,在本例中是"File",以及函数本身的实际定义

scss 复制代码
DIFile *Unit = DBuilder->createFile(KSDbgInfo.TheCU->getFilename(),
                                    KSDbgInfo.TheCU->getDirectory());

给我们一个DIFile,并询问我们上面创建的编译单元当前所在的目录和文件名。然后,现在,我们使用一些0的源位置(因为AST目前没有源位置信息)并构造函数定义:

ini 复制代码
DIScope *FContext = Unit;
unsigned LineNo = 0;
unsigned ScopeLine = 0;
DISubprogram *SP = DBuilder->createFunction(
    FContext, P.getName(), StringRef(), Unit, LineNo,
    CreateFunctionType(TheFunction->arg_size()),
    ScopeLine,
    DINode::FlagPrototyped,
    DISubprogram::SPFlagDefinition);
TheFunction->setSubprogram(SP);

现在我们有了一个DISubprogram,它包含了对函数所有元数据的引用。

9.7. 源代码的位置

对于调试信息来说,最重要的是准确的源代码位置------这使得将源代码映射回来成为可能。但是我们有一个问题,Kaleidoscope在词法分析器或解析器中没有任何源位置信息,所以我们需要添加它。

ini 复制代码
struct SourceLocation {
  int Line;
  int Col;
};
static SourceLocation CurLoc;
static SourceLocation LexLoc = {1, 0};

static int advance() {
  int LastChar = getchar();

  if (LastChar == '\n' || LastChar == '\r') {
    LexLoc.Line++;
    LexLoc.Col = 0;
  } else
    LexLoc.Col++;
  return LastChar;
}

在这组代码中,我们添加了一些关于如何跟踪"源文件"的行和列的功能。当我们对每个标记进行标记时,我们将当前的"词法位置"设置为标记开头的分类行和列。我们通过用新的advance()覆盖之前对getchar()的所有调用来实现这一点,advance()跟踪信息,然后我们在所有AST类中添加了一个源位置:

csharp 复制代码
class ExprAST {
  SourceLocation Loc;

  public:
    ExprAST(SourceLocation Loc = CurLoc) : Loc(Loc) {}
    virtual ~ExprAST() {}
    virtual Value* codegen() = 0;
    int getLine() const { return Loc.Line; }
    int getCol() const { return Loc.Col; }
    virtual raw_ostream &dump(raw_ostream &out, int ind) {
      return out << ':' << getLine() << ':' << getCol() << '\n';
    }

当我们创建一个新的表达式时,我们将它传递下去:

less 复制代码
LHS = std::make_unique<BinaryExprAST>(BinLoc, BinOp, std::move(LHS),
                                       std::move(RHS));

给我们每个表达式和变量的位置。

为了确保每条指令都能获得正确的源位置信息,我们必须在到达新的源位置时告诉Builder。为此我们使用了一个小的辅助函数:

scss 复制代码
void DebugInfo::emitLocation(ExprAST *AST) {
  if (!AST)
    return Builder->SetCurrentDebugLocation(DebugLoc());
  DIScope *Scope;
  if (LexicalBlocks.empty())
    Scope = TheCU;
  else
    Scope = LexicalBlocks.back();
  Builder->SetCurrentDebugLocation(
      DILocation::get(Scope->getContext(), AST->getLine(), AST->getCol(), Scope));
}

这里我们首先创建变量,给它范围(scope)、名称、源位置、类型,因为它是一个参数,所以给它参数索引。接下来,我们创建一个#dbg_declare记录,以在IR级别指示我们在alloca中获得了一个变量(并为该变量提供了一个起始位置),并为声明的作用域的开始设置一个源位置。

在这一点上需要注意的一件有趣的事情是,各种调试器都有基于过去为它们生成代码和调试信息的假设。在这种情况下,我们需要做一点隐藏的工作来避免为函数生成行信息,以便调试器知道在设置断点时跳过这些指令。所以在FunctionAST::CodeGen中,我们增加了一些行:

arduino 复制代码
// Unset the location for the prologue emission (leading instructions with no
// location in a function are considered part of the prologue and the debugger
// will run past them when breaking on a function)
KSDbgInfo.emitLocation(nullptr);

然后,当我们真正开始为函数体生成代码时,发出一个新的位置:

csharp 复制代码
KSDbgInfo.emitLocation(Body.get());

这样我们就有了足够的调试信息,可以在函数中设置断点、打印参数变量和调用函数。对于几行简单的代码来说还不错!

完整代码

相关推荐
xjz18422 天前
实现第一个语言前端LLVM教程(七)扩展语言:可变变量
llvm
Ciderw16 天前
LLVM编译器简介
c++·golang·编译·编译器·gcc·llvm·基础设施
天枢破军20 天前
【AI】零代码-A卡780M核显在Windows平台运行ollama跑端侧大模型
llvm·deepseek
witton1 个月前
macOS使用LLVM官方发布的tar.xz来安装Clang编译器
vscode·macos·cmake·clang·llvm·qtcreator·clang++
高铭杰2 个月前
Postgresql源码(141)JIT系列分析汇总
postgresql·jit·llvm
Lhuu(重开版3 个月前
2024硬件科技协会LLVM第二次考核题解
算法·ast·llvm
Eloudy3 个月前
C CPP 中注释的正则表达式
正则表达式·llvm
CYRUS_STUDIO3 个月前
使用 opt 优化 LLVM IR,定制 clang 实现函数名加密
c++·性能优化·llvm
CYRUS STUDIO3 个月前
编译 LLVM 源码,使用 Clion 调试 clang
c语言·c++·visual studio·clang·ndk·llvm·clion