Freigeben über


Grundlegendes zu Arm64EC ABI und Assemblycode

Arm64EC ("Emulation compatible") ist eine neue Binäre Anwendung (Application Binary Interface, ABI) zum Erstellen von Apps für Windows 11 auf Arm. Eine Übersicht über Arm64EC und das Erstellen von Win32-Apps als Arm64EC finden Sie unter Verwenden von Arm64EC zum Erstellen von Apps für Windows 11 auf Arm-Geräten.

Dieser Artikel bietet eine detaillierte Ansicht der Arm64EC ABI mit ausreichenden Informationen für einen Anwendungsentwickler, um Assembly-Code zu schreiben und zu debuggen, der für Arm64EC kompiliert wurde, einschließlich Debugging auf niedrigem Level/Assembler-Debugging und Schreiben von Assemblycode für die Arm64EC ABI.

Design von Arm64EC

Arm64EC bietet Funktionen und Leistung auf systemeigener Ebene und bietet gleichzeitig transparente und direkte Interoperabilität mit x64-Code, der unter Emulation ausgeführt wird.

Arm64EC ist hauptsächlich zum Klassischen Arm64 ABI addiert. Der klassische ABI hat sich sehr wenig geändert, aber die Arm64EC ABI-Teile wurden hinzugefügt, um die x64-Interoperabilität zu ermöglichen.

In diesem Dokument wird der Originalstandard Arm64 ABI als "Classic ABI" bezeichnet. Dieser Begriff vermeidet die Mehrdeutigkeit, die überladenen Begriffen wie "Native" inhärent ist. Arm64EC ist genauso nativ wie die ursprüngliche ABI.

Arm64EC vs. Arm64 Classic ABI

In der folgenden Liste wird darauf hingewiesen, wo Arm64EC von Arm64 Classic ABI abweicht.

Diese Unterschiede sind kleine Veränderungen, wenn man sieht, wie viel die gesamte ABI definiert.

Registrieren von Zuordnungen und blockierten Registern

Um die Interoperabilität auf Typebene mit x64-Code zu ermöglichen, kompiliert Arm64EC-Code mit den gleichen Präprozessorarchitekturdefinitionen wie x64-Code.

Mit anderen Worten, _M_AMD64 und _AMD64_ sie werden definiert. Einer der Typen, die von dieser Regel betroffen sind, ist die CONTEXT Struktur. Die CONTEXT Struktur definiert den Zustand der CPU an einem bestimmten Punkt. Sie wird für Elemente wie Exception Handling und GetThreadContext APIs verwendet. Der vorhandene x64-Code erwartet, dass der CPU-Kontext als x64-Struktur CONTEXT dargestellt wird, d. h. die CONTEXT Struktur, wie sie während der x64-Kompilierung definiert ist.

Sie müssen diese Struktur verwenden, um den CPU-Kontext darzustellen, während x64-Code und Arm64EC-Code ausgeführt werden. Der vorhandene Code versteht kein neuartiges Konzept, z. B. das CPU-Register, das von Funktion zu Funktion wechselt. Wenn Sie die x64-Struktur CONTEXT verwenden, um Arm64-Ausführungszustände darzustellen, ordnen Sie Arm64-Register effektiv in x64-Register zu.

Diese Zuordnung bedeutet auch, dass Sie keine Arm64-Register verwenden können, die nicht in das x64 CONTEXTpassen. Ihre Werte können jederzeit verloren gehen, wenn ein Vorgang CONTEXT verwendet (und einige Vorgänge können asynchron und unerwartet sein, z. B. der Garbage Collection-Vorgang einer verwalteten Laufzeitumgebung für Sprachen oder eines APC).

Die Windows-Header im SDK stellen die Zuordnungsregeln zwischen Arm64EC und x64 registern mit der ARM64EC_NT_CONTEXT Struktur dar. Diese Struktur ist im Wesentlichen eine Vereinigung der CONTEXT Struktur, genau wie sie für x64 definiert ist, aber mit einer zusätzlichen Arm64-Registerüberlagerung.

Zum Beispiel wird RCXX0 zugeordnet, RDXX1 zugeordnet, RSPSP zugeordnet, RIPPC zugeordnet und so weiter. Die Register x13, x14, x23, x24, x28 und v16 bis v31 haben keine Repräsentation und können deshalb nicht in Arm64EC verwendet werden.

Diese Registernutzungseinschränkung ist der erste Unterschied zwischen arm64 Classic und EC ABIs.

Anrufprüfer

Anrufprüfer sind seit der Einführung von Control Flow Guard (CFG) in Windows 8.1 Teil von Windows. Anrufprüfer sind Adress-Sanitizer für Funktionszeiger (bevor diese Dinge als Adress-Sanitizer bezeichnet wurden). Jedes Mal, wenn Sie Code mit der Option /guard:cfkompilieren, generiert der Compiler einen zusätzlichen Aufruf der Checker-Funktion direkt vor jedem indirekten Aufruf oder Sprung. Windows stellt die Prüffunktion selbst bereit. Bei CFG führt das System eine Gültigkeitsprüfung gegen die als sicher bekannten Aufrufziele durch. Binärdateien, die mit /guard:cf kompiliert wurden, enthalten ebenfalls diese Informationen.

In diesem Beispiel wird eine Anrufprüfer-Verwendung in Classic Arm64 gezeigt:

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

Im CFG-Fall gibt der Aufrufprüfer ganz einfach eine Rückmeldung, wenn das Ziel gültig ist, oder schlägt der Prozess schnell fehl, wenn es nicht gültig ist. Anrufprüfer verfügen über benutzerdefinierte Anrufkonventionen. Sie übernehmen den Funktionszeiger in einem Register, das nicht von der normalen Aufrufkonvention verwendet wird, und bewahren alle normalen Aufrufkonventionsregister auf. Auf diese Weise führen sie keine Registerüberlaufe um sie herum ein.

