了解 Arm64EC ABI 和組件程式碼
Arm64EC (“模擬兼容”) 是一個新的應用程式二進位介面 (ABI), 用於在 Arm 上建置適用於 Windows 11 的應用程式。 如需 Arm64EC 的概觀,以及如何開始將 Win32 應用程式建置為 Arm64EC,請參閱使用 Arm64EC 在 Arm 裝置上建置適用於 Windows 11 的應用程式。
本文件的目的是提供 Arm64EC ABI 的詳細檢視,其中包含足夠的資訊,讓應用程式開發人員撰寫和偵錯針對 Arm64EC 編譯的程式碼,包括低階/彙編程式偵錯,以及撰寫以 Arm64EC ABI 為目標的組件程式碼。
Arm64EC 的設計
Arm64EC 的設計目的是提供原生層級的功能和效能,同時提供透明且直接的互通性與模擬下執行的 x64 程式碼。
Arm64EC 大部分是傳統 Arm64 ABI 的附加項。 幾乎沒有什麼傳統 ABI 已變更,但已新增部分來啟用 x64 互通性。
在本檔中,原始的標準 Arm64 ABI 應稱為「傳統 ABI」。 這可避免多載字詞固有的模棱兩可,例如「原生」。 很明顯,Arm64EC 與原始 ABI 一樣為原生。
Arm64EC 與 Arm64 Classic ABI
下列清單指出 Arm64EC 與 Arm64 Classic ABI 的分歧。
從整個 ABI 定義的範圍來看,這些變更很小。
暫存器對應和封鎖暫存器
若要有與 x64 程式碼的類型層級互通性,Arm64EC 程式碼會使用與 x64 程式碼相同的前置處理器架構定義進行編譯。
換句話說,會定義 _M_AMD64
和 _AMD64_
。 受此規則影響的其中一種類型是 CONTEXT
結構。 CONTEXT
結構會定義指定時間點的 CPU 狀態。 它用於像 Exception Handling
和 GetThreadContext
API 之類的項目。 現有的 x64 程式碼預期 CPU 內容會表示為 x64 CONTEXT
結構,換句話說,即在 x64 編譯期間定義的 CONTEXT
結構。
執行 x64 程式碼以及 Arm64EC 程式碼時,這個結構必須用來表示 CPU 內容。 現有的程式碼不會了解新概念,例如從函式變更為函式的 CPU 暫存器集合。 如果 x64 CONTEXT
結構用來代表 Arm64 執行狀態,這表示 Arm64 暫存器實際上會對應到 x64 暫存器。
這也表示任何無法安裝到 x64 CONTEXT
的 Arm64 暫存器都不得使用,因為每當使用 CONTEXT
的作業時,其值可能隨時遺失 (有些暫存器可能異步且非預期,例如 Managed Language Runtime 的垃圾收集作業或 APC)。
Arm64EC 與 x64 暫存器之間的對應規則會以 SDK 中 Windows 標頭中的 ARM64EC_NT_CONTEXT
結構表示。 此結構基本上是 CONTEXT
結構的聯集,與 x64 完全相同,但具有額外的 Arm64 暫存器重疊。
例如,RCX
對應至 X0
、RDX
至 X1
、RSP
至 SP
、RIP
至 PC
等。我們也可以查看暫存器x13
、x14
、x23
、x24
、x28
、v16
-v31
沒有表示法,因此無法在 Arm64EC x中使用。
此暫存器使用限制是 Arm64 Classic 和 EC API 之間的第一個差異。
呼叫檢查程式
自 Windows 8.1 中引進控制流程防護 (CFG) 以來,呼叫檢查程式一直是 Windows 的一部分。 呼叫檢查程式是函式指標的位址清理程式 (在呼叫位址清理程式之前)。 每次使用選項 /guard:cf
編譯程式碼時,編譯程式都會在每次間接呼叫/跳躍之前,產生對檢查程式函式的額外呼叫。 檢查程式函式本身是由 Windows 提供,對於 CFG 則會針對已知狀況良好的呼叫目標執行有效性檢查。 這項資訊也會包含在使用 /guard:cf
編譯的二進位檔中。
這是在傳統 Arm64 中使用的呼叫檢查程式範例:
mov x15, <target>
adrp x16, __guard_check_icall_fptr
ldr x16, [x16, __guard_check_icall_fptr]
blr x16 ; check target function
blr x15 ; call function
在 CFG 案例中,如果目標有效,呼叫檢查程式只會傳回,如果不是有效,則會快速讓處理序失敗。 呼叫檢查程式具有自訂呼叫慣例。 它們將函數指標存放在正常呼叫慣例未使用的暫存器中,並保留所有正常呼叫慣例暫存器。 如此一來,他們就不會在它們周圍引入登記溢出。
呼叫檢查程式在所有其他 Windows ABIS 上都是選用的,但在 Arm64EC 上是強制性的。 在 Arm64EC 上,呼叫檢查程式會累積驗證所呼叫的函式架構的工作。 它們會驗證呼叫是否為另一個 EC(「模擬相容」)函式或必須在模擬下執行的 x64 函式。 在許多情況下,這能在執行階段進行驗證。
Arm64EC 呼叫檢查程式建置在現有的 Arm64 檢查程式之上,但有稍微不同的自訂呼叫慣例。 它們會採用額外的參數,而且可以修改包含目標位址的暫存器。 例如,如果目標是 x64 程式碼,控制項必須先傳送至模擬 Scaffolding 邏輯。
在 Arm64EC 中,相同的呼叫檢查程式使用會變成:
mov x11, <target>
adrp x9, __os_arm64x_check_icall_cfg
ldr x9, [x9, __os_arm64x_check_icall_cfg]
adrp x10, <name of the exit thunk>
add x10, x10, <name of the exit thunk>
blr x9 ; check target function
blr x11 ; call function
傳統 Arm64 的些微差異包括:
- 呼叫檢查程式的符號名稱不同。
- 目標位址是在
x11
而不是在x15
中提供。 - 目標位址 (
x11
) 是[in, out]
而不是[in]
。 - 透過提供
x10
的額外參數稱為「Exit Thunk」。
Exit Thunk 是 funclet,可將函式參數從 Arm64EC 呼叫慣例轉換成 x64 呼叫慣例。
Arm64EC 呼叫檢查程式是透過與 Windows 中其他 API 不同的符號而找到的。 在傳統 Arm64 ABI 上,呼叫檢查工具的符號為 __guard_check_icall_fptr
。 此符號會出現在 Arm64EC 中,但使用的是 x64 靜態連結的程式碼,而不是 Arm64EC 程式碼本身。 Arm64EC 程式碼會使用 __os_arm64x_check_icall
或 __os_arm64x_check_icall_cfg
。
在 Arm64EC 上,呼叫檢查程式不是選用的。 不過,CFG 仍然是選用的,如同其他 ABIS 的案例一樣。 CFG 可能會在編譯階段停用,或者即使啟用 CFG,也有理由不執行 CFG 檢查 (例如函式指標永遠不會位於 RW 記憶體中)。 如需使用 CFG 檢查的間接呼叫,應該使用 __os_arm64x_check_icall_cfg
檢查程式。 如果 CFG 已停用或不必要,則應該改用 __os_arm64x_check_icall
。
以下是傳統 Arm64、x64 和 Arm64EC 上呼叫檢查程式用法的摘要表,其中指出 Arm64EC 二進位檔可以有兩個選項,視程式碼的架構而定。
Binary | 代碼 | 未受保護的間接呼叫 | CFG 保護的間接呼叫 |
---|---|---|---|
x64 | x64 | 無呼叫檢查程式 | __guard_check_icall_fptr 或 __guard_dispatch_icall_fptr |
Arm64 Classic | Arm64 | 無呼叫檢查程式 | __guard_check_icall_fptr |
Arm64EC | x64 | 無呼叫檢查程式 | __guard_check_icall_fptr 或 __guard_dispatch_icall_fptr |
Arm64EC | __os_arm64x_check_icall |
__os_arm64x_check_icall_cfg |
獨立於 ABI,啟用 CFG 的程式碼 (參考 CFG 呼叫檢查程式的程式碼),並不表示在執行階段進行 CFG 保護。 受 CFG 保護的二進位檔可以在不支援 CFG 的系統上執行:呼叫檢查程式會在編譯時使用 no-op 協助程式進行初始化。 處理程序也可能透過組態停用 CFG。 在先前的 API 上停用 CFG (或 OS 支援不存在) 時,OS 在載入二進位時,不會更新呼叫檢查程式。 在 Arm64EC 上,如果停用 CFG 保護,OS 會設定 __os_arm64x_check_icall_cfg
(與 __os_arm64x_check_icall
相同),這在所有情況下仍會提供所需的目標架構檢查,但不會提供 CFG 保護。
如同傳統 Arm64 中的 CFG,目標函式 (x11
) 的呼叫必須緊接在呼叫檢查程序之後。 呼叫檢查程式的位址必須放在易變性暫存器中,而且目標函式的位址也不得複製到另一個暫存器或溢出至記憶體。
堆疊檢查程式
編譯程式會在每次函式配置大於頁面的堆疊區域時,自動使用 __chkstk
。 若要避免略過保護堆疊結尾的堆疊防護頁面,請呼叫 __chkstk
以確保已配置區域中的所有頁面都經過探查。
__chkstk
通常是從函式的初構呼叫。 因為這個緣故,以及為了產生最佳程式碼,它會使用自訂呼叫慣例。
這表示 x64 程式碼和 Arm64EC 程式碼需要自己的相異 __chkstk
函式,因為 Entry 和 Exit thunk 會採用標準呼叫慣例。
x64 和 Arm64EC 共用相同的符號命名空間,因此不能有兩個名為 __chkstk
的函式。 為了配合與現有 x64 程式碼的相容性,__chkstk
名稱將會與 x64 堆疊檢查程式相關聯。 Arm64EC 程式碼會改用 __chkstk_arm64ec
。
__chkstk_arm64ec
的自訂呼叫慣例與傳統 Arm64 __chkstk
相同:x15
提供配置大小 (以位元組為單位) 除以 16。 會保留所有非易變性暫存器,以及標準呼叫慣例中所涉及的所有易變性暫存器。
上述關於 __chkstk
的一切同樣適用於 __security_check_cookie
及其 Arm64EC 對應項:__security_check_cookie_arm64ec
。
Variadic 呼叫慣例
Arm64EC 遵循傳統 Arm64 ABI 呼叫慣例,但 Variadic 函式除外 (也稱為 varargs、具省略號 (. . .) 參數關鍵字的函式)。
針對 variadic 特定案例,Arm64EC 遵循與 x64 variadic 非常類似的呼叫慣例,只有少數差異。 以下是 Arm64EC variadic 的主要規則:
- 只有前 4 個暫存器用於參數傳遞:
x0
、x1
、x2
、x3
。 剩餘的參數會溢出至堆疊。 這會完全遵循 x64 variadic 呼叫慣例,而且與使用暫存器x0
->x7
的 Arm64 Classic 不同。 - 暫存器所傳遞的浮點/SIMD 參數將會使用一般用途暫存器,而不是 SIMD。 這與 Arm64 Classic 類似,與 x64 不同,其中 FP/SIMD 參數會在一般用途和 SIMD 暫存器中傳遞。 例如,針對在 x64 上呼叫為
f1(int, double)
的函式f1(int, …)
,會將第二個參數指派給RDX
和XMM1
。 在 Arm64EC 上,第二個參數只會指派給x1
。 - 透過暫存器以值方式傳遞結構時,會套用 x64 大小規則:大小剛好為 1、2、4 和 8 的結構會直接載入一般用途暫存器。 具有其他大小的結構會溢出到堆疊上,而溢出位置的指標會指派給暫存器。 這基本上會在低層級將值降級為參考。 在傳統 Arm64 ABI 上,任何大小高達 16 個位元組的結構會直接指派給一般用途暫存器。
- X4 暫存器會使用透過堆疊傳遞的第一個參數指標載入 (第 5 個參數)。 這不包括因為上述大小限制而溢出的結構。
- X5 暫存器會載入堆疊所傳遞之所有參數的大小,以位元組為單位 (所有參數的大小,從第 5 個開始)。 這不包括因為上述大小限制而溢出的值傳遞結構。
在下列範例中:下列 pt_nova_function
採用非變數形式的參數,因此遵循傳統 Arm64 呼叫慣例。 然後,它會使用完全相同的參數呼叫 pt_va_function
,但改為在 variadic 呼叫中。
struct three_char {
char a;
char b;
char c;
};
void
pt_va_function (
double f,
...
);
void
pt_nova_function (
double f,
struct three_char tc,
__int64 ull1,
__int64 ull2,
__int64 ull3
)
{
pt_va_function(f, tc, ull1, ull2, ull3);
}
pt_nova_function
會採用 5 個參數,其會依照傳統 Arm64 呼叫慣例規則指派:
- 'f' 是雙精度浮點數。 它會指派給 d0。
- 'tc' 是結構,大小為 3 個字節。 它會指派給 x0。
- ull1 是 8 位元組整數。 它會指派給 x1。
- ull2 是 8 位元組整數。 它會指派給 x2。
- ull3 是 8 位元組整數。 它會指派給 x3。
pt_va_function
是 variadic 函式,因此會遵循上述的 Arm64EC variadic 規則:
- 'f' 是雙精度浮點數。 它會指派給 x0。
- 'tc' 是結構,大小為 3 個字節。 它會溢出到堆疊,而其位置會載入 x1。
- ull1 是 8 位元組整數。 它會指派給 x2。
- ull2 是 8 位元組整數。 它會指派給 x3。
- ull3 是 8 位元組整數。 它會直接指派給堆疊。
- x4 會載入堆疊中 ull3 位置。
- x5 會以 ull3 的大小載入。
以下顯示 pt_nova_function
的可能編譯輸出,其說明了上述參數指派差異。
stp fp,lr,[sp,#-0x30]!
mov fp,sp
sub sp,sp,#0x10
str x3,[sp] ; Spill 5th parameter
mov x3,x2 ; 4th parameter to x3 (from x2)
mov x2,x1 ; 3rd parameter to x2 (from x1)
str w0,[sp,#0x20] ; Spill 2nd parameter
add x1,sp,#0x20 ; Address of 2nd parameter to x1
fmov x0,d0 ; 1st parameter to x0 (from d0)
mov x4,sp ; Address of the 1st in-stack parameter to x4
mov x5,#8 ; Size of the in-stack parameter area
bl pt_va_function
add sp,sp,#0x10
ldp fp,lr,[sp],#0x30
ret
ABI 新增項目
為了達到與 x64 程式碼的透明互通性,已對傳統 Arm64 ABI 進行多項附加。 它們會處理 Arm64EC 與 x64 之間的呼叫慣例差異。
下列清單包含下列新增項目:
Entry 和 Exit Thunks
Entry 和 Exit Thunks 負責將 Arm64EC 呼叫慣例 (大部分與傳統 Arm64 相同) 轉譯為 x64 呼叫慣例,反之亦然。
常見的誤解是,呼叫慣例可以透過套用至所有函式簽章的單一規則來轉換。 實際情況是呼叫慣例具有參數指派規則。 這些規則取決於參數類型,與 ABI 不同。 結果是,API 之間的轉譯特定於每個函式簽章,並隨每個參數的類型而變化。
以下列函數為例:
int fJ(int a, int b, int c, int d);
參數指派如下:
- Arm64:a -> x0、b -> x1、c -> x2、d -> x3
- x64:a -> RCX、b -> RDX、c -> R8、d -> r9
- Arm64 -> x64 翻譯:x0 -> RCX、x1 -> RDX、x2 -> R8、x3 -> R9
現在請考量不同的函式:
int fK(int a, double b, int c, double d);
參數指派如下:
- Arm64:a -> x0、b -> d0、c -> x1、d -> d1
- x64:a -> RCX、b -> XMM1、c -> R8、d -> XMM3
- Arm64 -> x64 翻譯:x0 -> RCX、d0 -> XMM1、x1 -> R8、d1 -> XMM3
這些範例示範參數指派和轉譯會依類型而有所不同,但也取決於清單中的上述參數類型。 第 3 個參數會說明此詳細資料。 在這兩個函式中,參數的類型為 「int」,但產生的轉譯不同。
Entry 和 Exit Thunks 因這個原因而存在,並特別針對每一個別函式簽章量身打造。
這兩種類型的 Thunk 本身都是函式。 當 x64 函式呼叫 Arm64EC 函式時,模擬器會自動呼叫用 Entry Thunks (執行 Enters Arm64EC)。 當 Arm64EC 函式呼叫 x64 函式時,呼叫檢查程式會自動叫用 Exit Thunks (執行 Exits Arm64EC)。
編譯 Arm64EC 程式碼時,編譯程式會為每個 Arm64EC 函式產生一個 Entry Thunk,使其與簽章相符。 編譯程式也會針對 Arm64EC 函式呼叫的每個函式產生 Exit Thunk。
請考慮下列範例:
struct SC {
char a;
char b;
char c;
};
int fB(int a, double b, int i1, int i2, int i3);
int fC(int a, struct SC c, int i1, int i2, int i3);
int fA(int a, double b, struct SC c, int i1, int i2, int i3) {
return fB(a, b, i1, i2, i3) + fC(a, c, i1, i2, i3);
}
編譯上述以 Arm64EC 為目標的程式碼時,編譯程式會產生:
- 'fA' 的程序代碼。
- 'fA' 的專案 Thunk
- 結束 『fB』 的 Thunk
- 結束 『fC』 的 Thunk
fA
Entry Thunk 產生於案例 fA
,並從 x64 程式碼呼叫。 fB
和 fC
的 Exit Thunk 產生於案例 fB
和/或 fC
,結果是變成 x64 程式碼。
相同的 Exit Thunk 可能會產生多次,因為編譯器會在呼叫網站而不是函式本身產生該參數。 這可能會產生大量的備援 Thunk,因此實際上,編譯程式會套用極少的最佳化規則,確保只有必要的 Thunk 才能進入最終二進位檔。
例如,在 Arm64EC 函式 A
呼叫 Arm64EC 函式 B
的二進位檔中不會匯出 B
,而且在外部 A
永遠不會得知其位址。 從 A
到 B
,以及 B
的 Entry Thunk,清除 Entry Thunk 都是安全的。 即使已針對不同的函式產生 Exit 和 Entry Thunk,也可以安全地將包含相同程式碼的所有 Exit 和 Entry Thunk 一起加上別名。
Exit Thunks
使用上述範例函式 fA
和 fB
fC
,這就是編譯程式產生 fB
和 fC
Exit Thunks 的方式:
Exit Thunk 至 int fB(int a, double b, int i1, int i2, int i3);
$iexit_thunk$cdecl$i8$i8di8i8i8:
stp fp,lr,[sp,#-0x10]!
mov fp,sp
sub sp,sp,#0x30
adrp x8,__os_arm64x_dispatch_call_no_redirect
ldr xip0,[x8]
str x3,[sp,#0x20] ; Spill 5th param (i3) into the stack
fmov d1,d0 ; Move 2nd param (b) from d0 to XMM1 (x1)
mov x3,x2 ; Move 4th param (i2) from x2 to R9 (x3)
mov x2,x1 ; Move 3rd param (i1) from x1 to R8 (x2)
blr xip0 ; Call the emulator
mov x0,x8 ; Move return from RAX (x8) to x0
add sp,sp,#0x30
ldp fp,lr,[sp],#0x10
ret
Exit Thunk 至 int fC(int a, struct SC c, int i1, int i2, int i3);
$iexit_thunk$cdecl$i8$i8m3i8i8i8:
stp fp,lr,[sp,#-0x20]!
mov fp,sp
sub sp,sp,#0x30
adrp x8,__os_arm64x_dispatch_call_no_redirect
ldr xip0,[x8]
str w1,[sp,#0x40] ; Spill 2nd param (c) onto the stack
add x1,sp,#0x40 ; Make RDX (x1) point to the spilled 2nd param
str x4,[sp,#0x20] ; Spill 5th param (i3) into the stack
blr xip0 ; Call the emulator
mov x0,x8 ; Move return from RAX (x8) to x0
add sp,sp,#0x30
ldp fp,lr,[sp],#0x20
ret
在此情況下 fB
,我們可以看到 'double' 參數的存在如何導致剩餘的 GP 快取器指派重新洗牌、Arm64 和 x64 的不同指派規則的結果。 我們也可以看到 x64 只指派 4 個參數給暫存器,因此第 5 個參數必須溢出到堆疊。
在 fC
情況下,第二個參數是 3 位元組長度的結構。 Arm64 允許任何大小結構直接指派給暫存器。 x64 只允許尺寸 1、2、4 和 8。 然後,這個 Exit Thunk 必須將此 struct
從暫存器傳送到堆疊,並改為將指標指派給暫存器。 這仍然會取用一個暫存器 (以攜帶指標),因此它不會變更其餘暫存器的工作指派:第 3 和第 4 個參數不會發生暫存器重新調整。 就 fB
情況而言,第 5 個參數必須溢出至堆疊。
Exit Thunk 的其他注意事項:
- 編譯器不會根據它們翻譯>的函數名稱來為它們命名,而是根據它們所處理的簽章來為它們命名。 這可讓您更輕鬆地尋找備援。
- Exit Thunk 會使用具有目標 (x64) 函式址的暫存器
x9
來呼叫。 這是由呼叫檢查程式所設定,並透過 Exit Thunk 不受干擾地傳遞至模擬器。
重新排列參數之後,Exit Thunk 接著會透過 __os_arm64x_dispatch_call_no_redirect
呼叫模擬器。
此時,有必要回顧一下呼叫檢查程式的函式,以及有關其自訂 ABI 的詳細資訊。 這是間接呼叫 fB
的樣貌外觀:
mov x11, <target>
adrp x9, __os_arm64x_check_icall_cfg
ldr x9, [x9, __os_arm64x_check_icall_cfg]
adrp x10, $iexit_thunk$cdecl$i8$i8di8i8i8 ; fB function's exit thunk
add x10, x10, $iexit_thunk$cdecl$i8$i8di8i8i8
blr x9 ; check target function
blr x11 ; call function
呼叫此呼叫檢查程式時:
x11
提供要呼叫的目標函式位址 (在此案例中為fB
)。 此時可能不知道目標函數是 Arm64EC 還是 x64。x10
提供符合所呼叫函式簽章的 Exit Thunk (在此案例中為fB
)。
呼叫檢查程式傳回的資料將取決於目標函式為 Arm64EC 或 x64。
如果目標是 Arm64EC:
x11
會傳回要呼叫的 Arm64EC 程式碼位址。 這可能是或可能不是提供的相同值。
如果目標為 x64 程式碼:
x11
會傳回 Exit Thunk 的位址。 這會從x10
中提供的輸入複製。x10
會傳回 Exit Thunk 的位址,不受輸入干擾。x9
會傳回目標 x64 函式。 這可能是或可能不是透過x11
提供的相同值。
呼叫檢查程式一律會讓呼叫慣例參數暫存器保持未干擾,如此一來,呼叫程式碼應透過 blr x11
(或在 tail 呼叫時進行 br x11
) 立即遵循對呼叫檢查程式的呼叫。 這些是暫存器呼叫檢查程式。 它們總是會保留超過標準非易變性暫存器:x0
-x8
、x15
(chkstk
) 和 q0
-q7
。
Entry Thunks
Entry Thunks 負責從 x64 到 Arm64 呼叫慣例所需的轉換。 基本上,這是 Exit Thunks 的反向,但還有一些方面需要考慮。
請考慮先前的編譯 fA
範例,即會產生 Entry Thunk,以便由 x64 程式碼呼叫 fA
。
Entry Thunk for int fA(int a, double b, struct SC c, int i1, int i2, int i3)
$ientry_thunk$cdecl$i8$i8dm3i8i8i8:
stp q6,q7,[sp,#-0xA0]! ; Spill full non-volatile XMM registers
stp q8,q9,[sp,#0x20]
stp q10,q11,[sp,#0x40]
stp q12,q13,[sp,#0x60]
stp q14,q15,[sp,#0x80]
stp fp,lr,[sp,#-0x10]!
mov fp,sp
ldrh w1,[x2] ; Load 3rd param (c) bits [15..0] directly into x1
ldrb w8,[x2,#2] ; Load 3rd param (c) bits [16..23] into temp w8
bfi w1,w8,#0x10,#8 ; Merge 3rd param (c) bits [16..23] into x1
mov x2,x3 ; Move the 4th param (i1) from R9 (x3) to x2
fmov d0,d1 ; Move the 2nd param (b) from XMM1 (d1) to d0
ldp x3,x4,[x4,#0x20] ; Load the 5th (i2) and 6th (i3) params
; from the stack into x3 and x4 (using x4)
blr x9 ; Call the function (fA)
mov x8,x0 ; Move the return from x0 to x8 (RAX)
ldp fp,lr,[sp],#0x10
ldp q14,q15,[sp,#0x80] ; Restore full non-volatile XMM registers
ldp q12,q13,[sp,#0x60]
ldp q10,q11,[sp,#0x40]
ldp q8,q9,[sp,#0x20]
ldp q6,q7,[sp],#0xA0
adrp xip0,__os_arm64x_dispatch_ret
ldr xip0,[xip0,__os_arm64x_dispatch_ret]
br xip0
目標函式的位址是由 x9
中的模擬器提供。
呼叫 Entry Thunk 之前,x64 模擬器會從堆疊將傳回地址彈出至 LR
暫存器。 然後,當控制項傳送至 Entry Thunk 時,LR
會指向 x64 程式碼。
模擬器也可以根據下列情況對堆疊執行另一個調整:Arm64 和 x64 API 都會定義堆疊對齊需求,其中堆疊必須在呼叫函式時對齊 16 個位元組。 執行 Arm64 程式碼時,硬體會強制執行此規則,但不會為 x64 的硬體強制執行。 在執行 x64 程式碼時,錯誤地呼叫具有未對齊堆疊的函式可能會無限期地被忽視,直到使用某些 16 位元組的對齊指令 (某些 SSE 指令執行) 或呼叫 Arm64EC 程式碼為止。
若要解決此潛在的相容性問題,在呼叫 Entry Thunk 之前,模擬器總是會將堆疊指標對齊 16 位元組,並將其原始值存放在 x4
暫存器中。 如此一來,Entry Thunks 一律會以對齊的堆疊開始執行,但仍然可以透過 x4
正確地參考在堆疊上傳遞的參數。
在非易變性 SIMD 暫存器方面,Arm64 和 x64 呼叫慣例之間會有顯著差異。 在 Arm64 上,暫存器中低 8 個位元組 (64 位元) 會被視為非易變性。 換句話說,只有 Qn
暫存器的 Dn
部分是非易變性。 在 x64 上,暫存器 XMMn
的完整 16 位元組會被視為非易變性。 此外,在 x64 上,XMM6
和 XMM7
為非易變性暫存器,而 D6 和 D7 (對應的 Arm64 暫存器) 則為易變性。
若要解決這些 SIMD 暫存器操作不對稱的問題,Entry Thunks 必須明確儲存 x64 中被視為非易變性的所有 SIMD 暫存器。 這只有在 Entry Thunks (而非 Exit Thunks) 上才需要,因為 x64 比 Arm64 更嚴格。 換句話說,在 x64 中暫存器儲存/保留規則超過所有情況下的 Arm64 需求。
為了在回溯堆疊時解決這些暫存器值的正確復原問題 (例如 setjmp + longjmp 或 throw + catch),引進了新的回溯作業碼:save_any_reg (0xE7)
。 這個新的 3 位元組回溯 opcode 可讓您儲存任何一般用途或 SIMD 暫存器 (包括視為易變的暫存器),以及包含完整大小的 Qn
暫存器。 這個新的 opcode 用於上述 Qn
暫存器溢出/填入作業。 save_any_reg
與 save_next_pair (0xE6)
相容。
如需參考,以下是屬於上述 Entry Thunk 的對應展開資訊:
Prolog unwind:
06: E76689.. +0004 stp q6,q7,[sp,#-0xA0]! ; Actual=stp q6,q7,[sp,#-0xA0]!
05: E6...... +0008 stp q8,q9,[sp,#0x20] ; Actual=stp q8,q9,[sp,#0x20]
04: E6...... +000C stp q10,q11,[sp,#0x40] ; Actual=stp q10,q11,[sp,#0x40]
03: E6...... +0010 stp q12,q13,[sp,#0x60] ; Actual=stp q12,q13,[sp,#0x60]
02: E6...... +0014 stp q14,q15,[sp,#0x80] ; Actual=stp q14,q15,[sp,#0x80]
01: 81...... +0018 stp fp,lr,[sp,#-0x10]! ; Actual=stp fp,lr,[sp,#-0x10]!
00: E1...... +001C mov fp,sp ; Actual=mov fp,sp
+0020 (end sequence)
Epilog #1 unwind:
0B: 81...... +0044 ldp fp,lr,[sp],#0x10 ; Actual=ldp fp,lr,[sp],#0x10
0C: E74E88.. +0048 ldp q14,q15,[sp,#0x80] ; Actual=ldp q14,q15,[sp,#0x80]
0F: E74C86.. +004C ldp q12,q13,[sp,#0x60] ; Actual=ldp q12,q13,[sp,#0x60]
12: E74A84.. +0050 ldp q10,q11,[sp,#0x40] ; Actual=ldp q10,q11,[sp,#0x40]
15: E74882.. +0054 ldp q8,q9,[sp,#0x20] ; Actual=ldp q8,q9,[sp,#0x20]
18: E76689.. +0058 ldp q6,q7,[sp],#0xA0 ; Actual=ldp q6,q7,[sp],#0xA0
1C: E3...... +0060 nop ; Actual=90000030
1D: E3...... +0064 nop ; Actual=ldr xip0,[xip0,#8]
1E: E4...... +0068 end ; Actual=br xip0
+0070 (end sequence)
在 Arm64EC 函式傳回之後, __os_arm64x_dispatch_ret
常式會用於重新輸入模擬器,回到 x64 程式碼 (由 LR
指向)。
Arm64EC 函式在保留用於儲存執行階段資訊之函式中的第一個指令之前,具有 4 個位元組。 您可以在這 4 個位元組中找到函式之 Entry Thunk 的相對位址。 從 x64 函式對 Arm64EC 函式執行呼叫時,模擬器會在函式開頭之前讀取 4 個位元組、遮罩下兩個位元,並將該數量新增至函式的位址。 這會產生要呼叫之 Entry Thunk 的位址。
Adjustor Thunks
Adjustor Thunks 是無簽章函式,在對其中一個參數執行一些轉換之後,只需將控制權 (Tail 呼叫) 傳送至另一個函式即可。 正在轉換的參數類型已知,但其餘所有參數都可以是任何項目,而且在任何數位中 – Adjustor Thunks 不會觸碰任何可能持有參數且不會觸碰堆疊的暫存器。 這就是 Adjustor Thunks 無簽章函式的功能。
Adjustor Thunks 可由編譯程式自動產生。 這在 C++ 多重繼承中很常見,其中除了對 this
指標的調整之外,任何虛方法都可以不加修改地委託給父類。
以下是真實範例:
[thunk]:CObjectContext::Release`adjustor{8}':
sub x0,x0,#8
b CObjectContext::Release
thunk 會減去指標的 this
8 個位元組,並將呼叫轉送至父類別。
總而言之,可從 x64 函式呼叫的 Arm64EC 函式必須具有相關聯的 Entry Thunk。 Entry Thunk 是特定簽章。 Arm64 無簽章函式,例如 Adjustor Thunks 需要不同的機制來處理無簽章函式。
Adjustor Thunk 的 Entry Thunk 會使用 __os_arm64x_x64_jump
協助程式延遲實際 Entry Thunk 工作的執行 (將參數從一個慣例調整到另一個慣例) 到下一個呼叫。 此時,簽章就變得明顯了。 如果 Adjustor Thunk 的目標變成 x64 函式,則這包括完全不執行呼叫慣例調整的選項。 請記住,在 Entry Thunk 開始執行時,參數會以 x64 形式顯示。
在上述範例中,請考慮程式碼在 Arm64EC 中的外觀。
Arm64EC 中的 Adjustor Thunk
[thunk]:CObjectContext::Release`adjustor{8}':
sub x0,x0,#8
adrp x9,CObjectContext::Release
add x11,x9,CObjectContext::Release
stp fp,lr,[sp,#-0x10]!
mov fp,sp
adrp xip0, __os_arm64x_check_icall
ldr xip0,[xip0, __os_arm64x_check_icall]
blr xip0
ldp fp,lr,[sp],#0x10
br x11
調整器 Thunk 的進入主幹
[thunk]:CObjectContext::Release$entry_thunk`adjustor{8}':
sub x0,x0,#8
adrp x9,CObjectContext::Release
add x9,x9,CObjectContext::Release
adrp xip0,__os_arm64x_x64_jump
ldr xip0,[xip0,__os_arm64x_x64_jump]
br xip0
向前快轉序列
某些應用程式會對駐留在它們不擁有但依賴的二進位檔案 (通常是作業系統二進位檔) 中的函數進行執行時間修改,以便在呼叫函數時可以繞過執行。 這也稱為掛勾。
概括而言,掛勾程序很簡單。 不過,在掛勾邏輯必須解決的可能變化情況下,掛勾是架構特定的,而且相當複雜。
一般而言,此處理序涉及下列項目:
- 判斷要掛勾之函式的位址。
- 將函式的第一個指令取代為跳至掛勾常式。
- 當完成掛勾時,請回到原始邏輯,包括執行已取代的原始指令。
變化來自下列項目:
- 第 1 個指令的大小:最好將它取代為相同大小或較小的 JMP,以避免取代函式的頂端,而其他執行緒可能會在發行小眾測試版中執行。
- 第一個指令的類型:如果第一個指令具有一些電腦相對的性質,則重新放置可能需要變更位移欄位之類的項目。 由於當指令移至遙遠的地方時,它們可能會溢位,因此可能需要提供具有不同指令的對等邏輯。
由於所有這些複雜度,很難找到強固和通用的掛勾邏輯。 應用程式中存在的邏輯通常只能應付應用程式預期在感興趣的特定 API 中遇到的一組有限案例。 不難想像應用程式相容性問題有多大。 如果程式碼看起來不再完全符合預期,即使是程式碼或編譯器最佳化的簡單變更也可能導致應用程式無法使用。
如果在設定勾點時遇到 Arm64 程式碼,這些應用程式會發生什麼事? 他們肯定會失敗。
向前快轉序列 (FFS) 函式可解決 Arm64EC 中的這項相容性需求。
FFS 非常小型的 x64 函式,不包含實際邏輯和實際 Arm64EC 函式的尾部呼叫。 它們是選用的,但預設針對所有 DLL 匯出和以 __declspec(hybrid_patchable)
修飾的任何函式啟用。
在這些情況下,當程式碼取得指定函式的指標時,不論是透過匯出案例中的 GetProcAddress
,或 __declspec(hybrid_patchable)
案例中的 &function
,產生的位址將會包含 x64 程式碼。 該 x64 程式碼會針對合法的 x64 函式傳遞,以滿足目前可用的大部分掛勾邏輯。
請思考下列範例 (為簡潔起見而省略了錯誤處理):
auto module_handle =
GetModuleHandleW(L"api-ms-win-core-processthreads-l1-1-7.dll");
auto pgma =
(decltype(&GetMachineTypeAttributes))
GetProcAddress(module_handle, "GetMachineTypeAttributes");
hr = (*pgma)(IMAGE_FILE_MACHINE_Arm64, &MachineAttributes);
變數中的 pgma
函式指標值將包含 『s FFS 的 GetMachineTypeAttributes
位址。
這是向前快轉序列的範例:
kernelbase!EXP+#GetMachineTypeAttributes:
00000001`800034e0 488bc4 mov rax,rsp
00000001`800034e3 48895820 mov qword ptr [rax+20h],rbx
00000001`800034e7 55 push rbp
00000001`800034e8 5d pop rbp
00000001`800034e9 e922032400 jmp 00000001`80243810
FFS x64 函式具有標準初構和終解,結尾為 Arm64EC 程式碼中實際 GetMachineTypeAttributes
函式的 tail 呼叫 (jump):
kernelbase!GetMachineTypeAttributes:
00000001`80243810 d503237f pacibsp
00000001`80243814 a9bc7bfd stp fp,lr,[sp,#-0x40]!
00000001`80243818 a90153f3 stp x19,x20,[sp,#0x10]
00000001`8024381c a9025bf5 stp x21,x22,[sp,#0x20]
00000001`80243820 f9001bf9 str x25,[sp,#0x30]
00000001`80243824 910003fd mov fp,sp
00000001`80243828 97fbe65e bl kernelbase!#__security_push_cookie
00000001`8024382c d10083ff sub sp,sp,#0x20
[...]
如果需要在兩個 Arm64EC 函式之間執行 5 個模擬的 x64 指令,這會相當沒有效率。 FFS 函式很特殊。 如果 FFS 函式維持不變,則不會真正執行。 呼叫檢查程式協助程式會有效率地檢查 FFS 是否已變更。 如果是這種情況,則會將呼叫直接傳輸至實際目的地。 如果 FFS 已以任何可能的方式變更,便不再是 FFS。 執行會傳送至已改變的 FFS,並執行那裡的任何程式碼,模擬繞道和任何掛勾邏輯。
當掛勾將執行傳回 FFS 的結尾時,最終會到達 Arm64EC 程式碼的 tail 呼叫,然後會在掛勾之後執行,就像應用程式預期的那樣。
在組件中撰寫 Arm64EC
Windows SDK 標頭和 C 編譯程式可以簡化撰寫 Arm64EC 組件的作業。 例如,C 編譯程式可用來針對未從 C 程式碼編譯的函式產生 Entry 和 Exit Thunk。
請考慮相當於下列 fD
函式的範例,該函式必須在組件 (ASM)中撰寫。 Arm64EC 和 x64 程式碼都可以呼叫此函式,而且 pfE
函式指標也可以指向 Arm64EC 或 x64 程式碼。
typedef int (PF_E)(int, double);
extern PF_E * pfE;
int fD(int i, double d) {
return (*pfE)(i, d);
}
以 ASM 撰寫 fD
的樣貌如下:
#include "ksarm64.h"
IMPORT __os_arm64x_check_icall_cfg
IMPORT |$iexit_thunk$cdecl$i8$i8d|
IMPORT pfE
NESTED_ENTRY_COMDAT A64NAME(fD)
PROLOG_SAVE_REG_PAIR fp, lr, #-16!
adrp x11, pfE ; Get the global function
ldr x11, [x11, pfE] ; pointer pfE
adrp x9, __os_arm64x_check_icall_cfg ; Get the EC call checker
ldr x9, [x9, __os_arm64x_check_icall_cfg] ; with CFG
adrp x10, |$iexit_thunk$cdecl$i8$i8d| ; Get the Exit Thunk for
add x10, x10, |$iexit_thunk$cdecl$i8$i8d| ; int f(int, double);
blr x9 ; Invoke the call checker
blr x11 ; Invoke the function
EPILOG_RESTORE_REG_PAIR fp, lr, #16!
EPILOG_RETURN
NESTED_END
end
在上述範例中:
- Arm64EC 使用與 Arm64 相同的處理序宣告和初構/終解巨集。
- 函式名稱應該由
A64NAME
巨集包裝。 將 C/C++ 程式碼編譯為 Arm64EC 時,編譯程式會將OBJ
標示為包含 Arm64EC 程式碼的ARM64EC
。ARMASM
不會出現這種情況。 編譯 ASM 程式碼時,有替代方式可通知連結器產生的程式碼是 Arm64EC。 這是在函式名稱前面加上#
。A64NAME
巨集會在定義_ARM64EC_
時 執行這項作業,並在未定義_ARM64EC_
時保留名稱不變。 這可讓您在 Arm64 與 Arm64EC 之間共用原始程式碼。 - 如果目標函數是 x64,則
pfE
函式指標必須先透過 EC 呼叫檢查程式與適當的 Exit Thunk 一起執行。
產生 Entry 和 Exit Thunk
下一個步驟是針對 fD
產生 Entry Thunk,並為 pfE
產生 Exit Thunk。 C 編譯程式可以使用 _Arm64XGenerateThunk
編譯程式關鍵字, 以最少付出來執行這項工作。
void _Arm64XGenerateThunk(int);
int fD2(int i, double d) {
UNREFERENCED_PARAMETER(i);
UNREFERENCED_PARAMETER(d);
_Arm64XGenerateThunk(2);
return 0;
}
int fE(int i, double d) {
UNREFERENCED_PARAMETER(i);
UNREFERENCED_PARAMETER(d);
_Arm64XGenerateThunk(1);
return 0;
}
_Arm64XGenerateThunk
關鍵字會指示 C 編譯程式使用函式簽章、忽略本文,並且產生 Exit Thunk (當參數為 1) 或 Entry Thunk (參數為 2)。
建議將 Thunk 產生放在自己的 C 檔中。 在隔離的檔案中,藉由傾印對應的 OBJ
符號甚至是反組譯碼,讓確認符號名稱更為簡單。
自訂 Entry Thunk
巨集已新增至 SDK,以協助撰寫自訂、手動編碼 Entry Thunk。 其中一個可以使用的情況是撰寫自訂 Adjustor Thunk。
大部分的 Adjustor Thunk 都是由 C++ 編譯程式產生,但也可以手動產生。 在泛型回呼將控制項傳輸到其中一個參數所識別的實際回呼的情況下,即可找到此項。
以下是 Arm64 Classic 程式碼中的範例:
NESTED_ENTRY MyAdjustorThunk
PROLOG_SAVE_REG_PAIR fp, lr, #-16!
ldr x15, [x0, 0x18]
adrp x16, __guard_check_icall_fptr
ldr x16, [x16, __guard_check_icall_fptr]
blr xip0
EPILOG_RESTORE_REG_PAIR fp, lr, #16
EPILOG_END br x15
NESTED_END
在此範例中,目標函式位址是透過第 1 個參數從透過參照所提供的結構元素中擷取的。 因為結構是可寫入的,所以必須透過控制流程防護 (CFG) 驗證目標位址。
下列範例示範移植到 Arm64EC 時,對等的 Adjustor Thunk 的樣貌:
NESTED_ENTRY_COMDAT A64NAME(MyAdjustorThunk)
PROLOG_SAVE_REG_PAIR fp, lr, #-16!
ldr x11, [x0, 0x18]
adrp xip0, __os_arm64x_check_icall_cfg
ldr xip0, [xip0, __os_arm64x_check_icall_cfg]
blr xip0
EPILOG_RESTORE_REG_PAIR fp, lr, #16
EPILOG_END br x11
NESTED_END
上述程式碼不提供 Exit Thunk (在 register x10 中)。 這是不可能的,因為程式碼可以針對許多不同的簽章執行。 此程式碼會利用呼叫端將 x10 設定為 Exit Thunk。 呼叫端會讓呼叫以明確簽章為目標。
上述程式碼需要 Entry Thunk 來處理呼叫端為 x64 程式碼時的案例。 這是使用自訂 Entry Thunks 的巨集來撰寫對應的 Entry Thunk 的方式:
ARM64EC_CUSTOM_ENTRY_THUNK A64NAME(MyAdjustorThunk)
ldr x9, [x0, 0x18]
adrp xip0, __os_arm64x_x64_jump
ldr xip0, [xip0, __os_arm64x_x64_jump]
br xip0
LEAF_END
與其他函式不同,此 Entry Thunk 最終不會將控制權傳輸至相關聯的函式 (Adjustor Thunk)。 在此情況下,功能本身 (執行參數調整) 會內嵌至 Entry Thunk,而控制項會透過 __os_arm64x_x64_jump
協助程式直接傳送至結尾目標。
動態產生 (JIT 編譯) Arm64EC 程式碼
在 Arm64EC 程式中,有兩種類型的可執行記憶體:Arm64EC 程式碼和 x64 程式碼。
作業系統會從載入的二進位檔擷取這項資訊。 x64 二進位檔全都是 x64,Arm64EC 包含 Arm64EC 與 x64 程式碼頁的範圍資料表。
動態產生的程式碼會如何? Just-in-time (JIT) 編譯程式會在執行階段產生程式碼,而不會受到任何二進位檔的支援。
這通常表示:
- 配置可寫入記憶體 (
VirtualAlloc
)。 - 將程式碼產生至配置的記憶體。
- 將記憶體從讀取寫入重新保護為讀取執行 (
VirtualProtect
)。 - 針對所有重要 (非分葉) 產生的函式 (
RtlAddFunctionTable
或RtlAddGrowableFunctionTable
) 新增展開函式項目。
對於微不足道的相容性原因,任何在 Arm64EC 程式中執行這些步驟的應用程式,都會導致程式碼被視為 x64 程式碼。 使用未修改的 x64 Java 執行階段、.NET 執行階段、JavaScript 引擎等任何處理序都會發生這種情況。
若要產生 Arm64EC 動態程式碼,此處理序大多相同,只有兩個差異:
- 配置記憶體時,請使用較新的
VirtualAlloc2
(而不是VirtualAlloc
或VirtualAllocEx
),並提供MEM_EXTENDED_PARAMETER_EC_CODE
屬性。 - 新增函式項目時:
- 它們必須是 Arm64 形式。 編譯 Arm64EC 程式碼時,
RUNTIME_FUNCTION
類型符合 x64 形式。 對於編譯 Arm64EC 時的 Arm64 形式,請改用ARM64_RUNTIME_FUNCTION
類型。 - 請勿使用較舊的
RtlAddFunctionTable
API。 請改用較新的RtlAddGrowableFunctionTable
API。
- 它們必須是 Arm64 形式。 編譯 Arm64EC 程式碼時,
以下是記憶體配置的範例:
MEM_EXTENDED_PARAMETER Parameter = { 0 };
Parameter.Type = MemExtendedParameterAttributeFlags;
Parameter.ULong64 = MEM_EXTENDED_PARAMETER_EC_CODE;
HANDLE process = GetCurrentProcess();
ULONG allocationType = MEM_RESERVE;
DWORD protection = PAGE_EXECUTE_READ | PAGE_TARGETS_INVALID;
address = VirtualAlloc2 (
process,
NULL,
numBytesToAllocate,
allocationType,
protection,
&Parameter,
1);
以及新增一個展開函式項目的範例:
ARM64_RUNTIME_FUNCTION FunctionTable[1];
FunctionTable[0].BeginAddress = 0;
FunctionTable[0].Flags = PdataPackedUnwindFunction;
FunctionTable[0].FunctionLength = nSize / 4;
FunctionTable[0].RegF = 0; // no D regs saved
FunctionTable[0].RegI = 0; // no X regs saved beyond fp,lr
FunctionTable[0].H = 0; // no home for x0-x7
FunctionTable[0].CR = PdataCrChained; // stp fp,lr,[sp,#-0x10]!
// mov fp,sp
FunctionTable[0].FrameSize = 1; // 16 / 16 = 1
this->DynamicTable = NULL;
Result == RtlAddGrowableFunctionTable(
&this->DynamicTable,
reinterpret_cast<PRUNTIME_FUNCTION>(FunctionTable),
1,
1,
reinterpret_cast<ULONG_PTR>(pBegin),
reinterpret_cast<ULONG_PTR>(reinterpret_cast<PBYTE>(pBegin) + nSize)
);