LLVM 目标无关代码生成器

警告

此功能尚在开发中。

简介

LLVM 目标无关代码生成器是一个框架,它提供了一套可重用的组件,用于将 LLVM 内部表示形式转换为指定目标的机器码——无论是汇编形式(适用于静态编译器)还是二进制机器码格式(适用于 JIT 编译器)。LLVM 目标无关代码生成器由六个主要组件组成

  1. 抽象目标描述 接口,这些接口独立于其使用方法捕获有关机器各个方面的关键属性。这些接口在 include/llvm/Target/ 中定义。

  2. 用于表示为目标生成的代码的类。这些类旨在足够抽象以表示任何目标机器的机器码。这些类在 include/llvm/CodeGen/ 中定义。在此级别,诸如“常量池条目”和“跳转表”之类的概念被明确公开。

  3. 用于表示目标文件级别代码的类和算法,即 MC 层。这些类表示诸如标签、节和指令之类的汇编级构造。在此级别,诸如“常量池条目”和“跳转表”之类的概念不存在。

  4. 目标无关算法 用于实现本机代码生成的各个阶段(寄存器分配、调度、栈帧表示等)。此代码位于 lib/CodeGen/ 中。

  5. 抽象目标描述接口的实现 用于特定目标。这些机器描述利用了 LLVM 提供的组件,并且可以选择提供自定义的目标特定传递,以构建特定目标的完整代码生成器。目标描述位于 lib/Target/ 中。

  6. 目标无关 JIT 组件。LLVM JIT 完全独立于目标(它使用 TargetJITInfo 结构来接口目标特定问题。目标无关 JIT 的代码位于 lib/ExecutionEngine/JIT 中。

根据您感兴趣的代码生成器部分,这些部分中的不同部分将对您有用。在任何情况下,您都应该熟悉目标描述机器码表示 类。如果您想为新目标添加后端,则需要为您的新目标实现目标描述 类并了解 LLVM 代码表示。如果您有兴趣实现新的 代码生成算法,它应该只依赖于目标描述和机器码表示类,确保它是可移植的。

代码生成器中必需的组件

LLVM 代码生成器的两个部分是代码生成器的高级接口以及可用于构建目标特定后端的可重用组件集。两个最重要的接口( TargetMachine DataLayout ) 是后端要适应 LLVM 系统必须定义的唯一接口,但如果要使用可重用的代码生成器组件,则必须定义其他接口。

此设计有两个重要的含义。首先,LLVM 可以支持完全非传统的代码生成目标。例如,C 后端不需要寄存器分配、指令选择或系统提供的任何其他标准组件。因此,它只实现了这两个接口,并执行自己的操作。请注意,自 LLVM 3.1 版本发布以来,C 后端已从主干中移除。另一个类似的代码生成器示例是(纯假设的)后端,它将 LLVM 转换为 GCC RTL 形式并使用 GCC 为目标发出机器码。

此设计还意味着可以在 LLVM 系统中设计和实现根本不同的代码生成器,这些代码生成器不使用任何内置组件。这样做根本不建议,但对于不适合 LLVM 机器描述模型的根本不同的目标可能是必需的:例如 FPGA。

代码生成器的高级设计

LLVM 目标无关代码生成器旨在支持标准基于寄存器的微处理器的有效和高质量代码生成。在此模型中,代码生成分为以下阶段

  1. 指令选择 — 此阶段确定以有效的方式在目标指令集中表达输入 LLVM 代码的方法。此阶段生成程序在目标指令集中的初始代码,然后使用 SSA 形式的虚拟寄存器和表示由于目标约束或调用约定而导致的任何必需寄存器分配的物理寄存器。此步骤将 LLVM 代码转换为目标指令的 DAG。

  2. 调度和形成 — 此阶段获取指令选择阶段生成的指令 DAG,确定指令的顺序,然后将指令作为 MachineInstrs 发出该顺序。请注意,我们在指令选择部分 中对此进行了描述,因为它在 SelectionDAG 上操作。

  3. 基于 SSA 的机器码优化 — 此可选阶段包含一系列在指令选择器生成的 SSA 形式上运行的机器码优化。诸如模调度或窥孔优化之类的优化在此处工作。

  4. 寄存器分配 — 将目标代码从 SSA 形式的无限虚拟寄存器文件转换为目标使用的具体寄存器文件。此阶段引入溢出代码并消除程序中的所有虚拟寄存器引用。

  5. 序言/结尾代码插入 — 一旦为函数生成了机器码并且已知所需的堆栈空间量(用于 LLVM alloca 和溢出槽),就可以插入函数的序言和结尾代码,并消除“抽象堆栈位置引用”。此阶段负责实现诸如帧指针消除和堆栈打包之类的优化。

  6. 后期机器码优化 — 对“最终”机器码进行的优化可以放在这里,例如溢出代码调度和窥孔优化。

  7. 代码发射 — 最后一个阶段实际上会输出当前函数的代码,无论是目标汇编格式还是机器码。

代码生成器基于这样一个假设:指令选择器将使用最佳模式匹配选择器来创建高质量的本地指令序列。基于模式扩展和积极的迭代窥孔优化的替代代码生成器设计要慢得多。这种设计允许通过允许使用不同复杂程度的组件来执行编译的任何步骤,从而实现高效的编译(对于 JIT 环境很重要)和积极的优化(在脱机生成代码时使用)。

除了这些阶段之外,目标实现还可以将任意目标特定的传递插入到流程中。例如,X86 目标使用一个特殊的传递来处理 80x87 浮点堆栈架构。其他具有特殊需求的目标可以根据需要使用自定义传递来支持。

使用 TableGen 进行目标描述

目标描述类需要对目标架构进行详细的描述。这些目标描述通常包含大量共同的信息(例如,add 指令几乎与 sub 指令相同)。为了允许最大程度地提取共性,LLVM 代码生成器使用 TableGen 概述 工具来描述目标机器的大块内容,这允许使用特定领域和特定目标的抽象来减少重复量。

随着 LLVM 的不断开发和完善,我们计划将越来越多的目标描述迁移到 .td 格式。这样做给我们带来了许多好处。最重要的是,它使 LLVM 更易于移植,因为它减少了需要编写的 C++ 代码量,以及在某人能够使某些功能正常工作之前需要理解的代码生成器的表面积。其次,它使更改变得更容易。特别是,如果表和其他内容都是由 tblgen 发射的,我们只需要在一个地方(tblgen)进行更改即可将所有目标更新到新的接口。

目标描述类

LLVM 目标描述类(位于 include/llvm/Target 目录中)提供了与任何特定客户端无关的目标机器的抽象描述。这些类旨在捕获目标的*抽象*属性(例如它具有的指令和寄存器),并且不包含任何特定的代码生成算法部分。

所有目标描述类(除了 DataLayout 类)都设计为由具体目标实现进行子类化,并具有实现的虚拟方法。为了访问这些实现, TargetMachine 类提供了目标应该实现的访问器。

The TargetMachine

The TargetMachine 类提供虚拟方法,这些方法用于通过 get*Info 方法(getInstrInfogetRegisterInfogetFrameInfo 等)访问各种目标描述类的目标特定实现。此类旨在由具体的目标实现(例如 X86TargetMachine)进行专门化,该实现实现了各种虚拟方法。唯一必需的目标描述类是 DataLayout 类,但如果要使用代码生成器组件,则也应实现其他接口。

The DataLayout

The DataLayout 类是唯一必需的目标描述类,也是唯一不可扩展的类(您不能从中派生新类)。DataLayout 指定有关目标如何为结构布局内存、各种数据类型的对齐要求、目标中指针的大小以及目标是小端还是大端的信息。

The TargetLowering

The TargetLowering 类主要由基于 SelectionDAG 的指令选择器使用,用于描述如何将 LLVM 代码降低到 SelectionDAG 操作。除其他事项外,此类指示

  • 各种 ValueType 使用的初始寄存器类,

  • 目标机器本地支持哪些操作,

  • setcc 操作的返回类型,

  • 用于移位量的类型,以及

  • 各种高级特性,例如将除以常数转换为乘法序列是否有效。

The TargetRegisterInfo

The TargetRegisterInfo 类用于描述目标的寄存器文件以及寄存器之间的任何交互。

寄存器在代码生成器中由无符号整数表示。物理寄存器(目标描述中实际存在的寄存器)是唯一的较小数字,而虚拟寄存器通常较大。请注意,寄存器 #0 保留为标志值。

处理器描述中的每个寄存器都有一个关联的 TargetRegisterDesc 条目,该条目为寄存器提供文本名称(用于汇编输出和调试转储)以及一组别名(用于指示一个寄存器是否与另一个寄存器重叠)。

除了每个寄存器的描述之外,TargetRegisterInfo 类还公开了一组处理器特定的寄存器类(TargetRegisterClass 类的实例)。每个寄存器类都包含具有相同属性的寄存器集(例如,它们都是 32 位整数寄存器)。指令选择器创建的每个 SSA 虚拟寄存器都有一个关联的寄存器类。当寄存器分配器运行时,它会将虚拟寄存器替换为该集中物理寄存器。

这些类的目标特定实现是从 TableGen 概述 中寄存器文件的描述自动生成的。

The TargetInstrInfo

The TargetInstrInfo 类用于描述目标支持的机器指令。描述定义了诸如操作码的助记符、操作数的数量、隐式寄存器使用和定义的列表、指令是否具有一些目标无关的属性(访问内存、可交换等)以及保存任何目标特定标志等内容。

The TargetFrameLowering

The TargetFrameLowering 类用于提供有关目标堆栈帧布局的信息。它保存堆栈增长的方向、进入每个函数时的已知堆栈对齐方式以及局部区域的偏移量。局部区域的偏移量是从函数进入时堆栈指针到可以存储函数数据(局部变量、溢出位置)的第一个位置的偏移量。

The TargetSubtarget

The TargetSubtarget 类用于提供有关正在目标化的特定芯片组的信息。子目标告知代码生成哪些指令受支持、指令延迟和指令执行路线图;即,哪些处理单元被使用,以什么顺序以及持续多长时间。

The TargetJITInfo

The TargetJITInfo 类公开了即时代码生成器使用的抽象接口,以执行目标特定的活动,例如发射存根。如果 TargetMachine 支持 JIT 代码生成,则应通过 getJITInfo 方法提供其中一个对象。

机器码描述类

在高级别上,LLVM 代码被翻译成由 MachineFunction , MachineBasicBlock MachineInstr 实例(在 include/llvm/CodeGen 中定义)形成的机器特定表示。此表示完全与目标无关,以其最抽象的形式表示指令:操作码和一系列操作数。此表示旨在支持机器代码的 SSA 表示以及寄存器分配的非 SSA 形式。

The MachineInstr

目标机器指令表示为 MachineInstr 类的实例。此类是表示机器指令的极其抽象的方式。特别是,它只跟踪操作码编号和一组操作数。

操作码编号是一个简单的无符号整数,仅对特定后端有意义。目标的所有指令都应在目标的*InstrInfo.td文件中定义。操作码枚举值是从此描述自动生成的。MachineInstr类没有任何关于如何解释指令的信息(即指令的语义是什么);为此,您必须参考 TargetInstrInfo 类。

机器指令的操作数可以是几种不同类型:寄存器引用、常数整数、基本块引用等。此外,机器操作数应标记为值的定义或使用(尽管只有寄存器可以是定义)。

按照惯例,LLVM 代码生成器对指令操作数进行排序,以便所有寄存器定义都位于寄存器使用之前,即使在通常以其他顺序打印的体系结构上也是如此。例如,SPARC add 指令:“add %i1, %i2, %i3” 将“%i1”和“%i2”寄存器相加并将结果存储到“%i3”寄存器中。在 LLVM 代码生成器中,操作数应存储为“%i3, %i1, %i2”:目标放在第一位。

将目标(定义)操作数保留在操作数列表的开头有几个优点。特别是,调试打印机将按以下方式打印指令

%r3 = add %i1, %i2

此外,如果第一个操作数是定义,则更容易创建指令,其唯一的定义是第一个操作数。

使用MachineInstrBuilder.h函数

机器指令是通过使用位于include/llvm/CodeGen/MachineInstrBuilder.h文件中的BuildMI函数创建的。BuildMI函数使构建任意机器指令变得容易。BuildMI函数的使用如下所示

// Create a 'DestReg = mov 42' (rendered in X86 assembly as 'mov DestReg, 42')
// instruction and insert it at the end of the given MachineBasicBlock.
const TargetInstrInfo &TII = ...
MachineBasicBlock &MBB = ...
DebugLoc DL;
MachineInstr *MI = BuildMI(MBB, DL, TII.get(X86::MOV32ri), DestReg).addImm(42);

// Create the same instr, but insert it before a specified iterator point.
MachineBasicBlock::iterator MBBI = ...
BuildMI(MBB, MBBI, DL, TII.get(X86::MOV32ri), DestReg).addImm(42);

// Create a 'cmp Reg, 0' instruction, no destination reg.
MI = BuildMI(MBB, DL, TII.get(X86::CMP32ri8)).addReg(Reg).addImm(42);

// Create an 'sahf' instruction which takes no operands and stores nothing.
MI = BuildMI(MBB, DL, TII.get(X86::SAHF));

// Create a self looping branch instruction.
BuildMI(MBB, DL, TII.get(X86::JNE)).addMBB(&MBB);

如果您需要添加定义操作数(除了可选的目标寄存器),则必须明确将其标记为定义操作数

MI.addReg(Reg, RegState::Define);

固定(预分配)寄存器

代码生成器需要注意的一个重要问题是固定寄存器。特别是,在指令流中经常存在寄存器分配器必须安排特定值位于特定寄存器中的位置。这可能是由于指令集的限制(例如,X86 只能使用EAX/EDX寄存器执行 32 位除法),或调用约定等外部因素。无论如何,指令选择器应发出在需要时将虚拟寄存器复制到物理寄存器或从物理寄存器复制出的代码。

例如,考虑以下简单的 LLVM 示例

define i32 @test(i32 %X, i32 %Y) {
  %Z = sdiv i32 %X, %Y
  ret i32 %Z
}

X86 指令选择器可能会为divret生成以下机器代码

;; Start of div
%EAX = mov %reg1024           ;; Copy X (in reg1024) into EAX
%reg1027 = sar %reg1024, 31
%EDX = mov %reg1027           ;; Sign extend X into EDX
idiv %reg1025                 ;; Divide by Y (in reg1025)
%reg1026 = mov %EAX           ;; Read the result (Z) out of EAX

;; Start of ret
%EAX = mov %reg1026           ;; 32-bit return value goes in EAX
ret

在代码生成结束时,寄存器分配器将合并寄存器并删除生成的恒等移动,生成以下代码

;; X is in EAX, Y is in ECX
mov %EAX, %EDX
sar %EDX, 31
idiv %ECX
ret

这种方法非常通用(如果它可以处理 X86 架构,它就可以处理任何架构!)并允许将有关指令流的所有目标特定知识隔离在指令选择器中。请注意,物理寄存器应该具有较短的生命周期以实现良好的代码生成,并且假设所有物理寄存器在进入和退出基本块时(在寄存器分配之前)都是死掉的。因此,如果您需要一个值跨基本块边界保持存活,它必须位于虚拟寄存器中。

调用破坏的寄存器

一些机器指令(如调用)会破坏大量物理寄存器。与其为所有这些寄存器添加<def,dead>操作数,不如使用MO_RegisterMask操作数。寄存器掩码操作数保存保留寄存器的位掩码,所有其他寄存器都被认为是由指令破坏的。

SSA 形式的机器代码

MachineInstr最初以 SSA 形式选择,并在寄存器分配发生之前以 SSA 形式维护。在大多数情况下,这非常简单,因为 LLVM 已经处于 SSA 形式;LLVM PHI 节点成为机器代码 PHI 节点,并且仅允许虚拟寄存器具有单个定义。

寄存器分配后,机器代码不再处于 SSA 形式,因为代码中不再有虚拟寄存器。

MachineBasicBlock

MachineBasicBlock类包含机器指令列表( MachineInstr 实例)。它大致对应于指令选择器的 LLVM 代码输入,但可能存在一对多映射(即一个 LLVM 基本块可以映射到多个机器基本块)。MachineBasicBlock类具有“getBasicBlock”方法,该方法返回它来自的 LLVM 基本块。

MachineFunction

MachineFunction类包含机器基本块列表( MachineBasicBlock 实例)。它与指令选择器的 LLVM 函数输入一一对应。除了基本块列表之外,MachineFunction还包含MachineConstantPoolMachineFrameInfoMachineFunctionInfoMachineRegisterInfo。有关更多信息,请参阅include/llvm/CodeGen/MachineFunction.h

MachineInstr Bundles

LLVM 代码生成器可以将指令序列建模为 MachineInstr 捆绑包。MI 捆绑包可以模拟包含任意数量并行指令的 VLIW 组/包。它还可以用于模拟不能合法分离的指令的顺序列表(可能存在数据依赖关系)(例如 ARM Thumb2 IT 块)。

从概念上讲,MI 捆绑包是一个 MI,其中嵌套了其他多个 MI

--------------
|   Bundle   | ---------
--------------          \
       |           ----------------
       |           |      MI      |
       |           ----------------
       |                   |
       |           ----------------
       |           |      MI      |
       |           ----------------
       |                   |
       |           ----------------
       |           |      MI      |
       |           ----------------
       |
--------------
|   Bundle   | --------
--------------         \
       |           ----------------
       |           |      MI      |
       |           ----------------
       |                   |
       |           ----------------
       |           |      MI      |
       |           ----------------
       |                   |
       |                  ...
       |
--------------
|   Bundle   | --------
--------------         \
       |
      ...

MI 捆绑包支持不会更改 MachineBasicBlock 和 MachineInstr 的物理表示。所有 MI(包括顶级和嵌套的 MI)都存储为 MI 的顺序列表。“捆绑”的 MI 使用“InsideBundle”标志进行标记。具有特殊 BUNDLE 操作码的顶级 MI 用于表示捆绑包的开始。可以将 BUNDLE MI 与不在捆绑包内也不表示捆绑包的单个 MI 混合使用。

MachineInstr 传递应该将 MI 捆绑包作为一个单元进行操作。成员方法已被教授如何正确处理捆绑包和捆绑包内的 MI。MachineBasicBlock 迭代器已修改为跳过捆绑的 MI 以强制执行捆绑包作为单个单元的概念。MachineBasicBlock 已添加了一个替代迭代器 instr_iterator,以允许传递迭代 MachineBasicBlock 中的所有 MI,包括嵌套在捆绑包内的 MI。顶级 BUNDLE 指令必须具有正确的寄存器 MachineOperand 集,这些寄存器表示捆绑的 MI 的累积输入和输出。

针对 VLIW 架构的 MachineInstr 的打包/捆绑通常应作为寄存器分配超级传递的一部分完成。更具体地说,确定哪些 MI 应该捆绑在一起的传递应该在代码生成器退出 SSA 形式后完成(即在二地址传递、PHI 消除和复制合并之后)。此类捆绑包应在虚拟寄存器被重写为物理寄存器后完成(即添加 BUNDLE MI 和输入和输出寄存器 MachineOperand)。这消除了向 BUNDLE 指令添加虚拟寄存器操作数的需要,这实际上会使虚拟寄存器定义和使用列表加倍。捆绑包可以使用虚拟寄存器并以 SSA 形式形成,但可能不适合所有用例。

“MC”层

MC 层用于表示和处理原始机器代码级别的代码,不包含“高级”信息,如“常量池”、“跳转表”、“全局变量”或类似信息。在此级别,LLVM 处理诸如标签名称、机器指令和目标文件中的节等内容。此层中的代码用于许多重要目的:代码生成器的尾端使用它来编写 .s 或 .o 文件,并且 llvm-mc 工具也使用它来实现独立的机器代码汇编器和反汇编器。

本节描述了一些重要的类。还有一些重要的子系统在此层交互,稍后将在本手册中介绍。

MCStreamer API

MCStreamer 最好被认为是汇编器 API。它是一个抽象 API,以不同的方式实现(例如,输出 .s 文件、输出 ELF .o 文件等),但其 API 直接对应于在 .s 文件中看到的内容。MCStreamer 每个指令都有一个方法,例如 EmitLabel、EmitSymbolAttribute、switchSection、emitValue(用于 .byte、.word)等,这些方法直接对应于汇编级指令。它还有一个 EmitInstruction 方法,用于将 MCInst 输出到流。

此 API 对两个客户端最重要:llvm-mc 独立汇编器实际上是一个解析器,它解析一行,然后调用 MCStreamer 上的方法。在代码生成器中,代码生成器的代码发射阶段将更高级别的 LLVM IR 和 Machine* 构造降低到 MC 层,通过 MCStreamer 发射指令。

在 MCStreamer 的实现方面,有两个主要的实现:一个用于输出 .s 文件(MCAsmStreamer),另一个用于输出 .o 文件(MCObjectStreamer)。MCAsmStreamer 是一个简单的实现,它为每个方法打印一个指令(例如 EmitValue -> .byte),但 MCObjectStreamer 实现了一个完整的汇编器。

对于目标特定的指令,MCStreamer 有一个 MCTargetStreamer 实例。每个需要它的目标都定义一个从它继承的类,并且非常类似于 MCStreamer 本身:它每个指令都有一个方法,并且有两个类继承自它,一个目标对象流和一个目标汇编流。目标汇编流只是打印它 (emitFnStart -> .fnstart),而对象流则实现它的汇编逻辑。

为了使 llvm 使用这些类,目标初始化必须调用 TargetRegistry::RegisterAsmStreamer 和 TargetRegistry::RegisterMCObjectStreamer,传递回调函数来分配相应的目标流并将其传递给 createAsmStreamer 或传递给相应的对象流构造函数。

MCContext

MCContext 类是 MC 层中各种唯一化数据结构的所有者,包括符号、节等。因此,这是您与之交互以创建符号和节的类。此类不能被子类化。

MCSymbol

MCSymbol 类表示汇编文件中的符号(也称为标签)。有两种有趣的符号:汇编器临时符号和普通符号。汇编器临时符号由汇编器使用和处理,但在生成目标文件时会被丢弃。这种区别通常通过在标签前添加前缀来表示,例如“L”标签在 MachO 中是汇编器临时标签。

MCSymbol 由 MCContext 创建并在其中唯一化。这意味着 MCSymbol 可以通过指针等价性进行比较以确定它们是否是同一个符号。请注意,指针不等式并不保证标签最终会位于不同的地址。输出类似于 .s 文件中的以下内容是完全合法的

foo:
bar:
  .byte 4

在这种情况下,foo 和 bar 符号都将具有相同的地址。

MCSection

MCSection 类表示特定于目标文件的一个节。它由特定于目标文件的实现(例如 MCSectionMachOMCSectionCOFFMCSectionELF)进行子类化,这些类由 MCContext 创建和唯一化。MCStreamer 具有当前节的概念,可以通过 SwitchToSection 方法更改该节(对应于 .s 文件中的“.section”指令)。

MCInst

MCInst 类是指令的目标无关表示。它是一个简单的类(比 MachineInstr 简单得多),它包含一个特定于目标的操作码和一个 MCOperand 的向量。MCOperand 反过来是一个简单的区分联合,包含三种情况:1)简单的立即数,2)目标寄存器 ID,3)作为 MCExpr 的符号表达式(例如“ Lfoo-Lbar+42”)。

