類型中繼資料

類型中繼資料是一種機制,允許 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 的地址會儲存在物件的虛擬函數表指標中。在 ABI 術語中,此地址稱為 地址點。同樣,當建構 B 類型的物件時,&B::f 的地址會儲存在虛擬函數表指標中。通過這種方式,B 的虛擬函數表物件中的虛擬函數表與 A 的虛擬函數表相容。

由於使用了多重繼承,因此 D 有點複雜。其虛擬函數表物件包含兩個虛擬函數表,一個與 A 的虛擬函數表相容,另一個與 C 的虛擬函數表相容。D 類型的物件包含兩個虛擬指標,一個屬於 A 子物件,包含與 A 的虛擬函數表相容的虛擬函數表的地址,另一個屬於 C 子物件,包含與 C 的虛擬函數表相容的虛擬函數表的地址。

以下顯示上述類別階層的完整相容性資訊。下表顯示類別名稱、該類別 vtable 中地址點的偏移量,以及與該地址點相容的其中一個類別名稱。

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

VTable for

偏移量

相容類別

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 中繼資料

為了允許從虛擬函式表中移除未使用的函式指標,我們需要知道每個可能使用它的虛擬函式呼叫是否對編譯器可知,或者其他轉譯單元是否可能透過虛擬函式表引入更多呼叫。這與虛擬函式表的連結不同,因為呼叫站點可能使用更廣泛可見的基類指標。例如,請考慮以下程式碼

__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、偏移量到頂部,...)仍然可以使用一般載入來存取。