html代码生成器_LLVM 目标无关代码生成器(第五章)

本文是一篇译文,翻译自:
https://llvm.org/docs/CodeGenerator.html​llvm.org
如有问题,敬请指出。
转载需注明出处,若需相关专业翻译服务,可联系我。

本文目录

  • 第一章 介绍
P2Tree:LLVM 目标无关代码生成器(第一章)​zhuanlan.zhihu.com
c8465116ccca5192c5422c303fcf3140.png
  • 第二章:目标描述类
P2Tree:LLVM 目标无关代码生成器(第二章)​zhuanlan.zhihu.com
cfdf5ebc3c74f86ba92617901741c25a.png
  • 第三章:机器代码描述类
P2Tree:LLVM 目标无关代码生成器(第三章)​zhuanlan.zhihu.com
b8caca95f3e9cc34fa585181bdcc9034.png
  • 第四章:MC 层
P2Tree:LLVM 目标无关代码生成器(第四章)​zhuanlan.zhihu.com
5c5b3aa0bd44e717841b03722d931ea7.png
  • 第五章:目标无关的代码生成算法
P2Tree:LLVM 目标无关代码生成器(第五章)​zhuanlan.zhihu.com
bf3fa2d8d57069438aa8ccb815a0cd70.png
  • 第六章:实现一个原生汇编器
P2Tree:LLVM 目标无关代码生成器(第六章)​zhuanlan.zhihu.com
e1066fa37d545406854a7b68977a9c3e.png

5 目标无关的代码生成算法

这一部分文档详细描述了前边 1.2 节 “代码生成器的高层设计” 中的概述内容。详细解释了代码生成的工作原理和设计思路。

5.1 指令选择

指令选择是 LLVM 代码生成中的一个重要步骤,它将高层的 LLVM 代码表示翻译为目标相关的机器指令。在学术界有多种不同的实现方法,LLVM 使用了基于 SelectionDAG 的指令选择器。

DAG 指令选择器的大部分内容是通过 tablegen 依赖 .td 文件生成的。我们的目标便是将这些 .td 文件生成一个完整的指令选择器,不过,当前仍然有一些工作必须依赖于 C++ 来完成。

5.1.1 SelectionDAG 的介绍

SelectionDAG 提供了一种抽象的代码表示方式来通过自动化技术完成指令选择构建(比如基于动态编程的最优模式匹配选择器,dynamic-programming based optimal pattern matching selectors)。它同时在其他代码生成阶段也非常适用,比如指令调度阶段(非常易于调度 DAG post-selection)。另外,可以通过 SelectionDAG 执行一些非常低层次(但仍然是目标无关的)的优化,这些优化需要大量的目标指令信息。

SelectionDAG 是一个有向无环图(Directed-Acyclic-Graph),其中的节点由 SDNode 类来定义。SDNode 最主要的内容是操作码(Opcode),操作码表示节点的一种操作。在 include/llvm/CodeGen/ISDOpcodes.h 文件中定义了很多节点操作码类型。

虽然大多数操作都定义一个值,但 DAG 中的一个 SDNode 却可以定义多个值。比如,一个 div/rem 的组合操作,可能定义出商和余数两个结果。同时一个 SDNode 也可能需要多个值。每个节点需要一些操作数,它们通过 DAG 的边来表示,边与那些定义这些值的节点相连(有向)。这些边由 SDValue 类来表示,因为节点可以定义多个值,所以 SDValue 实际上是 <SDNode, unsigned> 的对,表示某个边(某个值)发出的节点和对应的值的结果。每个值都有一个关联的 MVT(Machine Value Type),表示值的类型。

SelectionDAG 有两种不同的值类型:表示数据流和表示控制流依赖。数据流的值是简单的边,它们的类型是整形或浮点型。控制流的值用来表示一个链(chain),它们的类型是 MVT::Other。这些边(控制流的边)提供了约束拥有 side-effect 的节点的顺序(比如 load、store、call、return 等)。所有拥有 side-effect 的节点都应该有一个链的边(token chain)作为输入,并输出到一个新的节点。约定 token chain 的输入总是 0,输出总是一个操作的最后一个结果。不过,在指令选择之后(译注:之后全部为 machine node),machine node 在指令操作数最后还会有一个 chain,可能跟随有一个 glue 节点。

一个 SelectionDAG 会有一个 Entry 和一个 Root 节点。Entry 节点用来标记入口节点,其操作码是 ISD::EntryToken。Root 节点是最后一个节点(译注:最后的最后,side-effect 的最后),比如一个 basic block 的 return 节点。

一个非常重要的概念是合法 DAG 和不合法 DAG。一个合法的 DAG 是指只使用目标机器支持的操作数和类型构建的 DAG。比如,在 32 位 PowerPC 上,拥有 i1,i8,i16,i64 值类型的 DAG 是不合法的,使用 SREM 或 UREM 操作的 DAG 也是不合法的。合法化类型和合法化操作阶段负责将一个非法的 DAG 逐步转变为一个合法的 DAG。