MCInst 是用于在 MC 层表示机器指令的通用货币。它是指令编码器、指令打印机使用的类型,也是汇编解析器和反汇编器生成的类型。

目标文件格式

MC 层的对象编写器支持各种目标文件格式。由于目标文件格式的目标特定方面,每个目标仅支持 MC 层支持的格式的子集。大多数目标支持发出 ELF 对象。其他供应商特定的对象通常仅在该供应商支持的目标上受支持(即 MachO 仅在 Darwin 支持的目标上受支持,而 XCOFF 仅在支持 AIX 的目标上受支持)。此外,一些目标有自己的目标文件格式(即 DirectX、SPIR-V 和 WebAssembly)。

下表捕获了 LLVM 中目标文件支持的快照

表 101 目标文件格式

格式

支持的目标

COFF

AArch64、ARM、X86

DXContainer

DirectX

ELF

AArch64、AMDGPU、ARM、AVR、BPF、CSKY、Hexagon、Lanai、LoongArch、M86k、MSP430、MIPS、PowerPC、RISCV、SPARC、SystemZ、VE、X86

GOFF

SystemZ

MachO

AArch64、ARM、X86

SPIR-V

SPIRV

WASM

WebAssembly

XCOFF

PowerPC

目标无关代码生成算法

本节介绍了 代码生成器的高级设计 中描述的阶段。它解释了它们的工作原理以及其设计背后的一些基本原理。

