類型元數據

類型元數據是一種機制,允許 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

thunk for &D::h

當建構 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 的類型偏移量

VTable 對應於

偏移量

相容類別

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 等)仍然可以使用正常載入來存取。