LLVM 中的栈映射和补丁点

定义

在本文档中,我们将“运行时”统称为充当 LLVM 客户端的所有组件,包括 LLVM IR 生成器、对象代码使用者和代码修补程序。

栈映射记录特定指令地址处活动值的位置。这些活动值并不指跨越栈映射的所有 LLVM 活动值。相反,它们仅指运行时需要在此处处于活动状态的值。例如,它们可能是运行时需要在该点恢复程序执行的值,而与包含栈映射的已编译函数无关。

LLVM 将栈映射数据输出到指定栈映射段内的对象代码中。此栈映射数据包含每个栈映射的记录。该记录存储栈映射的指令地址,并包含每个映射值的条目。每个条目都将值的位置编码为寄存器、堆栈偏移量或常量。

补丁点是在运行时为修补新的指令序列保留空间的指令地址。补丁点看起来很像对 LLVM 的调用。它们接受遵循调用约定的参数,并可能返回值。它们还暗示栈映射生成,这允许运行时定位补丁点并找到活动值在该点的位置。

动机

此功能目前处于实验阶段,但在各种设置中都可能很有用,最明显的是运行时(JIT)编译器。补丁点内联函数的示例应用程序包括为多态方法分派实现内联调用缓存,或优化动态类型语言(如 JavaScript)中属性的检索。

此处记录的内联函数当前由开源 WebKit 项目中的 JavaScript 编译器使用,请参阅FTL JIT,但它们旨在在需要栈映射或代码修补时使用。由于内联函数具有实验性质,因此无法保证跨 LLVM 版本的兼容性。

本文档中描述的栈映射功能与计算栈映射中描述的功能不同。GCFunctionMetadata 提供了指向由GCRoot 内联函数捕获的已收集堆中的指针的位置,这也可以被视为“栈映射”。与上面定义的栈映射不同,GCFunctionMetadata 栈映射接口不提供将任意类型的活动寄存器值与指令地址关联起来的方法,也不指定结果栈映射的格式。此处描述的栈映射可能会向垃圾回收运行时提供更丰富的信息,但本文档中将不讨论此用法。

内联函数

以下两种内联函数可用于实现栈映射和补丁点:llvm.experimental.stackmapllvm.experimental.patchpoint。这两种内联函数都会生成栈映射记录,并且它们都允许某种形式的代码修补。它们可以独立使用(即llvm.experimental.patchpoint隐式生成栈映射,无需额外调用llvm.experimental.stackmap)。使用哪种内联函数取决于是否有必要为代码修补保留空间,以及是否应根据调用约定降低任何内联函数参数。 llvm.experimental.stackmap 不保留任何空间,也不期望任何调用参数。如果运行时在栈映射的地址处修补代码,它将破坏性地覆盖程序文本。这与llvm.experimental.patchpoint不同,后者保留了用于就地修补的空间,而不会覆盖周围的代码。llvm.experimental.patchpoint 内联函数还会根据其调用约定降低指定数量的参数。这允许修补的代码在不进行编组的情况下进行就地函数调用。

这些内联函数的每个实例都会在栈映射段中生成一个栈映射记录。该记录包括一个 ID,允许运行时唯一标识栈映射,以及代码中从封闭函数开头开始的偏移量。

llvm.experimental.stackmap’ 内联函数

语法:

declare void
  @llvm.experimental.stackmap(i64 <id>, i32 <numShadowBytes>, ...)

概述:

llvm.experimental.stackmap’ 内联函数在栈映射中记录指定值的位置,而不会生成任何代码。

操作数:

第一个操作数是要编码在栈映射中的 ID。第二个操作数是内联函数后跟随的影子字节数。后续的可变数量的操作数是将在栈映射中记录位置的活动值

要将此内联函数用作一个基本的栈映射,不带代码修补支持,可以将影子字节数设置为零。

语义:

栈映射内联函数不会就地生成任何代码,除非需要 nops 来覆盖其影子(见下文)。但是,它距函数入口的偏移量存储在栈映射中。这是紧随在栈映射之前的指令之后的相对指令地址。

栈映射 ID 允许运行时找到所需的栈映射记录。LLVM 将此 ID 直接传递给栈映射记录,而无需检查唯一性。

