类型元数据

类型元数据是一种机制,允许 IR 模块协同构建指向给定全局变量集内地址的指针集。LLVM 的 控制流完整性 实现使用此元数据来有效地检查(在每个调用站点)给定地址是否对应于给定类或函数类型的有效虚函数表或函数指针,以及其全程序去虚拟化传递使用元数据来识别给定虚函数调用的潜在被调用者。

要使用此机制,客户端会创建具有两个元素的元数据节点

  1. 全局变量中的字节偏移量(对于函数通常为零)

  2. 表示类型标识符的元数据对象

这些元数据节点通过使用全局对象元数据附件与 !type 元数据类型关联全局变量。

每个类型标识符必须唯一地标识全局变量或函数。

限制

当前实现仅支持在 x86-32 和 x86-64 架构上将元数据附加到函数。

一个内联函数,llvm.type.test,用于测试给定指针是否与类型标识符相关联。

使用类型元数据表示类型信息

本节介绍 Clang 如何使用类型元数据表示与虚函数表关联的 C++ 类型信息。

考虑以下继承层次结构

struct A {
  virtual void f();
};

struct B : A {
  virtual void f();
  virtual void g();
};

struct C {
  virtual void h();
};

struct D : A, C {
  virtual void f();
  virtual void h();
};

A、B、C 和 D 的虚函数表对象如下所示(在 Itanium ABI 下)

表 2 A、B、C、D 的虚函数表布局

0

1

2

3

4

5

6

A

A::offset-to-top

&A::rtti

&A::f

B

B::offset-to-top

&B::rtti

&B::f

&B::g

C

C::offset-to-top

&C::rtti

&C::h

D

D::offset-to-top

&D::rtti

&D::f

&D::h

D::offset-to-top

&D::rtti

用于 &D::h 的 thunk

当构造类型为 A 的对象时,A 的虚函数表对象中 &A::f 的地址存储在对象的虚函数表指针中。在 ABI 术语中,此地址称为 地址点。类似地,当构造类型为 B 的对象时,&B::f 的地址存储在虚函数表指针中。通过这种方式,B 的虚函数表对象中的虚函数表与 A 的虚函数表兼容。

由于使用了多重继承,D 稍微复杂一些。其虚函数表对象包含两个虚函数表,一个与 A 的虚函数表兼容,另一个与 C 的虚函数表兼容。类型为 D 的对象包含两个虚函数指针,一个属于 A 子对象并包含与 A 的虚函数表兼容的虚函数表的地址,另一个属于 C 子对象并包含与 C 的虚函数表兼容的虚函数表的地址。

上面类层次结构的完整兼容性信息如下所示。下表显示了类的名称、类虚函数表中地址点的偏移量以及与该地址点兼容的类的名称之一。

表 3 A、B、C、D 的类型偏移量

虚函数表用于

偏移量

兼容类

A

16

A

B

16

A

B

C

16

C

D

16

A

D

48

C

下一步是将此兼容性信息编码到 IR 中。实现方法是为每个兼容类创建以其命名类型元数据,并使用该元数据关联每个虚函数表中的每个兼容地址点。例如,这些类型元数据条目编码了上面层次结构的兼容性信息

@_ZTV1A = constant [...], !type !0
@_ZTV1B = constant [...], !type !0, !type !1
@_ZTV1C = constant [...], !type !2
@_ZTV1D = constant [...], !type !0, !type !3, !type !4

!0 = !{i64 16, !"_ZTS1A"}
!1 = !{i64 16, !"_ZTS1B"}
!2 = !{i64 16, !"_ZTS1C"}
!3 = !{i64 16, !"_ZTS1D"}
!4 = !{i64 48, !"_ZTS1C"}

使用此类型元数据,我们现在可以使用 llvm.type.test 内联函数来测试给定指针是否与类型标识符兼容。反过来,如果 llvm.type.test 对特定指针返回 true,我们还可以静态确定特定虚函数调用可能调用的虚函数的身份。例如,如果程序假设指针是 !"_ZST1A" 的成员,我们知道该地址只能是 _ZTV1A+16_ZTV1B+16_ZTV1D+16 之一(即 A、B 和 D 的虚函数表的地址点)。如果我们随后从该指针加载地址,我们知道该地址只能是 &A::f&B::f&D::f 之一。

测试地址是否属于类型

如果程序使用 llvm.type.test 测试地址,这将导致链接时优化传递 LowerTypeTests 将对该内联函数的调用替换为有效的代码以执行类型成员测试。在高级别上,传递将把引用的全局变量在对象文件中连续的内存区域中布局,构造映射到该内存区域的位向量,并在每个 llvm.type.test 调用站点生成代码以针对这些位向量测试指针。由于布局操作,在 LTO 时必须提供全局变量的定义。有关更多信息,请参阅 控制流完整性设计文档