5.1.2 SelectionDAG 指令选择过程

SelectionDAG 的指令选择分为以下几个步骤:

  • 构造初始化的 DAG:这个阶段将输入的 LLVM IR 翻译为一个非法的 SelectionDAG;
  • 优化 SelectionDAG:这个阶段完成一些在 SelectionDAG 上的简单优化,试图简化它,并识别一些目标机器支持的元指令(比如 rotate 和 div/rem 组合)。这样可以让代码更加高效,使后边的工作简单一些;
  • 合法化 SelectionDAG 类型:这个阶段消除 SelectionDAG 中那些目标平台不支持的类型;
  • 优化 SelectionDAG:类型合法化后,再次做一些优化,清理由前者产生的冗余(译注:也被称为 combine 操作);
  • 合法化 SelectionDAG 操作:这个阶段消除 SelectionDAG 中那些目标平台不支持的操作;
  • 优化 SelectionDAG:同理,再做一些优化,清理由前者产生的低效的实现 (译注:也被称为 combine 操作);
  • 通过 DAG 做指令选择:最后,目标指令选择器将 DAG 中的操作匹配为目标指令。这个阶段将目标无关的指令全部翻译为目标相关的指令;
  • SelectionDAG 指令调度和规范化:最后一步,为 DAG 形式下的目标指令做线性顺序调度,并将它们发射到 MachineFunction 结构中。这一步骤中用到了传统的调度技术。

在所有这些步骤结束后,SelectionDAG 结构就会被销毁,并继续后续的步骤。