LLVM 保证在栈映射的指令偏移量之后的指令的影子,在此期间,基本块的末尾或对llvm.experimental.stackmapllvm.experimental.patchpoint 的另一次调用都不会发生。这允许运行时在响应从代码外部触发的事件时修补此处的代码。栈映射后的指令的代码可能会输出在栈映射的影子中,并且这些指令可能会被破坏性修补覆盖。如果没有影子字节,这种破坏性修补可能会覆盖当前函数之外的程序文本或数据。我们不允许栈映射影子重叠,以便运行时不需要考虑这种情况。

例如,一个具有 8 字节影子的栈映射

call void @runtime()
call void (i64, i32, ...) @llvm.experimental.stackmap(i64 77, i32 8,
                                                      ptr %ptr)
%val = load i64, ptr %ptr
%add = add i64 %val, 3
ret i64 %add

可能需要一个字节的 nop 填充

0x00 callq _runtime
0x05 nop                <--- stack map address
0x06 movq (%rdi), %rax
0x07 addq $3, %rax
0x0a popq %rdx
0x0b ret                <---- end of 8-byte shadow

现在,如果运行时需要使已编译代码无效,它可能会在栈映射的地址处修补 8 字节的代码,如下所示

0x00 callq _runtime
0x05 movl  $0xffff, %rax <--- patched code at stack map address
0x0a callq *%rax         <---- end of 8-byte shadow

这样,在对运行时的正常调用返回后,代码将执行对特殊入口点的修补调用,该入口点可以根据栈映射中找到的值重建栈帧。

llvm.experimental.patchpoint.*’ 内联函数

语法:

declare void
  @llvm.experimental.patchpoint.void(i64 <id>, i32 <numBytes>,
                                     ptr <target>, i32 <numArgs>, ...)
declare i64
  @llvm.experimental.patchpoint.i64(i64 <id>, i32 <numBytes>,
                                    ptr <target>, i32 <numArgs>, ...)

概述:

llvm.experimental.patchpoint.*’ 内联函数创建对指定<target> 的函数调用,并在栈映射中记录指定值的位置。

操作数:

第一个操作数是 ID,第二个操作数是为可修补区域保留的字节数,第三个操作数是函数的目标地址(可选为空),第四个操作数指定以下可变操作数中有多少个被视为函数调用参数。其余的可变数量的操作数是将在栈映射中记录位置的活动值

语义:

补丁点内联函数生成一个栈映射。如果地址不是常量空值,它还会发出对<target> 指定的地址的函数调用。函数调用及其参数将根据内联函数调用处的调用约定进行降低。具有非空返回值的内联函数变体也会根据调用约定返回值。

在 PowerPC 上,请注意<target> 必须是间接调用的目标的 ABI 函数指针。具体来说,在为 ELF V1 ABI 编译时,<target> 是通常用作 C/C++ 函数指针表示的函数描述符地址。

请求零个补丁点参数是有效的。在这种情况下,所有可变操作数都像llvm.experimental.stackmap.* 一样处理。区别在于仍将保留用于修补的空间,将发出调用,并且允许返回值。

参数的位置通常不会记录在栈映射中,因为它们已经被调用约定固定了。其余的活动值将记录其位置,这可能是寄存器、堆栈位置或常量。已经引入了一种特殊调用约定与栈映射一起使用,即 anyregcc,它强制将参数加载到寄存器中,但允许动态分配这些寄存器。这些参数寄存器将记录其寄存器位置在栈映射中,以及其余的活动值

补丁点还会发出 nops 以覆盖至少<numBytes> 的指令编码空间。因此,客户端必须确保<numBytes> 足以在支持的目标上对目标地址的调用进行编码。如果调用目标是常量空值,则没有最低要求。零字节空目标补丁点是有效的。

运行时可能会修补为修补点发出的代码,包括调用序列和空操作。但是,运行时不能对 LLVM 在保留空间内发出的代码做任何假设。不允许部分修补。运行时必须修补所有保留的字节,必要时用空操作填充。

此示例显示了一个保留 15 个字节的修补点,在 $rdi 中有一个参数,并且根据原生调用约定在 $rax 中有一个返回值。

%target = inttoptr i64 -281474976710654 to ptr
%val = call i64 (i64, i32, ...)
         @llvm.experimental.patchpoint.i64(i64 78, i32 15,
                                           ptr %target, i32 1, ptr %ptr)
