基於 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