指令选择

指令选择是将呈现给代码生成器的 LLVM 代码转换为特定于目标的机器指令的过程。文献中有一些众所周知的方法可以做到这一点。LLVM 使用基于 SelectionDAG 的指令选择器。

DAG 指令选择器的一部分是从目标描述 (*.td) 文件生成的。我们的目标是让整个指令选择器从这些 .td 文件生成,尽管目前仍然有一些需要自定义 C++ 代码。

GlobalISel 是另一个指令选择框架。

SelectionDAG 简介

SelectionDAG 以一种适合于使用自动技术(例如基于动态规划的最优模式匹配选择器)进行指令选择的方式为代码表示提供了抽象。它也非常适合代码生成的其它阶段;特别是指令调度(SelectionDAG 在选择后非常接近调度 DAG)。此外,SelectionDAG 提供了一个主机表示,其中可以执行各种非常低级(但目标无关)的 优化;这些优化需要目标有效支持的指令的广泛信息。

SelectionDAG 是一个有向无环图,其节点是 SDNode 类的实例。 SDNode 的主要有效负载是其操作码(Opcode),它指示节点执行的操作以及操作的操作数。各种操作节点类型在 include/llvm/CodeGen/ISDOpcodes.h 文件的顶部进行了描述。

尽管大多数操作定义单个值,但图中的每个节点都可以定义多个值。例如,组合的除法/取余操作将定义被除数和余数。许多其他情况也需要多个值。每个节点也具有一定数量的操作数,这些操作数是到定义使用值的节点的边。因为节点可以定义多个值,所以边由 SDValue 类的实例表示,它是一个 <SDNode, unsigned> 对,分别指示正在使用的节点和结果值。由 SDNode 生成的每个值都与一个关联的 MVT(机器值类型)相关联,指示该值的类型是什么。

SelectionDAG 包含两种不同类型的值:表示数据流的值和表示控制流依赖项的值。数据值是带有整数或浮点值类型的简单边。控制边表示为“链”边,其类型为 MVT::Other。这些边在具有副作用的节点之间提供排序(例如加载、存储、调用、返回等)。所有具有副作用的节点都应将令牌链作为输入并产生一个新的令牌链作为输出。按照惯例,令牌链输入始终是操作数 #0,链结果始终是操作产生的最后一个值。但是,在指令选择之后,机器节点在其指令操作数之后具有其链,并且后面可能跟着胶合节点。

SelectionDAG 具有指定的“入口”和“根”节点。入口节点始终是具有 ISD::EntryToken 操作码的标记节点。根节点是令牌链中最后的副作用节点。例如,在单个基本块函数中,它将是返回节点。

SelectionDAG 的一个重要概念是“合法”与“非法”DAG 的概念。目标的合法 DAG 是一个仅使用支持的操作和支持的类型的 DAG。例如,在 32 位 PowerPC 上,具有 i1、i8、i16 或 i64 类型的值的 DAG 将是非法的,使用 SREM 或 UREM 操作的 DAG 也是非法的。合法化类型合法化操作 阶段负责将非法 DAG 转换为合法 DAG。

SelectionDAG 指令选择过程

基于 SelectionDAG 的指令选择包括以下步骤

  1. 构建初始 DAG — 此阶段执行从输入 LLVM 代码到非法 SelectionDAG 的简单转换。

  2. 优化 SelectionDAG — 此阶段对 SelectionDAG 执行简单的优化以简化它,并识别元指令(如旋转和 div/rem 对)以供支持这些元操作的目标使用。这使得生成的代码更有效率,并且 从 DAG 选择指令 阶段(如下所示)更简单。

  3. 合法化 SelectionDAG 类型 — 此阶段转换 SelectionDAG 节点以消除目标上不支持的任何类型。

  4. 优化 SelectionDAG — 运行 SelectionDAG 优化器以清理类型合法化暴露出的冗余。

  5. 合法化 SelectionDAG 操作 — 此阶段转换 SelectionDAG 节点以消除目标上不支持的任何操作。

  6. 优化 SelectionDAG — 运行 SelectionDAG 优化器以消除操作合法化引入的效率低下。

  7. 从 DAG 中选择指令 — 最后,目标指令选择器将 DAG 操作与目标指令匹配。此过程将目标无关的输入 DAG 转换为另一个目标指令的 DAG。

  8. SelectionDAG 调度和形成 — 最后一个阶段为目标指令 DAG 中的指令分配线性顺序,并将它们输出到正在编译的 MachineFunction 中。此步骤使用传统的预处理调度技术。

所有这些步骤完成后,SelectionDAG 将被销毁,并运行代码生成过程的其余部分。

调试这些步骤最常见的方法之一是使用 -debug-only=isel,它会在每个步骤之后打印出 DAG 以及其他信息(如调试信息)。或者,-debug-only=isel-dump 仅显示 DAG 转储,但可以使用 -filter-print-funcs=<function names> 根据函数名称过滤结果。

了解这里发生了什么的一个好方法是利用一些 LLC 命令行选项。以下选项会弹出一个窗口,在特定时间显示 SelectionDAG(如果您在使用此选项时仅在控制台上打印错误,则可能需要配置您的系统以添加对它的支持)。

  • -view-dag-combine1-dags 显示在构建 DAG 后、第一个优化过程之前 DAG。

  • -view-legalize-dags 显示在合法化之前 DAG。

  • -view-dag-combine2-dags 显示在第二个优化过程之前 DAG。

  • -view-isel-dags 显示在选择阶段之前 DAG。

  • -view-sched-dags 显示在调度之前 DAG。

-view-sunit-dags 显示调度程序的依赖关系图。此图基于最终的 SelectionDAG,其中必须一起调度的节点捆绑到单个调度单元节点中,并且省略了与调度无关的直接操作数和其他节点。

选项 -filter-view-dags 允许选择您感兴趣的可视化基本块的名称,并过滤所有以前的 view-*-dags 选项。

初始 SelectionDAG 构造

初始 SelectionDAG 由 SelectionDAGBuilder 类从 LLVM 输入天真地窥孔展开。此过程的目的是尽可能多地将低级、目标特定的细节暴露给 SelectionDAG。此过程大多是硬编码的(例如,LLVM add 变为 SDNode add,而 getelementptr 展开为明显的算术运算)。此过程需要目标特定的钩子来降低调用、返回、可变参数等。对于这些功能, 目标降低 接口被使用。

SelectionDAG LegalizeTypes 阶段

合法化阶段负责将 DAG 转换为仅使用目标本机支持的类型。

将不支持的标量类型的值转换为支持类型的值主要有两种方法:将小类型转换为大类型(“提升”)以及将大整数类型分解为较小的类型(“扩展”)。例如,目标可能要求所有 f32 值都被提升为 f64,并且所有 i1/i8/i16 值都被提升为 i32。相同的目标可能要求所有 i64 值都被扩展为 i32 值对。这些更改可以根据需要插入符号扩展和零扩展,以确保最终代码的行为与输入相同。

将不支持的向量类型的值转换为支持类型的值主要有两种方法:必要时多次拆分向量类型,直到找到合法类型,以及通过在末尾添加元素来扩展向量类型以将其舍入到合法类型(“扩展”)。如果向量一直向下拆分为单元素部分,并且没有找到支持的向量类型,则将元素转换为标量(“标量化”)。

目标实现通过在其 TargetLowering 构造函数中调用 addRegisterClass 方法来告诉合法化器哪些类型受支持(以及为它们使用哪个寄存器类)。

SelectionDAG 合法化阶段

合法化阶段负责将 DAG 转换为仅使用目标本机支持的操作。

目标通常具有奇怪的约束,例如不支持每个支持数据类型上的每个操作(例如,X86 不支持字节条件移动,PowerPC 不支持从 16 位内存位置进行符号扩展加载)。合法化通过对另一个操作序列进行开放编码来模拟操作(“扩展”),通过将一种类型提升为支持该操作的较大类型(“提升”),或使用目标特定的钩子来实现合法化(“自定义”)来解决此问题。