%add = add i64 %val, 3
ret i64 %add

可能生成

0x00 movabsq $0xffff000000000002, %r11 <--- patch point address
0x0a callq   *%r11
0x0d nop
0x0e nop                               <--- end of reserved 15-bytes
0x0f addq    $0x3, %rax
0x10 movl    %rax, 8(%rsp)

请注意,不会记录任何栈映射位置。如果修补的代码序列不需要将参数固定到特定的调用约定寄存器,则可以使用 anyregcc 约定。

%val = call anyregcc @llvm.experimental.patchpoint(i64 78, i32 15,
                                                   ptr %target, i32 1,
                                                   ptr %ptr)

栈映射现在指示 %ptr 参数和返回值的位置。

Stack Map: ID=78, Loc0=%r9 Loc1=%r8

修补代码序列现在可以使用恰好分配在 %r8 中的参数,并返回分配在 %r9 中的值。

0x00 movslq 4(%r8) %r9              <--- patched code at patch point address
0x03 nop
...
0x0e nop                            <--- end of reserved 15-bytes
0x0f addq    $0x3, %r9
0x10 movl    %r9, 8(%rsp)

栈映射格式

LLVM 模块中存在栈映射或修补点内联函数会强制代码发出创建一个 栈映射段。此段的格式如下所示。

Header {
  uint8  : Stack Map Version (current version is 3)
  uint8  : Reserved (expected to be 0)
  uint16 : Reserved (expected to be 0)
}
uint32 : NumFunctions
uint32 : NumConstants
uint32 : NumRecords
StkSizeRecord[NumFunctions] {
  uint64 : Function Address
  uint64 : Stack Size (or UINT64_MAX if not statically known)
  uint64 : Record Count
}
Constants[NumConstants] {
  uint64 : LargeConstant
}
StkMapRecord[NumRecords] {
  uint64 : PatchPoint ID
  uint32 : Instruction Offset
  uint16 : Reserved (record flags)
  uint16 : NumLocations
  Location[NumLocations] {
    uint8  : Register | Direct | Indirect | Constant | ConstantIndex
    uint8  : Reserved (expected to be 0)
    uint16 : Location Size
    uint16 : Dwarf RegNum
    uint16 : Reserved (expected to be 0)
    int32  : Offset or SmallConstant
  }
  uint32 : Padding (only if required to align to 8 byte)
  uint16 : Padding
  uint16 : NumLiveOuts
  LiveOuts[NumLiveOuts]
    uint16 : Dwarf RegNum
    uint8  : Reserved
    uint8  : Size in Bytes
  }
  uint32 : Padding (only if required to align to 8 byte)
}

每个位置的第一个字节编码一个类型,指示如何解释 RegNumOffset 字段,如下所示。

编码

类型

描述

0x1

寄存器

Reg

寄存器中的值

0x2

直接

Reg + Offset

帧索引值

0x3

间接

[Reg + Offset]

溢出值

0x4

常量

Offset

小常量

0x5

ConstIndex

Constants[Offset]

大常量

在常见情况下,值在寄存器中可用,并且 Offset 字段将为零。溢出到栈的值被编码为 Indirect 位置。运行时必须从栈地址加载这些值,通常采用 [BP + Offset] 的形式。如果 alloca 值直接传递给栈映射内联函数,则 LLVM 可以将帧索引折叠到栈映射中,作为优化以避免分配寄存器或栈槽。这些帧索引将以 Direct 位置的形式编码,即 BP + Offset。LLVM 还可以通过直接在栈映射中发出常量来优化常量,或者在 Constant 位置的 Offset 中,或者在常量池中,由 ConstantIndex 位置引用。

在每个调用点,还会记录一个“liveout”寄存器列表。这些是跨栈映射保持活跃的寄存器,因此必须由运行时保存。当修补点内联函数与默认情况下将大多数寄存器保留为被调用者保存的调用约定一起使用时,这是一个重要的优化。

liveout 寄存器列表中的每个条目都包含一个 DWARF 寄存器编号和以字节为单位的大小。栈映射格式故意省略了特定的子寄存器信息。相反,运行时必须保守地解释此信息。例如,如果栈映射报告 %rax 中的一个字节,则该值可能在 %al%ah 中。在实践中这无关紧要,因为运行时只需保存 %rax。但是,如果栈映射报告 %ymm0 中的 16 个字节,则运行时可以通过仅保存 %xmm0 来安全地进行优化。

