基於 LLVM 自制編譯器(9)——調試信息

語言: CN / TW / HK

通過 8 章的教程,我們實現了一門支持函數和變量的編程語言。那麼,如果代碼運行或編譯出錯時,我們該如何調試程序呢?

本質上,源碼調試是基於 格式化數據 實現的, 格式化數據可以輔助調試器實現二進制和機器狀態轉換至程序員編寫的源碼 。在 LLVM 中,我們通常使用一種稱為 DWARF 的格式。 DWARF 是一種緊湊的編碼,可以表示類型、源碼位置、變量位置

本章,我們將介紹如何基於 DWARF 為 Kaleidoscope 實現調試能力。

目前我們無法通過 JIT 進行調試,因此我們需要將我們的程序編譯為小型且獨立的東西。作為其中的一部分,我們將對語言的運行和程序的編譯方式進行一些修改。 這意味着我們將擁有一個源文件,其中包含一個用 Kaleidoscope 編寫的簡單程序,而不是交互式 JIT。它確實涉及一個限制,即我們一次只能有一個“頂級”命令,以減少必要的更改數量。

def fib(x)
  if x < 3 then
    1
  else
    fib(x-1)+fib(x-2);

fib(10)

實現難點

編譯器支持調試信息的主要實現難點在於 已優化的代碼

首先,編譯器代碼優化使得保留源碼位置更加困難。在 LLVM IR 中,我們為每個 IR 級指令保留源碼位置。優化通道同樣也會保存新創建指令的源碼位置,但合併的指令只能保留一個位置,這可能會導致在單步執行優化程序時出現跳轉。

其次,部分優化通道可能會移動變量的位置,從而導致變量難以追溯。

源碼位置

從實現難點中我們可以看出,實現調試的關鍵在於 源碼位置 。因此,我們定義了 SourceLocation 數據結構,用於存儲源碼位置信息,同時使用兩個全局變量 CurLocLexLoc 分別存儲詞法分析時的當前位置信息和 token 位置信息。

struct SourceLocation {
  int Line;
  int Col;
};
static SourceLocation CurLoc;
static SourceLocation LexLoc = {1, 0};

為了讓 AST 支持存儲源碼位置,我們對 ExprAST 擴展了 Loc 字段,用於保存對應表達式的位置信息,如下所示。同時, ExprAST 提供了幾個便利方法,用於讀取行和列的信息。

/// ExprAST - Base class for all expression nodes.
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';
  }
};

那麼源碼位置在哪裏讀取呢?很顯然,在詞法分析階段進行讀取。因此,我們實現了一個新的詞法分析輸入器 advance() ,如下所示。

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

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

同時,我們將詞法分析器的輸入器 getchar() 替換成 advance() ,從而實現了字符、位置等信息的讀取。

調試信息

接下來,我們定義一個 DebugInfo 結構用於表示調試信息,其主要包含三個字段:

  • TheCUDICompileUnit * 類型,用於表示一個編譯單元。在編譯過程中,一個源文件對應一個編譯單元。
  • DblTyDIType * 類型,用於表示一個數據類型。由於 Kaleidoscope 只包含一種類型 double ,因此這裏只定義一個字段。
  • LexicalBlocksvector<DIScope *> 類型,用於表示一個作用域棧,棧頂的作用域表示當前作用域。
struct DebugInfo {
  DICompileUnit *TheCU;
  DIType *DblTy;
  std::vector<DIScope *> LexicalBlocks;

  void emitLocation(ExprAST *AST);
  DIType *getDoubleTy();
} KSDbgInfo;

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

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

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));
}

為了確保每條指令都能獲得正確的源碼位置,當處於新的源碼位置時,我們必須通知 IRBuilder 。為此,我們通過 DebugInfo 提供一個輔助方法 emitLocation

DWARF 生成設置

對於支持 LLVM IR,我們通過 IRBuilder 來實現代碼生成。對於支持調試信息,我們通過 DIBuilder 來構建 調試元數據

這裏,我們使用 DIBuilder 來構建所有的 IR 級別描述。基於 DIBuilder 構建 LLVM IR 的前提是必須構建一個模塊。因此,我們在構建模塊之後立即構建 DIBuilder ,並將其作為全局靜態變量,以便於使用,如下所示。