标识函数的类型标识符将转换为跳转表,跳转表是一段代码块,该代码块包含类型标识符关联的每个函数的一个分支指令,该指令分支到目标函数。传递将重定向任何已获取的函数地址到相应的跳转表条目。在对象文件的符号表中,跳转表条目采用原始函数的身份,以便从模块外部获取的地址将通过模块内部完成的任何验证。

跳转表可能会调用外部函数,因此在 LTO 时不需要它们的定义。请注意,如果外部定义的函数与类型标识符相关联,则不能保证其在模块内的身份与其在模块外部的身份相同,因为如果需要跳转表,前者将是跳转表条目。

GlobalLayoutBuilder 类负责有效地布局全局变量以最大程度地减少底层位集的大小。

示例:

target datalayout = "e-p:32:32"

@a = internal global i32 0, !type !0
@b = internal global i32 0, !type !0, !type !1
@c = internal global i32 0, !type !1
@d = internal global [2 x i32] [i32 0, i32 0], !type !2

define void @e() !type !3 {
  ret void
}

define void @f() {
  ret void
}

declare void @g() !type !3

!0 = !{i32 0, !"typeid1"}
!1 = !{i32 0, !"typeid2"}
!2 = !{i32 4, !"typeid2"}
!3 = !{i32 0, !"typeid3"}

declare i1 @llvm.type.test(i8* %ptr, metadata %typeid) nounwind readnone

define i1 @foo(i32* %p) {
  %pi8 = bitcast i32* %p to i8*
  %x = call i1 @llvm.type.test(i8* %pi8, metadata !"typeid1")
  ret i1 %x
}

define i1 @bar(i32* %p) {
  %pi8 = bitcast i32* %p to i8*
  %x = call i1 @llvm.type.test(i8* %pi8, metadata !"typeid2")
  ret i1 %x
}

define i1 @baz(void ()* %p) {
  %pi8 = bitcast void ()* %p to i8*
  %x = call i1 @llvm.type.test(i8* %pi8, metadata !"typeid3")
  ret i1 %x
}

define void @main() {
  %a1 = call i1 @foo(i32* @a) ; returns 1
  %b1 = call i1 @foo(i32* @b) ; returns 1
  %c1 = call i1 @foo(i32* @c) ; returns 0
  %a2 = call i1 @bar(i32* @a) ; returns 0
  %b2 = call i1 @bar(i32* @b) ; returns 1
  %c2 = call i1 @bar(i32* @c) ; returns 1
  %d02 = call i1 @bar(i32* getelementptr ([2 x i32]* @d, i32 0, i32 0)) ; returns 0
  %d12 = call i1 @bar(i32* getelementptr ([2 x i32]* @d, i32 0, i32 1)) ; returns 1
  %e = call i1 @baz(void ()* @e) ; returns 1
  %f = call i1 @baz(void ()* @f) ; returns 0
  %g = call i1 @baz(void ()* @g) ; returns 1
  ret void
}

!vcall_visibility 元数据

为了允许从虚函数表中删除未使用的函数指针,我们需要知道每个可能使用它的虚函数调用是否为编译器已知,或者其他翻译单元是否可以通过虚函数表引入更多调用。这与虚函数表的链接不同,因为调用站点可能正在使用更广泛可见的基类的指针。例如,考虑以下代码

__attribute__((visibility("default")))
struct A {
  virtual void f();
};

__attribute__((visibility("hidden")))
struct B : A {
  virtual void f();
};

使用 LTO,我们知道所有可以看到 B 声明的代码都对我们可见。但是,指向 B 的指针可以转换为 A* 并传递给另一个链接单元,然后该单元可以在其上调用 f。此调用将从 B 的虚函数表(使用对象指针)加载,然后调用 B::f。这意味着我们无法从 B 的虚函数表中删除函数指针,或者 B::f 的实现。但是,如果我们可以看到所有了解任何动态基类的代码(如果 B 仅继承自具有隐藏可见性的类,则会出现这种情况),则此优化将有效。

此概念在 IR 中由附加到虚函数表对象的 !vcall_visibility 元数据表示,并具有以下值

行为

0(或省略)

公开

使用此虚函数表的虚函数调用可以从外部代码进行。

1

链接单元

所有可能使用此虚函数表的虚函数调用都在当前 LTO 单元中,这意味着在执行 LTO 链接后它们将位于当前模块中。

2

翻译单元

所有可能使用此虚函数表的虚函数调用都在当前模块中。

此外,从标记有 !vcall_visibility 元数据(非零值)的虚函数表加载的所有函数指针都必须使用 llvm.type.checked.load 内联函数完成,以便可以将虚函数调用站点与它们可能从中加载的虚函数表相关联。虚函数表的其他部分(RTTI、offset-to-top 等)仍然可以使用普通加载进行访问。