栈映射格式是 LLVM SVN 版本和运行时之间的契约。它目前处于实验阶段,可能会在短期内发生变化,但最大程度地减少更新运行时的需求非常重要。因此,栈映射设计以简单性和可扩展性为导向。表示的紧凑性是次要的,因为运行时预计会在编译模块后立即解析数据,并将其信息编码为自己的格式。由于运行时控制着段的分配,因此它可以对多个模块重用相同的栈映射空间。

栈映射支持目前仅在 64 位平台上实现。但是,32 位实现应该能够使用相同的格式,并且浪费的空间很少。

栈映射段

JIT 编译器可以通过 LLVM C API LLVMCreateSimpleMCJITMemoryManager() 提供自己的内存管理器来轻松访问此段。创建内存管理器时,JIT 会提供一个回调:LLVMMemoryManagerAllocateDataSectionCallback()。当 LLVM 创建此段时,它会调用回调并传递段名称。JIT 可以此时记录段的内存中地址,并在以后解析它以恢复栈映射数据。

对于 MachO(例如在 Darwin 上),栈映射段名称为“__llvm_stackmaps”。段名称为“__LLVM_STACKMAPS”。

对于 ELF(例如在 Linux 上),栈映射段名称为“.llvm_stackmaps”。段名称为“__LLVM_STACKMAPS”。

栈映射用法

本文档中描述的栈映射支持可用于精确确定代码中特定位置的值的位置。LLVM 不会维护这些值与任何更高级别实体之间的任何映射。运行时必须能够仅根据 ID、偏移量以及位置、记录和函数的顺序来解释栈映射记录,LLVM 会保留这些信息。

请注意,这与调试信息的目的是不同的,调试信息是尽力尝试跟踪每个指令处命名变量的位置。

此设计的另一个重要动机是允许运行时在执行到达与栈映射关联的指令地址时接管栈帧。运行时必须能够重建栈帧并使用栈映射提供的信息恢复程序执行。例如,执行可能会在解释器或同一函数的重新编译版本中恢复。

此用法限制了 LLVM 优化。显然,LLVM 不能在栈映射中移动存储。但是,加载也必须保守地处理。如果加载可能会触发异常,则将其提升到栈映射之上可能会无效。例如,运行时可以确定,鉴于类型系统的当前状态,加载可以安全地执行而无需类型检查。如果在加载的函数的某些激活在栈上存在时类型系统发生了更改,则加载变得不安全。运行时可以通过立即修补当前调用点和加载之间(通常,运行时只需修补所有栈映射位置以使函数无效)的所有栈映射位置来防止后续执行该加载。如果编译器将加载提升到栈映射之上,则程序可能会在运行时接管控制权之前崩溃。

为了强制执行这些语义,栈映射和修补点内联函数被视为可能读取和写入所有内存。这可能会比某些客户端期望的更限制优化。可以通过将调用点标记为“只读”来避免此限制。将来,我们还可以允许将元数据添加到内联函数调用以表达别名,从而允许优化将某些加载提升到栈映射之上。

直接栈映射条目

栈映射段 中所示,直接栈映射位置记录帧索引的地址。此地址本身就是运行时请求的值。这与间接位置不同,间接位置引用必须从中加载请求值的栈位置。直接位置可以传达 alloca 的地址,而间接位置处理寄存器溢出。

例如

entry:
  %a = alloca i64...
  llvm.experimental.stackmap(i64 <ID>, i32 <shadowBytes>, ptr %a)

运行时可以在编译后立即或在任何时间之后确定此 alloca 在栈上的相对位置。这与寄存器和间接位置不同,因为运行时只能在执行到达栈映射的指令地址时读取这些位置中的值。

此功能要求 LLVM 在入口块 alloca 直接被内联函数使用时对其进行特殊处理。(这是 llvm.gcroot 内联函数强加的相同要求。)LLVM 变换不能用任何中间值替换 alloca。运行时只需检查栈映射的位置是否为直接位置类型即可验证这一点。

支持的架构

对栈映射生成和相关内联函数的支持需要每个后端的一些代码。目前,LLVM 的后端中只有一小部分受支持。当前支持的架构为 X86_64、PowerPC、AArch64 和 SystemZ。