static std::unique_ptr<DIBuilder> DBuilder;

主流程

上面,我們讓詞法分析器支持讀取位置信息,並且定義了 DebugInfo 類型用於表示調試信息。

下面,我們來修改編譯器的主流程。

int main() {
  InitializeNativeTarget();
  InitializeNativeTargetAsmPrinter();
  InitializeNativeTargetAsmParser();

  // Install standard binary operators.
  // 1 is lowest precedence.
  BinopPrecedence['='] = 2;
  BinopPrecedence['<'] = 10;
  BinopPrecedence['+'] = 20;
  BinopPrecedence['-'] = 20;
  BinopPrecedence['*'] = 40; // highest.

  // Prime the first token.
  getNextToken();

  TheJIT = ExitOnErr(KaleidoscopeJIT::Create());

  InitializeModule();

  // Add the current debug info version into the module.
  TheModule->addModuleFlag(Module::Warning, "Debug Info Version",
                           DEBUG_METADATA_VERSION);

  // Darwin only supports dwarf2.
  if (Triple(sys::getProcessTriple()).isOSDarwin())
    TheModule->addModuleFlag(llvm::Module::Warning, "Dwarf Version", 2);

  // Construct the DIBuilder, we do this here because we need the module.
  DBuilder = std::make_unique<DIBuilder>(*TheModule);

  // Create the compile unit for the module.
  // Currently down as "fib.ks" as a filename since we're redirecting stdin
  // but we'd like actual source locations.
  KSDbgInfo.TheCU = DBuilder->createCompileUnit(
      dwarf::DW_LANG_C, DBuilder->createFile("fib.ks", "."),
      "Kaleidoscope Compiler", false, "", 0);

  // Run the main "interpreter loop" now.
  MainLoop();

  // Finalize the debug info.
  DBuilder->finalize();

  // Print out all of the generated code.
  TheModule->print(errs(), nullptr);

  return 0;
}

在主流程中,當模塊初始化完畢之後,我們基於模塊構造 DIBuilder 。然後通過 DIBuilder 構造一個編譯單元,並存儲在全局變量 KSDgbInfoTheCU 字段中。中間開始執行代碼的編譯。最後,通過調用 DBuilder->finalize() 確定調試信息。

這裏有幾點值得注意:

  • 首先,當我們為一種 Kaleidoscope 語言生成編譯單元時,我們使用了 C 語言常量。這是因為調試器不一定能理解它無法識別的語言的調用或 ABI,相對而言,我們在 LLVM 代碼生成中使用 C ABI,這是比較準確的。這能夠確保我們可以真正從調試器調用函數並讓它們執行。
  • 其次,我們在 createCompileUnit 調用中看到 fib.ks 。這裏是一個默認的硬編碼值,因為我們使用 shell 重定向將源代碼輸入 Kaleidoscope 編譯器。在常規的編譯前端中,我們會有一個輸入文件名。

在主流程中, MainLoop() 包含了編譯器的核心邏輯。接下來,我們來看看其中函數的定義與調用是如何支持調試信息的。

函數

上面,我們介紹了編譯單元和源碼位置。現在,我們為函數定義支持調試信息中。如下所示,我們對 FunctionAST::codegen() 進行了改造,使其支持插入調試信息。