目标实现通过在其 TargetLowering 构造函数中调用 setOperationAction 方法来告诉合法化器哪些操作不受支持(以及采取上述三种操作中的哪一种)。

如果目标具有合法的向量类型,则预计它将为 shufflevector IR 指令的常见形式生成高效的机器代码,使用这些类型。这可能需要对从 shufflevector IR 创建的 SelectionDAG 向量操作进行自定义合法化。应该处理的 shufflevector 形式包括

  • 向量选择 — 向量的每个元素都从两个输入向量的对应元素中选择。此操作在目标汇编中也可能称为“混合”或“按位选择”。此类型的 shuffle 直接映射到 shuffle_vector SelectionDAG 节点。

  • 插入子向量 — 将向量放置到从索引 0 开始的较长向量类型中。此类型的 shuffle 直接映射到 insert_subvector SelectionDAG 节点,其中 index 操作数设置为 0。

  • 提取子向量 — 从索引 0 开始从较长向量类型中提取向量。此类型的 shuffle 直接映射到 extract_subvector SelectionDAG 节点,其中 index 操作数设置为 0。

  • 散布 — 向量的所有元素都具有相同的标量元素。此操作在目标汇编中也可能称为“广播”或“复制”。shufflevector IR 指令可能会更改向量长度,因此此操作可能映射到多个 SelectionDAG 节点,包括 shuffle_vectorconcat_vectorsinsert_subvectorextract_subvector

在存在合法化过程之前,我们要求每个目标选择器都支持和处理每个操作符和类型,即使它们不被本机支持。合法化阶段的引入允许所有规范化模式在目标之间共享,并且使优化规范化代码变得非常容易,因为它仍然以 DAG 的形式存在。

SelectionDAG 优化阶段:DAG 组合器

SelectionDAG 优化阶段在代码生成中运行多次,在 DAG 构建后立即运行,并在每次合法化后运行一次。此过程的第一次运行允许清理初始代码(例如,执行依赖于知道操作符具有受限类型输入的优化)。此过程的后续运行清理了合法化过程生成的混乱代码,这使得合法化变得非常简单(它可以专注于使代码合法,而不是专注于生成良好且合法的代码)。

执行的一类重要的优化是优化插入的符号扩展和零扩展指令。我们目前使用临时技术,但将来可能会转向更严格的技术。以下是一些关于该主题的优秀论文

扩展整数算术
Kevin Redwine 和 Norman Ramsey
2004 年国际编译器构造会议 (CC)

有效的符号扩展消除
Motohiro Kawahito、Hideaki Komatsu 和 Toshio Nakatani
2002 年 ACM SIGPLAN 编程语言设计与实现会议论文集。

SelectionDAG 选择阶段

选择阶段是指令选择的大部分目标特定代码。此阶段将合法的 SelectionDAG 作为输入,将目标支持的指令与该 DAG 匹配,并生成一个新的目标代码 DAG。例如,考虑以下 LLVM 片段

%t1 = fadd float %W, %X
%t2 = fmul float %t1, %Y
%t3 = fadd float %t2, %Z

此 LLVM 代码对应于一个看起来大致像这样的 SelectionDAG

(fadd:f32 (fmul:f32 (fadd:f32 W, X), Y), Z)

如果目标支持浮点乘加 (FMA) 操作,则可以将其中一个加法与乘法合并。例如,在 PowerPC 上,指令选择器的输出可能如下所示 DAG

(FMADDS (FADDS W, X), Y, Z)

FMADDS 指令是一个三元指令,它将前两个操作数相乘并加上第三个操作数(作为单精度浮点数)。 FADDS 指令是一个简单的二元单精度加法指令。要执行此模式匹配,PowerPC 后端包含以下指令定义

def FMADDS : AForm_1<59, 29,
                    (ops F4RC:$FRT, F4RC:$FRA, F4RC:$FRC, F4RC:$FRB),
                    "fmadds $FRT, $FRA, $FRC, $FRB",
                    [(set F4RC:$FRT, (fadd (fmul F4RC:$FRA, F4RC:$FRC),
                                           F4RC:$FRB))]>;
def FADDS : AForm_2<59, 21,
                    (ops F4RC:$FRT, F4RC:$FRA, F4RC:$FRB),
                    "fadds $FRT, $FRA, $FRB",
                    [(set F4RC:$FRT, (fadd F4RC:$FRA, F4RC:$FRB))]>;

指令定义的突出显示部分指示用于匹配指令的模式。DAG 操作符(如 fmul/fadd)在 include/llvm/Target/TargetSelectionDAG.td 文件中定义。“F4RC”是输入和结果值的寄存器类。

TableGen DAG 指令选择器生成器读取 .td 文件中的指令模式,并自动构建目标模式匹配代码的部分。它具有以下优势

  • 在编译器编译时,它会分析你的指令模式,并告诉你你的模式是否有意义。

  • 它可以处理模式匹配中操作数的任意约束。特别是,很容易表达诸如“匹配任何 13 位符号扩展的值”之类的语句。例如,请参阅 PowerPC 后端中的 immSExt16 和相关的 tblgen 类。

  • 它了解为定义的模式定义的几个重要恒等式。例如,它知道加法是可交换的,因此它允许上面的 FMADDS 模式匹配“(fadd X, (fmul Y, Z))”以及“(fadd (fmul X, Y), Z)”,而无需目标作者专门处理这种情况。

  • 它拥有一个功能齐全的类型推断系统。特别是,你很少需要显式地告诉系统你的模式的哪些部分是什么类型。在上面的 FMADDS 案例中,我们不必告诉 tblgen 模式中的所有节点都是 'f32' 类型。它能够从 F4RC 类型为 'f32' 的事实中推断并传播此知识。

  • 目标可以定义自己的(并依赖于内置的)“模式片段”。模式片段是可以重用的模式块,在编译器编译时会内联到你的模式中。例如,整数“(not x)”操作实际上被定义为一个模式片段,它扩展为“(xor x, -1)”,因为 SelectionDAG 没有本地的 ' not' 操作。目标可以根据需要定义自己的简写片段。请参阅 ' not' 和 ' ineg' 的定义以了解示例。

  • 除了指令之外,目标还可以使用 'Pat' 类指定任意模式,这些模式映射到一个或多个指令。例如,PowerPC 无法使用一条指令将任意整数立即数加载到寄存器中。为了告诉 tblgen 如何执行此操作,它定义了

    // Arbitrary immediate support.  Implement in terms of LIS/ORI.
    def : Pat<(i32 imm:$imm),
              (ORI (LIS (HI16 imm:$imm)), (LO16 imm:$imm))>;
    

    如果加载立即数到寄存器的单指令模式都不匹配,则将使用此模式。此规则表示“匹配任意 i32 立即数,将其转换为 ORI('或一个 16 位立即数')和 LIS('加载 16 位立即数,其中立即数左移 16 位')指令”。为了使这能够工作, LO16/HI16 节点转换用于操作输入立即数(在本例中,获取立即数的高 16 位或低 16 位)。

  • 当使用 'Pat' 类将模式映射到具有一个或多个复杂操作数的指令(例如 X86寻址模式)时,模式可以完整地使用 ComplexPattern 指定操作数,或者可以分别指定复杂操作数的组成部分。后者例如由 PowerPC 后端用于预增量指令

    def STWU  : DForm_1<37, (outs ptr_rc:$ea_res), (ins GPRC:$rS, memri:$dst),
                    "stwu $rS, $dst", LdStStoreUpd, []>,
                    RegConstraint<"$dst.reg = $ea_res">, NoEncode<"$ea_res">;
    
    def : Pat<(pre_store GPRC:$rS, ptr_rc:$ptrreg, iaddroff:$ptroff),
              (STWU GPRC:$rS, iaddroff:$ptroff, ptr_rc:$ptrreg)>;
    

    在这里, ptroffptrreg 操作数对被映射到 STWU 指令中类 memri 的复杂操作数 dst 上。

  • 虽然系统确实自动化了很多,但它仍然允许你编写自定义 C++ 代码来匹配特殊情况,如果有一些难以表达的东西。

虽然它有很多优点,但该系统目前有一些局限性,主要是因为它正在开发中,尚未完成。

  • 总的来说,无法定义或匹配定义多个值的 SelectionDAG 节点(例如 SMUL_LOHILOADCALL 等)。这是你目前仍然*必须*为你的指令选择器编写自定义 C++ 代码的主要原因。

  • 还没有很好的方法来支持匹配复杂的寻址模式。将来,我们将扩展模式片段以允许它们定义多个值(例如 X86寻址模式的四个操作数,这些操作数目前使用自定义 C++ 代码匹配)。此外,我们将扩展片段,以便一个片段可以匹配多个不同的模式。

  • 我们还没有自动推断像 isStore/isLoad 这样的标志。

  • 我们还没有自动生成 合法化器 支持的寄存器和操作集。

  • 我们还没有办法引入自定义合法化节点。

尽管存在这些限制,但指令选择器生成器对于典型指令集中大多数二进制和逻辑操作仍然非常有用。如果你遇到任何问题或无法弄清楚如何做某事,请告诉 Chris!

SelectionDAG 调度和形成阶段

调度阶段获取选择阶段的目标指令的 DAG 并分配一个顺序。调度程序可以根据机器的各种约束选择一个顺序(即最小寄存器压力的顺序或尝试覆盖指令延迟)。一旦建立顺序,DAG 就会转换为 的列表 MachineInstrs 并且 SelectionDAG 被销毁。

请注意,此阶段在逻辑上与指令选择阶段是分开的,但在代码中与它紧密相关,因为它在 SelectionDAG 上操作。

SelectionDAG 的未来方向

  1. 可选的逐函数选择。

  2. .td 文件自动生成整个选择器。

基于 SSA 的机器代码优化

待编写

活动区间

活动区间是变量活跃的范围(区间)。它们被一些 寄存器分配器 传递使用,以确定需要相同物理寄存器的两个或多个虚拟寄存器是否在程序中的同一时间点处于活跃状态(即,它们冲突)。当这种情况发生时,必须溢出一个虚拟寄存器。

活动变量分析

确定变量活动区间的第一步是计算指令后立即死亡的寄存器集(即,指令计算该值,但从未使用)以及指令使用的但从未在指令之后使用的寄存器集(即,它们被杀死)。为函数中的每个虚拟寄存器和可分配寄存器物理寄存器计算活动变量信息。这是以非常有效的方式完成的,因为它使用 SSA 来稀疏地计算虚拟寄存器的生命周期信息(这些寄存器采用 SSA 形式),并且只需要在一个块内跟踪物理寄存器。在寄存器分配之前,LLVM 可以假设物理寄存器仅在一个基本块内处于活跃状态。这允许它进行单一的局部分析来解决每个基本块内的物理寄存器生命周期。如果物理寄存器不可分配寄存器(例如,堆栈指针或条件代码),则不会对其进行跟踪。