有一种可视化的技巧,可以通过一些简短的 llc 命令行参数来检查 DAG 在不同阶段的形式。llc 中使用以下的一些选项,会弹出一个窗口,展示 SelectionDAG 的形式(这个功能依赖于 dot 工具的支持,如果有问题,可以查看以下内容来配置:

https://releases.llvm.org/8.0.0/docs/ProgrammersManual.html#viewing-graphs-while-debugging-code​releases.llvm.org

以下是支持的 llc 命令行参数,指定不同的参数来输出不同阶段的 DAG 形式。

  • -view-dag-combine1-dags 会展示 DAG 刚初始化,在第一次优化之前的形式;
  • -view-legalize-dags 会展示合法化之前的形式;
  • -view-dag-combine2-dags 会展示第二次优化之前的形式;
  • -view-isel-dags 会展示指令选择之前的形式;
  • -view-sched-dags 会展示指令调度之前的形式;
  • -view-sunit-dags 会展示调度依赖图。这个图基于最终的 SelectionDAG 完成,其中的节点必须已经被调度绑定在一个调度单元中,直接操作数和其他与调度无关的节点会被省略。
  • -filter-view-dags 选择可以支持指定一个 basic block 的名称并输出其前边 -view 的图。

5.1.3 初始化 SelectionDAG 构造

初始化 SelectionDAG 是通过 SelectionDAGBuilder 类将 LLVM 类直接通过窥孔展开。这一阶段的目的就是在 SelectionDAG 中暴露尽可能多的低层次的、目标相关的细节。这一部分大多数代码都是手动编码的(比如 LLVM 的 add 指令会翻译为一个 SDNode 的 add 节点,而 getelementptr 指令会被展开成多条算术指令)。这个阶段需要一些目标相关的 hooks(译注:俗称钩子函数)来下降 call、return、varargs 等。TargetLowering 接口用来完成这些工作。

5.1.4 SelectionDAG 合法化类型阶段

合法化类型阶段将 DAG 中的类型转换为目标平台原生支持的类型。

有两种方式来完成标量转换的工作:一种是将小类型转换为大类型(promoting);另一种是将大类型转换为小类型(expanding)。比如,目标需要将所有的 f32 类型提升到 f64 类型,所有的 i1、i8、i16 提升到 i32 类型,所有的 i64 类型展开成 i32 类型的组合对。这些改变需要插入符号扩展或零扩展的操作,确保转换后的行为与输入的一致。

有两种方式来完成向量转换的工作:一种是拆分(splitting),如需要可拆分多次,直到找到支持的类型;另一种是扩宽(widening),比如在向量末端增加元素。如果在拆分中,已经拆到单个元素了但依然找不到向量类型,那就将其转换为标量(scalarizing)。

目标实现会指导类型合法化阶段,明确哪些类型是支持的(以及哪些寄存器类别是需要的),通过调用 TargetLowering 构造函数中的 addRegisterClass 方法。

5.1.5 SelectionDAG 合法化操作阶段

(译注:原文中标题为 SelectionDAG Legalize Phase,实则是指合法化操作阶段)

合法化操作阶段是将 DAG 中的操作转换为目标平台原生支持的操作。

目标机器总会有一些奇怪的约束,比如不能为所有它支持的数据类型提供操作(比如,X86 中,不支持条件 move,PowerPC 中,不支持有符号扩展的 16 位 load)。合法化操作用来处理这种情况,它使用一系列的操作来模拟这个不支持的操作(expansion),或者提升类型来匹配到大类型支持的操作(promotion),或者使用目标相关的 hook 来实现合法化(custom)。

目标实现会指导操作合法化阶段,明确哪些操作是支持的(以上三种操作能够覆盖的),通过调用 TargetLowering 构造函数中的 setOperationAction 方法。

早期实现的指令选择工作,是直接让指令选择器选择这些操作和类型,即便它们本身不支持。合法化阶段的引入,可以允许在目标平台之间共享所有的规范化范式(canonicalization pattern),并且这些范式仍然是 DAG 形式,因此可以轻松的优化这些结构。

5.1.6 SelectionDAG 优化阶段:DAG Combiner

SelectionDAG 的优化阶段会被运行多次,一次是在 DAG 刚刚被创建时,另外也会在每次合法化之后运行。第一次运行时,会将初始化的 DAG 中的一些代码清理一下(这依赖于操作是有限制类型的输入的优化)。之后的运行会将合法化之后产生的糟糕代码清理一下,这会让合法化的代码更清爽(让合法化阶段只需要专注于合法化,而不用担心如何权衡好的代码和合法的代码)。

这个优化阶段一个很重要的部分是插入符号扩展或零扩展的指令。我们当前使用的是 ad-hoc 技术,但也有可能在将来使用更严谨的技术,以下是一些 paper:

  1. "Widening integer arithmetic"
http://www.eecs.harvard.edu/~nr/pubs/widen-abstract.html​www.eecs.harvard.edu

Kevin Redwine and Norman Ramsey

International Conference on Compiler Construction (CC) 2004

2. "Effective sign extension elimination"

http://portal.acm.org/citation.cfm?doid=512529.512552​portal.acm.org

Motohiro Kawahito, Hideaki Komatsu, and Toshio Nakatani

Proceedings of the ACM SIGPLAN 2002 Conference on Programming Language Design and Implementation.

5.1.7 SelectionDAG 指令选择阶段

(译注:这部分是狭义的指令选择,真正完成选择的目的)

指令选择是代码生成指令选择阶段的主体。这一阶段将整个 SelectionDAG 作为输入,通过模式匹配,将目标平台支持的指令映射到 DAG 中,并产生一个由目标机器码组成的 DAG。比如,以下 LLVM 代码片段:

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

这段代码在 SelectionDAG 中的形式大致如下:

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

如果目标平台支持浮点的乘加操作(multiply-and-add,FMA 指令),其中一个加法就可以和乘法合并。在 PowerPC 中,可能会将上边 DAG 选择为:

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

FMADDS 指令是一个三元运算指令,它将前两个操作数相乘,再加上第三个操作数(单精度浮点操作)。FADDS 指令是一个简单的二元运算指令。为了实现这种匹配,PowerPC 的后端需要在 .td 文件中做如下指令定义:

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 的指令选择生成实现会在 .td 文件中读取指令模式,并自动的生成指令匹配代码。它具有以下的特点:

  • 在编译编译器时,它会分析你给出的指令模式,并告诉你模式是否有意义;
  • 可以处理操作数的任意约束。特别是,它会直接完成如 “匹配一个具有 13 位符号扩展值的立即数”。比如,PowerPC 后端中的 immSExt16。
  • 它能掌握多种重要的模式定义的知识。比如,它知道加法是可交换的,所以 FMADDS 模式既可以匹配 (fadd X, (fmul Y, Z)),也可以匹配 (fadd (fmul X, Y), Z),这些知识不需要你去主动处理。
  • 它具有全特性的类型推导系统。特别是,你基本不需要告诉这个系统你的模式需要什么类型,比如 FMADDS 这个例子,我们没有告诉系统这个模式的所有操作节点都是 f32 类型的,它也可以通过引用和传播来得知 F4RC 应该有个 f32 类型。
  • 你可以定义属于目标特殊的模式片段。模式片段可以重用已有的模式,比如,整形的 (not x) 操作可以被重写为 (xor x, -1),这可能是因为 SelectionDAG 不支持原生的 not 操作。可以定义属于特定目标特有的模式片段。notineg 便是例子。
  • 除了指令以外,还可以指定任意模式来映射到一个或多个指令(通过 Pat 类)。比如,PowerPC 不能通过一条指令来 load 任意整形立即数到一个寄存器。可以按如下定义:
  // Arbitrary immediate support.  Implement in terms of LIS/ORI.
  def : Pat<(i32 imm:$imm),
            (ORI (LIS (HI16 imm:$imm)), (LO16 imm:$imm))>;

如果匹配不到通过一条指令将立即数 load 到寄存器,那就会使用到这个模式。这个定义说明了对于任意的 i32 类型立即数,将其转换为 ORI(or 一个 16 位立即数) 和一个 LIS(load 16 位立即数,这个立即数被左移 16 位)指令的组合。为了完成这个工作,需要 LO16/HI16 节点作为输入立即数。

  • 当使用 Pat 类来将一个单指令的模式映射到多个操作时(比如 [X86 的地址模式](https://releases.llvm.org/8.0.0/docs/CodeGenerator.html#x86-addressing-mode)),这个模式指定了操作数是一个 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)>;

在这里,ptroff 和 ptrreg 两个操作数匹配到了 STWU 中的复杂操作数 dst。

虽然这个系统基本可以自动化运行,有时仍然需要你来编写一些 C++ 的代码片段来处理一些特殊的情况(对于 tablegen 来说很难表现的描述)。

虽然有如此多的特点,当前这个系统依然有一些缺陷,目前依然在开发中:

  • 现在还没有办法匹配定义了多个值的节点(比如 SMUL_LOHILOADCALL 等)。这是你依然需要编写 C++ 代码的主要原因;
  • 还没有更好的能支持复杂地址模式的匹配方案。将来,我们会扩展模式片段来允许定义多种不同的模式(比如 X86 地址模式中的 4 个操作数的情况,当前依然是手工实现的)。另外,我们会扩展模式片段,让它可以一次匹配到多种不同模式;
  • 我们还不能自动引用 `isStore/isLoad` 标记;
  • 我们还不能自动生成合法化阶段支持的寄存器和操作;
  • 我们还不能支持 custom 形式的合法化节点;

除了这些缺陷,当前的指令选择生成器依然对于大多数位运算和逻辑运算非常有效。如果你在使用过程中有任何问题,请告诉 Chris。

5.1.8 SelectionDAG 调度和规范化阶段

调度阶段会为目标指令实现的 DAG 分配线性顺序。调度器会依赖于多种的机器约束(比如按最小寄存器压力或者按最大化利用指令 latency)来规划顺序。当一个顺序被调度好之后,DAG 就会转换为 MachineInstr 结构,并销毁掉 DAG。

需要注意,虽然这一部分放到了指令选择章节来讲,但它从逻辑上讲是与指令选择分开的,这里放在一起的原因是在代码中,它们紧密相连,因为他们都是在操作 SelectionDAG。

译注:这一部分似乎是没有写完,目前只有这点内容,调度还是很重要的内容。

5.1.9 SelectionDAG 的未来方向

  1. 可选的函数级别指令选择(function-at-a-time selection)。
  2. 完全依赖 .td 文件生成指令选择器。

5.2 基于 SSA 的机器代码优化(原文未完成)

译注:原文未完成状态。

5.3 活动区间(Live Intervals)

活动区间就是一个变量存活的范围。它是在寄存器分配阶段被用来决定在程序中,两个(或更多)虚拟寄存器是否需要相同的物理寄存器(也就是寄存器冲突)。当这种情况发生时,其中一个虚拟寄存器需要被 spill。

5.3.1 活动变量分析

决定变量的活动区间的第一步是计算那些在一条指令结束时即立即无效(dead)的寄存器集合(比如,某条指令计算出一个值,但这个值在下文未被使用),还需要计算那些在当前指令中被使用,但过了这条指令后就不再使用的寄存器集合(被当前指令 kill)。活动变量的信息是在函数范围内对每个虚拟寄存器做计算,并分配物理寄存器。这会以一种非常有效的方式来完成,因为当前程序是以 SSA 形式来稀疏计算虚拟寄存器的生存信息,只需要在 block 级别(没有分支的代码块)去追踪物理寄存器。在寄存器分配阶段之前,LLVM 可以假设物理寄存器只在单一的 basic block 中存活,所以这是一种简单的、局部的分析。有些物理寄存器不是可分配的,比如栈指针寄存器或条件码寄存器,这些寄存器不会被追踪。

物理寄存器可能是一个函数的 live in 或 live out 值。Live in 值通常是放置在寄存器中的函数参数,Live out 值是放在寄存器中的返回值。Live in 通常会在活动区间分析时被设定一个假的被定义指令。如果某个 basic block 的最后一条指令是 return,那么将被标记为 live out。(原文:Live in values are marked as such, and are given a dummy “defining” instruction during live intervals analysis. If the last basic block of a function is a `return`, then it’s marked as using all live out values in the function.)

PHI 节点会做特殊处理,因为在对 CFG (译注:控制流图) 的深度优先遍历过程中计算活动变量信息时,不能保证 PHI 节点使用的虚拟寄存器在使用之前被定义了。当遇到 PHI 节点时,只处理其定义部分,其使用部分会在其它 basic block 中被处理。

在当前 basic block 中的 PHI 节点,我们假定在当前 basic block 的结尾做赋值,并遍历后续的 basic block。如果后续的 basic block 也有 PHI 节点并且其操作数是当前的 PHI 节点,那就把当前这个变量在当前 basic block 和后续 basic block 中标记为活动状态,直到遇到 PHI 节点的定义指令为止。

5.3.2 活动区间分析

经过活动变量分析之后,我们已经拥有了完成活动区间分析和构造活动区间的信息。我们先对 basic block 和指令做编号,然后处理 live in 值。Live in 值位于物理寄存器,所以我们假设在当前 basic block 结尾处它们会被 kill。虚拟寄存器的活动区间按照机器指令的顺序来计算,比如 [i, j) 就是一个活动区间,1 >= i >= j > N,N 是当前 basic block 的总指令数。

译注:原文内容未完成。

5.4 寄存器分配

寄存器分配问题是将一个有无限的虚拟寄存器参与的程序 Pv,映射成等效的只由有限的物理寄存器参与的程序 Pp。每种不同的目标机器都具有不同数量和功能的物理寄存器,如果物理寄存器的数量不能够满足所有虚拟寄存器使用,一些虚拟寄存器的值就会被映射到内存,这被称为 spilled virtuals。

5.4.1 LLVM 中如何表示寄存器

在 LLVM 中,物理寄存器被标记为从 1 到 1023 的整数。你可以查看 XXXGenRegisterInfo.inc 文件来了解物理寄存器的标记。比如在 X86 架构中,通过查看 lib/Target/X86/X86GenRegisterInfo.inc 文件,我们会看到 32 位寄存器 EAX 被标记为 43,MMX 寄存器 MM0 被标记为 65。

有些架构中多个寄存器可能共享相同的物理地址空间,典型的是 X86 平台。比如 EAX、AX、AL 这 3 个寄存器,它们是共享低 8 位物理空间的。这些寄存器在 LLVM 中被标记为 aliased。可以在 XXXRegisterInfo.td 中查看到哪些寄存器是 aliased 寄存器,另外,MCRegAliasIterator 类可以枚举出指定寄存器的所有 aliased 寄存器。

LLVM 中的物理寄存器可以分组为寄存器组。同一个寄存器组中的寄存器具有相同的功能,并且可以互相替代。每个虚拟寄存器只能映射到一个特定的物理寄存器组。比如说,在 X86 架构中,一些虚拟寄存器只能分配给 8 位的物理寄存器(所以会定义占用 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);
}

有时为了调试的目的,可能在特定平台下需要修改物理寄存器的数量,这必须静态修改,也就是在 XXXRegisterInfo.td 文件中,搜索 RegisterClass,通常最后一个参数就是指定其中所包含的物理寄存器列表,只需要注释或修改这部分代码,就可以简单的避免在寄存器分配中使用到这些寄存器。一种更加规范的做法是利用分配顺序(allocation order)这个特性来排除一些寄存器,可以参考 lib/Target/X86/X86RegisterInfo.td 中的代码。

虚拟寄存器也可以通过整型值来引用。不同于物理寄存器,虚拟寄存器之间不会共享相同的整型值。物理寄存器在 XXXRegisterInfo.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() 方法。指令可能会使用(use)或定义(def)一个寄存器,比如 ADD reg:1026 := reg:1025 reg:1024 会使用 2015 和 1024 来定义 1026(译注:原文似乎有错,已修正)。给定一个寄存器操作数,可以通过 MachineOperand::isUse() 来判断是否是指令使用的寄存器,通过 MachineOperand::isDef() 来判断是否是指定定义的寄存器。

寄存器分配之前也会使用到一些物理寄存器,我们称之为预着色寄存器(pre-colored register)。在一些场景下会使用预着色寄存器,比如函数调用时的传参,以及特殊指令的设计需要。有两种类型的预着色寄存器:隐式定义的和显式定义的。显式定义寄存器就是普通的操作数,可以通过 MachineInstr::getOperand(int)::getReg() 来获取并使用。通过 TargetInstrInfo::get(opcode)::ImplicitDefs 可以获取指令隐式定义的寄存器(opcode 就是指令的编码)。两者的一个重要的差异是显式寄存器是静态定义在指令中的,而后者通常依赖于程序编译的情况。比如,一个表示函数调用的指令可能总是隐式调用或使用某一类相同的隐式寄存器(而不是确定的某一个)。可以通过 TargetInstrInfo::get(opcode)::ImplicitUses 来获取一个指令隐式使用的寄存器。预着色寄存器对任何寄存器分配算法都会施加强制约束(约束分配行为)。寄存器分配器必须能够保证所有的预着色寄存器都不能够被仍然存活的虚拟寄存器值所替代。

5.4.2 将虚拟寄存器映射到物理寄存器

有两种可以将虚拟寄存器与物理寄存器(或内存区域,也就是栈空间)映射的方法。第一种方法是直接映射(direct mapping),这是基于对 TargetRegisterInfo 和 MachineOperand 的应用。第二种方法是间接映射(indirect mapping),依赖于 VirtRegMap 类,可以插入 load/store 操作来与内存进行交互。

直接映射提供了更为灵活的寄存器分配方案,但是这需要更多的干预工作且容易出错。通常程序员需要指定在哪里插入 load/store 指令来将数据从内存中读取或写入内存。使用 MachineOperand::setReg(p_reg) 来完成对虚拟寄存器映射物理寄存器的操作,使用 TargetInstrInfo::storeRegToStackSlot()TargetInstrInfo::loadRegFromStackSlot 来插入 store 和 load 指令。

间接映射将复杂的 load/store 指令插入机制与程序员分割开来。使用 VirtRegMap::assignVirt2Phys(vreg, preg) 方法来实现映射,使用 VirtRegMap::assignVirt2StackSlot(vreg) 方法可以指定将虚拟寄存器映射到内存,后者可以返回虚拟寄存器在栈上的位置。有时我们希望把虚拟寄存器映射到指定的栈位置,使用 VirtRegMap::assignVirt2StackSlot(vreg, stack_location) 。间接映射也依然需要映射到物理寄存器,这个物理寄存器是指虚拟寄存器在 store 到栈空间之前和 load 出来之后的存放位置。

在间接映射中,当所有虚拟寄存器都映射到了物理寄存器或是内存空间之后,还需要使用 spiller 对象来在代码中恰当位置插入 load/store 指令。每一个映射到内存空间的虚拟寄存器都需要在被定义之后 store 到内存,在被使用之前 load 出来。spiller 的实现依然是重用 load/store 指令,从而避免多余指令。可以在 lib/CodeGen/RegAllocLinearScan.cpp 中的 RegAllocLinearScan::runOnMachineFunction 中了解 spiller 的调用。

5.4.3 处理双地址指令

除了那些很特别的情况(比如函数调用),LLVM Machine Code 指令是三地址码。这意思是,每一条指令都使用最多两个寄存器并定义最多一个寄存器。然而,一些架构使用双地址码。这种情况下,被定义的那个寄存器同时也是一个使用的寄存器,比如一条指令类似:ADD %EAX, %EBX,它的功能是 %EAX = %EAX + %EBX

为了正确处理这种指令,LLVM 必须将三地址码转换为两个双地址码指令。LLVM 中,使用 TwoAddressInstructionPass 来处理这种情况,这个 pass 必须在寄存器分配之前执行,执行之后,输出的指令就不再是 SSA 形式了。比如,对于一条三地址码:%a = ADD %b, %c 将转换为:

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

需要注意,内部实现中,第二条指令的 %a 必须表示为 ADD %a[def/use], %c,这表示寄存器操作数 %a 同时作为指令的 def 和 use。

5.4.4 SSA 解构阶段

在寄存器分配阶段的一个很重要的转换是 SSA 解构阶段。 SSA 解构可以简化很多在程序控制流图上的分析,然而,传统指令集并不支持 PHI 指令 (译注:PHI 指令是 SSA 中的重要指令)。所以,为了能够生成机器可执行的代码,编译器必须在保留语义的同时,使用其它指令来替换掉 PHI 指令。

完成这个工作的方法有很多种。最传统的一种 PHI 解构算法是将 PHI 指令替换为 copy 指令,这种策略就是 LLVM 所采用的。SSA 解构算法在 lib/CodeGen/PHIElimination.cpp 文件中实现。为了调用这个 pass,标识符 PHIEliminationID 必须在寄存器分配代码中被标记为 required。

5.4.5 指令折叠

指令折叠是一个寄存器分配阶段的优化算法,它能够将不需要的 copy 指令删除,比如以下指令:

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

可以被折叠为一条指令:

%EAX = LOAD %mem_address

通过 TargetRegisterInfo::foldMemoryOperand() 方法来完成指令折叠。需要留心的是,折叠后的指令可以和原始指令不再相同。使用该方法的一个示例可以参考 lib/CodeGen/LiveIntervalAnalysis.cpp 中的 LiveIntervals::addIntervalsForSpills 方法。

5.4.6 内建的寄存器分配器

LLVM 框架提供给应用开发者 3 种不同的寄存器分配器:

  • Fast:这个寄存器分配器是默认 debug 编译时调用的。它在 Basic Block 级别做寄存器分配,尝试尽可能把值放到寄存器中以及重用寄存器。
  • Basic:这是一种递增实现的寄存器分配器。活动范围按照启发式驱动的顺序一次分配给一个寄存器。因为代码可以在分配阶段在线重写,这个框架可以允许有兴趣的开发者作为扩展来开发。它并不是一个有生产力的寄存器分配器,但对于分类错误和作为性能基准来讲,它是一种有应用潜力的独立模块。
  • Greedy:这是默认分配器。它对 Basic 分配器做了高度优化,合并了全局活动范围的割裂状态。这个分配器努力生成最小化 spill 代价的代码。
  • PBQP:基于 PBQP 实现的寄存器分配器。PBQP 的全称为 Partitioned Boolean Quadratic Programming。这个分配器通过构造一个 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

5.5 Prolog/Epilog 代码插入(原文未完成)

译注:这部分内容应该是没有完成,原文中这部分内容应该属于下一节。

5.6 Compact Unwind(未完成)

从函数抛出异常需要 unwind(译注:不确定这个词怎么翻译,其涵义是对函数调用结构的展开)函数。如何 unwind 函数的指导信息通常来自于 DWARF unwind 信息段(或 frame 信息)。不过这个信息通常用于开发调试器时用来输出 backtrace 信息,而且每个函数中的帧描述块(Frame Description Entry,FDE)需要大概 20 到 30 个 bytes。另外,在运行时映射一个函数的地址与之对应的 FDE 也需要有额外开销。所以,另一种 unwind 方法被提出,叫做 compact unwind,这种方法在每个函数中只需要占用 4 个 bytes。

Compact unwind,是一个 32 位的值,它的编码与架构相关,其中包括了如寄存器如何保存与还原,以及如何 unwind 函数的信息。当链接器输出最终的链接后可执行文件后,将会产生一个 __TEXT,__unwind_info 段。这个段提供一种轻量且快速的方式为运行时系统提供对函数 unwind 信息的读取。当我们为函数生成 compact unwind 信息时,它会首先编码到__TEXT,__unwind_info 段。而当我们为 函数生成 DWARF unwind 信息时,这个段中还会包含有关于 FDE 的偏移信息。

在 X86 架构中,有三种不同的方式来编码 compact unwind 信息:

  • 含帧指针信息的函数(EBPRBP

基于 EBP/RBP 的栈帧函数,当在返回地址压入栈之后,会立即将 EBP/RBP 压入栈,然后将 ESP/RSP 覆盖原来的 EBP/RBP 。这样,当需要 unwind 时,用当前的 EBP/RBP 值来恢复 ESP/RSP ,然后再次弹出栈到 PC 中完成返回。必须要将所有需要还原的非易失性寄存器保存在 EBP - 4EBP - 1020RBP - 8RBP - 1020)的堆栈范围内。偏移量 (32 位模式下是 4,64 位模式下是 8)被编码为 16-32 位 (掩码是 0x00FF0000)。下标中的条目是将保存的寄存器编码到 0-14 位 (掩码是 0x00007FFF)。

Compact Numberi386 寄存器x86-64 寄存器
1EBXRBX
2ECXR12
3EDXR13
4EDIR14
5ESIR15
6EBPRBP
  • 没有帧指针、较小的常数栈尺寸的函数

为了返回,会把一个常量(以 compact unwind 的形式编码)添加到 ESP/RSP 中。然后通过栈中弹出到 PC 完成返回。所有需要还原的非易失性寄存器必须在返回地址之后被保存到栈中。堆栈大小(32 位模式下是 4,64 位模式下是 8)被编码为 16-32 位 (掩码是 0x00FF0000)。在 32 位模式下,最大堆栈大小是 1024 字节;在 64 位模式下,最大堆栈大小是 2048 字节。保存的寄存器数量被编码到 9-12 位 (掩码是 0x00001C00)。第 0-9 位包含了保存的寄存器和其顺序(掩码是 0x000003FF)。相关算法可参考 lib/Target/X86FrameLowering.cpp 中的 encodeCompactUnwindRegistersWithoutFrame() 函数。

  • 没有帧指针、较大的常数栈尺寸

这种情况和较小尺寸的没有帧指针的情况很类似。但是因为堆栈尺寸太大,无法在 compact unwind 中进行编码。相反,它要求函数在 Prolog 中包含有 subl $nnnnnn, %esp 指令。压缩编码在函数中的第 9-12 位(掩码是 0x00001C00)中保存有 $nnnnnn 值的偏移。

5.7 后端 Machine Code 优化(原文未完成)

译注:原文未完成。

5.8 代码发射

代码发射部分的作用是将代码生成器的抽象层(比如 MachineFunction,MachineInstr 等)下降为 MC 层的抽象(比如 MCInst,MCStreamer 等)。这个工作由多个不同的类参与完成:目标无关的 AsmPrinter 类,目标相关的子类(比如 SparcAsmPrinter)和 TargetLoweringObjectFile 类。

因为 MC 层工作在目标文件一级的抽象级别,所以它不再包括如函数、全局变量这一类描述。它会考虑如标号(label)、伪指令(directives)和指令。一个很关键的类是 MCStreamer 类,它是一个高效的 Assembler 的抽象实现 (可以继承它来实现输出汇编 .s 文件或 ELF .o 文件等)。MCStreamer 为每一种元素提供一个方法,比如 EmitLabelEmitSymbolAttributeSwitchSection 等,从而可以直接和汇编级别的那些概念相联系起来。

如果你对实现一个机器的代码生成器很感兴趣,那么有三个事情需要认真考虑:

  1. 首先,你需要实现一个 AsmPrinter 的子类。这个类实现了一些通用的将 MachineFunction 下降为 MC 标号结构的方法。AsmPrinter 基类提供了一些有用的方法和操作,也允许你自己重写一些操作。你可以很自由的实现生成 ELF,COFF 或 MachO 的目标文件,这是因为 TargetLoweringObjectFile 类实现了大多数通用逻辑。
  2. 其次,你需要实现针对你的机器平台的指令输出。指令输出通过解析 MCInst 结构的内容,然后通过 raw_ostream 来映射成文本。这部分大多数的描述都在 .td 文件中实现(比如 add $dst, $src1, $src2),但你还需要手动实现一些操作数的输出方法。
  3. 第三,你需要实现将 MachineInstr 下降成 MCInst,通常实现在 <target>MCInstLower.cpp 文件中。这个下降过程通常都是目标相关的,而且还负责对跳转表、常量池、全局变量地址等内容下降为 MCLabel 的过程。另外,它还负责展开伪指令为对应真实的机器指令。生成的 MCInst 会被送到指令输出器或编码器。

最后,你可以选择实现 MCCodeEmitter 的子类来将 MCInst 下降为机器代码和重定位信息。如果你需要直接输出一个 .o 这样的目标文件,或者要实现一个汇编器,这个工作就是必要的。

5.8.1 发射函数栈信息

如果 TargetLoweringObjectFile::StackSizeSection 不为空且 TargetOptions::EmitStackSizeSection 被启用 (指定-stack-size-section 参数) 时,会发射一个包含函数栈尺寸信息的段。这个段包含一个数组,每个元素是一个对(pairs)包含有函数符号值(指针长度)和对应的栈尺寸(无符号 LEB128,译注:小端编码的 128 位可变长度编码),栈尺寸是指仅包括函数 prologue 分配的空间,函数动态分配的栈空间不包括在内。

5.9 VLIW 打包

对于超长指令字(Very Long Instruction Word, VLIW)体系架构,编译器需要负责将合法的指令打包成功能单元。所以,编译器需要创建叫做 packets 或 bundles 的指令组。在 LLVM 中,VLIW 打包器作为一个独立的后端平台无关的模块来完成指令打包工作。

5.9.1 将指令映射到功能单元

在 VLIW 的 bundle 中,指令被映射到许多功能单元中。在实现打包的过程中,编译器必须知道哪条指令要能加到哪个 bundle 中。这个需求其实很复杂,因为编译器需要测试所有可能的指令和 bundle 的映射关系。因此,为了减轻编译时的复杂度,VLIW 打包器解析对应后端的指令类并在编译器构建期间生成一些打包表,再使用一个后端无关的 API 接口调用这些表来决定一条指令加入 bundle 是否合法。

5.9.2 打包表如何生成和使用

VLIW 打包器从对应后端的调度信息中读取所有指令,并创建一个确定有限状态机(DFA)来表示 bundle 的状态。一个 DFA 包含 3 个主要元素:输入、状态和转移。输入的集合用来表示要加入一个 bundle 的指令,状态表示指令加入到 bundle 的可能代价,转移发生在一条指令加入一个 bundle 时。如果一条指令能够加入到一个 bundle,那么在 DFA 中就会存在对应的一个转移。如果不存在转移,就表示指令到 bundle 的映射不存在(指令不能加入该 bundle)。

为了生成这样一个打包表,编译器会生成一个 TargetGenDFAPacketizer.inc 的文件。提供的 API 具有 3 个函数: DFAPacketizer::clearResources()DFAPacketizer::reserveResources(MachineInstr *MI)DFAPacketizer::canResources(MachineInstr *MI)。这几个方法可以用来将指令加入一个 bundle,以及检查一条指令是否能够加入一个 bundle。可以在 llvm/CodeGen/DFAPacketizer.h 中了解详细信息。


翻译完毕,之前还没想到这一节这么长,有极个别章节自己不是很熟悉,所以就按字面涵义翻译了,如果有看不懂的还是参考原文比较好。目前感觉这个文档依然对很多细节讲的不是很清楚,目前没找到更详细的说明文档,只能搭配看代码了。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值