Anrufprüfer sind für alle anderen Windows-ABIs optional, aber für Arm64EC obligatorisch. Auf Arm64EC sammeln Anrufprüfer die Aufgabe, die Architektur der aufgerufenen Funktion zu überprüfen. Sie überprüfen, ob der Aufruf eine andere EC-Funktion ("Emulationskompatible") oder eine x64-Funktion ist, die unter Emulation ausgeführt werden muss. In vielen Fällen kann dies nur zur Laufzeit überprüft werden.

Arm64EC-Anrufprüfer bauen auf den vorhandenen Arm64-Prüfern auf, haben jedoch eine etwas andere benutzerdefinierte Anrufkonvention. Sie nehmen einen zusätzlichen Parameter, und sie können das Register ändern, das die Zieladresse enthält. Wenn das Ziel z. B. x64-Code ist, muss das Steuerelement zuerst in die Emulationsgerüstlogik übertragen werden.

In Arm64EC würde die gleiche Anrufprüfer-Verwendung zu:

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

Zu den geringfügigen Unterschieden von Classic Arm64 gehören:

  • Der Symbolname für die Anrufprüfer unterscheidet sich.
  • Die Zieladresse wird x11 anstelle von x15.
  • Die Zieladresse (x11) ist [in, out] anstelle von [in].
  • Es gibt einen zusätzlichen Parameter, der über x10" Exit Thunk" bereitgestellt wird.

Ein Exit Thunk ist ein Funclet, das Funktionsparameter aus der Arm64EC-Aufrufkonvention in x64-Aufrufkonvention transformiert.

Die Arm64EC-Anrufüberprüfung befindet sich über ein anderes Symbol als für die anderen ABIs in Windows. Auf dem klassischen Arm64 ABI ist __guard_check_icall_fptrdas Symbol für die Anrufprüfer . Dieses Symbol wird in Arm64EC vorhanden sein, aber es ist für x64 statisch verknüpften Code vorhanden, nicht Arm64EC-Code selbst. Arm64EC-Code verwendet entweder __os_arm64x_check_icall oder __os_arm64x_check_icall_cfg.

Bei Arm64EC sind Anrufprüfer nicht optional. CFG ist jedoch weiterhin optional, wie es bei anderen ABIs der Fall ist. CFG kann zur Kompilierungszeit deaktiviert werden, oder es gibt einen legitimen Grund, eine CFG-Überprüfung auch dann nicht auszuführen, wenn CFG aktiviert ist (z. B. der Funktionszeiger befindet sich nie im RW-Speicher). Für einen indirekten Anruf mit CFG-Überprüfung sollte die __os_arm64x_check_icall_cfg Prüfer verwendet werden. Wenn CFG deaktiviert oder unnötig ist, __os_arm64x_check_icall sollte stattdessen verwendet werden.

Nachfolgend finden Sie eine Zusammenfassungstabelle der Verwendung der Anrufprüfer auf classic Arm64, x64 und Arm64EC, wobei angegeben wird, dass eine Arm64EC-Binärdatei je nach Architektur des Codes zwei Optionen haben kann.

Binär Programmcode Ungeschützter indirekter Aufruf CFG-geschützter indirekter Aufruf
x64 x64 keine Anrufprüfer __guard_check_icall_fptr oder __guard_dispatch_icall_fptr
Arm64 Classic ARM64 keine Anrufprüfer __guard_check_icall_fptr
Arm64EC x64 keine Anrufprüfer __guard_check_icall_fptr oder __guard_dispatch_icall_fptr
Arm64EC __os_arm64x_check_icall __os_arm64x_check_icall_cfg

Unabhängig von der ABI bedeutet CFG-aktivierter Code (Code mit Verweis auf die CFG-Aufrufprüfer) zur Laufzeit keinen CFG-Schutz. CFG-geschützte Binärdateien können auf Systemen, die CFG nicht unterstützen, herunterlaufen: Die Anrufprüfung wird zur Kompilierungszeit mit einem No-Op-Hilfsprogramm initialisiert. Ein Prozess kann auch CFG durch Konfiguration deaktiviert haben. Wenn CFG deaktiviert ist (oder die Betriebssystemunterstützung nicht vorhanden ist) auf früheren ABIs aktualisiert das Betriebssystem einfach nicht die Anrufüberprüfung, wenn die Binärdatei geladen wird. Wenn der CFG-Schutz auf Arm64EC deaktiviert ist, legt __os_arm64x_check_icall_cfg das Betriebssystem das gleiche fest wie __os_arm64x_check_icall, das weiterhin die erforderliche Überprüfung der Zielarchitektur in allen Fällen bereitstellt, jedoch keinen CFG-Schutz.

Wie bei CFG in Classic Arm64 muss der Aufruf der Zielfunktion (x11) sofort dem Aufruf der Anrufprüfung folgen. Die Adresse der Anrufüberprüfung muss in ein veränderliches Register eingefügt werden, und weder die Adresse der Zielfunktion noch die Adresse der Zielfunktion sollte jemals in ein anderes Register kopiert oder in den Speicher übergelaufen werden.

Stapelprüfer

__chkstk wird automatisch vom Compiler verwendet, wenn eine Funktion einen Bereich auf dem Stapel zuweist, der größer als eine Seite ist. Um zu vermeiden, dass die Stack Guard-Seite übersprungen wird, die das Ende des Stapels schützt, aufgerufen, um sicherzustellen, __chkstk dass alle Seiten im zugeordneten Bereich durchsucht werden.

__chkstk wird in der Regel aus dem Prolog der Funktion aufgerufen. Aus diesem Grund und für eine optimale Codegenerierung wird eine benutzerdefinierte Aufrufkonvention verwendet.