物理寄存器可能在函数中处于活跃状态或退出函数。活跃输入值通常是寄存器中的参数。活跃输出值通常是寄存器中的返回值。活跃输入值被标记为这样,并在活动区间分析期间被赋予一个虚拟“定义”指令。如果函数的最后一个基本块是 return,则将其标记为使用函数中所有活跃输出值。

PHI 节点需要特殊处理,因为从函数 CFG 的深度优先遍历计算活动变量信息不会保证 PHI 节点使用的虚拟寄存器在使用之前被定义。当遇到 PHI 节点时,仅处理定义,因为使用将在其他基本块中处理。

对于当前基本块的每个 PHI 节点,我们模拟在当前基本块末尾的赋值并遍历后继基本块。如果后继基本块具有 PHI 节点,并且其中一个 PHI 节点的操作数来自当前基本块,则该变量将被标记为在当前基本块及其所有前驱基本块内处于活跃状态,直到遇到具有定义指令的基本块为止。

活动区间分析

现在我们拥有了执行活动区间分析并构建活动区间本身所需的信息。我们首先对基本块和机器指令进行编号。然后我们处理“活跃输入”值。这些在物理寄存器中,因此假定物理寄存器在基本块结束时被杀死。虚拟寄存器的活动区间是针对机器指令 [1, N] 的某个排序计算的。活动区间是一个区间 [i, j),其中 1 >= i >= j > N,对于该区间,变量处于活跃状态。

注意

更多内容即将推出…

寄存器分配

寄存器分配问题在于将一个可以使用无限数量虚拟寄存器的程序 Pv,映射到一个包含有限(可能很小)数量物理寄存器的程序 Pp。每个目标架构都有不同数量的物理寄存器。如果物理寄存器的数量不足以容纳所有虚拟寄存器,则其中一些必须映射到内存中。这些虚拟寄存器称为溢出虚拟寄存器

LLVM中寄存器的表示方式

在LLVM中,物理寄存器用整数表示,通常范围从1到1023。要查看特定架构的编号方式,可以阅读该架构的GenRegisterNames.inc文件。例如,通过检查lib/Target/X86/X86GenRegisterInfo.inc,我们看到32位寄存器EAX用43表示,而MMX寄存器MM0映射到65。

一些架构包含共享相同物理位置的寄存器。一个显著的例子是X86平台。例如,在X86架构中,寄存器EAXAXAL共享前8位。这些物理寄存器在LLVM中被标记为别名。对于特定架构,可以通过检查其RegisterInfo.td文件来查看哪些寄存器是别名。此外,类MCRegAliasIterator枚举了所有与某个寄存器存在别名的物理寄存器。

在LLVM中,物理寄存器被分组到寄存器类中。同一寄存器类中的元素在功能上是等价的,可以互换使用。每个虚拟寄存器只能映射到特定类的物理寄存器。例如,在X86架构中,一些虚拟寄存器只能分配到8位寄存器。寄存器类由TargetRegisterClass对象描述。要确定虚拟寄存器是否与给定的物理寄存器兼容,可以使用以下代码

bool RegMapping_Fer::compatible_class(MachineFunction &mf,
                                      unsigned v_reg,
                                      unsigned p_reg) {
  assert(TargetRegisterInfo::isPhysicalRegister(p_reg) &&
         "Target register must be physical");
  const TargetRegisterClass *trc = mf.getRegInfo().getRegClass(v_reg);
  return trc->contains(p_reg);
}

有时,主要出于调试目的,更改目标架构中可用物理寄存器的数量很有用。这必须在TargetRegisterInfo.td文件中静态完成。只需使用grep查找RegisterClass,其最后一个参数是寄存器列表。简单地注释掉一些寄存器,就可以避免它们被使用。更优雅的方式是显式地从分配顺序中排除一些寄存器。请参阅lib/Target/X86/X86RegisterInfo.tdGR8寄存器类的定义,了解这方面的示例。

虚拟寄存器也用整数表示。与物理寄存器相反,不同的虚拟寄存器永远不会共享相同的编号。物理寄存器在TargetRegisterInfo.td文件中静态定义,应用程序开发人员无法创建,而虚拟寄存器则不同。为了创建新的虚拟寄存器,可以使用方法MachineRegisterInfo::createVirtualRegister()。此方法将返回一个新的虚拟寄存器。使用IndexedMap<Foo, VirtReg2IndexFunctor>来保存每个虚拟寄存器的信息。如果需要枚举所有虚拟寄存器,可以使用函数TargetRegisterInfo::index2VirtReg()查找虚拟寄存器编号。

for (unsigned i = 0, e = MRI->getNumVirtRegs(); i != e; ++i) {
  unsigned VirtReg = TargetRegisterInfo::index2VirtReg(i);
  stuff(VirtReg);
}

在寄存器分配之前,指令的操作数大多是虚拟寄存器,尽管也可以使用物理寄存器。要检查给定的机器操作数是否为寄存器,可以使用布尔函数MachineOperand::isRegister()。要获取寄存器的整数代码,可以使用MachineOperand::getReg()。一条指令可以定义或使用一个寄存器。例如,ADD reg:1026 := reg:1025 reg:1024定义了寄存器1024,并使用了寄存器1025和1026。对于给定的寄存器操作数,方法MachineOperand::isUse()指示该寄存器是否被指令使用。方法MachineOperand::isDef()指示该寄存器是否被定义。

我们将寄存器分配之前存在于LLVM位代码中的物理寄存器称为预着色寄存器。预着色寄存器在许多不同的情况下使用,例如,传递函数调用的参数以及存储特定指令的结果。预着色寄存器有两种类型:隐式定义的和显式定义的。显式定义的寄存器是普通操作数,可以使用MachineInstr::getOperand(int)::getReg()访问。要检查指令隐式定义了哪些寄存器,可以使用TargetInstrInfo::get(opcode)::ImplicitDefs,其中opcode是目标指令的操作码。显式和隐式物理寄存器之间的一个重要区别是,后者对每个指令都静态定义,而前者可能会根据正在编译的程序而有所不同。例如,表示函数调用的指令将始终隐式定义或使用相同的物理寄存器集。要读取指令隐式使用的寄存器,可以使用TargetInstrInfo::get(opcode)::ImplicitUses。预着色寄存器对任何寄存器分配算法都施加约束。寄存器分配器必须确保在它们还存活时,不会被虚拟寄存器的值覆盖。

虚拟寄存器到物理寄存器的映射

有两种方法可以将虚拟寄存器映射到物理寄存器(或内存槽)。第一种方法,我们称之为直接映射,基于使用TargetRegisterInfoMachineOperand类的某些方法。第二种方法,我们称之为间接映射,依赖于VirtRegMap类来插入加载和存储指令,将值发送到内存和从内存获取值。

直接映射为寄存器分配器的开发者提供了更大的灵活性;但是,它更容易出错,并且需要更多的实现工作。基本上,程序员必须指定在要编译的目标函数中应该在哪里插入加载和存储指令,以便获取和存储内存中的值。要将物理寄存器分配给给定操作数中存在的虚拟寄存器,可以使用MachineOperand::setReg(p_reg)。要插入存储指令,可以使用TargetInstrInfo::storeRegToStackSlot(...),要插入加载指令,可以使用TargetInstrInfo::loadRegFromStackSlot

间接映射使应用程序开发者免于插入加载和存储指令的复杂性。要将虚拟寄存器映射到物理寄存器,可以使用VirtRegMap::assignVirt2Phys(vreg, preg)。要将某个虚拟寄存器映射到内存,可以使用VirtRegMap::assignVirt2StackSlot(vreg)。此方法将返回vreg的值将位于其中的栈槽。如果需要将另一个虚拟寄存器映射到相同的栈槽,可以使用VirtRegMap::assignVirt2StackSlot(vreg, stack_location)。使用间接映射时需要考虑的一个重要点是,即使虚拟寄存器映射到内存,它仍然需要映射到物理寄存器。这个物理寄存器是虚拟寄存器在存储之前或重新加载之后应该找到的位置。

如果使用间接策略,在所有虚拟寄存器都映射到物理寄存器或栈槽后,需要使用一个spiller对象在代码中放置加载和存储指令。每个已映射到栈槽的虚拟寄存器将在定义后存储到内存中,并在使用前加载。spiller的实现尝试回收加载/存储指令,避免不必要的指令。有关如何调用spiller的示例,请参阅lib/CodeGen/RegAllocLinearScan.cpp中的RegAllocLinearScan::runOnMachineFunction

处理双地址指令

除了极少数例外情况(例如,函数调用),LLVM机器代码指令是三地址指令。也就是说,每个指令最多期望定义一个寄存器,并且最多使用两个寄存器。但是,一些架构使用双地址指令。在这种情况下,定义的寄存器也是使用的寄存器之一。例如,X86中的指令ADD %EAX, %EBX实际上等效于%EAX = %EAX + %EBX

为了生成正确的代码,LLVM必须将表示双地址指令的三地址指令转换为真正的双地址指令。LLVM为此特定目的提供了TwoAddressInstructionPass传递。它必须在寄存器分配之前运行。执行后,生成的代码可能不再是SSA形式。例如,在将指令%a = ADD %b %c转换为两条指令时,就会发生这种情况:

%a = MOVE %b
%a = ADD %a %c

请注意,在内部,第二个指令表示为ADD %a[def/use] %c。即,寄存器操作数%a同时被指令使用和定义。

SSA分解阶段

寄存器分配期间发生的另一个重要转换称为SSA分解阶段。SSA形式简化了对程序控制流图执行的许多分析。但是,传统的指令集没有实现PHI指令。因此,为了生成可执行代码,编译器必须用其他保留其语义的指令替换PHI指令。

有许多方法可以安全地从目标代码中删除PHI指令。最传统的PHI分解算法用复制指令替换PHI指令。这是LLVM采用的策略。SSA分解算法在lib/CodeGen/PHIElimination.cpp中实现。为了调用此传递,必须在寄存器分配器的代码中将标识符PHIEliminationID标记为必需。

指令折叠

指令折叠是在寄存器分配期间执行的一种优化,用于消除不必要的复制指令。例如,以下指令序列

%EBX = LOAD %mem_address
%EAX = COPY %EBX

可以安全地替换为单个指令

%EAX = LOAD %mem_address

可以使用 TargetRegisterInfo::foldMemoryOperand(...) 方法折叠指令。折叠指令时必须小心;折叠后的指令可能与原始指令有很大不同。有关其用法的示例,请参阅 LiveIntervals::addIntervalsForSpillslib/CodeGen/LiveIntervalAnalysis.cpp 中。

内置寄存器分配器

