Udostępnij przez


Zrozumienie ABI Arm64EC i kodu asemblera

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.

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 x11 zamiast x15.
  • 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, . x3 Pozostał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ą rejestry x0 przez x7.
  • 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ów RDX i XMM1. Na Arm64EC, drugi parametr jest przypisany tylko do x1.
  • 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.
  • ull1 jest 8-bajtową liczbą całkowitą. Jest przypisywane do x1.
  • ull2 jest 8-bajtową liczbą całkowitą. Jest przypisywane do x2.
  • ull3 jest 8-bajtową liczbą całkowitą. Jest przypisywane do x3.

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.
  • ull1 jest 8-bajtową liczbą całkowitą. Jest przypisywane do x2.
  • ull2 jest 8-bajtową liczbą całkowitą. Jest przypisywane do x3.
  • ull3 jest 8-bajtową liczbą całkowitą. Przypisuje bezpośrednio do stosu.
  • x4 ładuje lokalizację ull3 elementu w stosie.
  • x5 ładuje rozmiar ull3.

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:

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 x9 tak, aby zawierał adres docelowej funkcji (x64). Exit Thunk wywołuje emulator, przekazując x9 w 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ń:

  • x11 dostarcza adres funkcji docelowej do wywołania (fB w tym przypadku). W tym momencie kontroler wywołań może nie wiedzieć, czy funkcja docelowa jest Arm64EC lub x64.
  • x10 dostarcza 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:

  • x11 Zwraca adres kodu Arm64EC do wywołania. Ta wartość może być taka sama jak podana.

Jeśli element docelowy to kod x64:

  • x11 Zwraca adres Exit Thunk. Ten adres jest kopiowany z danych wejściowych podanych w pliku x10.
  • x10 Zwraca adres Exit Thunk, pozostawiony bez zmian w wyniku danych wejściowych.
  • x9 Zwraca docelową funkcję x64. Ta wartość może być taka sama jak ta podana w elemencie x11.

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 oznacza OBJ jako ARM64EC zawierający kod Arm64EC. To oznaczenie nie ma miejsce w przypadku ARMASM. Podczas kompilowania kodu ASM można poinformować linkera, że wygenerowany kod to Arm64EC, prefiksując nazwę funkcji za pomocą polecenia #. A64NAME Makro 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 pfE za 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 (RtlAddFunctionTable lub RtlAddGrowableFunctionTable).

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 (zamiast VirtualAlloc lub VirtualAllocEx) i podaj MEM_EXTENDED_PARAMETER_EC_CODE atrybut .
  • Podczas dodawania wpisów funkcji:
    • Muszą być w formacie Arm64. Podczas kompilowania kodu Arm64EC typu RUNTIME_FUNCTION jest zgodny z formatem x64. W przypadku formatu Arm64, podczas kompilowania Arm64EC, użyj typu ARM64_RUNTIME_FUNCTION zamiast tego.
    • Nie używaj starszego RtlAddFunctionTable interfejsu API. Zawsze używaj nowszego RtlAddGrowableFunctionTable interfejsu API.

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)
);