指令引用调试信息

本文档解释了 LLVM 如何使用值跟踪或指令引用,在编译的代码生成阶段确定调试信息的变量位置。本文内容面向从事代码生成目标和优化 Pass 的人员。对于任何对底层调试信息处理感兴趣的人员,本文也可能具有一定的参考价值。

问题陈述

在编译结束时,LLVM 必须生成 DWARF 位置列表(或类似列表),描述在变量的词法作用域中的每条指令,变量可以在哪个寄存器或堆栈位置找到。我们可以通过编译跟踪变量所在的虚拟寄存器,但这很容易受到寄存器分配期间的寄存器优化和指令移动的影响。

解决方案:指令引用

在指令引用模式下,LLVM 不是识别变量值所在的虚拟寄存器,而是引用定义该值的机器指令和操作数位置。考虑 LLVM IR 中引用指令值的方式

%2 = add i32 %0, %1
  #dbg_value(metadata i32 %2,

在 LLVM IR 中,IR Value 与计算该值的指令是同义的,以至于在内存中,Value 是指向计算指令的指针。指令引用在 LLVM 的代码生成后端(指令选择之后)实现了这种关系。考虑下面的 X86 汇编和指令引用调试信息,它对应于早期的 LLVM IR

%2:gr32 = ADD32rr %0, %1, implicit-def $eflags, debug-instr-number 1
DBG_INSTR_REF 1, 0, !123, !456, debug-location !789

当函数仍然是 SSA 形式时,虚拟寄存器 %2 足以标识由指令计算的值——但是函数最终会离开 SSA 形式,并且寄存器优化将掩盖所需值所在的寄存器。相反,标识指令值的更一致方法是引用定义该值的 MachineOperand:独立于由该 MachineOperand 定义的寄存器。在上面的代码中,DBG_INSTR_REF 指令引用指令编号一,操作数零,而 ADD32rr 具有附加的 debug-instr-number 属性,指示它是指令编号一。

将变量位置与寄存器解耦避免了涉及寄存器分配和优化的困难,但在指令被优化时需要额外的检测。用计算相同值的优化版本替换指令的优化必须保留指令编号,或者记录从旧指令/操作数编号对到新指令/操作数对的替换——参见 MachineFunction::substituteDebugValuesForInst。如果未执行调试信息维护,或者指令作为死代码被消除,则变量位置会被安全地丢弃并标记为“已优化掉”。例外情况是指令被修改而不是替换,这始终需要调试信息维护。

寄存器分配器注意事项

当寄存器分配器运行时,调试指令不直接引用任何虚拟寄存器,因此在寄存器分配期间无需进行昂贵的位置维护(即 LiveDebugVariables)。调试指令从函数中解除链接,然后在寄存器分配完成后重新链接。

例外情况是 PHI 指令:一旦寄存器分配完成,这些指令就会在控制流合并处变为隐式定义,并且附加到 PHI 指令的任何调试编号都会丢失。为了规避这个问题,PHI 的调试编号在寄存器分配开始时(phi-node-elimination)被记录,然后在寄存器分配完成后插入 DBG_PHI 指令。这需要在寄存器分配期间维护变量所在的寄存器,但维护的是单个位置(块入口点)而不是指令范围。

一个例子,在寄存器分配之前

bb.2:
  %2 = PHI %1, %bb.0, %2, %bb.1, debug-instr-number 1

之后

bb.2:
  DBG_PHI $rax, 1

LiveDebugValues

在优化和代码布局完成后,关于变量值的信息必须转换为变量位置,即寄存器和堆栈槽。这在 [LiveDebugValues pass][LiveDebugValues] 中执行,其中调试指令和机器代码被分离成两个独立的函数

  • 一个将值分配给变量名,

  • 一个将值分配给机器寄存器和堆栈槽。

LLVM 现有的 SSA 工具用于为每个函数放置 PHI,在变量值和机器位置中包含的值之间,值传播消除了任何不必要的 PHI。然后可以将两者连接起来,为函数中的每条指令将变量映射到值,再将值映射到位置。

这个过程的关键是能够识别值在寄存器和堆栈位置之间的移动,以便在值驻留在机器中的整个时间内保留值的位置。

所需目标支持和过渡指南

指令引用可以在任何目标上工作,但可能覆盖率较差。要良好地支持指令引用,需要

  • 实现目标钩子,以允许 LiveDebugValues 在机器中跟踪值,

  • 检测特定于目标的优化,以保留指令编号。

目标钩子

必须实现 TargetInstrInfo::isCopyInstrImpl 以识别任何类似复制的指令——LiveDebugValues 使用它来识别值何时在寄存器之间移动。

需要 TargetInstrInfo::isLoadFromStackSlotPostFETargetInstrInfo::isStoreToStackSlotPostFE 来识别 spill 和 restore 指令。每个都应分别返回目标或源寄存器。LiveDebugValues 将跟踪值从/到堆栈槽的移动。此外,任何写入堆栈 spill 的指令都应附加一个 MachineMemoryOperand,以便 LiveDebugValues 可以识别槽已被破坏。

特定于目标的优化检测

优化有两种类型:一种是修改 MachineInstr 使其执行不同的操作,另一种是创建新指令来替换旧指令的操作。

前者必须进行检测——相关问题是,由于修改,任何操作数中的任何寄存器定义是否会产生不同的值。如果答案是肯定的,那么引用该操作数的 DBG_INSTR_REF 指令有可能最终将不同的值分配给变量,从而向调试开发人员呈现意外的变量值。在这种情况下,在修改后的指令上调用 MachineInstr::dropDebugNumber() 以擦除其指令编号。任何引用它的 DBG_INSTR_REF 都将产生一个空的变量位置,在调试器中显示为“已优化掉”。

对于后一种优化类型,为了提高覆盖率,您应该记录指令编号替换:从旧指令编号/操作数对到新指令编号/操作数对的映射。考虑如果我们用双地址加法指令替换三地址加法指令

%2:gr32 = ADD32rr %0, %1, debug-instr-number 1

变为

%2:gr32 = ADD32rr %0(tied-def 0), %1, debug-instr-number 2

MachineFunction 中记录了从“指令编号 1 操作数 0”到“指令编号 2 操作数 0”的替换。在 LiveDebugValues 中,DBG_INSTR_REF 将通过替换表映射,以找到它引用的值的最新指令编号/操作数编号。

使用 MachineFunction::substituteDebugValuesForInst 自动生成旧指令和新指令之间的替换。它假定旧指令中作为定义的任何操作数在新指令中都是相同操作数位置的定义。这在大多数情况下都有效,例如在上面的示例中。

如果旧指令和新指令之间的操作数编号不对齐,请使用 MachineInstr::getDebugInstrNum 获取新指令的指令编号,并使用 MachineFunction::makeDebugValueSubstitution 记录旧指令和新指令中寄存器定义之间的映射。如果旧指令计算的某些值不再由新指令计算,则不记录替换——LiveDebugValues 将安全地丢弃现在不可用的变量值。

如果您的目标克隆指令,就像 TailDuplicator 优化 Pass 一样,请勿尝试保留指令编号或记录任何替换。MachineFunction::CloneMachineInstr 应删除任何克隆指令的指令编号,以避免重复编号出现在 LiveDebugValues 中。处理重复指令是指令引用的自然扩展,目前尚未实现。

[LiveDebugValues]: project:SourceLevelDebugging.rst#LiveDebugValues 变量位置的扩展