LLVM 基础设施为应用程序开发人员提供了三种不同的寄存器分配器

  • 快速 - 这是调试版本的默认寄存器分配器。它在基本块级别分配寄存器,尝试将值保存在寄存器中并在适当的情况下重用寄存器。

  • 基本 - 这是寄存器分配的一种增量方法。活动范围按启发式驱动的顺序一次分配给寄存器。由于代码可以在分配期间动态重写,因此此框架允许将有趣的分配器开发为扩展。它本身不是生产寄存器分配器,但对于分类错误和作为性能基线来说,可能是一个有用的独立模式。

  • 贪婪 - 默认分配器。这是基本分配器的高度调整的实现,它包含全局活动范围拆分。此分配器努力最大程度地减少溢出代码的成本。

  • PBQP - 基于分区布尔二次规划 (PBQP) 的寄存器分配器。此分配器通过构建表示所考虑的寄存器分配问题的 PBQP 问题,使用 PBQP 求解器解决此问题,并将解决方案映射回寄存器分配来工作。

llc 中使用的寄存器分配器类型可以通过命令行选项 -regalloc=... 选择。

$ llc -regalloc=linearscan file.bc -o ln.s
$ llc -regalloc=fast file.bc -o fa.s
$ llc -regalloc=pbqp file.bc -o pbqp.s

序言/结语代码插入

注意

待编写

紧凑展开

抛出异常需要展开函数。有关如何展开给定函数的信息传统上以 DWARF 展开(也称为帧)信息表示。但是,该格式最初是为调试器回溯而开发的,并且每个帧描述条目 (FDE) 每个函数需要约 20-30 字节。从函数中的地址映射到运行时相应的 FDE 的成本也很高。另一种展开编码称为紧凑展开,每个函数只需要 4 字节。

紧凑展开编码是一个 32 位值,它以特定于体系结构的方式进行编码。它指定要还原哪些寄存器以及从何处还原,以及如何展开函数。当链接器创建最终链接的映像时,它将创建一个 __TEXT,__unwind_info 部分。此部分是运行时访问任何给定函数的展开信息的一种快速简便的方法。如果我们为函数发出紧凑展开信息,则该紧凑展开信息将编码在 __TEXT,__unwind_info 部分中。如果我们发出 DWARF 展开信息,则 __TEXT,__unwind_info 部分将包含最终链接映像中 __TEXT,__eh_frame 部分中 FDE 的偏移量。

对于 X86,紧凑展开编码有三种模式

带帧指针的函数 (``EBP`` 或 ``RBP``)

EBP/RBP 基于帧,其中 EBP/RBP 在返回地址之后立即推送到堆栈中,然后 ESP/RSP 移动到 EBP/RBP。因此,要展开,ESP/RSP 使用当前 EBP/RBP 值恢复,然后通过弹出堆栈恢复 EBP/RBP,并且通过再次弹出堆栈到 PC 中完成返回。所有需要恢复的非易失性寄存器都必须保存在堆栈上从 EBP-4EBP-1020 (RBP-8RBP-1020) 开始的小范围内。偏移量(在 32 位模式下除以 4,在 64 位模式下除以 8)编码在位 16-23 中(掩码:0x00FF0000)。保存的寄存器编码在位 0-14 中(掩码:0x00007FFF)作为以下表格中的五个 3 位条目

紧凑编号

i386 寄存器

x86-64 寄存器

1

EBX

RBX

2

ECX

R12

3

EDX

R13

4

EDI

R14

5

ESI

R15

6

EBP

RBP

无帧且堆栈大小为小常数 (``EBP`` 或 ``RBP`` 未用作帧指针)

要返回,一个常数(编码在紧凑展开编码中)添加到 ESP/RSP。然后通过弹出堆栈到 PC 中完成返回。所有需要恢复的非易失性寄存器都必须保存在返回地址之后的堆栈上。堆栈大小(在 32 位模式下除以 4,在 64 位模式下除以 8)编码在位 16-23 中(掩码:0x00FF0000)。32 位模式下的最大堆栈大小为 1024 字节,64 位模式下的最大堆栈大小为 2048 字节。保存的寄存器数编码在位 9-12 中(掩码:0x00001C00)。位 0-9(掩码:0x000003FF)包含保存了哪些寄存器及其顺序。(有关编码算法,请参阅 lib/Target/X86FrameLowering.cpp 中的 encodeCompactUnwindRegistersWithoutFrame() 函数。)

无帧且堆栈大小为大常数 (``EBP`` 或 ``RBP`` 未用作帧指针)

此情况类似于“无帧且堆栈大小为小常数”情况,但堆栈大小太大,无法编码在紧凑展开编码中。相反,它要求函数在其序言中包含“subl $nnnnnn, %esp”。紧凑编码包含函数中 $nnnnnn 值的偏移量,位于位 9-12 中(掩码:0x00001C00)。

后期机器代码优化

注意

待编写

代码发射

代码生成的代码发射步骤负责将代码生成抽象(如 MachineFunctionMachineInstr 等)降低到 MC 层使用的抽象(MCInstMCStreamer 等)。这是通过结合几个不同的类来完成的:(错误命名的)与目标无关的 AsmPrinter 类、AsmPrinter 的目标特定子类(如 SparcAsmPrinter)以及 TargetLoweringObjectFile 类。

由于 MC 层在目标文件抽象级别工作,因此它没有函数、全局变量等的概念。相反,它考虑标签、指令和指令。此时使用的关键类是 MCStreamer 类。这是一个抽象 API,它以不同的方式实现(例如,输出 .s 文件、输出 ELF .o 文件等),它实际上是“汇编程序 API”。MCStreamer 每个指令都有一个方法,例如 EmitLabel、EmitSymbolAttribute、switchSection 等,这些方法直接对应于汇编级指令。

如果您有兴趣为目标实现代码生成器,则必须为目标实现三件重要的事情

  1. 首先,您需要一个目标的 AsmPrinter 子类。此类实现将 MachineFunction 转换为 MC 标签构造的一般降低过程。AsmPrinter 基类提供许多有用的方法和例程,并且还允许您以某些重要方式覆盖降低过程。如果您正在实现 ELF、COFF 或 MachO 目标,则您应该可以免费获得大部分降低,因为 TargetLoweringObjectFile 类实现了大部分通用逻辑。

  2. 其次,您需要为目标实现指令打印机。指令打印机获取一个 MCInst 并将其呈现为文本形式的 raw_ostream。其中大部分是从 .td 文件自动生成的(当您在指令中指定类似“add $dst, $src1, $src2”的内容时),但您需要实现例程来打印操作数。

  3. 第三,您需要实现将 MachineInstr 降低到 MCInst 的代码,通常在“<target>MCInstLower.cpp”中实现。此降低过程通常是特定于目标的,并且负责将跳转表条目、常量池索引、全局变量地址等转换为 MCLabels(如果适用)。此转换层还负责将代码生成器使用的伪操作扩展为它们对应的实际机器指令。由此生成的 MCInst 会被馈送到指令打印机或编码器。

最后,您可以选择性地实现 MCCodeEmitter 的子类,该子类将 MCInst 降低为机器代码字节和重定位。如果您想支持直接 .o 文件发射,或者想为目标实现汇编程序,这很重要。

发出函数堆栈大小信息

TargetLoweringObjectFile::StackSizesSection 不为空且 TargetOptions::EmitStackSizeSection 设置(-stack-size-section)时,将发出包含函数堆栈大小元数据的部分。该部分将包含函数符号值(指针大小)和堆栈大小(无符号 LEB128)对的数组。堆栈大小值仅包括在函数序言中分配的空间。不包括具有动态堆栈分配的函数。

VLIW 分组器

在超长指令字 (VLIW) 体系结构中,编译器负责将指令映射到体系结构上可用的功能单元。为此,编译器创建了一组称为数据包捆绑包的指令。LLVM 中的 VLIW 分组器是一种与目标无关的机制,用于启用机器指令的分组。

从指令到功能单元的映射

VLIW 目标中的指令通常可以映射到多个功能单元。在打包过程中,编译器必须能够判断是否可以将一条指令添加到包中。这个决定可能很复杂,因为编译器必须检查所有可能的指令到功能单元的映射。因此,为了减轻编译时间复杂度,VLIW 打包器会解析目标的指令类并在编译构建时生成表格。然后,可以使用提供的机器无关 API 查询这些表格,以确定是否可以在包中容纳一条指令。

打包表如何生成和使用

打包器从目标的行程表中读取指令类,并创建一个确定性有限自动机 (DFA) 来表示包的状态。DFA 由三个主要元素组成:输入、状态和转换。生成的 DFA 的输入集表示正在添加到包中的指令。状态表示包中指令对功能单元的可能使用情况。在 DFA 中,从一个状态到另一个状态的转换发生在将指令添加到现有包时。如果存在功能单元到指令的合法映射,则 DFA 包含相应的转换。转换的缺失表示不存在合法映射,并且无法将指令添加到包中。

要为 VLIW 目标生成表格,请将 TargetGenDFAPacketizer.inc 作为目标添加到目标目录中的 Makefile 中。导出的 API 提供了三个函数:DFAPacketizer::clearResources()DFAPacketizer::reserveResources(MachineInstr *MI)DFAPacketizer::canReserveResources(MachineInstr *MI)。这些函数允许目标打包器将指令添加到现有包中,并检查是否可以将指令添加到包中。有关更多信息,请参阅 llvm/CodeGen/DFAPacketizer.h

实现本地汇编器

虽然您可能正在阅读本文是因为您想编写或维护编译器后端,但 LLVM 也完全支持构建本地汇编器。我们努力从 .td 文件(特别是指令语法和编码)中自动生成汇编器,这意味着可以将大部分手动和重复的数据输入分解并与编译器共享。

指令解析

注意

待编写

指令别名处理

一旦指令被解析,它就会进入 MatchInstructionImpl 函数。MatchInstructionImpl 函数执行别名处理,然后进行实际匹配。

别名处理是将同一指令的不同词法形式规范化为一个表示形式的阶段。可以实现几种不同的别名类型,它们按处理顺序(从最简单/最弱到最复杂/最强大)列出。通常,您希望使用满足指令需求的第一个别名机制,因为它将允许更简洁的描述。

助记符别名

别名处理的第一阶段是对允许使用两个不同助记符的指令类进行简单的指令助记符重新映射。此阶段是对一个输入助记符到一个输出助记符的简单且无条件的重新映射。此形式的别名无法查看操作数,因此重新映射必须适用于给定助记符的所有形式。助记符别名定义很简单,例如 X86 有

def : MnemonicAlias<"cbw",     "cbtw">;
def : MnemonicAlias<"smovq",   "movsq">;
def : MnemonicAlias<"fldcww",  "fldcw">;
def : MnemonicAlias<"fucompi", "fucomip">;
def : MnemonicAlias<"ud2a",    "ud2">;

… 以及许多其他。使用 MnemonicAlias 定义,助记符会简单直接地重新映射。虽然 MnemonicAlias 无法查看指令的任何方面(例如操作数),但它们可以通过 Requires 子句依赖于全局模式(匹配器支持的相同模式)

def : MnemonicAlias<"pushf", "pushfq">, Requires<[In64BitMode]>;
def : MnemonicAlias<"pushf", "pushfl">, Requires<[In32BitMode]>;

在此示例中,助记符会根据当前指令集映射到不同的助记符。

指令别名