Dies bedeutet, dass x64-Code und Arm64EC-Code eigene, unterschiedliche __chkstk Funktionen benötigen, da Entry- und Exit-Thunks standardmäßige Aufrufkonventionen annehmen.

x64 und Arm64EC verwenden denselben Symbolnamespace, sodass keine zwei Funktionen benannt __chkstkwerden können. Um die Kompatibilität mit bereits vorhandenem x64-Code zu berücksichtigen, __chkstk wird der Name der x64-Stapelprüfung zugeordnet. Stattdessen wird Arm64EC-Code verwendet __chkstk_arm64ec .

Die benutzerdefinierte Anrufkonvention ist __chkstk_arm64ec identisch mit dem klassischen Arm64 __chkstk: x15 stellt die Größe der Zuordnung in Byte bereit, dividiert durch 16. Alle nicht veränderliche Register sowie alle veränderliche Register, die an der Standardanrufkonvention beteiligt sind, bleiben erhalten.

Alles, was oben gesagt wurde __chkstk , gilt gleichermaßen für __security_check_cookie und sein Arm64EC-Gegenstück: __security_check_cookie_arm64ec.

Variadische Anrufkonvention

Arm64EC folgt der klassischen Arm64 ABI-Aufrufkonvention, mit Ausnahme von variadischen Funktionen (auch als Varargs oder Funktionen mit dem Schlüsselwort Ellipse (. .) bezeichnet).

Für den variadischen Spezifischen Fall folgt Arm64EC einer Aufrufkonvention, die mit nur wenigen Unterschieden sehr ähnlich wie x64 variadic ist. Die folgende Liste zeigt die wichtigsten Regeln für arm64EC variadic:

  • Nur die ersten vier Register werden für die Parameterübergabe verwendet: x0, x1, , x2. x3 Die übrigen Parameter werden auf den Stack übertragen. Diese Regel folgt genau der x64 variadischen Aufrufkonvention und unterscheidet sich von Arm64 Classic, bei der Register x0x7 verwendet werden.
  • Gleitkomma- und SIMD-Parameter, die über ein Register übergeben werden, verwenden ein Allzweckregister, kein SIMD-Register. Diese Regel ähnelt Arm64 Classic und unterscheidet sich von x64, wobei FP/SIMD-Parameter sowohl in einem allgemeinen als auch im SIMD-Register übergeben werden. Zum Beispiel wird bei einer Funktion f1(int, …), die als f1(int, double) aufgerufen wird, der zweite Parameter sowohl RDX als auch XMM1 zugewiesen, wenn x64 verwendet wird. Auf Arm64EC wird der zweite Parameter nur x1zugewiesen.
  • Beim Übergeben von Strukturen nach Wert über ein Register gelten x64-Größenregeln: Strukturen mit genau 1, 2, 4 und 8 Byte werden direkt in das allgemeine Register geladen. Strukturen mit anderen Größen werden auf den Stapel ausgelagert, und dem Register wird ein Zeiger auf den ausgelagerten Speicherort zugewiesen. Diese Regel herabgestuft im Wesentlichen den Nachwert auf niedriger Ebene in "By-Reference". Auf dem klassischen Arm64 ABI werden Strukturen jeder Größe bis zu 16 Byte direkt allgemeinen Registern zugewiesen.
  • Das x4 Register lädt einen Zeiger auf den ersten Parameter, der über stapel übergeben wird (der fünfte Parameter). Diese Regel enthält keine Strukturen, die aufgrund der zuvor beschriebenen Größenbeschränkungen übergelaufen sind.
  • Das x5-Register lädt die Größe aller Parameter in Bytes, die über den Stapel übergeben werden (Größe aller Parameter, beginnend ab dem fünften). Diese Regel schließt Strukturen aus, die aufgrund der zuvor beschriebenen Größenbeschränkungen durch Wertübertragung überschritten werden.

Im folgenden Beispiel pt_nova_function werden Parameter in einer nicht variadischen Form verwendet, sodass sie der klassischen Arm64-Aufrufkonvention folgt. Anschließend wird pt_va_function er mit genau denselben Parametern aufgerufen, aber stattdessen in einem variadischen Aufruf.

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 akzeptiert fünf Parameter, die nach den Klassischen Arm64-Aufrufkonventionsregeln zugewiesen werden:

  • 'f' ist ein Double. Es wird d0 zugewiesen.
  • "tc" ist eine Struktur mit einer Größe von 3 Bytes. Es wird x0 zugewiesen.
  • ull1 ist eine ganze Zahl mit 8 Byte. Es weist x1 zu.
  • ull2 ist eine ganze Zahl mit 8 Byte. Es wird zu x2 zugewiesen.
  • ull3 ist eine ganze Zahl mit 8 Byte. Es wird zu x3 zugewiesen.

pt_va_function ist eine variadische Funktion, sodass sie den zuvor beschriebenen Variadischen Regeln von Arm64EC folgt:

  • 'f' ist ein Double. Es wird auf x0 zugewiesen.
  • "tc" ist eine Struktur mit einer Größe von 3 Bytes. Es fließt in den Stack über und wird in x1 geladen.
  • ull1 ist eine ganze Zahl mit 8 Byte. Es wird x2 zugewiesen.
  • ull2 ist eine ganze Zahl mit 8 Byte. Es wird x3 zugewiesen.
  • ull3 ist eine ganze Zahl mit 8 Byte. Es weist direkt dem Stack zu.
  • x4 lädt die Adresse von ull3 im Stapel.
  • x5 lädt die Größe von ull3.

Das folgende Beispiel zeigt die mögliche Kompilierungsausgabe für pt_nova_function, die die zuvor beschriebenen Parameterzuweisungsunterschiede veranschaulicht.

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 Ergänzungen

