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