LLVM 中的堆疊映射和修補點¶
定義¶
在本文中,我們將「執行階段」統稱為所有作為 LLVM 客戶端的元件,包括 LLVM IR 產生器、目的碼消費者和程式碼修補程式。
堆疊映射會記錄特定指令地址的 活動值
位置。這些 活動值
並非指堆疊映射中所有活動的 LLVM 值。相反的,它們只是執行階段需要在此時保持活動的值。例如,它們可能是執行階段需要用於恢復程式執行,且獨立於包含堆疊映射的已編譯函式的值。
LLVM 會將堆疊映射資料發出至指定 堆疊映射區段 內的目的碼。此堆疊映射資料包含每個堆疊映射的記錄。該記錄會儲存堆疊映射的指令地址,並包含每個映射值的項目。每個項目都會將值的位置編碼為暫存器、堆疊偏移量或常數。
修補點是一個指令地址,在該地址處會保留空間,以便在執行階段修補新的指令序列。修補點看起來很像對 LLVM 的呼叫。它們會採用遵循呼叫慣例的引數,並且可能會傳回值。它們也暗示了堆疊映射的產生,這讓執行階段能夠找到修補點並找到該點的 活動值
位置。
動機¶
此功能目前處於實驗階段,但在各種情況下都可能派上用場,最明顯的是執行階段 (JIT) 編譯器。修補點內建函式的應用範例包括為多型方法分派實作內聯快取,或最佳化 JavaScript 等動態類型語言中屬性的擷取。
這裡記載的內建函式目前由開放原始碼 WebKit 專案中的 JavaScript 編譯器使用,請參閱 FTL JIT,但它們的設計目的是在需要堆疊映射或程式碼修補時使用。由於內建函式處於實驗階段,因此不保證 LLVM 版本之間的相容性。
本文中描述的堆棧映射功能與 計算堆棧映射 中描述的功能不同。 GCFunctionMetadata 提供指向 GCRoot 內建函數所捕獲的已收集堆的指標位置,這也可以被視為「堆棧映射」。 與上面定義的堆棧映射不同,GCFunctionMetadata 堆棧映射介面不提供將任意類型的活動暫存器值與指令地址相關聯的方法,也不指定生成的堆棧映射的格式。 此處描述的堆棧映射可能會向垃圾收集運行時提供更豐富的信息,但本文檔中不會討論這種用法。
內建函數¶
以下兩種內建函數可用於實現堆棧映射和補丁點:llvm.experimental.stackmap
和 llvm.experimental.patchpoint
。 這兩種內建函數都會生成堆棧映射記錄,並且都允許某種形式的代碼修補。 它們可以獨立使用(即 llvm.experimental.patchpoint
隱式生成堆棧映射,而無需額外調用 llvm.experimental.stackmap
)。 選擇使用哪個取決於是否有必要為代碼修補保留空間,以及是否應根據調用約定降低任何內建函數參數。 llvm.experimental.stackmap
不會保留任何空間,也不期望任何調用參數。 如果運行時在堆棧映射的地址處修補代碼,它將破壞性地覆蓋程序文本。 這與 llvm.experimental.patchpoint
不同,後者為就地修補保留空間,而不會覆蓋周圍的代碼。 llvm.experimental.patchpoint
內建函數還會根據其調用約定降低指定數量的參數。 這允許修補的代碼進行就地函數調用,而無需封送處理。
這些內建函數的每個實例都會在 堆棧映射區段 中生成一個堆棧映射記錄。 該記錄包括一個 ID,允許運行時唯一標識堆棧映射,以及從封閉函數的開頭開始的代碼中的偏移量。
‘llvm.experimental.stackmap
’ 內建函數¶
語法:¶
declare void
@llvm.experimental.stackmap(i64 <id>, i32 <numShadowBytes>, ...)
概述:¶
‘llvm.experimental.stackmap
’ 內建函數記錄堆棧映射中指定值的位置,而不會生成任何代碼。
操作數:¶
第一個操作數是要在堆棧映射中編碼的 ID。 第二個操作數是內建函數後面的陰影字節數。 後面的可變數量的操作數是 活動 值
,其位置將記錄在堆棧映射中。
要將此內建函數用作沒有代碼修補支持的簡單堆棧映射,可以將陰影字節數設置為零。
語義:¶
堆疊映射內建函數不會在其位置生成任何程式碼,除非需要 nop 來覆蓋其陰影(見下文)。 但是,它與函數入口的偏移量存儲在堆疊映射中。 這是緊跟在堆疊映射之前的指令之後的相對指令地址。
堆疊映射 ID 允許運行時定位所需的堆疊映射記錄。 LLVM 將此 ID 直接傳遞給堆疊映射記錄,而不檢查唯一性。
LLVM 保證在堆疊映射的指令偏移量之後會有一段指令陰影,在此期間,既不會出現基本塊的末尾,也不會出現對 llvm.experimental.stackmap
或 llvm.experimental.patchpoint
的另一個調用。 這允許運行時響應從程式碼外部觸發的事件來修補此處的程式碼。 堆疊映射之後的指令的程式碼可以在堆疊映射的陰影中發出,並且這些指令可能會被破壞性修補覆蓋。 如果沒有陰影位元組,這種破壞性修補可能會覆蓋當前函數之外的程序文本或數據。 我們不允許重疊的堆疊映射陰影,因此運行時不需要考慮這種極端情況。
例如,具有 8 位元組陰影的堆疊映射
call void @runtime()
call void (i64, i32, ...) @llvm.experimental.stackmap(i64 77, i32 8,
ptr %ptr)
%val = load i64, ptr %ptr
%add = add i64 %val, 3
ret i64 %add
可能需要一個位元組的 nop 填充
0x00 callq _runtime
0x05 nop <--- stack map address
0x06 movq (%rdi), %rax
0x07 addq $3, %rax
0x0a popq %rdx
0x0b ret <---- end of 8-byte shadow
現在,如果運行時需要使已編譯的程式碼失效,它可以在堆疊映射的地址處修補 8 個位元組的程式碼,如下所示
0x00 callq _runtime
0x05 movl $0xffff, %rax <--- patched code at stack map address
0x0a callq *%rax <---- end of 8-byte shadow
這樣,在對運行時的正常調用返回後,程式碼將執行對特殊入口點的修補調用,該入口點可以從堆疊映射定位的值重建堆疊幀。
‘llvm.experimental.patchpoint.*
’ 內建函數¶
語法:¶
declare void
@llvm.experimental.patchpoint.void(i64 <id>, i32 <numBytes>,
ptr <target>, i32 <numArgs>, ...)
declare i64
@llvm.experimental.patchpoint.i64(i64 <id>, i32 <numBytes>,
ptr <target>, i32 <numArgs>, ...)
概述:¶
‘llvm.experimental.patchpoint.*
’ 內建函數會建立對指定 <target>
的函數調用,並在堆疊映射中記錄指定值的位置。
運算元:¶
第一個運算元是 ID,第二個運算元是為可修補區域保留的位元組數,第三個運算元是函數的目標地址(可選為空),第四個運算元指定後面的變量運算元中有多少個被視為函數調用參數。 其餘的可變數量的運算元是 活動值
,其位置將記錄在堆疊映射中。
語義:¶
修補點內建函數會生成堆疊映射。 如果地址不是常量 null,它還會發出對 <target>
指定的地址的函數調用。 函數調用及其參數根據內建函數調用點指定的調用約定降低。 具有非 void 返回類型的內建函數的變體也會根據調用約定返回值。
在 PowerPC 上,請注意 <target>
必須是指向間接調用的預期目標的 ABI 函數指針。 具體來說,當為 ELF V1 ABI 編譯時,<target>
是通常用作 C/C++ 函數指針表示的函數描述符地址。
請求零個修補點參數是有效的。 在這種情況下,所有變量運算元都像 llvm.experimental.stackmap.*
一樣處理。 不同之處在於仍然會為修補保留空間,會發出調用,並且允許返回值。
參數的位置通常不會記錄在堆疊映射表中,因為它們已經由呼叫慣例固定。其餘 live values
的位置將會被記錄,可以是暫存器、堆疊位置或常數。為配合堆疊映射表,引入了一種特殊的呼叫慣例 anyregcc,它強制將參數載入暫存器中,但允許動態分配這些暫存器。除了其餘的 live values
之外,這些參數暫存器的暫存器位置也將記錄在堆疊映射表中。
修補點還會發出 nops 以涵蓋至少 <numBytes>
的指令編碼空間。因此,用戶端必須確保 <numBytes>
足以在支援的目標上編碼對目標地址的呼叫。如果呼叫目標是常數 null,則沒有最低要求。零位元組的 null 目標修補點是有效的。
執行階段可能會修補為修補點發出的程式碼,包括呼叫序列和 nops。但是,執行階段可能不會假設 LLVM 在保留空間內發出的程式碼有任何特定行為。不允許部分修補。執行階段必須修補所有保留的位元組,必要時用 nops 填充。
此範例顯示一個保留 15 個位元組的修補點,根據原生呼叫慣例,在 $rdi 中有一個參數,在 $rax 中有一個回傳值。
%target = inttoptr i64 -281474976710654 to ptr
%val = call i64 (i64, i32, ...)
@llvm.experimental.patchpoint.i64(i64 78, i32 15,
ptr %target, i32 1, ptr %ptr)
%add = add i64 %val, 3
ret i64 %add
可能會產生
0x00 movabsq $0xffff000000000002, %r11 <--- patch point address
0x0a callq *%r11
0x0d nop
0x0e nop <--- end of reserved 15-bytes
0x0f addq $0x3, %rax
0x10 movl %rax, 8(%rsp)
請注意,不會記錄任何堆疊映射表位置。如果修補的程式碼序列不需要將參數固定到特定的呼叫慣例暫存器,則可以使用 anyregcc
慣例。
%val = call anyregcc @llvm.experimental.patchpoint(i64 78, i32 15,
ptr %target, i32 1,
ptr %ptr)
堆疊映射表現在指示 %ptr 參數和回傳值的位置。
Stack Map: ID=78, Loc0=%r9 Loc1=%r8
修補程式碼序列現在可以使用恰好分配在 %r8 中的參數,並回傳分配在 %r9 中的值。
0x00 movslq 4(%r8) %r9 <--- patched code at patch point address
0x03 nop
...
0x0e nop <--- end of reserved 15-bytes
0x0f addq $0x3, %r9
0x10 movl %r9, 8(%rsp)
堆疊映射表格式¶
LLVM 模組中存在堆疊映射表或修補點內建函式,會強制程式碼發出以建立 堆疊映射區段。此區段的格式如下:
Header {
uint8 : Stack Map Version (current version is 3)
uint8 : Reserved (expected to be 0)
uint16 : Reserved (expected to be 0)
}
uint32 : NumFunctions
uint32 : NumConstants
uint32 : NumRecords
StkSizeRecord[NumFunctions] {
uint64 : Function Address
uint64 : Stack Size (or UINT64_MAX if not statically known)
uint64 : Record Count
}
Constants[NumConstants] {
uint64 : LargeConstant
}
StkMapRecord[NumRecords] {
uint64 : PatchPoint ID
uint32 : Instruction Offset
uint16 : Reserved (record flags)
uint16 : NumLocations
Location[NumLocations] {
uint8 : Register | Direct | Indirect | Constant | ConstantIndex
uint8 : Reserved (expected to be 0)
uint16 : Location Size
uint16 : Dwarf RegNum
uint16 : Reserved (expected to be 0)
int32 : Offset or SmallConstant
}
uint32 : Padding (only if required to align to 8 byte)
uint16 : Padding
uint16 : NumLiveOuts
LiveOuts[NumLiveOuts]
uint16 : Dwarf RegNum
uint8 : Reserved
uint8 : Size in Bytes
}
uint32 : Padding (only if required to align to 8 byte)
}
每個位置的第一個位元組編碼了一個類型,該類型指示如何解釋 RegNum
和 Offset
欄位,如下所示:
編碼 |
類型 |
值 |
說明 |
0x1 |
暫存器 |
Reg |
暫存器中的值 |
0x2 |
直接 |
Reg + Offset |
框架索引值 |
0x3 |
間接 |
[Reg + Offset] |
溢出值 |
0x4 |
常數 |
Offset |
小常數 |
0x5 |
ConstIndex |
Constants[Offset] |
大常數 |
在常見情況下,寄存器中會有一個值,而 Offset
欄位會是零。溢出到堆疊的值會被編碼為 Indirect
位置。執行階段必須從堆疊地址載入這些值,通常採用 [BP + Offset]
的形式。如果將 alloca
值直接傳遞給堆疊映射內建函式,則 LLVM 可以將框架索引折疊到堆疊映射中,作為一種避免分配寄存器或堆疊位置的優化。這些框架索引將在 Direct
位置中以 BP + Offset
的形式進行編碼。LLVM 還可以通過直接在堆疊映射中發出常數來優化常數,無論是在 Constant
位置的 Offset
中,還是在由 ConstantIndex
位置引用的常數池中。
在每個呼叫站點,還會記錄一個「liveout」寄存器列表。這些是在堆疊映射中保持活動狀態的寄存器,因此必須由執行階段保存。當 patchpoint 內建函式與預設將大多數寄存器保留為被呼叫者保存的呼叫約定一起使用時,這是一個重要的優化。
liveout 寄存器列表中的每個條目都包含一個 DWARF 寄存器編號和以位元組為單位的尺寸。堆疊映射格式特意省略了特定的子寄存器資訊。相反,執行階段必須保守地解釋此資訊。例如,如果堆疊映射報告 %rax
處有一個位元組,則該值可能在 %al
或 %ah
中。實際上這並不重要,因為執行階段只會保存 %rax
。但是,如果堆疊映射報告 %ymm0
處有 16 個位元組,則執行階段可以通過僅保存 %xmm0
來安全地進行優化。
堆疊映射格式是 LLVM SVN 修訂版和執行階段之間的契約。它目前處於實驗階段,並且可能會在短期內發生變化,但是最大程度地減少更新執行階段的需求非常重要。因此,堆疊映射設計的動機是簡單性和可擴展性。表示形式的緊湊性是次要的,因為預計執行階段會在編譯模組後立即解析數據,並以自己的格式對資訊進行編碼。由於執行階段控制區段的分配,因此它可以為多個模組重複使用相同的堆疊映射空間。
堆疊映射支援目前僅針對 64 位元平台實作。但是,32 位元實作應該能夠使用相同的格式,並且浪費的空間量微不足道。
堆疊映射區段¶
JIT 編譯器可以通過 LLVM C API LLVMCreateSimpleMCJITMemoryManager()
提供自己的記憶體管理器來輕鬆訪問此區段。建立記憶體管理器時,JIT 會提供一個回調函式:LLVMMemoryManagerAllocateDataSectionCallback()
。當 LLVM 建立此區段時,它會呼叫回調函式並傳遞區段名稱。JIT 可以在此時記錄區段的記憶體地址,並稍後解析它以恢復堆疊映射數據。
對於 MachO(例如在 Darwin 上),堆疊映射區段名稱為「__llvm_stackmaps」。區段名稱為「__LLVM_STACKMAPS」。
對於 ELF(例如在 Linux 上),堆疊映射區段名稱為「.llvm_stackmaps」。區段名稱為「__LLVM_STACKMAPS」。
堆疊映射的使用¶
本文所述的堆疊映射支援可用於精確確定程式碼中特定位置的值的位置。LLVM 不會維護這些值與任何更高級別實體之間的任何映射。執行階段必須能夠僅根據 ID、偏移量以及 LLVM 保留的位置、記錄和函數的順序來解釋堆疊映射記錄。
請注意,這與偵錯資訊的目標截然不同,後者是盡力追蹤每個指令處命名變數的位置。
這種設計的一個重要動機是允許執行階段在執行到達與堆疊映射關聯的指令位址時佔用堆疊框架。執行階段必須夠使用堆疊映射提供的信息重建堆疊框架並繼續執行程式。例如,執行可能會在解譯器或相同函數的重新編譯版本中繼續。
這種用法限制了 LLVM 優化。顯然,LLVM 不能跨堆疊映射移動存儲。但是,載入也必須保守地處理。如果載入可能會觸發異常,則將其提升到堆疊映射之上可能是無效的。例如,執行階段可能會確定在類型系統的當前狀態下,執行載入是安全的,無需進行類型檢查。如果在載入函數的某些激活存在於堆疊上的同時類型系統發生變化,則載入將變得不安全。執行階段可以通過立即修補位於當前呼叫站點和載入之間的任何堆疊映射位置來防止後續執行該載入(通常,執行階段只會修補所有堆疊映射位置以使函數失效)。如果編譯器已將載入提升到堆疊映射之上,則程式可能會在執行階段可以收回控制權之前崩潰。
為了強制執行這些語義,stackmap 和 patchpoint 內建函數被認為可能會讀取和寫入所有內存。這可能會限制優化,超出了某些客戶的期望。通過將呼叫站點標記為“唯讀”,可以避免此限制。未來,我們也可能允許將元數據添加到內建函數呼叫中以表達別名,從而允許優化將某些載入提升到堆疊映射之上。
直接堆疊映射條目¶
如堆疊映射區段所示,直接堆疊映射位置記錄了框架索引的地址。此地址本身就是執行階段請求的值。這與間接位置不同,間接位置指的是必須從中載入請求值的堆疊位置。直接位置可以傳達 alloca 的地址,而間接位置處理寄存器溢出。
例如
entry:
%a = alloca i64...
llvm.experimental.stackmap(i64 <ID>, i32 <shadowBytes>, ptr %a)
執行階段可以在編譯後立即或之後的任何時間確定此 alloca 在堆疊上的相對位置。這與寄存器和間接位置不同,因為執行階段只能在執行到達堆疊映射的指令地址時讀取這些位置中的值。
此功能要求 LLVM 在直接使用內建函數時特殊處理入口塊 alloca。(這是 llvm.gcroot 內建函數所施加的相同要求。)LLVM 轉換不得將 alloca 替換為任何 intervening 值。執行階段可以通過簡單地檢查堆疊映射的位置是否是直接位置類型來驗證這一點。
支援的架構¶
對 StackMap 生成和相關內建函數的支援需要為每個後端提供一些程式碼。目前,僅支援 LLVM 後端的一個子集。目前支援的架構是 X86_64、PowerPC、AArch64 和 SystemZ。