别名处理最通用的阶段发生在匹配过程中:它为匹配器提供了新的匹配形式以及要生成的特定指令。指令别名有两部分:要匹配的字符串和要生成的指令。例如

def : InstAlias<"movsx $src, $dst", (MOVSX16rr8W GR16:$dst, GR8  :$src)>;
def : InstAlias<"movsx $src, $dst", (MOVSX16rm8W GR16:$dst, i8mem:$src)>;
def : InstAlias<"movsx $src, $dst", (MOVSX32rr8  GR32:$dst, GR8  :$src)>;
def : InstAlias<"movsx $src, $dst", (MOVSX32rr16 GR32:$dst, GR16 :$src)>;
def : InstAlias<"movsx $src, $dst", (MOVSX64rr8  GR64:$dst, GR8  :$src)>;
def : InstAlias<"movsx $src, $dst", (MOVSX64rr16 GR64:$dst, GR16 :$src)>;
def : InstAlias<"movsx $src, $dst", (MOVSX64rr32 GR64:$dst, GR32 :$src)>;

这展示了指令别名的强大示例,根据汇编中存在的操作数以多种不同的方式匹配相同的助记符。指令别名的结果可以包含与目标指令不同的顺序的操作数,并且可以多次使用输入,例如

def : InstAlias<"clrb $reg", (XOR8rr  GR8 :$reg, GR8 :$reg)>;
def : InstAlias<"clrw $reg", (XOR16rr GR16:$reg, GR16:$reg)>;
def : InstAlias<"clrl $reg", (XOR32rr GR32:$reg, GR32:$reg)>;
def : InstAlias<"clrq $reg", (XOR64rr GR64:$reg, GR64:$reg)>;

此示例还显示了绑定的操作数仅列出一次。在 X86 后端,XOR8rr 有两个输入 GR8 和一个输出 GR8(其中一个输入绑定到输出)。InstAliases 获取没有绑定操作数重复项的扁平化操作数列表。指令别名的结果还可以使用立即数和固定的物理寄存器,这些寄存器作为结果中简单的立即数操作数添加,例如

// Fixed Immediate operand.
def : InstAlias<"aad", (AAD8i8 10)>;

// Fixed register operand.
def : InstAlias<"fcomi", (COM_FIr ST1)>;

// Simple alias.
def : InstAlias<"fcomi $reg", (COM_FIr RST:$reg)>;

指令别名也可以具有 Requires 子句以使其特定于子目标。

如果后端支持,则指令打印机可以自动发出别名而不是被别名的内容。它通常会导致更好、更易读的代码。如果最好打印出被别名的内容,则将“0”作为 InstAlias 定义的第三个参数传递。

指令匹配

注意

待编写

特定于目标的实现说明

本文档的此部分解释了特定于特定目标的代码生成器的功能或设计决策。

尾调用优化

尾调用优化(被调用者重用调用者的堆栈)目前在 x86/x86-64、PowerPC、AArch64 和 WebAssembly 上受支持。如果满足以下条件,则在 x86/x86-64、PowerPC 和 AArch64 上执行。

  • 调用者和被调用者具有调用约定 fastcccc 10(GHC 调用约定)、cc 11(HiPE 调用约定)、tailccswifttailcc

  • 调用是尾调用 - 在尾部位置(ret 立即跟随 call,并且 ret 使用 call 的值或为空)。

  • 启用了选项 -tailcallopt 或调用约定为 tailcc

  • 满足平台特定的约束。

x86/x86-64 约束

  • 未使用可变参数列表。

  • 在生成 GOT/PIC 代码的 x86-64 上,仅支持模块本地调用(可见性 = hidden 或 protected)。

PowerPC 约束

  • 未使用可变参数列表。

  • 未使用 byval 参数。

  • 在 ppc32/64 GOT/PIC 上,仅支持模块本地调用(可见性 = hidden 或 protected)。

WebAssembly 约束

  • 未使用可变参数列表

  • 启用了“tail-call”目标属性。

  • 调用者和被调用者的返回类型必须匹配。除非被调用者也是 void,否则调用者不能为 void。

AArch64 约束

  • 未使用可变参数列表。

示例

调用方式为 llc -tailcallopt test.ll

declare fastcc i32 @tailcallee(i32 inreg %a1, i32 inreg %a2, i32 %a3, i32 %a4)

define fastcc i32 @tailcaller(i32 %in1, i32 %in2) {
  %l1 = add i32 %in1, %in2
  %tmp = tail call fastcc i32 @tailcallee(i32 inreg %in1, i32 inreg %in2, i32 %in1, i32 %l1)
  ret i32 %tmp
}

-tailcallopt 的影响

为了在被调用者具有比调用者更多参数的情况下支持尾调用优化,使用了“被调用者弹出参数”约定。这目前会导致每个未进行尾调用优化的 fastcc 调用(因为未满足上述一个或多个约束)后跟堆栈的重新调整。因此,在这种情况下,性能可能会更差。

同级调用优化

同级调用优化是尾调用优化的一种受限形式。与上一节中描述的尾调用优化不同,当未指定 -tailcallopt 选项时,它可以自动对任何尾调用执行。

目前在满足以下约束条件时在 x86/x86-64 上执行同级调用优化

  • 调用者和被调用者具有相同的调用约定。它可以是 cfastcc

  • 调用是尾调用 - 在尾部位置(ret 立即跟随 call,并且 ret 使用 call 的值或为空)。

  • 调用者和被调用者具有匹配的返回类型,或者不使用被调用者的结果。

  • 如果被调用者的任何参数都通过堆栈传递,则它们必须在调用者自己的传入参数堆栈中可用,并且帧偏移量必须相同。

示例

declare i32 @bar(i32, i32)

define i32 @foo(i32 %a, i32 %b, i32 %c) {
entry:
  %0 = tail call i32 @bar(i32 %a, i32 %b)
  ret i32 %0
}

X86 后端

X86 代码生成器位于 lib/Target/X86 目录中。此代码生成器能够针对各种 x86-32 和 x86-64 处理器,并包括对 MMX 和 SSE 等 ISA 扩展的支持。

支持的 X86 目标三元组

以下是 X86 后端支持的已知目标三元组。这不是一个详尽的列表,添加人们测试的目标三元组将很有用。

  • i686-pc-linux-gnu — Linux

  • i386-unknown-freebsd5.3 — FreeBSD 5.3

  • i686-pc-cygwin — Win32 上的 Cygwin

  • i686-pc-mingw32 — Win32 上的 MingW

  • i386-pc-mingw32msvc — Linux 上的 MingW 交叉编译器

  • i686-apple-darwin* — X86 上的 Apple Darwin

  • x86_64-unknown-linux-gnu — Linux

支持的 X86 调用约定

以下特定于目标的调用约定为后端所知

  • x86_StdCall — 在 Microsoft Windows 平台上看到的 stdcall 调用约定 (CC ID = 64)。

  • x86_FastCall — 在 Microsoft Windows 平台上看到的 fastcall 调用约定 (CC ID = 65)。

  • x86_ThisCall — 类似于 X86_StdCall。将第一个参数传递给 ECX,其他参数通过堆栈传递。被调用者负责清理堆栈。此约定由 MSVC 在其 ABI 中默认用于方法 (CC ID = 70)。

在 MachineInstr 中表示 X86 地址模式

x86 有一种非常灵活的访问内存的方式。它能够在整数指令中直接形成以下表达式的内存地址(使用 ModR/M寻址)

SegmentReg: Base + [1,2,4,8] * IndexReg + Disp32

为了表示这一点,LLVM 为此表单的每个内存操作数跟踪不少于 5 个操作数。这意味着“加载”形式的“mov”按此顺序具有以下 MachineOperands

Index:        0     |    1        2       3           4          5
Meaning:   DestReg, | BaseReg,  Scale, IndexReg, Displacement Segment
OperandTy: VirtReg, | VirtReg, UnsImm, VirtReg,   SignExtImm  PhysReg

存储和其他所有指令都以相同的方式和相同的顺序处理四个内存操作数。如果段寄存器未指定(regno = 0),则不会生成段覆盖。“Lea”操作没有指定段寄存器,因此它们的内存引用只有 4 个操作数。

支持的 X86 地址空间

x86 具有一个特性,可以通过 x86 段寄存器执行对不同地址空间的加载和存储操作。指令上的段覆盖前缀字节会导致指令的内存访问转到指定的段。LLVM 地址空间 0 是默认地址空间,包括栈以及程序中任何未限定的内存访问。地址空间 1-255 目前保留供用户定义的代码使用。GS 段由地址空间 256 表示,FS 段由地址空间 257 表示,SS 段由地址空间 258 表示。其他 x86 段尚未分配地址空间编号。

虽然这些地址空间可能看起来类似于通过 thread_local 关键字实现的 TLS,并且通常使用相同的底层硬件,但它们之间存在一些根本差异。

thread_local 关键字应用于全局变量,并指定它们应分配在线程局部内存中。不涉及类型限定符,并且可以使用普通指针指向这些变量,并使用普通加载和存储访问它们。thread_local 关键字在 LLVM IR 级别上与目标无关(尽管 LLVM 尚未针对某些配置实现它)。

相反,特殊地址空间应用于静态类型。每个加载和存储在其地址操作数类型中都有一个特定的地址空间,这决定了访问哪个地址空间。LLVM 会忽略全局变量上的这些特殊地址空间限定符,并且不提供直接在其中分配存储空间的方法。在 LLVM IR 级别上,这些特殊地址空间的行为部分取决于底层操作系统或运行时环境,并且它们是特定于 x86 的(并且 LLVM 在某些情况下尚未正确处理它们)。

某些操作系统和运行时环境使用(或将来可能使用)FS/GS 段寄存器用于各种低级目的,因此在考虑它们时应谨慎。

指令命名

指令名称由基本名称、默认操作数大小和每个操作数的一个字符以及可选的特殊大小组成。例如

ADD8rr      -> add, 8-bit register, 8-bit register
IMUL16rmi   -> imul, 16-bit register, 16-bit memory, 16-bit immediate
IMUL16rmi8  -> imul, 16-bit register, 16-bit memory, 8-bit immediate
MOVSX32rm16 -> movsx, 32-bit register, 16-bit memory

PowerPC 后端

PowerPC 代码生成器位于 lib/Target/PowerPC 目录中。代码生成可重新定位到 PowerPC ISA 的几个变体或子目标;包括 ppc32、ppc64 和 altivec。

LLVM PowerPC ABI

LLVM 遵循 AIX PowerPC ABI,有两个偏差。LLVM 使用 PC 相对(PIC)或静态寻址来访问全局值,因此不使用 TOC (r2)。其次,r31 用作帧指针,以允许栈帧动态增长。LLVM 利用没有 TOC 的优势,在调用方帧的 PowerPC 链接区域提供空间来保存帧指针。PowerPC ABI 的其他详细信息可以在 PowerPC ABI 中找到。注意:此链接描述了 32 位 ABI。64 位 ABI 类似,除了 GPR 的空间为 8 字节宽(而不是 4)并且 r13 保留供系统使用。

