Nuta
Dostęp do tej strony wymaga autoryzacji. Możesz spróbować się zalogować lub zmienić katalog.
Dostęp do tej strony wymaga autoryzacji. Możesz spróbować zmienić katalogi.
Arm64EC ("Emulacja zgodna") to nowy interfejs binarny aplikacji (ABI) do tworzenia aplikacji dla systemu Windows 11 na Arm. Aby zapoznać się z omówieniem usługi Arm64EC i sposobem rozpoczęcia tworzenia aplikacji Win32 jako arm64EC, zobacz Korzystanie z usługi Arm64EC do tworzenia aplikacji dla systemu Windows 11 na urządzeniach arm.
Ten artykuł zawiera szczegółowy obraz ABI Arm64EC, oferując wystarczającą ilość informacji dla dewelopera aplikacji, który chce pisać i debugować kod skompilowany dla Arm64EC, w tym debugowanie na poziomie niskiego poziomu/asemblerskie oraz tworzenie kodu asemblerowego skierowanego na ABI Arm64EC.
Projektowanie Arm64EC
Arm64EC zapewnia funkcje i wydajność na poziomie natywnym, zachowując przezroczystą i bezpośrednią interoperacyjność z kodem x64 pracującym pod emulacją.
Arm64EC jest głównie dodatkiem do klasycznego arm64 ABI. Klasyczny ABI niewiele się zmienił, ale arm64EC ABI wprowadził elementy, aby umożliwić współdziałanie z x64.
W tym dokumencie oryginalny standard Arm64 ABI jest określany jako "Klasyczny ABI". Ten termin pozwala uniknąć niejednoznaczności związanej z przeciążonym terminem, takim jak "Native". Arm64EC jest równie natywny jak oryginalny ABI.
Arm64EC a Arm64 Classic ABI
Poniższa lista wskazuje, gdzie arm64EC różni się od arm64 Classic ABI.
- Mapowanie rejestrów i zablokowane rejestry
- kontrolerzy połączeń
- sprawdzania stosu
- Variadyczna konwencja wywoływania
Te różnice są małymi zmianami w kontekście tego, jak wiele definiuje cała ABI.
Mapowanie rejestrów i zablokowane rejestry
Aby umożliwić współdziałanie na poziomie typu z kodem x64, kod Arm64EC kompiluje się z tymi samymi definicjami architektury preprocesora co kod x64.
Innymi słowy, _M_AMD64 i _AMD64_ są zdefiniowane. Jednym z typów, których dotyczy ta reguła, jest struktura CONTEXT. Struktura CONTEXT definiuje stan procesora CPU w danym momencie. Są używane w przypadku takich elementów jak interfejsy API Exception Handling i GetThreadContext. Istniejący kod x64 oczekuje, że kontekst CPU będzie reprezentowany jako struktura x64 CONTEXT lub, innymi słowy, struktura CONTEXT zdefiniowana podczas kompilacji x64.
Należy użyć tej struktury, aby reprezentować kontekst procesora CPU podczas wykonywania kodu x64 i kodu Arm64EC. Istniejący kod nie rozumie nowej koncepcji, takiej jak zestaw rejestrów procesora CPU zmieniający się z funkcji na funkcję. Jeśli używasz struktury x64 do reprezentowania stanów wykonywania Arm64, skutecznie odwzorowujesz rejestry Arm64 na rejestry x64.
To mapowanie oznacza również, że nie można używać żadnych rejestrów Arm64, które nie mieszczą się w architekturze x64 CONTEXT. Wartości mogą zostać utracone w dowolnym momencie, gdy operacja używa CONTEXT (a niektóre operacje mogą być asynchroniczne i nieoczekiwane, takie jak operacja Garbage Collection środowiska uruchomieniowego języka zarządzanego lub APC).
Nagłówki systemu Windows w zestawie SDK reprezentują reguły mapowania między rejestrami Arm64EC i x64 przy użyciu struktury ARM64EC_NT_CONTEXT. Ta struktura jest zasadniczo unią struktury CONTEXT, dokładnie taką, jak jest zdefiniowana dla x64, ale z dodatkową nakładką rejestru Arm64.
Na przykład RCX na X0, RDX na X1, RSP na SP, RIP na PC, i tak dalej. Rejestry x13, , x14, x23x24, x28, i v16 za pośrednictwem v31 nie mają reprezentacji i w związku z tym nie można ich używać w arm64EC.
To ograniczenie użycia rejestru jest pierwszą różnicą między Arm64 Classic oraz EC ABIs.
Kontrolery połączeń
Sprawdzacze wywołań są częścią systemu Windows od czasu wprowadzenia Control Flow Guard (CFG) w systemie Windows 8.1. Kontrolery wywołań to środki odczyszające adresy dla wskaźników funkcji (zanim te elementy zostały nazwane sanitizerami adresowymi). Za każdym razem, gdy kompilujesz kod z opcją /guard:cf, kompilator generuje dodatkowe wywołanie funkcji sprawdzania tuż przed każdym wywołaniem pośrednim lub przeskokiem. System Windows udostępnia samą funkcję sprawdzania. W przypadku CFG przeprowadza sprawdzanie poprawności względem uznanych za dobre celów wywołań. Pliki binarne skompilowane przy /guard:cf użyciu zawierają również te informacje.
W tym przykładzie pokazano użycie narzędzia do sprawdzania wywołań w klasycznym rozwiązaniu 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
W przypadku CFG kontroler wywołań po prostu zwraca status, jeśli element docelowy jest prawidłowy, lub natychmiast powoduje niepowodzenie procesu, jeśli nie jest. Sprawdzacze połączeń mają niestandardowe konwencje wywoływania. Pobierają wskaźnik funkcji w rejestrze, który nie jest używany przez normalną konwencję wywoływania i zachowują wszystkie normalne rejestry konwencji wywoływania. W ten sposób nie powodują wycieków rejestru wokół siebie.
Moduły sprawdzania wywołań są opcjonalne we wszystkich innych interfejsach API systemu Windows, ale obowiązkowe w usłudze Arm64EC. W Arm64EC sprawdzacze wywołań przejmują zadanie weryfikacji architektury wywoływanej funkcji. Sprawdzają, czy wywołanie jest inną funkcją EC ("Zgodna z emulacją") lub funkcją x64, która musi być wykonywana w ramach emulacji. W wielu przypadkach można to zweryfikować tylko w czasie wykonywania.
Moduły sprawdzania wywołań Arm64EC opierają się na istniejących modułach sprawdzania arm64, ale mają nieco inną niestandardową konwencję wywoływania. Przyjmują dodatkowy parametr i mogą modyfikować rejestr zawierający adres docelowy. Jeśli na przykład element docelowy to kod x64, najpierw należy przetransferować kontrolkę do logiki szkieletu emulacji.
W środowisku Arm64EC użycie narzędzia sprawdzania wywołań stałoby się następujące:
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
Niewielkie różnice w porównaniu z klasycznym arm64 obejmują:
- Nazwa symbolu dla kontrolera połączeń jest inna.
- Adres docelowy jest dostarczany
x11zamiastx15. - Adres docelowy (
x11) to[in, out]zamiast[in]. - Istnieje dodatkowy parametr przekazywany za pośrednictwem
x10, zwany "Exit Thunk".
Exit Thunk to funclet, który przekształca parametry funkcji z konwencji wywoływania Arm64EC na konwencję wywoływania x64.
Kontroler wywołań Arm64EC znajduje się za pomocą innego symbolu niż jest używany dla innych interfejsów API w systemie Windows. W klasycznym ABI dla Arm64 symbolem dla sprawdzania wywołań jest __guard_check_icall_fptr. Ten symbol będzie obecny w architekturze Arm64EC, ale jest dostępny dla statycznie połączonego kodu x64, a nie samego kodu Arm64EC. Kod Arm64EC będzie używać metody __os_arm64x_check_icall lub __os_arm64x_check_icall_cfg.
W usłudze Arm64EC moduły sprawdzania wywołań nie są opcjonalne. Jednak CFG jest nadal opcjonalny, podobnie jak w przypadku innych ABI. CfG może być wyłączona w czasie kompilacji lub może istnieć uzasadniony powód, aby nie wykonać sprawdzania CFG nawet wtedy, gdy cfG jest włączony (np. wskaźnik funkcji nigdy nie znajduje się w pamięci RW). W przypadku wywołania pośredniego z kontrolą CFG należy użyć sprawdzarki __os_arm64x_check_icall_cfg. Jeśli CFG jest wyłączone lub niepotrzebne, należy zamiast tego użyć __os_arm64x_check_icall.
Poniżej znajduje się tabela podsumowania użycia narzędzia do sprawdzania wywołań w klasycznym architekturze Arm64, x64 i Arm64EC, zwracając uwagę na fakt, że plik binarny Arm64EC może mieć dwie opcje w zależności od architektury kodu.
| Dwójkowy | Code | Niechronione wywołanie pośrednie | Wywołanie pośrednie zabezpieczone przez CFG |
|---|---|---|---|
| x64 | x64 | brak sprawdzania połączeń |
__guard_check_icall_fptr lub __guard_dispatch_icall_fptr |
| Arm64 Classic | Arm64 | brak sprawdzania połączeń | __guard_check_icall_fptr |
| Arm64EC | x64 | brak sprawdzania połączeń |
__guard_check_icall_fptr lub __guard_dispatch_icall_fptr |
| Arm64EC | __os_arm64x_check_icall |
__os_arm64x_check_icall_cfg |
Niezależnie od ABI, posiadanie kodu z włączoną funkcją CFG (kod z odwołaniem do kontrolek wywołań CFG) nie oznacza ochrony przez CFG w czasie wykonywania. Pliki binarne chronione przez usługę CFG mogą działać w dół, w systemach, które nie obsługują usługi CFG: moduł sprawdzania wywołań jest inicjowany za pomocą pomocnika no-op w czasie kompilacji. Proces może mieć również wyłączoną funkcję CFG przez konfigurację. Jeśli usługa CFG jest wyłączona (lub obsługa systemu operacyjnego nie istnieje) w poprzednich interfejsach API system operacyjny po prostu nie zaktualizuje narzędzia sprawdzania wywołań po załadowaniu pliku binarnego. W Arm64EC, jeśli ochrona CFG jest wyłączona, system operacyjny ustawi __os_arm64x_check_icall_cfg tak samo jak __os_arm64x_check_icall, co nadal zapewni wymaganą kontrolę docelowej architektury we wszystkich przypadkach, ale nie ochronę CFG.
Podobnie jak w przypadku CFG w klasycznym Arm64, wywołanie funkcji docelowej (x11) musi natychmiast następować po wywołaniu funkcji Call Checker. Adres modułu Call Checker musi zostać umieszczony w rejestrze nietrwałym, i ani on, ani adres funkcji docelowej, nigdy nie powinny być kopiowane do innego rejestru lub zapisywane do pamięci.
Kontrolery stosu
__chkstk jest używany automatycznie przez kompilator za każdym razem, gdy funkcja przydziela obszar na stosie większym niż strona. Aby uniknąć pomijania strony stosu chroniącej koniec stosu, __chkstk jest wywoływane, aby upewnić się, że wszystkie strony w przydzielonym obszarze są sprawdzane.
__chkstk jest zwykle wywoływana z prologu funkcji. Z tego powodu, dla optymalnego generowania kodu, używa niestandardowej konwencji wywoływania.
Oznacza to, że kod x64 i kod Arm64EC potrzebują własnych, odrębnych, __chkstk funkcji, ponieważ thunks Entry i Exit zakładają standardowe konwencje wywoływania.
Przestrzeń nazw symboli x64 i Arm64EC jest współdzielona, więc nie można mieć dwóch funkcji o nazwie __chkstk. Aby umożliwić zgodność ze wstępnie istniejącym kodem x64, nazwa __chkstk zostanie skojarzona z narzędziem sprawdzania stosu x64. Zamiast tego zostanie użyty __chkstk_arm64ec kod Arm64EC.
Niestandardowa konwencja wywoływania dla __chkstk_arm64ec jest taka sama jak w przypadku klasycznego Arm64 __chkstk: x15 podaje rozmiar alokacji w bajtach, podzielony przez 16. Nieulotne i ulotne rejestry zaangażowane w standardową konwencję wywoływania są zachowywane.
Wszystko, co powiedziano powyżej o __chkstk, ma zastosowanie również do __security_check_cookie oraz jego odpowiednika Arm64EC: __security_check_cookie_arm64ec.
Wariadyczna konwencja wywołania
Arm64EC przestrzega klasycznej konwencji wywołania ABI Arm64, z wyjątkiem funkcji wariadycznych (nazywanych również varargs lub funkcjami ze słowem kluczowym parametru wielokropka (. . .).
W przypadku wariadycznym Arm64EC stosuje konwencję wywoławczą bardzo podobną do variadycznej x64, z jedynie kilkoma różnicami. Na poniższej liście przedstawiono główne reguły dla Arm64EC variadic:
- Tylko pierwsze cztery rejestry są używane do przekazywania parametrów:
x0, ,x1x2, .x3Pozostałe parametry są przenoszone na stos. Ta reguła dokładnie przestrzega konwencji wywoływania variadycznego x64 i różni się od konwencji Arm64 Classic, gdzie używane są rejestryx0przezx7. - Parametry zmiennoprzecinkowe i SIMD przekazywane przez rejestr używają rejestru ogólnego przeznaczenia, a nie SIMD. Ta reguła jest podobna do Arm64 Classic i różni się od x64, gdzie parametry FP/SIMD są przekazywane zarówno w rejestrze ogólnego przeznaczenia, jak i w rejestrze SIMD. Na przykład w przypadku funkcji
f1(int, …)o nazwie ,f1(int, double)na x64 drugi parametr jest przypisywany do parametrówRDXiXMM1. Na Arm64EC, drugi parametr jest przypisany tylko dox1. - Podczas przekazywania struktur według wartości w rejestrze obowiązują reguły rozmiaru x64: Struktury o rozmiarach dokładnie 1, 2, 4 i 8 bajtów są ładowane bezpośrednio do rejestru ogólnego przeznaczenia. Struktury o innych rozmiarach rozlewają się na stos, a wskaźnik do rozlanej lokalizacji jest przypisywany do rejestru. Ta reguła zasadniczo obniża wartość by-reference na niskim poziomie. W klasycznym ABI Arm64 struktury o dowolnym rozmiarze do 16 bajtów są przypisywane bezpośrednio do rejestrów ogólnego przeznaczenia.
- Rejestr
x4ładuje wskaźnik do pierwszego parametru przekazanego za pośrednictwem stosu (piąty parametr). Ta reguła nie obejmuje rozlanych struktur ze względu na ograniczenia rozmiaru opisane wcześniej. - Rejestr
x5ładuje rozmiar w bajtach wszystkich parametrów przekazywanych przez stos (rozmiar wszystkich parametrów, począwszy od piątego). Ta reguła nie obejmuje struktur przekazywanych przez wartość rozlaną z powodu ograniczeń rozmiaru opisanych wcześniej.
W poniższym przykładzie pt_nova_function przyjmuje parametry w postaci niezmiennej, więc jest zgodne z konwencją wywoływania klasycznego Arm64. Następnie wywołuje pt_va_function z dokładnie tymi samymi parametrami, ale w wywołaniu wariadzkim.
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 Przyjmuje pięć parametrów, które przypisuje zgodnie z klasycznymi regułami konwencji wywoływania arm64:
- "f" jest podwójne. Przypisuje do
d0. - "tc" to struktura o rozmiarze 3 bajtów. Jest przypisywane do
x0. -
ull1jest 8-bajtową liczbą całkowitą. Jest przypisywane dox1. -
ull2jest 8-bajtową liczbą całkowitą. Jest przypisywane dox2. -
ull3jest 8-bajtową liczbą całkowitą. Jest przypisywane dox3.
pt_va_function jest funkcją wariadyczną, więc jest zgodna z regułami variadic arm64EC opisanymi wcześniej:
- "f" jest podwójne. Jest przypisywane do
x0. - "tc" to struktura o rozmiarze 3 bajtów. Rozla się na stos, a jego lokalizacja ładuje się do
x1. -
ull1jest 8-bajtową liczbą całkowitą. Jest przypisywane dox2. -
ull2jest 8-bajtową liczbą całkowitą. Jest przypisywane dox3. -
ull3jest 8-bajtową liczbą całkowitą. Przypisuje bezpośrednio do stosu. -
x4ładuje lokalizacjęull3elementu w stosie. -
x5ładuje rozmiarull3.
W poniższym przykładzie przedstawiono możliwe dane wyjściowe kompilacji dla pt_nova_functionelementu , które ilustrują opisane wcześniej różnice przypisania parametrów.
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
Dodatki ABI
Aby osiągnąć przezroczystą współdziałanie z kodem x64, należy wprowadzić wiele dodatków do klasycznego interfejsu ABI Arm64. Te dodatki obsługują różnice w konwencjach wywoływania między ARM64EC i x64.
Poniższa lista zawiera następujące dodatki:
- Procedury wejścia i wyjścia
- Wyjściowe Thunks
- Thunks Wejściowe
- Wskaźniki Ajustujące
- Fast-Forward Sekwencje
Wejście i wyjście thunks
Wejścia i wyjścia thunki tłumaczą konwencję wywoływania Arm64EC, która jest głównie taka sama jak klasyczna Arm64, na konwencję wywoływania x64 i odwrotnie.
Typowe błędne przekonanie polega na tym, że można zmieniać konwencje wywołań, postępując zgodnie z pojedynczą regułą zastosowaną do wszystkich sygnatur funkcji. W rzeczywistości konwencje wywoływania mają reguły przypisywania parametrów. Te reguły zależą od typu parametru i różnią się od ABI do ABI. Konsekwencją jest to, że tłumaczenie między interfejsami binarnymi aplikacji (ABI) jest uzależnione od każdego podpisu funkcji i różni się w zależności od typu poszczególnych parametrów.
Rozważmy następującą funkcję:
int fJ(int a, int b, int c, int d);
Przypisanie parametru odbywa się w następujący sposób:
- Arm64: a -> x0, b -> x1, c -> x2, d -> x3
- x64: a -> RCX, b -> RDX, c -> R8, d -> r9
- Tłumaczenie arm64 -> x64: x0 -> RCX, x1 -> RDX, x2 -> R8, x3 -> R9
Teraz rozważ inną funkcję:
int fK(int a, double b, int c, double d);
Przypisanie parametru odbywa się w następujący sposób:
- Arm64: a -> x0, b -> d0, c -> x1, d -> d1
- x64: a -> RCX, b -> XMM1, c -> R8, d -> XMM3
- Tłumaczenie arm64 -> x64: x0 -> RCX, d0 -> XMM1, x1 -> R8, d1 -> XMM3
W tych przykładach pokazano, że przypisanie parametrów i tłumaczenie różnią się w zależności od typów powyższych parametrów na liście. Ten szczegół jest zilustrowany przez trzeci parametr. W obu funkcjach typ parametru to int, ale wynikowe tłumaczenie jest inne.
Z tego powodu istnieją thunks do wejścia i wyjścia, które są specjalnie dostosowane do podpisu każdej poszczególnej funkcji.
Oba typy thunks są funkcjami. Emulator automatycznie wywołuje thunki wejściowe, gdy funkcje x64 wywołują funkcje Arm64EC (wykonanie Enters Arm64EC). Moduły sprawdzania wywołań automatycznie wywołują thunksy wyjściowe, gdy funkcje Arm64EC wywołują funkcje x64 (wykonywanie zakończenie Arm64EC).
Podczas kompilowania kodu Arm64EC kompilator generuje funkcję wyjściową typu thunk dla każdej funkcji Arm64EC, zgodnej z jej sygnaturą. Kompilator generuje również exit thunk dla każdej funkcji, którą wywołuje funkcja Arm64EC.
Rozważmy następujący przykład:
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);
}
Podczas kompilowania poprzedniego kodu przeznaczonego dla usługi Arm64EC kompilator generuje:
- Kod dla elementu
fA. - Wpis thunk dla
fA - Zakończ thunk dla
fB - Wyjście z thunk dla
fC
Kompilator generuje fA wpis typu thunk, gdy fA jest wywoływane z kodu x64. Kompilator generuje procedury końcowe dla fB i fC, jeśli fB i fC są kodem x64.
Kompilator może wielokrotnie wygenerować to samo wyjście thunk, ponieważ generuje je w lokacji wywołania, a nie samej funkcji. Takie duplikowanie może spowodować znaczną ilość nadmiarowych thunks. Aby uniknąć tego duplikowania, kompilator stosuje podstawowe zasady optymalizacji, aby upewnić się, że tylko wymagane procedury pomocnicze trafiają do finalnego pliku binarnego.
Na przykład w pliku binarnym, gdzie funkcja Arm64EC A wywołuje funkcję Arm64EC B, B nie jest eksportowana i jej adres nigdy nie jest znany poza A. Bezpieczne jest usunięcie wyjściowego thunk z A do B, a także wejściowego thunk dla B. Można również bezpiecznie aliasować wszystkie elementy exit i entry thunks, które zawierają ten sam kod, nawet jeśli zostały wygenerowane dla odrębnych funkcji.
Wyjście z thunks
Korzystając z przykładowych funkcji fA, fB i fC w poprzedniej sekcji, kompilator generuje zarówno thunks wyjścia fB jak i fC w następujący sposób:
Wyjście z thunk do 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
Wyjdź z funkcji 'thunk' do 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
W tym fB przypadku obecność parametru double powoduje przetasowanie pozostałego przypisania rejestrów GP, co wynika z różnych reguł przypisania Arm64 i x64. Można również zauważyć, że x64 przypisuje tylko cztery parametry do rejestrów, więc piąty parametr musi zostać zapisywany na stosie.
W tym fC przypadku drugi parametr jest strukturą długości 3 bajtów. Arm64 umożliwia przypisywanie dowolnej struktury rozmiaru bezpośrednio do rejestru. X64 zezwala tylko na rozmiary 1, 2, 4 i 8. Ten Exit Thunk musi przenieść to struct z rejestru na stos i zamiast tego przypisać wskaźnik do rejestru. Takie podejście nadal zużywa jeden rejestr (do przenoszenia wskaźnika), więc nie zmienia przypisań dla pozostałych rejestrów: żadne przetasowanie rejestru nie ma miejsca dla trzeciego i czwartego parametru. Tak jak w przypadku fB, piąty parametr należy przenieść na stos.
Dodatkowe zagadnienia dotyczące wyjścia z Thunks:
- Kompilator nadaje im nazwy nie według nazwy funkcji, którą tłumaczą, lecz raczej na podstawie jej sygnatury. Ta konwencja nazewnictwa ułatwia znajdowanie redundancji.
- Kontroler wywołań ustawia rejestr
x9tak, aby zawierał adres docelowej funkcji (x64). Exit Thunk wywołuje emulator, przekazującx9w niezmienionej formie.
Po przeorganizowaniu parametrów Exit Thunk zwykle odwołuje się do emulatora za pośrednictwem __os_arm64x_dispatch_call_no_redirect.
W tym momencie warto przejrzeć funkcję modułu sprawdzania wywołań i jego niestandardowego interfejsu ABI. Oto, jak wygląda pośrednie wywołanie 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
Podczas uruchamiania sprawdzania połączeń:
-
x11dostarcza adres funkcji docelowej do wywołania (fBw tym przypadku). W tym momencie kontroler wywołań może nie wiedzieć, czy funkcja docelowa jest Arm64EC lub x64. -
x10dostarcza Exit Thunk zgodny z sygnaturą wywoływanej funkcji (w tym przypadkufB).
Dane zwracane przez moduł sprawdzania wywołań zależą od tego, czy funkcja docelowa to Arm64EC czy x64.
Jeśli docelową platformą jest Arm64EC:
-
x11Zwraca adres kodu Arm64EC do wywołania. Ta wartość może być taka sama jak podana.
Jeśli element docelowy to kod x64:
-
x11Zwraca adres Exit Thunk. Ten adres jest kopiowany z danych wejściowych podanych w plikux10. -
x10Zwraca adres Exit Thunk, pozostawiony bez zmian w wyniku danych wejściowych. -
x9Zwraca docelową funkcję x64. Ta wartość może być taka sama jak ta podana w elemenciex11.
Kontrolery wywołań zawsze pozostawiają niezakłócone rejestry parametrów konwencji wywoływania. Kod wywołujący powinien natychmiast wywołać sprawdzanie wywołań za pomocą blr x11 (lub br x11 w przypadku wywołania końcowego). Kontrolery połączeń zawsze zachowują te rejestry powyżej i poza standardowymi rejestrami nietrwałymi: x0-x8, x15(chkstk), oraz q0-q7.
"Entry Thunks"
Entry Thunks dbają o przekształcenia z konwencji wywoływania x64 na konwencję wywoływania Arm64. Ta transformacja jest zasadniczo odwróceniem Exit Thunks, ale wiąże się z kilkoma innymi aspektami do rozważenia.
Rozważmy poprzedni przykład kompilowania fApliku . Zostanie wygenerowany kod Entry Thunk, aby kod x64 mógł wywołać metodę fA.
Wpis Thunk dla 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
Emulator udostępnia adres funkcji docelowej w pliku x9.
Przed wywołaniem metody Entry Thunk emulator x64 popsuje adres zwrotny ze stosu LR do rejestru.
LR Oczekuje się, że będzie wskazywać na kod x64, gdy kontrola zostanie przekazana do Entry Thunk.
Emulator może również wykonać inną korektę stosu, w zależności od następujących: Zarówno arm64, jak i x64 ABI definiują wymaganie wyrównania stosu, w którym stos musi być wyrównany do 16 bajtów w punkcie wywoływanej funkcji. Podczas uruchamiania kodu Arm64 sprzęt wymusza tę regułę, ale sprzęt nie wymusza tego dla x64. Podczas uruchamiania kodu x64 błędne wywoływanie funkcji z niewyrównanym stosem może pozostać niezauważone przez dłuższy czas, dopóki nie zostanie użyta jakaś instrukcja wyrównania do 16 bajtów (jak to robią niektóre instrukcje SSE) lub wywołany zostanie kod Arm64EC.
Aby rozwiązać ten potencjalny problem ze zgodnością, przed wywołaniem funkcji Entry Thunk emulator zawsze wyrównuje wskaźnik stosu do 16 bajtów i przechowuje jego oryginalną wartość w rejestrze x4. W ten sposób funkcja Entry Thunks zawsze rozpoczyna wykonywanie z wyrównanym stosem, ale nadal może poprawnie odwoływać się do parametrów przekazanych na stosie za pośrednictwem metody x4.
Jeśli chodzi o nietrwałe rejestry SIMD, istnieje znacząca różnica między konwencjami wywoływania Arm64 i x64. W arm64, niskie 8 bajtów (64 bity) rejestru są uważane za nietrwałe. Innymi słowy, tylko część Dn rejestrów Qn jest nietrwała. W przypadku x64 cała 16 bajtów rejestru XMMn jest uważana za nietrwałą. Ponadto na x64 rejestry XMM6 i XMM7 są nietrwałe, podczas gdy rejestry D6 i D7 (odpowiadające rejestry Arm64) są niestabilne.
Aby rozwiązać te asymetrie manipulacji rejestru SIMD, Entry Thunks musi jawnie zapisać wszystkie rejestry SIMD, które są uważane za nietrwałe w x64. To zapisywanie danych jest potrzebne tylko w przypadku Entry Thunks (nie Exit Thunks), ponieważ x64 jest bardziej rygorystyczne niż Arm64. Innymi słowy, reguły zapisywania i zachowywania rejestrów w architekturze x64 przekraczają wymagania Arm64 w każdym przypadku.
Aby poprawnie odzyskać te wartości rejestru podczas odwijania stosu (na przykład setjmp + longjmp lub throw + catch), wprowadzono nowy kod operacji odwijania: save_any_reg (0xE7). Ten nowy 3-bajtowy kod unwind umożliwia zapisywanie dowolnego rejestru ogólnego przeznaczenia lub SIMD (w tym tych uważanych za ulotne), w tym pełnowymiarowych rejestrów Qn. Ten nowy kod operacyjny jest używany w operacjach przepełnienia i wypełnienia rejestru Qn.
save_any_reg jest zgodny z save_next_pair (0xE6).
Do celów referencyjnych, poniższe informacje dotyczące unwind dotyczą wcześniej przedstawionego 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)
Po powrocie funkcji Arm64EC, procedura ponownie wprowadza emulator do kodu x64 (wskazywanego przez LR).
Funkcje Arm64EC rezerwują cztery bajty przed pierwszą instrukcją w funkcji do przechowywania informacji, które mają być używane w czasie wykonywania. W tych czterech bajtach można znaleźć względny adres Entry Thunk dla danej funkcji. Podczas wykonywania wywołania z funkcji x64 do funkcji Arm64EC emulator odczytuje cztery bajty przed rozpoczęciem funkcji, maskuje dolne dwa bity i dodaje tę ilość do adresu funkcji. Ten proces generuje adres elementu Entry Thunk do wywołania.
Dostosoowywujący thunks
Dostosowywacz Thunks to funkcje bez podpisów, które przekazują sterowanie do (tail-call) innej funkcji. Przed przeniesieniem kontrolki przekształcają jeden z parametrów. Typ przekształconych parametrów jest znany, ale wszystkie pozostałe parametry mogą być dowolne i mogą być w dowolnej liczbie. Adjustor Thunks nie ingerują w żadne rejestry, które mogą zawierać parametry, i nie dotykają stosu. Ta cecha sprawia, że Adjustor Thunks są funkcjami bez sygnatury.
Kompilator może automatycznie generować Adjustor Thunks. Ta generacja jest powszechna, na przykład w przypadku dziedziczenia wielokrotnego w C++, gdzie każda metoda wirtualna może delegować do klasy nadrzędnej bez modyfikacji, z wyjątkiem modyfikacji wskaźnika this.
W poniższym przykładzie przedstawiono rzeczywisty scenariusz:
[thunk]:CObjectContext::Release`adjustor{8}':
sub x0,x0,#8
b CObjectContext::Release
Thunk odejmuje 8 bajtów od wskaźnika this i przekazuje wywołanie do klasy nadrzędnej.
Podsumowując, funkcje Arm64EC, które można wywołać z funkcji x64, muszą mieć przypisany element Entry Thunk. Wpis Thunk jest specyficzny dla podpisu. Funkcje bez podpisu arm64, takie jak Adjustor Thunks, potrzebują innego mechanizmu, który może obsługiwać funkcje bez podpisu.
Entry Thunk z Adjustor Thunk używa pomocnika __os_arm64x_x64_jump w celu odroczenia wykonania rzeczywistej pracy Entry Thunk (polegającej na dostosowaniu parametrów z jednej konwencji do drugiej) do następnego wywołania. W tej chwili podpis staje się widoczny. Obejmuje to opcję niedokonywania dostosowań konwencji wywołań wcale, jeśli element docelowy Adjustora Thunka okazuje się być funkcją x64. Pamiętaj, że już po tym, jak uruchomi się "Entry Thunk", parametry znajdują się w postaci x64.
W powyższym przykładzie rozważ, jak wygląda kod w usłudze Arm64EC.
Regulator Thunk w Arm64EC
[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
Wejściowy pień Adjustor 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
Sekwencje przewijania do przodu
Niektóre aplikacje dokonują modyfikacji funkcji w czasie ich wykonywania w plikach binarnych, które są im niepodległe, ale od których zależą – często w plikach binarnych systemu operacyjnego – w celu zmiany ścieżki wykonania podczas wywołania funkcji. Ten proces jest również znany jako przechwytywanie.
Ogólnie rzecz biorąc, proces hookowania jest prosty. Jednak szczegółowo zaczepianie jest specyficzne dla architektury i dość złożone, biorąc pod uwagę potencjalne odmiany logiki zaczepiania, którą musi się zająć.
Ogólnie rzecz biorąc, proces obejmuje następujące kroki:
- Określ adres funkcji, która ma być podłączona.
- Zastąp pierwszą instrukcję funkcji skokiem do procedury haka.
- Po zakończeniu procedury hakowania wróć do oryginalnej logiki, która obejmuje uruchomienie zastąpionej oryginalnej instrukcji.
Różnice wynikają z następujących elementów:
- Rozmiar pierwszej instrukcji: Dobrym pomysłem jest zastąpienie jej instrukcją JMP o takim samym rozmiarze lub mniejszym, aby uniknąć zmiany początkowych instrukcji funkcji, podczas gdy inny wątek może być w trakcie jej wykonywania.
- Typ pierwszej instrukcji: Jeśli pierwsza instrukcja ma jakiś względny charakter komputera, przeniesienie go może wymagać zmiany elementów takich jak pola przemieszczania. Ponieważ mogą one przepełnić się po przeniesieniu instrukcji do odległego miejsca, ta zmiana może wymagać zapewnienia równoważnej logiki z różnymi instrukcjami całkowicie.
Ze względu na całą tę złożoność, niezawodna i ogólna logika hakowania jest rzadko spotykana. Często logika obecna w aplikacjach może poradzić sobie tylko z ograniczonym zestawem przypadków, które aplikacja oczekuje napotkać w określonych interfejsach API, których interesuje. Nie trudno sobie wyobrazić, ile jest problemu ze zgodnością aplikacji. Nawet prosta zmiana w kodzie lub optymalizacji kompilatora może spowodować, że aplikacje będą bezużyteczne, jeśli kod nie będzie już wyglądał dokładnie tak, jak oczekiwano.
Co się stanie z tymi aplikacjami, jeśli napotkali kod Arm64 podczas konfigurowania haka? Z pewnością zawiodliby.
Funkcje sekwencji szybkiego przekazywania (FFS) odpowiadają temu wymaganiu zgodności w usłudze Arm64EC.
FFS to bardzo małe funkcje x64, które nie zawierają rzeczywistej logiki i wykonują optymalizowane wywołania do rzeczywistej funkcji Arm64EC. Są one opcjonalne, ale domyślnie włączone dla wszystkich eksportów bibliotek DLL i dla dowolnej funkcji oznaczonej __declspec(hybrid_patchable).
W takich przypadkach, gdy kod uzyskuje wskaźnik do danej funkcji, poprzez GetProcAddress w przypadku eksportu lub &function w przypadku __declspec(hybrid_patchable), adres wynikowy zawiera kod x64. Ten kod x64 przechodzi jako prawidłowa funkcja x64, spełniając większość obecnie dostępnych logik hakowania.
Rozważmy następujący przykład (obsługa błędów pominięta w celu zwięzłości):
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);
Wartość wskaźnika funkcji w zmiennej pgma zawiera adres FFS elementu GetMachineTypeAttributes.
W tym przykładzie przedstawiono sekwencję Fast-Forward:
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
Funkcja FFS x64 ma kanoniczny prolog i epilog, zakończona wywołaniem ogonowym (skok) do rzeczywistej funkcji GetMachineTypeAttributes w kodzie Arm64EC.
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
[...]
Byłoby to dość nieefektywne, gdyby wymagane było uruchomienie pięciu emulowanych instrukcji x64 między dwiema funkcjami Arm64EC. Funkcje FFS są specjalne. Funkcje FFS nie działają, jeśli pozostaną niezmienione. Pomocnik sprawdzania wywołań skutecznie sprawdza, czy usługa FFS nie została zmieniona. Jeśli tak jest, połączenie przenosi się bezpośrednio do docelowego miejsca. Jeśli FFS zostanie zmieniony w jakikolwiek możliwy sposób, to nie jest już FFS. Wykonywanie jest transferowane do zmienionego pakietu FFS i uruchamia dowolny kod, emulując objazd i dowolną logikę podłączania.
Gdy hak przenosi wykonanie z powrotem do końca FFS, ostatecznie dociera do wywołania ogonowego kodu Arm64EC, który jest wykonywany po haku, zgodnie z oczekiwaniami aplikacji.
Tworzenie Arm64EC w języku asemblera
Nagłówki zestawu Windows SDK i kompilator języka C upraszczają zadanie tworzenia zestawu Arm64EC. Na przykład można użyć kompilatora języka C do generowania thunks wejściowych i wychodzących dla funkcji, które nie są kompilowane z kodu w języku C.
Rozważmy przykładowy odpowiednik następującej funkcji fD , którą musisz utworzyć w zestawie (ASM). Kod Arm64EC i x64 może wywołać tę funkcję, a pfE wskaźnik funkcji może wskazywać kod Arm64EC lub x64.
typedef int (PF_E)(int, double);
extern PF_E * pfE;
int fD(int i, double d) {
return (*pfE)(i, d);
}
Pisanie fD w usłudze ASM może wyglądać podobnie do następującego kodu:
#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
W poprzednim przykładzie:
- Arm64EC używa tej samej deklaracji procedury i makr prolog/epilog co Arm64.
- Zawijaj nazwy funkcji za pomocą makra
A64NAME. Podczas kompilowania kodu C lub C++ jako Arm64EC kompilator oznaczaOBJjakoARM64ECzawierający kod Arm64EC. To oznaczenie nie ma miejsce w przypadkuARMASM. Podczas kompilowania kodu ASM można poinformować linkera, że wygenerowany kod to Arm64EC, prefiksując nazwę funkcji za pomocą polecenia#.A64NAMEMakro wykonuje tę operację, gdy_ARM64EC_jest zdefiniowana i pozostawia nazwę bez zmian, gdy_ARM64EC_nie jest zdefiniowana. Takie podejście umożliwia udostępnianie kodu źródłowego między arm64 i arm64EC. - Najpierw należy uruchomić wskaźnik funkcji
pfEza pośrednictwem kontrolera wywołań EC, wraz z odpowiednim wyjściem typu thunk, na wypadek gdyby funkcja docelowa działała na architekturze x64.
Generowanie thunks dla wejścia i wyjścia
Następnym krokiem jest wygenerowanie funkcji wejścia thunk dla fD i funkcji wyjścia thunk dla pfE. Kompilator języka C może wykonać to zadanie z minimalnym nakładem pracy przy użyciu słowa kluczowego kompilatora _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;
}
Słowo kluczowe _Arm64XGenerateThunk nakazuje kompilatorowi języka C użycie podpisu funkcji, zignorowanie ciała funkcji i wygenerowanie thunka wyjścia (gdy parametr wynosi 1) lub thunka wejścia (gdy parametr wynosi 2).
Umieść generacji thunk we własnym pliku C. Umieszczenie w izolowanych plikach ułatwia potwierdzenie nazw symboli poprzez zrzut odpowiednich OBJ symboli lub nawet poprzez wykonanie dezasemblacji.
Niestandardowe wpisy thunks
SDK zawiera makra, które ułatwiają tworzenie niestandardowych, ręcznie kodowanych thunks. Te makra można używać podczas tworzenia niestandardowych zestawów dostrajań.
Większość thunks regulatora jest generowana przez kompilator języka C++, ale można je również wygenerować ręcznie. Możesz ręcznie wygenerować funkcję dostrajającą, gdy ogólny callback przekazuje kontrolę do rzeczywistego callbacku, a jeden z parametrów identyfikuje rzeczywisty callback.
W poniższym przykładzie pokazano tunk regulatora w kodzie klasycznym arm64:
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
W tym przykładzie pierwszy parametr zawiera odwołanie do struktury. Kod pobiera adres funkcji docelowej z elementu tej struktury. Ponieważ struktura jest zapisywalna, funkcja Control Flow Guard (CFG) musi zweryfikować adres docelowy.
W poniższym przykładzie pokazano, jak przenieść odpowiednik adjustor thunk do Arm64EC.
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
Powyższy kod nie dostarcza pliku exit thunk (w rejestrze x10). Takie podejście nie jest możliwe, ponieważ kod może być wykonywany dla wielu różnych podpisów. Ten kod wykorzystuje ustawienie funkcji wywołującej x10 w thunk wyjściowym. Wywołujący wykonuje połączenie przeznaczone dla określonego podpisu.
Powyższy kod wymaga wpisu thunk, aby rozwiązać ten przypadek, gdy obiekt wywołujący jest kodem x64. W poniższym przykładzie pokazano, jak poprawnie opisać odpowiednią stawkę wejściową przy użyciu makra dla niestandardowych stawek wejściowych.
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
W przeciwieństwie do innych funkcji, ten entry thunk nie przenosi ostatecznie kontroli do skojarzonej funkcji (regulator thunk). W takim przypadku wpis thunk osadza samą funkcję (wykonanie korekty parametru) i przenosi kontrolę bezpośrednio do celu końcowego przez pomocnika __os_arm64x_x64_jump.
Dynamiczne generowanie kodu Arm64EC (kompilowanie JIT)
W procesach Arm64EC istnieją dwa typy pamięci wykonywalnej: kod Arm64EC i kod x64.
System operacyjny wyodrębnia te informacje z załadowanych plików binarnych. Pliki binarne x64 to wszystkie pliki binarne x64, a pliki binarne Arm64EC zawierają tabelę zakresów dla stron kodowych Arm64EC i x64.
Co z dynamicznie generowanym kodem? Kompilatory just in time (JIT) generują kod w czasie wykonywania, który nie jest wspierany przez żaden plik binarny.
Zazwyczaj ten proces obejmuje następujące kroki:
- Przydzielanie zapisywalnej pamięci (
VirtualAlloc). - Tworzenie kodu w przydzielonej pamięci.
- Ponowne zabezpieczenie pamięci przed odczytem i zapisem w celu wykonania odczytu (
VirtualProtect). - Dodawanie wpisów funkcji odwijania dla wszystkich nietrywialnych (nielistkowych) wygenerowanych funkcji (
RtlAddFunctionTablelubRtlAddGrowableFunctionTable).
Ze względu na trywialną zgodność, jeśli aplikacja wykonuje te kroki w procesie Arm64EC, system operacyjny traktuje kod jako kod x64. Takie zachowanie występuje w przypadku każdego procesu, który używa niezmodyfikowanego środowiska uruchomieniowego x64 Java, środowiska uruchomieniowego platformy .NET, aparatu JavaScript itd.
Aby wygenerować kod dynamiczny arm64EC, wykonaj ten sam proces z dwoma różnicami:
- Podczas przydzielania pamięci użyj nowszego
VirtualAlloc2(zamiastVirtualAlloclubVirtualAllocEx) i podajMEM_EXTENDED_PARAMETER_EC_CODEatrybut . - Podczas dodawania wpisów funkcji:
- Muszą być w formacie Arm64. Podczas kompilowania kodu Arm64EC typu
RUNTIME_FUNCTIONjest zgodny z formatem x64. W przypadku formatu Arm64, podczas kompilowania Arm64EC, użyj typuARM64_RUNTIME_FUNCTIONzamiast tego. - Nie używaj starszego
RtlAddFunctionTableinterfejsu API. Zawsze używaj nowszegoRtlAddGrowableFunctionTableinterfejsu API.
- Muszą być w formacie Arm64. Podczas kompilowania kodu Arm64EC typu
W poniższym przykładzie przedstawiono alokację pamięci:
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);
W poniższym przykładzie pokazano, jak dodać jeden wpis funkcji odwijania:
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)
);
Windows on Arm