Um eine transparente Interoperabilität mit x64-Code zu erzielen, nehmen Sie viele Ergänzungen zum klassischen Arm64 ABI vor. Diese Erweiterungen behandeln die Unterschiede der Aufrufkonventionen zwischen Arm64EC und x64.

Die folgende Liste enthält diese Ergänzungen:

Ein- und Ausstiegsthunks

Eingangs- und Exit-Thunks übersetzen die Arm64EC-Aufrufkonvention (größtenteils identisch mit der klassischen Arm64) in die x64-Aufrufkonvention und umgekehrt.

Ein häufiges Missverständnis besteht darin, dass Sie Aufrufkonventionen konvertieren können, indem Sie einer einzelnen Regel folgen, die auf alle Funktionssignaturen angewendet wird. Die Realität ist, dass Aufrufkonventionen Über Parameterzuweisungsregeln verfügen. Diese Regeln hängen vom Parametertyp ab und unterscheiden sich von ABI zu ABI. Eine Folge ist, dass die Übersetzung zwischen ABIs für jede Funktionssignatur spezifisch ist, je nach Typ der einzelnen Parameter.

Betrachten Sie die folgende Funktion:

int fJ(int a, int b, int c, int d);

Die Parameterzuweisung erfolgt wie folgt:

  • Arm64: a -> x0, b -> x1, c -> x2, d -> x3
  • x64: a -> RCX, b -> RDX, c -> R8, d -> r9
  • Arm64 -> x64 Übersetzung: x0 -> RCX, x1 -> RDX, x2 -> R8, x3 -> R9

Betrachten Sie nun eine andere Funktion:

int fK(int a, double b, int c, double d);

Die Parameterzuweisung erfolgt wie folgt:

  • Arm64: a -> x0, b -> d0, c -> x1, d -> d1
  • x64: a -> RCX, b -> XMM1, c -> R8, d -> XMM3
  • Arm64 -> x64 Übersetzung: x0 -> RCX, d0 -> XMM1, x1 -> R8, d1 -> XMM3

Diese Beispiele veranschaulichen, dass die Parameterzuweisung und -übersetzung je nach Typ variieren, aber auch von den Typen der vorherigen Parameter in der Liste abhängig sind. Dieses Detail wird durch den dritten Parameter veranschaulicht. In beiden Funktionen ist intder Typ des Parameters, aber die resultierende Übersetzung unterscheidet sich.

Aus diesem Grund existieren Eingangs- und Ausgangs-Thunks und sind speziell auf jede einzelne Funktionssignatur zugeschnitten.

Beide Arten von Thunks sind Funktionen. Der Emulator ruft Eingabe-Thunks automatisch auf, wenn x64-Funktionen Arm64EC-Funktionen aufrufen (Ausführung Enters Arm64EC). Anrufprüfer rufen automatisch exit thunks auf, wenn Arm64EC-Funktionen x64-Funktionen aufrufen (Ausführung Exits Arm64EC).

Beim Kompilieren von Arm64EC-Code generiert der Compiler einen Entry-Thunk für jede Arm64EC-Funktion, die zu ihrer Signatur passt. Der Compiler generiert auch einen Exit-Thunk für jede Funktion, die eine Arm64EC-Funktion aufruft.

Betrachten Sie das folgende Beispiel:

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

Beim Kompilieren des vorherigen Codes für Arm64EC generiert der Compiler Folgendes:

  • Code für fA.
  • Einstiegs-Thunk für fA
  • Beenden Sie Thunk für fB
  • Beenden Sie das Thunk für fC

Der Compiler generiert den fA Eintrag-Thunk im Fall, dass fA aus x64-Code aufgerufen wird. Der Compiler generiert Exit-Thunks für fB und fC im Fall, dass fB und fC x64-Code sind.

Der Compiler generiert möglicherweise mehrmals denselben Exit-Thunk, da er sie an der Aufrufwebsite und nicht an der Funktion selbst generiert. Diese Duplizierung kann zu einer beträchtlichen Menge redundanter Thunks führen. Um diese Duplizierung zu vermeiden, wendet der Compiler triviale Optimierungsregeln an, damit sichergestellt ist, dass nur die erforderlichen Thunks in die endgültige Binärdatei aufgenommen werden.

In einer Binärdatei, in der die Arm64EC-Funktion A die Arm64EC-Funktion B aufruft, wird die Arm64EC-Funktion B nicht exportiert, und ihre Adresse außerhalb von A niemals bekannt ist. Es ist sicher, den Ausgangs-Thunk von A zu B entfernen, zusammen mit dem Einstiegs-Thunk für B. Es ist auch sicher, alle Exit- und Entry-Thunks zusammen zu aliasen, die denselben Code enthalten, auch wenn sie für unterschiedliche Funktionen generiert wurden.

Beenden von Thunks

Mit den Beispielfunktionen fA, fB und fC im vorherigen Abschnitt generiert der Compiler sowohl fB- als auch fC-Exit-Thunks wie folgt:

Beenden Sie Thunk zu 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

Beenden Sie Thunk zu 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

In diesem fB Fall bewirkt das Vorhandensein eines double Parameters, dass die verbleibende GP-Registerzuweisung neu zugeordnet wird, ein Ergebnis der unterschiedlichen Zuweisungsregeln von Arm64 und x64. Sie können auch sehen, dass x64 nur vier Parameter zu Registern zuweist, sodass der fünfte Parameter auf den Stapel ausgelagert werden muss.

fC Im Fall ist der zweite Parameter eine Struktur von 3-Byte-Länge. Arm64 ermöglicht die direkte Zuweisung einer Größenstruktur zu einem Register. x64 lässt nur größen 1, 2, 4 und 8 zu. Dieser Exit Thunk muss diese Variable struct vom Register auf den Stapel übertragen und stattdessen einen Zeiger auf das Register zuweisen. Bei diesem Ansatz wird weiterhin ein Register verwendet (um den Zeiger zu tragen), sodass keine Zuordnungen für die verbleibenden Register geändert werden: Für die dritten und vierten Parameter erfolgt keine Registeränderung. Genau wie im fB Fall muss der fünfte Parameter auf den Stapel übertragen werden.

