如何在调试信息中包括函数定义、源位置和变量位置

168 阅读6分钟

在这篇文章中,我们继续讨论在编程语言中调试和调试信息涉及的其他方面。我们讨论如何在调试信息中包括函数定义、源位置和变量位置。

目录:

  1. 介绍
  2. DWARF发射设置
  3. 函数定义
  4. 源位置
  5. 变量位置
  6. 总结
  7. 参考资料

先决条件

在编程语言中添加调试支持

介绍

我们将学习如何包含调试信息,这将使我们更容易调试用Kaleidoscope编写的代码。
首先,我们需要了解什么

编译单元DWARF

编译单元是DWARF中一段代码的顶级容器。它包含单个翻译单元的类型和功能数据。这意味着我们首先需要建立的是一个fib.kbs文件。

DWARF是一个紧凑的编码,用于表示类型、源位置和变量位置。它将协助我们在Kaleidoscope中包括调试信息。

DWARF排放设置

LLVM有一个DiBuilder类,类似于之前讨论的IRBuilder。它负责构建给定LLVM IR的调试元数据,
产生的元数据将有一个类似于IRBuilder和LLVM IR

1:1对应关系。
在本文中,我们将使用这个类来构建所有的IR级描述。由于构建需要一个模块,我们需要在构建出模块后再进行构建。在这个演示中,为了简单起见,我们把它作为一个全局静态变量。

我们创建一个小容器,负责缓存频繁的数据。首先,我们编写编译单元,我们也将为一个单一的类型编写代码。在这个例子中,我们不关心多类型的表达式。
下面的代码实现了上面讨论的内容:

static 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里面我们构造了我们的模块:

DBuilder = new DIBuilder(*TheModule);

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

在为Kaleidoscope制作编译单元时,我们使用了C语言的常量。我们这样做是因为调试器不会理解它不认识的语言的调用约定或默认ABI,我们在LLVM代码生成过程中遵循CABI,因为它最接近于正确的东西。
其次,在调用createCompileUnit
时,fibs.ks
是默认的硬编码值,因为我们使用I/O重定向来将源代码放入Kaleidoscope编译器中。通常情况下,我们会有一个输入文件。

最后,我们最终确定调试信息,我们在接近函数结束时做这个,并把模块转储出来:

DBuilder->finalize();

函数定义

我们已经完成了编译单元源码位置的工作,下一步是为调试信息添加函数定义。为此,在PrototypeAST::codegen()中,我们添加以下几行代码来描述我们子程序的上下文。在我们的例子中是File和实际的函数定义。
我们有以下的上下文。

DIFile *Unit = DBuilder->createFile(KSDbgInfo.TheCU.getFilename(), KSDbgInfo.TheCU.getDirectory());

上面产生一个DIFile,并要求编译单元提供我们当前所在的目录和文件名。我们使用一些源文件的位置并构建一个函数定义:

DIScope *FContext = Unit;
unsigned LineNo = 0;
unsigned ScopeLine = 0;
DISubprogram *SP = DBuilder->createFunction(
    FContext, P.getName(), StringRef(), Unit, LineNo,
    CreateFunctionType(TheFunction->arg_size(), Unit),
    false /* internal linkage */, true /* definition */, ScopeLine,
    DINode::FlagPrototyped, false);
TheFunction->setSubprogram(SP);

在这一点上,我们有一个DISubprogram,包含对所有函数元数据的引用。

源位置

准确的源码位置对调试信息非常重要,这是因为它使我们有可能将源码映射回来。在这个阶段,Kaleidoscope在词典解析器中没有任何源码位置信息。
我们按以下方式添加它:

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

我们增加了跟踪源文件的行和列的功能。当我们对每个标记进行词法分析时,我们将当前的词法位置设置为标记开始的分类行和列。
为此,我们使用跟踪信息的new advance()覆盖了所有以前对getchar()的 调用,然后我们将为所有AST类添加一个源位置:

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

当一个新的表达式被创建时,我们传递下来的源位置为我们提供了每个表达式和变量的位置:

LHS = std::make_unique<BinaryExprAST>(BinLoc, BinOp, std::move(LHS), std::move(RHS));

为了确保每条指令都能得到合适的源位置信息,我们每次切换位置时都会通知Builder,我们使用下面的辅助函数

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

上述函数告诉主IRBuilder我们当前所处的位置和范围。范围可以是编译单元级别的,也可以是最接近的封闭词块,比如说当前的函数。我们通过创建一个作用域堆栈来表示这一点:

std::vector<DIScope *> LexicalBlocks;

当我们开始为每个函数生成代码时,我们将作用域推到栈的顶部:

KSDbgInfo.LexicalBlocks.push_back(SP);

另外,在函数的代码生成结束时,我们应该将作用域从作用域栈中弹回:

// Pop off the lexical block for the function since we added it
// unconditionally.
KSDbgInfo.LexicalBlocks.pop_back();

最后,我们在每次开始为一个新的AST对象生成代码时,都会发出位置:

KSDbgInfo.emitLocation(this);

变量的位置

我们还需要能够打印当前作用域中的变量。首先,我们把函数参数设置好,这样我们就可以得到一个像样的回溯,看到函数是如何被调用的:

// 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 the variable symbol table.
  NamedValues[Arg.getName()] = Alloca;
}

在上面,我们首先创建一个变量,给它一个作用域SP、名称、源位置、类型和一个参数索引,因为它是一个参数。
然后我们创建一个lvm.dbg.declaration调用,在IR级别上表明我们有一个变量在一个alloca
中,并在declaration上设置一个源位置作为作用域的起始。

注意,调试器有基于过去如何编码和为其生成的调试信息的假设。在这种情况下,我们执行一个小黑客来避免为函数序言生成信息,这样调试器就知道在设置断点时如何跳过这些指令。我们通过在FunctionAST::CodeGen中添加以下一行来做到这一点。

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

最后,我们发出一个新的位置,开始为函数主体生成代码:

KSDbgInfo.emitLocation(Body.get());

在这一点上,我们有足够的调试信息来设置函数中的断点,打印参数变量,并调用函数。

总结

一个编译单元是DWARF中一段代码的顶级容器。它包含了单个翻译单元的类型和函数数据。另一方面,DWARF是一个紧凑的编码,用于表示类型、源位置和变量位置。
在这篇文章中,我们已经学会了在调试Kaleidoscope
代码时如何包含调试信息。

参考资料

引导编译器