类型元数据

类型元数据是一种机制,允许 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 的地址存储在对象的 vtable 指针中。在 ABI 术语中,此地址称为地址点。类似地,当构造类型 B 的对象时,&B::f 的地址存储在 vtable 指针中。通过这种方式,B 的虚函数表对象中的 vtable 与 A 的 vtable 兼容。

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

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

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

适用于的虚函数表

偏移量

兼容类

A

16

A

B

16

A

B

C

16

C

D

16

A

D

48

C

下一步是将此兼容性信息编码到 IR 中。实现方式是创建以每个兼容类命名的类型元数据,我们将每个 vtable 中每个兼容的地址点与这些类型元数据关联起来。例如,以下类型元数据条目编码了上述层次结构的兼容性信息

@_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 的 vtable 的地址点)。如果我们然后从该指针加载地址,我们知道该地址只能是 &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 元数据

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

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

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

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

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

行为

0(或省略)

公开

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

1

链接单元

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

2

翻译单元

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

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