当今的高级语言通常使用聚合数据类型和面向对象编程(OOP)构造。LLVM IR对聚合数据类型有一定的支持,而像类这样的OOP构造则需要自己实现。添加聚合类型引发了一个问题:如何传递聚合类型的参数。不同的平台有不同的规则,这也反映在IR中。遵守调用约定也确保可以调用系统函数。
在本章中,您将学习如何将聚合数据类型和指针转换为LLVM IR,以及如何以符合系统的方式将参数传递给函数。您还将学习如何在LLVM IR中实现类和虚函数。
本章将涵盖以下主题:
到本章结束时,您将获得为聚合数据类型和OOP构造创建LLVM IR的知识。您还将了解如何根据平台的规则传递聚合数据类型。
对于几乎所有应用程序来说,像INTEGER
这样的基本类型是不够的。例如,要表示数学对象,如矩阵或复数,您必须基于现有类型构造新的数据类型。这些新数据类型通常被称为聚合或复合。
数组是同一类型元素的序列。在LLVM中,数组始终是静态的,这意味着元素数量是常数。tinylang
类型ARRAY [10] OF INTEGER
或C类型long[10]
在IR中表示如下:
[10 x i64]
结构体是不同类型的复合体。在编程语言中,它们通常用命名成员表示。例如,在tinylang
中,结构体写作RECORD x: REAL; color: INTEGER; y: REAL; END;
,在C中是struct { float x; long color; float y; };
。在LLVM IR中,只列出了类型名称:
{ float, i64, float }
为了访问成员,使用数值索引。像数组一样,第一个元素的索引号为0
。
根据数据布局字符串中的规范,此结构的成员在内存中排列。有关LLVM中数据布局字符串的更多信息,请参见第4章,IR代码生成基础。
此外,如有必要,会插入未使用的填充字节。如果您需要控制内存布局,那么可以使用打包结构,其中所有元素都有1字节对齐。在C中,我们使用__packed__
属性,如下所示:
struct __attribute__((__packed__)) { float x; long long color; float y; }
同样,LLVM IR中的语法略有不同,如下所示:
<{ float, i64, float }>
加载到寄存器中时,数组和结构体被视为一个单元。例如,不能引用数组值寄存器%x
的单个元素,如%x[3]
。这是由于SSA形式,因为无法确定%x[i]
和%x[j]
是否引用同一元素。相反,我们需要特殊指令来提取和插入数组的单个元素值。要读取第二个元素,我们使用以下指令:
%el2 = extractvalue [10 x i64] %x, 1
我们也可以更新元素,例如第一个:
%xnew = insertvalue [10 x i64] %x, i64 %el2, 0
这两个指令也适用于结构体。例如,要从寄存器%pt
中访问color
成员,您可以写如下:
%color = extractvalue { float, float, i64 } %pt, 2
这两个指令都有一个重要的限制:索引必须是常数。对于结构体,这很容易解释。索引号只是名称的替代,像C这样的语言没有动态计算结构成员名称的概念。对于数组,简单地说就是无法高效实现。当元素数量小且已知时,这两个指令在特定情况下有价值。例如,可以将复数建模为两个浮点数的数组。传递这个数组是合理的,在计算过程中始终清楚必须访问数组的哪一部分。
对于前端的一般用途,我们不得不转而使用指向内存的指针。LLVM中的所有全局值都表示为指针。让我们声明一个@arr
全局变量,作为八个i64
元素的数组。这等同于C声明long arr[8]
:
@arr = common global [8 x i64] zeroinitializer
要访问数组的第二个元素,必须进行地址计算以确定索引元素的地址。然后可以从该地址加载值,并将其放入函数@second
,如下所示:
define i64 @second() {
%1 = load i64, ptr getelementptr inbounds ([8 x i64], ptr @arr, i64 0, i64 1)
ret i64 %1
}
getelementptr
指令是地址计算的主力。因此,它需要更多的解释。第一个操作数[8 x i64]
是指令操作的基本类型。第二个操作数ptr @arr
指定了基础指针。请注意这里的细微差别:我们声明了一个有八个元素的数组,但因为所有全局值都被视为指针,所以我们有一个指向数组的指针。在C语法中,我们真正使用的是long (*arr)[8]
!结果是,我们必须首先对指针取消引用,然后才能索引元素,例如在C中的arr[0][1]
。第三个操作数i64 0
取消引用指针,第四个操作数i64 1
是元素索引。此计算的结果是索引元素的地址。请注意,此指令不涉及内存。
除了结构体外,索引参数不需要是常数。因此,getelementptr
指令可以在循环中用于检索数组的元素。结构体在这里处理得不同:只能使用常数,并且类型必须是i32
。
有了这些知识,数组很容易集成到[第4章],IR代码生成基础的代码生成器中。convertType()
方法必须扩展以创建类型。如果Arr
变量保存了数组的类型指示符,并假设数组中的元素数量是整数字面量,那么我们可以添加以下内容到convertType()
方法来处理数组:
if (auto *ArrayTy =
llvm::dyn_cast<ArrayTypeDeclaration>(Ty)) {
llvm::Type *Component =
convertType(ArrayTy->getType());
Expr *Nums = ArrayTy->getNums();
uint64_t NumElements =
llvm::cast<IntegerLiteral>(Nums)
->getValue()
.getZExtValue();
llvm::Type *T =
llvm::ArrayType::get(Component, NumElements);
// TypeCache is a mapping between the original
// TypeDeclaration (Ty) and the current Type (T).
return TypeCache[Ty] = T;
}
此类型可用于声明全局变量。对于局部变量,我们需要为数组分配内存。我们在过程的第一个基本块中这样做:
for (auto *D : Proc->getDecls()) {
if (auto *Var =
llvm::dyn_cast<VariableDeclaration>(D)) {
llvm::Type *Ty = mapType(Var);
if (Ty->isAggregateType()) {
llvm::Value *Val = Builder.CreateAlloca(Ty);
// The following method requires a BasicBlock (Curr),
// a VariableDeclation (Var), and an llvm::Value (Val)
writeLocalVariable(Curr, Var, Val);
}
}
}
要读写数组元素,我们需要生成getelementptr
指令。这被添加到emitExpr()
(读取值)和emitStmt()
(写入值)方法中。要读取数组的元素,首先读取变量的值。然后,处理变量的选择器。对于每个索引,评估表达式并存储值。基于这个列表,计算被引用元素的地址并加载值:
auto &Selectors = Var->getSelectors();
for (auto I = Selectors.begin(), E = Selectors.end();
I != E; ) {
if (auto *IdxSel =
llvm::dyn_cast<IndexSelector>(*I)) {
llvm::SmallVector<llvm::Value *, 4> IdxList;
while (I != E) {
if (auto *Sel =
llvm::dyn_cast<IndexSelector>(*I)) {
IdxList.push_back(emitExpr(Sel->getIndex()));
++I;
} else
break;
}
Val = Builder.CreateInBoundsGEP(Val->getType(), Val, IdxList);
Val = Builder.CreateLoad(
Val->getType(), Val);
}
// . . . Check for additional selectors and handle
// appropriately by generating getelementptr and load.
else {
llvm::report_fatal_error("Unsupported selector");
}
}
写入数组元素使用相同的代码,但不生成load
指令。相反,您在store
指令中使用指针作为目标。对于记录,您使用类似的方法。记录成员的选择器包含常量字段索引,名为Idx
。您将此常量转换为常量LLVM值:
llvm::Value *FieldIdx = llvm::ConstantInt::get(Int32Ty, Idx);
然后您可以在Builder.CreateGEP()
方法中像数组一样使用这个值。
现在,您应该知道如何将聚合数据类型转换为LLVM IR。以符合系统的方式传递这些类型的值需要一些注意,您将在下一部分中学习如何正确实现它。
在添加了数组和记录到代码生成器后,您可能注意到有时生成的代码并不按预期执行。原因是到目前为止,我们忽略了平台的调用约定。每个平台都定义了自己的规则,说明同一程序或库中的一个函数如何调用另一个函数。这些规则总结在ABI文档中。典型信息包括:
使用的种类很多。在某些平台上,聚合数据类型总是间接传递,意味着聚合的副本被放置在堆栈上,只有指向副本的指针作为参数传递。在其他平台上,小型聚合(比如128或256位宽)在寄存器中传递,只有超过该阈值时才使用间接参数传递。有些平台还使用浮点和向量寄存器传递参数,而其他平台要求浮点值在整数寄存器中传递。
当然,这些都是有趣的低级内容。不幸的是,它泄露到了LLVM IR中。起初,这是令人惊讶的。毕竟,我们在LLVM IR中定义了函数的所有参数类型!结果证明这还不够。为了理解这一点,让我们考虑复数。一些语言为复数内置了数据类型。例如,C99有float _Complex
(等等)。旧版本的C没有复数类型,但您可以轻松定义struct Complex { float re, im; }
,并在此类型上创建算术操作。这两种类型都可以映射到LLVM IR的{ float, float }
类型。
如果ABI现在规定内置的复数类型的值在两个浮点寄存器中传递,但用户定义的聚合总是间接传递,那么仅用函数给出的信息对于LLVM来说不足以决定如何传递这个特定参数。不幸的结果是我们需要向LLVM提供更多信息,这些信息是高度ABI特定的。
向LLVM指定这些信息有两种方式:参数属性和类型重写。您需要使用哪种方式取决于目标平台和代码生成器。最常用的参数属性包括:
inreg
指定参数在寄存器中传递byval
指定参数按值传递。参数必须是指针类型。被指向数据的隐藏副本被制作出来,并将此指针传递给被调用的函数。zeroext
和 signext
指定传递的整数值应该进行零扩展或符号扩展。sret
指定此参数包含指向内存的指针,该内存用于从函数返回聚合类型。虽然所有代码生成器都支持zeroext
、signext
和sret
属性,但只有一些支持inreg
和byval
。可以使用addAttr()
方法向函数的参数添加属性。例如,要在参数Arg
上设置inreg
属性,您可以调用以下内容:
Arg->addAttr(llvm::Attribute::InReg);
要设置多个属性,可以使用llvm::AttrBuilder
类。
提供额外信息的另一种方式是使用类型重写。使用这种方法,您可以伪装原始类型。您可以执行以下操作:
要在不改变值的位的情况下在类型之间进行转换,您使用bitcast
指令。bitcast
指令可以操作简单数据类型,如整数和浮点值。当通过整数寄存器传递浮点值时,必须将浮点值转换为整数。在LLVM中,32位浮点值表示为float
,32位位整数表示为i32
。浮点值可以以以下方式转换为整数:
%intconv = bitcast float %fp to i32
此外,bitcast
指令要求两种类型具有相同的大小。
向参数添加属性或更改类型并不复杂。但是您如何知道需要实现什么?首先,您应该了解目标平台上使用的调用约定。例如,Linux上的ELF ABI为每个支持的CPU平台记录了文档,所以您可以查找该文档并熟悉它。
还有关于LLVM代码生成器要求的文档。信息来源是clang实现,您可以在https://github.com/llvm/llvm-project/blob/main/clang/lib/CodeGen/TargetInfo.cpp找到。这个单一文件包含了所有支持平台的ABI特定操作,并且也是所有信息收集的地方。
在本节中,您学习了如何生成符合平台ABI的函数调用的IR。下一节将介绍为类和虚函数创建IR的不同方法。
许多现代编程语言使用类支持面向对象。类是高级语言构造,在本节中,我们将探讨如何将类构造映射到LLVM IR。
类是数据和方法的集合。类可以继承另一个类,可能添加更多数据字段和方法,或覆盖现有的虚方法。让我们用Oberon-2中的类来说明这一点,这也是tinylang
的一个很好的模型。Shape
类定义了一个具有颜色和面积的抽象形状:
TYPE Shape = RECORD
color: INTEGER;
PROCEDURE (VAR s: Shape) GetColor(): INTEGER;
PROCEDURE (VAR s: Shape) Area(): REAL;
END;
GetColor
方法只返回颜色编号:
PROCEDURE (VAR s: Shape) GetColor(): INTEGER;
BEGIN RETURN s.color; END GetColor;
抽象形状的面积无法计算,所以这是一个抽象方法:
PROCEDURE (VAR s: Shape) Area(): REAL;
BEGIN HALT; END;
Shape
类型可以扩展为表示Circle
类:
TYPE Circle = RECORD (Shape)
radius: REAL;
PROCEDURE (VAR s: Circle) Area(): REAL;
END;
对于圆形,可以计算面积:
PROCEDURE (VAR s: Circle) Area(): REAL;
BEGIN RETURN 2 * radius * radius; END;
类型也可以在运行时查询。如果形状是Shape
类型的变量,那么我们可以用这种方式制定类型测试:
IF shape IS Circle THEN (* … *) END;
除了不同的语法外,这与C++中的工作方式非常相似。与C++的一个显著区别是Oberon-2语法使隐式的this
指针显式化,称之为方法的接收者。
要解决的基本问题是如何在内存中布局类,以及如何实现方法的动态调用和运行时类型检查。对于内存布局,这很简单。Shape
类只有一个数据成员,我们可以将其映射到相应的LLVM结构类型:
@Shape = type { i64 }
Circle
类添加了另一个数据成员。解决方案是在末尾追加新的数据成员:
@Circle = type { i64, float }
原因是一个类可以有许多子类。采用这种策略,公共基类的数据成员始终具有相同的内存偏移量,并且也使用相同的索引通过getelementptr
指令访问字段。
要实现方法的动态调用,我们必须进一步扩展LLVM结构。如果在Shape
对象上调用Area()
函数,则会调用抽象方法,导致应用程序停止。如果它在Circle
对象上调用,则调用相应的方法来计算圆的面积。另一方面,GetColor()
函数可以针对这两个类的对象调用。
实现这一点的基本思路是为每个对象关联一个带有函数指针的表。这里,一个表会有两个条目:一个用于GetColor()
方法,另一个用于Area()
函数。Shape
类和Circle
类各有这样一个表。表中Area()
函数的条目不同,因为它根据对象的类型调用不同的代码。这个表被称为虚方法表,通常缩写为vtable。
单独的vtable并不有用。我们必须将它与对象连接起来。为此,我们总是将指向vtable的指针作为结构的第一个数据成员添加。在LLVM层面,这就是@Shape
类型变成的样子:
@Shape = type { ptr, i64 }
@Circle
类型也进行了类似的扩展。
生成的内存结构如图5.1所示:
在LLVM IR的角度来看,Shape
类的虚方法表(vtable)可以像下面这样可视化,其中两个指针对应于GetColor()
和GetArea()
方法,如图5.1所示:
@ShapeVTable = constant { ptr, ptr } { GetColor(), Area() }
此外,LLVM没有void指针。相反,使用指向字节的指针。随着隐藏的vtable
字段的引入,现在也需要有一种方式来初始化它。在C++中,这是调用构造函数的一部分。在Oberon-2中,当内存被分配时,该字段会自动初始化。
然后通过以下步骤执行对方法的动态调用:
getelementptr
指令计算vtable指针的偏移量。call
指令间接通过指针调用函数。我们也可以在LLVM IR中可视化对虚拟方法(如Area()
)的动态调用。首先,我们从Shape
类的相应指定位置加载指针。以下加载表示加载指向Shape
的实际vtable的指针:
// Load a pointer from the corresponding location.
%ptrToShapeObj = load ptr, ...
// Load the first element of the Shape class.
%vtable = load ptr, ptr %ptrToShapeObj, align 8
接下来,getelementptr
获取到调用Area()
方法的偏移量:
%offsetToArea = getelementptr inbounds ptr, ptr %vtable, i64 1
然后,我们加载指向Area()
的函数指针:
%ptrToAreaFunction = load ptr, ptr %offsetToArea, align 8
最后,通过指针调用Area()
函数,类似于之前强调的通用步骤:
%funcCall = call noundef float %ptrToAreaFunction(ptr noundef nonnull align 8 dereferenceable(12) %ptrToShapeObj)
正如我们所见,即使在单一继承的情况下,生成的LLVM IR也可能看起来非常冗长。尽管生成对方法的动态调用的一般过程听起来并不高效,但大多数CPU架构只需两条指令就可以执行这种动态调用。
此外,要将函数转换为方法,需要对对象数据的引用。这是通过将指向数据的指针作为方法的第一个参数传递来实现的。在Oberon-2中,这是显式接收者。在类似C++的语言中,它是隐式的this
指针。
有了vtable,我们为每个类在内存中有一个唯一的地址。这是否也有助于运行时类型测试?答案是它只在有限的方式上有帮助。为了说明问题,让我们用一个从Circle
类继承的Ellipse
类扩展类层次结构。这在数学意义上并不是经典的is-a关系。
如果我们有一个Shape
类型的shape
变量,那么我们可以将shape
变量中存储的vtable指针与Circle
类的vtable指针进行比较来实现shape IS Circle
类型测试。这种比较只在shape
具有确切的Circle
类型时才返回true。然而,如果shape
实际上是Ellipse
类型,那么即使Ellipse
类型的对象在所有只需要Circle
类型对象的地方都可以使用,比较也会返回false。
显然,我们需要做更多的事情。解决方案是将运行时类型信息扩展到虚方法表中。您需要存储多少信息取决于源语言。为了支持运行时类型检查,存储指向基类vtable的指针就足够了,然后就像图5.2所示:
如前所述,如果测试失败,那么将重复该测试,使用指向基类vtable的指针。这将重复进行,直到测试返回true或者,如果没有基类,返回false。与调用动态函数相比,类型测试是一项成本较高的操作,因为在最坏的情况下,需要遍历继承层次结构直到根类。
如果您了解整个类层次结构,那么可以采用高效的方法:以深度优先的顺序对类层次结构的每个成员进行编号。然后,类型测试变成与数字或区间的比较,这可以在常数时间内完成。实际上,这就是LLVM自己的运行时类型测试方法,我们在上一章中了解到了。
将运行时类型信息与vtable结合是一个设计决策,要么是源语言强制的,要么只是实现细节。例如,如果您需要详细的运行时类型信息,因为源语言支持运行时的反射,并且您有没有vtable的数据类型,那么结合两者并不是一个好主意。在C++中,这种结合导致具有虚拟函数的类(因此没有vtable)没有运行时类型数据附加到它上。
通常,编程语言支持接口,这是一组虚拟方法的集合。接口很重要,因为它们增加了有用的抽象。我们将在下一节中看看可能的接口实现。
诸如Java之类的语言支持接口。接口是一组抽象方法的集合,类似于没有数据成员且只定义了抽象方法的基类。接口提出了一个有趣的问题,因为实现接口的每个类可以在vtable中的不同位置拥有相应的方法。原因很简单,vtable中函数指针的顺序是从源语言中类定义中的函数顺序派生的。接口的定义与此无关,不同的顺序是常态。
由于在接口中定义的方法可以有不同的顺序,我们将为每个实现的接口附加一个表到类中。对于接口的每个方法,此表可以指定方法在vtable中的索引或vtable中存储的函数指针的副本。如果在接口上调用方法,那么将搜索接口的相应vtable,获取指向该函数的指针,然后调用该方法。将两个I1
和I2
接口添加到Shape
类中,会产生以下布局:
这里的关键在于我们必须找到正确的虚方法表(vtable)。我们可以使用类似于运行时类型测试的方法:我们可以通过接口vtable的列表执行线性搜索。我们可以为每个接口分配一个唯一编号(例如,内存地址),并使用这个编号来识别这个vtable。这个方案的缺点是显而易见的:通过接口调用方法比在类上调用相同的方法需要更多的时间。对这个问题没有简单的缓解措施。
一个好的方法是用哈希表替换线性搜索。在编译时,类实现的接口是已知的。因此,我们可以构造一个完美的哈希函数,该函数将接口编号映射到接口的vtable。可能需要一个已知的唯一编号来识别接口进行构建,所以内存地址并不有帮助,但还有其他方法可以计算唯一编号。如果源代码中的符号名称是唯一的,那么总是可以计算符号的加密哈希,例如MD5
,并使用该哈希作为编号。这个计算发生在编译时,因此没有运行时成本。
结果比线性搜索快得多,只需要常数时间。然而,它涉及到对一个数字进行几个算术操作,比类类型的方法调用要慢。
通常,接口也参与运行时类型测试,使列表搜索变得更长。当然,如果实现了哈希表方法,那么它也可以用于运行时类型测试。
一些语言允许多个父类。这对实现提出了一些有趣的挑战,我们将在下一节中掌握这一点。
多重继承增加了另一个挑战。如果一个类继承自两个或更多的基类,那么我们需要以这样一种方式组合数据成员,以便它们仍然可以从方法中访问。像单继承情况一样,解决方案是追加所有数据成员,包括隐藏的vtable指针。
Circle
类不仅是一个几何形状,还是一个图形对象。为了模拟这一点,我们让Circle
类继承自Shape
类和GraphicObj
类。在类布局中,Shape
类的字段首先出现。然后,我们追加所有GraphicObj
类的字段,包括隐藏的vtable指针。之后,我们添加Circle
类的新数据成员,最终结构如图5.4所示:
这种方法有几个含义。现在可以有几个指向对象的指针。指向Shape
或Circle
类的指针指向对象的顶部,而指向GraphicObj
类的指针指向这个对象内部,即嵌入的GraphicObj
对象的开始部分。在比较指针时必须考虑这一点。
调用虚拟方法也受到影响。如果一个方法在GraphicObj
类中定义,那么这个方法预期GraphicObj
类的类布局。如果这个方法没有在Circle
类中被覆盖,那么有两种可能性。简单的情况是,如果该方法调用是通过一个指向GraphicObj
实例的指针完成的:在这种情况下,你在GraphicObj
类的vtable中查找方法的地址并调用该函数。更复杂的情况是,如果你用指向Circle
类的指针调用该方法。再次,您可以在Circle
类的vtable中查找方法的地址。被调用的方法期望this
指针是GraphicObj
类的一个实例,因此我们也必须调整该指针。我们可以做到这一点,因为我们知道GraphicObj
类在Circle
类中的偏移量。
如果GraphicObj
类中的方法在Circle
类中被覆盖,那么如果通过指向Circle
类的指针调用该方法,则无需特别操作。然而,如果该方法是通过指向GraphicObj
实例的指针调用的,那么我们需要进行另一次调整,因为该方法需要一个指向Circle
实例的this
指针。在编译时,我们无法计算这种调整,因为我们不知道这个GraphicObj
实例是否是多重继承层次结构的一部分。为了解决这个问题,我们将在调用方法之前需要对this
指针进行的调整存储在vtable中的每个函数指针旁边,如图5.5所示:
现在,方法调用变成了以下几个步骤:
this
指针。这种方法也可以用于实现接口。由于接口只有方法,每个实现的接口都会为对象添加一个新的vtable指针。这更容易实现,也可能更快,但它增加了每个对象实例的开销。
在最坏的情况下,如果你的类只有一个64位的数据字段但实现了10个接口,那么你的对象在内存中需要96字节:类本身的vtable指针占8字节,数据成员占8字节,每个接口的vtable指针占10*8字节。
为了支持对对象的有意义的比较,并进行运行时类型测试,我们需要首先规范化指向对象的指针。如果我们在vtable中添加一个字段,包含到对象顶部的偏移量,那么我们总是可以调整指针指向真正的对象。在Circle
类的vtable中,这个偏移量是0,但在嵌入的GraphicObj
类的vtable中不是。当然,是否需要实现这一点取决于源语言的语义。
LLVM本身并不偏好特定的面向对象特性的实现方式。正如本节所见,我们可以使用LLVM提供的数据类型实现所有方法。此外,正如我们看到的一个单继承的LLVM IR示例,值得注意的是,当涉及到多重继承时,IR可能变得更加冗长。如果您想尝试一种新的方法,那么首先在C中做一个原型是一个好方法。所需的指针操作可以快速转换为LLVM IR,但在更高级别的语言中推理功能更容易。
通过本节所获得的知识,你可以在自己的代码生成器中实现将常见的面向对象编程语言构造降低为LLVM IR。你已经掌握了如何在内存中表示单继承、单继承与接口或多重继承,以及如何实现类型测试和查找虚拟函数的方法,这些都是面向对象编程语言的核心概念。
在本章中,您学习了如何将聚合数据类型和指针翻译为LLVM IR代码。您还了解了应用程序二进制接口的复杂性。最后,您了解了将类和虚拟函数转换为LLVM IR的不同方法。有了本章的知识,您将能够为大多数实际编程语言创建一个LLVM IR代码生成器。
在下一章中,您将学习有关IR生成的一些高级技巧。异常处理在现代编程语言中相当常见,LLVM对此也有一定的支持。将类型信息附加到指针上可以帮助某些优化,因此我们也将添加这一点。最后但同样重要的是,对许多开发人员来说,调试应用程序的能力是必不可少的,所以我们还将在代码生成器中添加调试元数据的生成。