Zusätzliche Überlegungen für Exit Thunks:

  • Der Compiler benennt sie nicht nach dem Funktionsnamen, von und zu dem sie umwandeln, sondern nach der Signatur, die sie adressieren. Diese Benennungskonvention erleichtert das Auffinden von Redundanzen.
  • Der Anrufprüfer setzt das Register x9, um die Adresse der Ziel(x64)-Funktion zu übernehmen. Der Exit Thunk ruft den Emulator auf und übergibt x9 ohne Änderungen.

Nach dem Neuanordnen der Parameter ruft "Exit Thunk" den Emulator über __os_arm64x_dispatch_call_no_redirect auf.

An diesem Punkt lohnt es sich, die Funktion der Anrufprüfer und der benutzerdefinierten ABI zu überprüfen. So sieht ein indirekter Aufruf fB aus:

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

Beim Aufrufen der Anrufprüfer:

  • x11 stellt die Adresse der Zielfunktion bereit, die aufgerufen werden soll (fB in diesem Fall). An diesem Punkt weiß die Anrufprüfer möglicherweise nicht, ob die Zielfunktion Arm64EC oder x64 ist.
  • x10 liefert einen Exit Thunk, der der Signatur der aufgerufenen Funktion entspricht (fB in diesem Fall).

Die von der Aufrufüberprüfung zurückgegebenen Daten hängen davon ab, ob die Zielfunktion Arm64EC oder x64 ist.

Wenn das Ziel Arm64EC lautet:

  • x11 gibt die Adresse des Arm64EC-Codes zurück, der aufgerufen werden soll. Dieser Wert kann mit dem wert identisch sein, der angegeben ist.

Wenn das Ziel x64-Code ist:

  • x11 gibt die Adresse des Exit Thunks zurück. Diese Adresse wird aus der Eingabe x10 kopiert.
  • x10 gibt die Adresse des Exit Thunk zurück, unverändert durch die Eingabe.
  • x9 gibt die x64-Zielfunktion zurück. Dieser Wert kann mit dem in der Datei x11bereitgestellten Wert identisch sein.

Anrufprüfer lassen immer Aufrufkonventionsparameter-Register unberührt. Der aufrufende Code sollte dem Aufruf der Anrufprüfung sofort folgen blr x11 (oder br x11 bei einem Tail-Call). Anrufprüfer behalten zusätzlich zu standardmäßigen nicht veränderlichen Registern x0-x8, x15(chkstk) und q0-q7.

Eintrag Thunks

Entry Thunks kümmern sich um die Transformationen, die von der x64 in die Arm64-Aufrufkonventionen erforderlich sind. Diese Transformation ist im Wesentlichen die Umgekehrte von Exit Thunks, umfasst aber einige weitere Aspekte, die berücksichtigt werden müssen.

Betrachten Sie das vorherige Beispiel für die Kompilierung fA. Ein Entry Thunk wird generiert, sodass x64-Code aufgerufen fAwerden kann.

Eintrag Thunk für 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

Der Emulator stellt die Adresse der Zielfunktion in x9.

Vor dem Aufrufen des Entry Thunk füllt der x64-Emulator die Absenderadresse aus dem Stapel in das LR Register ein. LR soll dann auf x64-Code zeigen, wenn die Steuerung an den Entry Thunk übergeht.

Der Emulator kann auch eine weitere Anpassung am Stack vornehmen, je nach dem Folgenden: Sowohl Arm64- als auch x64-ABIs definieren eine Stackausrichtungsanforderung, bei der der Stack zu dem Zeitpunkt, an dem eine Funktion aufgerufen wird, auf 16 Bytes ausgerichtet sein muss. Beim Ausführen von Arm64-Code erzwingt Hardware diese Regel, aber es gibt keine Hardwareerzwingung für x64. Beim Ausführen von x64-Code können fehlerhafte Aufrufe von Funktionen mit einem nicht korrekt ausgerichteten Stack möglicherweise unbemerkt bleiben, bis eine 16-Byte-Ausrichtungsanweisung verwendet wird (was einige SSE-Anweisungen tun) oder Arm64EC-Code aufgerufen wird.

Um dieses potenzielle Kompatibilitätsproblem zu beheben, richtet der Emulator vor dem Aufrufen des Entry Thunk immer den Stack Pointer auf 16 Bytes aus und speichert den ursprünglichen Wert im x4 Register. Auf diese Weise beginnen Entry Thunks die Ausführung immer mit einem ausgerichteten Stapel, können aber trotzdem korrekt auf die Parameter, die über den Stapel übergeben werden, verweisen.

Wenn es um nicht veränderliche SIMD-Register geht, gibt es einen erheblichen Unterschied zwischen den Konventionen für Arm64- und x64-Aufrufe. Bei Arm64 gelten die niedrigen 8 Bytes (64 Bit) des Registers als nicht veränderlich. Mit anderen Worten, nur der Dn Teil der Qn Register ist nicht veränderlich. Bei x64 gilt die gesamte 16 Bytes des XMMn Registers als nicht veränderlich. Darüber hinaus sind auf x64 und XMM6 nicht veränderliche Register vorhanden, XMM7 während D6 und D7 (die entsprechenden Arm64-Register) veränderlich sind.