Function *FunctionAST::codegen() {
  // Transfer ownership of the prototype to the FunctionProtos map, but keep a
  // reference to it for use below.
  auto &P = *Proto;
  FunctionProtos[Proto->getName()] = std::move(Proto);
  Function *TheFunction = getFunction(P.getName());
  if (!TheFunction)
    return nullptr;

  // If this is an operator, install it.
  if (P.isBinaryOp())
    BinopPrecedence[P.getOperatorName()] = P.getBinaryPrecedence();

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

  // Create a subprogram DIE for this function.
  DIFile *Unit = DBuilder->createFile(KSDbgInfo.TheCU->getFilename(),
                                      KSDbgInfo.TheCU->getDirectory());
  DIScope *FContext = Unit;
  unsigned LineNo = P.getLine();
  unsigned ScopeLine = LineNo;
  DISubprogram *SP = DBuilder->createFunction(
      FContext, 
      P.getName(), 
      StringRef(), 
      Unit, 
      LineNo,
      CreateFunctionType(TheFunction->arg_size(), Unit), 
      ScopeLine,
      DINode::FlagPrototyped, 
      DISubprogram::SPFlagDefinition
  );
  TheFunction->setSubprogram(SP);

  // Push the current scope.
  KSDbgInfo.LexicalBlocks.push_back(SP);

  // 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);

  // Record the function arguments in the NamedValues map.
  NamedValues.clear();
  unsigned ArgIdx = 0;
  for (auto &Arg : TheFunction->args()) {
    // Create an alloca for this variable.
    AllocaInst *Alloca = CreateEntryBlockAlloca(TheFunction, Arg.getName());

    // Create a debug descriptor for the variable.
    DILocalVariable *D = DBuilder->createParameterVariable(
        SP, Arg.getName(), ++ArgIdx, Unit, LineNo, KSDbgInfo.getDoubleTy(),
        true);

    DBuilder->insertDeclare(Alloca, D, DBuilder->createExpression(),
                            DILocation::get(SP->getContext(), LineNo, 0, SP),
                            Builder->GetInsertBlock());

    // Store the initial value into the alloca.
    Builder->CreateStore(&Arg, Alloca);

    // Add arguments to variable symbol table.
    NamedValues[std::string(Arg.getName())] = Alloca;
  }

FunctionAST::codegen() 中關於調試信息相關的邏輯如下

  • 基於編譯單元 KSDbgInfo.TheCU ,調用 DBuilder->createFile 方法,構造 DIFile 調試文件。
  • 根據函數行號、函數名、函數作用域、 DIFile 等信息,調用 DBuilder->createFunction 方法,構造 DISubprogram 調試函數,其包含了對函數多有元數據的引用,可用於輔助支持調試。
  • 開始解析函數內容。由於函數支持嵌套作用域。因此,將當前的函數作用域向 KSDbgInfo.LexicalBlocks 作用域棧壓棧。
  • 開始解析函數參數。為了避免為函數原型生成行信息,我們調用 KSDbgInfo.emitLocation(nullptr) 對源碼位置進行復位。
  • 函數參數解析過程,對於每一個參數,調用 DBuilder->createParameterVariable 方法,構造 DILocalVariable 調試變量。基於調試變量,調用 DBuilder->insertDeclare 方法(lvm.dbg.declare),聲明引用一個 alloca 指令分配的變量,並設置源碼位置。
  • 結束解析函數參數,開始解析函數體。我們調用 KSDbgInfo.emitLocation(Body.get()) 對源碼位置進行設置。
  • 結束解析函數內容。將當前作用域從 KSDbgInfo.LexicalBlocks 作用域棧中出棧。

注意,並不是所有代碼都需要包含行信息。在 FunctionAST::codegen() 方法中,我們專門通過 KSDbgInfo.emitLocation(nullptr) 避免為函數原型生成行信息。

AOT 編譯模式

之前,我們實現的編譯器始終是基於 JIT 編譯模式,現在,我們實現的編譯器將基於 AOT 編譯模式。對此,我們在源碼中刪除交互式輸入的相關邏輯,如下所示。

int main() {
  ...

  BinopPrecedence['*'] = 40; // highest.

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

  ...
}

測試

最後,我們可以通過以下命令行將 Kaleidoscope 代碼編譯為可執行程序。然後,輸入 Kaleidoscope 源碼文件,生成 LLRVM IR,其中包含了 DWARF 調試信息。

$ Kaleidoscope-Ch9 < fib.ks

總結

本文,我們對 Kaleidoscope 進行了改造,使其能夠支持 DWARF 調試信息。DWARF 調試信息作用非常大,可以輔助我們進行代碼調試,後續有機會,我們將繼續深入瞭解一下 DWARF。

參考

  1. Kaleidoscope: Adding Debug Information
  2. The DWARF Debugging Standard
  3. Source Level Debugging with LLVM