帧布局

PowerPC 帧的大小通常在函数调用的持续时间内是固定的。由于帧大小固定,因此可以通过从栈指针计算的固定偏移量访问帧中的所有引用。例外情况是当存在动态 alloca 或可变大小数组时,则使用基指针 (r31) 作为栈指针的代理,而栈指针可以自由增长或缩小。如果 llvm-gcc 未传递 -fomit-frame-pointer 标志,也会使用基指针。栈指针始终与 16 字节对齐,以便为 altivec 向量分配的空间将正确对齐。

调用帧的布局如下(低内存位于顶部)

链接

参数区域

动态区域

局部变量区域

已保存寄存器区域


前一个帧

链接区域用于被调用方在分配自己的帧之前保存特殊寄存器。只有三个条目与 LLVM 相关。第一个条目是前一个栈指针 (sp),也称为链接。这允许像 gdb 或异常处理程序这样的探测工具快速扫描栈中的帧。函数结尾也可以使用链接从栈中弹出帧。链接区域中的第三个条目用于从 lr 寄存器保存返回地址。最后,如上所述,最后一个条目用于保存前一个帧指针 (r31)。链接区域中的条目的大小与 GPR 相同,因此链接区域在 32 位模式下为 24 字节长,在 64 位模式下为 48 字节长。

32 位链接区域

0 已保存的 SP (r1)
4 已保存的 CR
8 已保存的 LR
12 保留
16 保留
20 已保存的 FP (r31)

64 位链接区域

0 已保存的 SP (r1)
8 已保存的 CR
16 已保存的 LR
24 保留
32 保留
40 已保存的 FP (r31)

参数区域用于存储传递给被调用函数的参数。根据 PowerPC ABI,前几个参数实际上是通过寄存器传递的,参数区域中的空间未使用。但是,如果没有足够的寄存器或被调用方是 thunk 或 vararg 函数,则可以将这些寄存器参数溢出到参数区域。因此,参数区域必须足够大以存储调用方执行的最大调用序列的所有参数。大小还必须至少足够大以溢出寄存器 r3-r10。这允许被调用方(例如 thunk 和 vararg 函数)在不知道调用签名的情况下,有足够的空间来缓存参数寄存器。因此,参数区域至少为 32 字节(在 64 位模式下为 64 字节)。还要注意,由于参数区域是从帧顶部计算的固定偏移量,因此被调用方可以使用从栈指针(或基指针)计算的固定偏移量访问其拆分参数。

结合有关链接、参数区域和对齐的信息。栈帧在 32 位模式下至少为 64 字节,在 64 位模式下至少为 128 字节。

动态区域最初大小为零。如果函数使用动态 alloca,则会在栈中添加空间,链接和参数区域将移到栈顶部,并且新的空间立即在链接和参数区域下方可用。移动链接和参数区域的成本很小,因为只需要复制链接值。可以通过将原始帧大小添加到基指针轻松获取链接值。请注意,动态空间中的分配需要遵守 16 字节对齐。

局部变量区域是 llvm 编译器为局部变量保留空间的地方。

已保存寄存器区域是 llvm 编译器在进入被调用方时溢出被调用方保存的寄存器的地方。

序言/结尾

llvm 序言和结尾与 PowerPC ABI 中描述的相同,但以下例外情况除外。在创建帧后溢出被调用方保存的寄存器。这允许 llvm 结尾/序言支持与其他目标通用。基指针被调用方保存的寄存器 r31 保存在链接区域的 TOC 槽中。这简化了为基指针分配空间的过程,并使它便于以编程方式和在调试期间定位。

动态分配

注意

待办事项 - 更多内容即将推出。

NVPTX 后端

lib/Target/NVPTX 下的 NVPTX 代码生成器是 NVIDIA NVPTX 代码生成器的开源版本,用于 LLVM。它由 NVIDIA 贡献,并且是 CUDA 编译器 (nvcc) 中使用的代码生成器的移植版本。它针对 PTX 3.0/3.1 ISA,并且可以针对任何计算能力大于或等于 2.0(Fermi)的设备。

此目标具有生产质量,并且应该与官方 NVIDIA 工具链完全兼容。

代码生成器选项

选项 描述
sm_20 将着色器模型/计算能力设置为 2.0
sm_21 将着色器模型/计算能力设置为 2.1
sm_30 将着色器模型/计算能力设置为 3.0
sm_35 将着色器模型/计算能力设置为 3.5
ptx30 目标 PTX 3.0
ptx31 目标 PTX 3.1

扩展的伯克利数据包过滤器 (eBPF) 后端

扩展 BPF(或 eBPF)类似于用于过滤网络数据包的原始(“经典”)BPF(cBPF)。bpf() 系统调用执行与 eBPF 相关的各种操作。对于 cBPF 和 eBPF 程序,Linux 内核在加载程序之前会对其进行静态分析,以确保它们不会损害正在运行的系统。eBPF 是一种 64 位 RISC 指令集,设计用于与 64 位 CPU 进行一对一映射。操作码采用 8 位编码,定义了 87 条指令。有 10 个寄存器,按如下所述的函数分组。

R0        return value from in-kernel functions; exit value for eBPF program
R1 - R5   function call arguments to in-kernel functions
R6 - R9   callee-saved registers preserved by in-kernel functions
R10       stack frame pointer (read only)

指令编码(算术和跳转)

eBPF 正在重用经典的大多数操作码编码,以简化经典 BPF 到 eBPF 的转换。对于算术和跳转指令,8 位“代码”字段被分成三个部分

+----------------+--------+--------------------+
|   4 bits       |  1 bit |   3 bits           |
| operation code | source | instruction class  |
+----------------+--------+--------------------+
(MSB)                                      (LSB)

三个 LSB 位存储指令类别,其中之一为

BPF_LD     0x0
BPF_LDX    0x1
BPF_ST     0x2
BPF_STX    0x3
BPF_ALU    0x4
BPF_JMP    0x5
(unused)   0x6
BPF_ALU64  0x7

当 BPF_CLASS(code) == BPF_ALU 或 BPF_ALU64 或 BPF_JMP 时,第 4 位编码源操作数

BPF_X     0x1  use src_reg register as source operand
BPF_K     0x0  use 32 bit immediate as source operand

而四个 MSB 位存储操作码

BPF_ADD   0x0  add
BPF_SUB   0x1  subtract
BPF_MUL   0x2  multiply
BPF_DIV   0x3  divide
BPF_OR    0x4  bitwise logical OR
BPF_AND   0x5  bitwise logical AND
BPF_LSH   0x6  left shift
BPF_RSH   0x7  right shift (zero extended)
BPF_NEG   0x8  arithmetic negation
BPF_MOD   0x9  modulo
BPF_XOR   0xa  bitwise logical XOR
BPF_MOV   0xb  move register to register
BPF_ARSH  0xc  right shift (sign extended)
BPF_END   0xd  endianness conversion

如果 BPF_CLASS(code) == BPF_JMP,BPF_OP(code) 为以下之一

BPF_JA    0x0  unconditional jump
BPF_JEQ   0x1  jump ==
BPF_JGT   0x2  jump >
BPF_JGE   0x3  jump >=
BPF_JSET  0x4  jump if (DST & SRC)
BPF_JNE   0x5  jump !=
BPF_JSGT  0x6  jump signed >
BPF_JSGE  0x7  jump signed >=
BPF_CALL  0x8  function call
BPF_EXIT  0x9  function return

指令编码(加载、存储)

对于加载和存储指令,8 位“代码”字段被划分为

+--------+--------+-------------------+
| 3 bits | 2 bits |   3 bits          |
|  mode  |  size  | instruction class |
+--------+--------+-------------------+
(MSB)                             (LSB)

大小修饰符为以下之一

BPF_W       0x0  word
BPF_H       0x1  half word
BPF_B       0x2  byte
BPF_DW      0x3  double word

模式修饰符为以下之一

BPF_IMM     0x0  immediate
BPF_ABS     0x1  used to access packet data
BPF_IND     0x2  used to access packet data
BPF_MEM     0x3  memory
(reserved)  0x4
(reserved)  0x5
BPF_XADD    0x6  exclusive add

数据包数据访问 (BPF_ABS、BPF_IND)

两个非通用指令:(BPF_ABS | <size> | BPF_LD) 和 (BPF_IND | <size> | BPF_LD) 用于访问数据包数据。寄存器 R6 是一个隐式输入,必须包含指向 sk_buff 的指针。寄存器 R0 是一个隐式输出,包含从数据包中获取的数据。寄存器 R1-R5 是临时寄存器,不得用于跨 BPF_ABS | BPF_LD 或 BPF_IND | BPF_LD 指令存储数据。这些指令也有隐式的程序退出条件。当 eBPF 程序试图访问超出数据包边界的的数据时,解释器将中止程序的执行。

BPF_IND | BPF_W | BPF_LD 等效于

R0 = ntohl(*(u32 *) (((struct sk_buff *) R6)->data + src_reg + imm32))

eBPF 映射

eBPF 映射用于在内核和用户空间之间共享数据。目前已实现的类型包括哈希表和数组,未来可能会扩展支持布隆过滤器、基数树等。映射由其类型、最大元素数量、键大小和值大小(以字节为单位)定义。eBPF 系统调用支持对映射进行创建、更新、查找和删除操作。

函数调用

函数调用的参数通过最多五个寄存器 (R1 - R5) 传递。返回值通过一个专用的寄存器 (R0) 传递。另外四个寄存器 (R6 - R9) 是被调用者保存的,这些寄存器中的值在内核函数中会得到保留。R0 - R5 是内核函数中的临时寄存器,因此,如果 eBPF 程序需要跨函数调用存储/恢复这些寄存器中的值,则必须自行进行操作。可以通过只读的帧指针 R10 访问栈。eBPF 寄存器在 x86_64 和其他 64 位架构上与硬件寄存器一一映射。例如,x86_64 内核 JIT 将它们映射为

R0 - rax
R1 - rdi
R2 - rsi
R3 - rdx
R4 - rcx
R5 - r8
R6 - rbx
R7 - r13
R8 - r14
R9 - r15
R10 - rbp

因为 x86_64 ABI 规定使用 rdi、rsi、rdx、rcx、r8、r9 传递参数,而 rbx、r12 - r15 是被调用者保存的。

程序启动

一个 eBPF 程序接收一个参数,并包含一个唯一的 eBPF 主例程;程序不包含 eBPF 函数。函数调用仅限于一组预定义的内核函数。程序的大小限制为 4K 指令:这确保了快速终止和有限数量的内核函数调用。在运行 eBPF 程序之前,验证器会执行静态分析,以防止代码中出现循环,并确保寄存器使用和操作数类型有效。

AMDGPU 后端

AMDGPU 代码生成器位于 lib/Target/AMDGPU 目录中。此代码生成器能够针对各种 AMD GPU 处理器。有关更多信息,请参阅 AMDGPU 后端用户指南