Um diese SIMD-Register manipulationsasymmetrien zu adressieren, muss Entry Thunks explizit alle SIMD-Register speichern, die in x64 als nicht veränderlich betrachtet werden. Diese Einsparung ist nur bei Entry Thunks (nicht Exit Thunks) erforderlich, da x64 strenger als Arm64 ist. Mit anderen Worten, die Registersicherungs- und Erhaltungsregeln in x64 übertreffen die Arm64-Anforderungen in allen Fällen.

Um die korrekte Wiederherstellung dieser Registerwerte beim Abwickeln des Stapels zu gewährleisten (z. B. setjmp + longjmp oder throw + catch), wurde ein neuer Unwind-Opcode eingeführt: save_any_reg (0xE7). Mit diesem neuen 3-Byte-Relax-Opcode können Alle Register für allgemeine Zwecke oder SIMD (einschließlich der als veränderlich eingestuften) und einschließlich vollwertiger Qn Register gespeichert werden. Dieser neue Opcode wird für die Qn Registerüberlauf- und Füllvorgänge verwendet. save_any_reg ist kompatibel mit save_next_pair (0xE6).

Als Referenz gehören die folgenden Unwind-Informationen zu dem zuvor vorgestellten Eintrag '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)

Nachdem die Arm64EC-Funktion zurückkehrt, wechselt die __os_arm64x_dispatch_ret-Routine in den Emulator zurück, wie von LR angegeben, zum x64-Code.

Arm64EC-Funktionen reservieren die vier Bytes vor der ersten Anweisung in der Funktion zum Speichern von Informationen, die zur Laufzeit verwendet werden sollen. In diesen vier Bytes ist die relative Adresse des Eintragsthunks für die Funktion enthalten. Beim Ausführen eines Aufrufs einer x64-Funktion an eine Arm64EC-Funktion liest der Emulator die vier Bytes vor dem Start der Funktion, maskiert die unteren beiden Bits und fügt diesen Betrag der Adresse der Funktion hinzu. Dieser Prozess erzeugt die Adresse des Entry-Thunks, der aufgerufen werden soll.

Adjustor Thunks

Adjustor Thunks sind Signaturenlose Funktionen, die die Steuerung an eine andere Funktion übertragen (Tail-Call). Bevor sie die Steuerung übertragen, transformieren sie einen der Parameter. Der Typ der zu transformierenden Parameter ist bekannt, aber alle verbleibenden Parameter können alles sein und in einer beliebigen Zahl sein. Adjustor Thunks berühren keine Register, die potenziell einen Parameter enthalten, und sie berühren den Stapel nicht. Dieses Merkmal macht Adjustor Thunks zu Funktionen ohne Signatur.

Der Compiler kann Adjustor Thunks automatisch generieren. Diese Generation ist bei der mehrfachen Vererbung in C++ üblich, wobei jede virtuelle Methode ohne Änderung an die übergeordnete Klasse delegieren kann, mit Ausnahme einer Anpassung an den this Zeiger.

Das folgende Beispiel zeigt ein reales Szenario:

[thunk]:CObjectContext::Release`adjustor{8}':
    sub         x0,x0,#8
    b           CObjectContext::Release

Der Thunk subtrahiert 8 Bytes an den this Zeiger und leitet den Aufruf an die übergeordnete Klasse weiter.

Zusammenfassend müssen arm64EC-Funktionen, die von x64-Funktionen aufgerufen werden können, über einen zugeordneten Entry Thunk verfügen. Der Eintrag Thunk ist unterschriftsspezifisch. Arm64-Funktionen ohne Signaturen, z. B. Adjustor Thunks, benötigen einen anderen Mechanismus, der signaturlose Funktionen verarbeiten kann.

Der Entry Thunk eines Adjustor Thunk verwendet den __os_arm64x_x64_jump Helfer, um die Ausführung der echten Entry Thunk-Arbeit zu verzögern (die Parameter von einer Konvention auf die andere anzupassen) auf den nächsten Aufruf zurückstellen. Es ist zu diesem Zeitpunkt, dass die Signatur offensichtlich wird. Dazu gehört auch die Möglichkeit, überhaupt keine Anrufkonventionsanpassungen durchzuführen, wenn sich das Ziel des Adjustor Thunk als x64-Funktion herausstellt. Denken Sie daran, dass sich die Parameter in ihrer x64-Form befinden, wenn ein Entry Thunk gestartet wird.

Überlegen Sie sich im obigen Beispiel, wie der Code in Arm64EC aussieht.

Adjustor Thunk in 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

Adjustor Thunks Einstiegstrunk

[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

Schnelle Vorwärtssequenzen

Einige Anwendungen nehmen Laufzeitänderungen an Funktionen vor, die sich in Binärdateien befinden, die sie nicht besitzen, aber von – häufig Betriebssystem-Binärdateien – abhängen, um die Ausführung zu umleiten, wenn die Funktion aufgerufen wird. Dieser Prozess wird auch als Hooking bezeichnet.

Auf einer höheren Ebene ist der Einbindungsprozess einfach. Im Detail ist hooking jedoch architekturspezifisch und ziemlich komplex, da die potenziellen Variationen der Hookinglogik adressiert werden müssen.

Im Allgemeinen umfasst der Prozess die folgenden Schritte:

  • Bestimmen Sie die Adresse der zu verbindenden Funktion.
  • Ersetzen Sie die erste Anweisung der Funktion durch einen Sprung zur Hook-Routine.
  • Wenn der Haken fertig ist, kehren Sie zur ursprünglichen Logik zurück, die das Ausführen der vertriebenen ursprünglichen Anweisung umfasst.

Die Variationen ergeben sich aus Folgenden:

  • Die Größe der ersten Anweisung: Es ist ratsam, sie durch einen JMP gleicher oder kleinerer Größe zu ersetzen, um zu vermeiden, dass der Anfang der Funktion ersetzt wird, während sie möglicherweise von einem anderen Thread ausgeführt wird.
  • Der Typ der ersten Anweisung: Wenn die erste Anweisung eine PC-relative Natur aufweist, kann die Umsetzung Änderungen an Elementen wie den Verschiebungsfeldern erforderlich machen. Da sie überlaufen können, wenn eine Anweisung an einen entfernten Ort verschoben wird, erfordert diese Änderung möglicherweise, die gleichwertige Logik mit insgesamt anderen Anweisungen bereitzustellen.

Aufgrund dieser Komplexität ist robuste und generische Hookinglogik selten zu finden. Häufig kann die Logik, die in Anwendungen vorhanden ist, nur mit einer begrenzten Anzahl von Fällen umgehen, die die Anwendung in den spezifischen APIs erwartet, für die sie sich interessiert. Es ist nicht schwierig, sich vorzustellen, wie viel von einem Anwendungskompatibilitätsproblem dies ist. Selbst eine einfache Änderung der Code- oder Compileroptimierungen kann Anwendungen unbrauchbar machen, wenn der Code nicht mehr genau wie erwartet aussieht.

Was passiert mit diesen Anwendungen, wenn sie beim Einrichten eines Hooks Arm64-Code gefunden haben? Sie würden sicherlich scheitern.

Diese Kompatibilitätsanforderung wird in Arm64EC von Funktionen für schnelle Vorwärtssequenzen (FAST-Forward Sequence, FFS) erfüllt.

FFS sind sehr kleine x64-Funktionen, die keine echte Logik und keinen Tail-Aufruf der echten Arm64EC-Funktion enthalten. Sie sind optional, aber standardmäßig für alle DLL-Exporte sowie für jede Funktion aktiviert, die mit __declspec(hybrid_patchable) versehen ist.

Wenn Code einen Zeiger auf eine bestimmte Funktion abruft, entweder im GetProcAddress Exportfall oder im &function__declspec(hybrid_patchable) Fall, enthält die resultierende Adresse x64-Code in diesen Fällen. Dieser x64-Code gilt als eine legitime x64-Funktion und entspricht dem Großteil der derzeit verfügbaren Hooking-Logik.

Betrachten Sie das folgende Beispiel (Fehlerbehandlung wird aus Platzgründen weggelassen):

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

Der Funktionszeigerwert in der pgma Variablen enthält die Adresse des GetMachineTypeAttributesFFS.

Dieses Beispiel zeigt eine Schnellvorlauf-Sequenz:

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

Die FFS x64-Funktion verfügt über einen kanonischen Prolog und Epilog, der mit einem Tail-Call (Jump) endet, bis zur realen GetMachineTypeAttributes Funktion im Arm64EC-Code:

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
                           [...]

Es wäre ziemlich ineffizient, wenn es erforderlich wäre, fünf emulierte x64-Anweisungen zwischen zwei Arm64EC-Funktionen auszuführen. FFS-Funktionen sind besonders. FFS-Funktionen werden nicht wirklich ausgeführt, wenn sie unverändert bleiben. Der Anrufprüfungshelfer überprüft effizient, dass das FFS nicht geändert wurde. Wenn dies der Fall ist, wird der Anruf direkt an den tatsächlichen Bestimmungsort weitergeleitet. Wenn der FFS auf beliebige Weise geändert wird, ist es kein FFS mehr. Die Programmausführung wird auf den geänderten FFS übertragen und führt den dort möglicherweise vorhandenen Code aus, der den Umweg und sämtliche Hooking-Logik emuliert.

Wenn der Hook die Ausführung an das Ende des FFS zurückgibt, erreicht er schließlich den Tail-Aufruf des Arm64EC-Codes, der dann nach dem Hook ausgeführt wird, genau wie die Anwendung erwartet.

Erstellen von Arm64EC im Assembler

Windows SDK-Header und der C-Compiler vereinfachen die Erstellung der Arm64EC-Assembly. Sie können z. B. den C-Compiler verwenden, um Eingabe- und Exit-Thunks für Funktionen zu generieren, die nicht aus C-Code kompiliert werden.

Betrachten Sie das Beispiel einer Entsprechung mit der folgenden Funktion fD , die Sie in der Assembly (ASM) erstellen müssen. Sowohl Arm64EC- als auch x64-Code können diese Funktion aufrufen, und der pfE Funktionszeiger kann entweder auf Arm64EC- oder x64-Code verweisen.

typedef int (PF_E)(int, double);

extern PF_E * pfE;

int fD(int i, double d) {
    return (*pfE)(i, d);
}

Das Schreiben fD in ASM sieht möglicherweise wie der folgende Code aus:

#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

Im vorherigen Beispiel:

  • Arm64EC verwendet dieselbe Prozedurdeklaration und Prolog-/Epilog-Makros wie Arm64.
  • Funktionsnamen mit A64NAME Makro umschließen. Wenn Sie C- oder C++-Code als Arm64EC kompilieren, kennzeichnet der Compiler den OBJ als ARM64EC, der Arm64EC-Code enthält. Diese Markierung geschieht nicht mit ARMASM. Wenn Sie ASM-Code kompilieren, können Sie den Linker darüber informieren, dass der erzeugte Code Arm64EC ist, indem Sie dem Funktionsnamen das Präfix voranstellen.# Das A64NAME Makro führt diesen Vorgang aus, wenn _ARM64EC_ er definiert ist und den Namen unverändert lässt, wenn _ARM64EC_ er nicht definiert ist. Dieser Ansatz ermöglicht das Freigeben von Quellcode zwischen Arm64 und Arm64EC.
  • Sie müssen zuerst den pfE Funktionszeiger durch die EC-Aufrufprüfung durchführen, zusammen mit dem entsprechenden Exit-Thunk, falls die Zielfunktion x64 ist.

Generierung von Einstiegs- und Ausgangs-Thunks

Der nächste Schritt besteht darin, den Einstiegs-Thunk für fD und den Ausstiegs-Thunk für pfE zu generieren. Der C-Compiler kann diese Aufgabe mit minimalem Aufwand ausführen, indem das _Arm64XGenerateThunk Compilerschlüsselwort verwendet wird.

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

Das _Arm64XGenerateThunk Schlüsselwort weist den C-Compiler an, die Funktionssignatur zu verwenden, den Text zu ignorieren und entweder einen Exit-Thunk (wenn der Parameter 1 ist) oder einen Eingabe-Thunk zu generieren (wenn der Parameter 2 ist).

Platzieren Sie die Thunk-Generation in einer eigenen C-Datei. Wenn Sie sich in isolierten Dateien befinden, ist es einfacher, die Symbolnamen zu bestätigen, indem sie die entsprechenden OBJ Symbole verwerfen oder sogar zerlegen.

Benutzerdefinierte Einstiegsthunks

Das SDK enthält Makros, mit denen Sie benutzerdefinierte, handcodierte Eingabe-Thunks erstellen können. Sie können diese Makros verwenden, wenn Sie benutzerdefinierte Anpassungs-Thunks erstellen.

Die meisten Adjustor-Thunks werden vom C++-Compiler generiert, aber Sie können sie auch manuell generieren. Möglicherweise generieren Sie manuell einen Adjustor-Thunk, wenn ein generischer Callback die Kontrolle an den tatsächlichen Callback übergibt, und einer der Parameter den tatsächlichen Callback identifiziert.

Das folgende Beispiel zeigt einen Anpassungs-Thunk im Arm64 Classic-Code:

    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

In diesem Beispiel stellt der erste Parameter einen Verweis auf eine Struktur bereit. Der Code ruft die Zielfunktionsadresse aus einem Element dieser Struktur ab. Da die Struktur schreibbar ist, muss control Flow Guard (CFG) die Zieladresse überprüfen.

Das folgende Beispiel zeigt, wie Sie den entsprechenden Adjustor-Thunk für Arm64EC portieren.

    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

Der vorangehende Code stellt keinen Exit-Thunk (im Register x10) zur Verfügung. Dieser Ansatz ist nicht möglich, da der Code für viele verschiedene Signaturen ausgeführt werden kann. Dieser Code nutzt die Einstellung des Aufrufers, bei der x10 auf den Exit-Thunk gesetzt wird. Der Aufrufer führt den Anruf für eine explizite Signatur aus.

Der vorangehende Code benötigt einen Eintrags-Thunk, um den Fall zu beheben, wenn der Aufrufer x64-Code ist. Das folgende Beispiel zeigt, wie Sie den entsprechenden Eintrag thunk mithilfe des Makros für benutzerdefinierte Eingabe-Thunks erstellen:

    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

Im Gegensatz zu anderen Funktionen überträgt dieser Entry Thunk letztendlich nicht die Programmsteuerung an die zugeordnete Funktion (den Adjustor Thunk). In diesem Fall integriert der Entry-Thunk die Funktionalität selbst (führt die Parameteranpassung durch) und überträgt die Kontrolle direkt durch den __os_arm64x_x64_jump-Helper an das Endziel.

Dynamisch generierender Arm64EC-Code (JIT-Kompilierung)

In Arm64EC-Prozessen gibt es zwei Arten von ausführbarem Speicher: Arm64EC-Code und x64-Code.

Das Betriebssystem extrahiert diese Informationen aus den geladenen Binärdateien. x64-Binärdateien sind alle x64, und Arm64EC-Binärdateien enthalten eine Bereichstabelle für Arm64EC im Vergleich zu x64-Codeseiten.

Was ist mit dynamisch generierten Code? Just-in-Time -Compiler (JIT) generieren Code zur Laufzeit, der nicht durch eine Binärdatei gesichert wird.

In der Regel umfasst dieser Vorgang die folgenden Schritte:

  • Zuordnen des schreibbaren Speichers (VirtualAlloc).
  • Erstellen des Codes im zugewiesenen Speicher.
  • Erneutes Schützen des Speichers von Lese-/Schreibzugriff auf Lese- und Ausführungszugriff (VirtualProtect).
  • Hinzufügen von Entspannfunktionseinträgen für alle nicht trivialen (nicht blattfreien) generierten Funktionen (RtlAddFunctionTable oder RtlAddGrowableFunctionTable).

Aus trivialen Kompatibilitätsgründen betrachtet das Betriebssystem den Code als x64-Code, wenn eine Anwendung diese Schritte in einem Arm64EC-Prozess ausführt. Dieses Verhalten geschieht für jeden Prozess, der die unveränderte x64-Java-Runtime, .NET-Runtime, das JavaScript-Modul usw. verwendet.

Führen Sie zum Generieren von dynamischem Arm64EC-Code denselben Prozess mit zwei Unterschieden aus:

  • Verwenden Sie beim Zuweisen des Speichers den neueren VirtualAlloc2 (anstelle von VirtualAlloc oder VirtualAllocEx) und geben Sie das Attribut MEM_EXTENDED_PARAMETER_EC_CODE an.
  • Beim Hinzufügen von Funktionseinträgen:
    • Sie müssen im Arm64-Format vorliegen. Beim Kompilieren von Arm64EC-Code entspricht der RUNTIME_FUNCTION Typ dem x64-Format. Verwenden Sie für das Arm64-Format beim Kompilieren von Arm64EC stattdessen den ARM64_RUNTIME_FUNCTION Typ.
    • Verwenden Sie nicht die ältere RtlAddFunctionTable API. Verwenden Sie immer die neuere RtlAddGrowableFunctionTable API.

Das folgende Beispiel zeigt die Speicherzuweisung:

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

Im folgenden Beispiel wird gezeigt, wie Sie einen Funktionseintrag zum Ausspannen hinzufügen:

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