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.

Zweck dieses Dokuments ist es, eine detaillierte Ansicht der Arm64EC ABI mit ausreichendEn Informationen für einen Anwendungsentwickler bereitzustellen, um Code zu schreiben und zu debuggen, der für Arm64EC kompiliert wurde, einschließlich Low-Level-/Assembler-Debugging und Schreiben von Assemblycode für die Arm64EC ABI.

Design von Arm64EC

Arm64EC wurde entwickelt, um Funktionen und Leistung auf systemeigener Ebene bereitzustellen und gleichzeitig transparente und direkte Interoperabilität mit x64-Code bereitzustellen, der unter emulation ausgeführt wird.

Arm64EC ist hauptsächlich zum Klassischen Arm64 ABI addiert. Sehr wenig von der klassischen ABI wurde geändert, aber Teile wurden hinzugefügt, um die x64-Interoperabilität zu ermöglichen.

In diesem Dokument wird der Originalstandard Arm64 ABI als "Classic ABI" bezeichnet. Dadurch wird die Mehrdeutigkeit vermieden, die überladenen Ausdrücken wie "Native" inhärent ist. Arm64EC, um klar zu sein, ist jedes Mal so systemisch wie die ursprüngliche ABI.

Arm64EC vs Arm64 Classic ABI

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

Dies sind kleine Änderungen, wenn man sieht, wie viel die gesamte ABI definiert.

Registrieren von Zuordnungen und blockierten Registern

Damit die Interoperabilität auf Typebene mit x64-Code erfolgen kann, wird Arm64EC-Code mit den gleichen Definitionen der Präprozessorarchitektur wie x64-Code kompiliert.

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.

Diese Struktur muss verwendet werden, um den CPU-Kontext während der Ausführung von x64-Code sowie Arm64EC-Code darzustellen. Bestehender Code würde kein neues Konzept verstehen, z. B. das CPU-Register, das von Funktion zu Funktion wechselt. Wenn die x64-Struktur CONTEXT verwendet wird, um Arm64-Ausführungszustände darzustellen, bedeutet dies, dass Arm64-Register effektiv in x64-Registern zugeordnet werden.

Es bedeutet auch, dass alle Arm64-Register, die nicht in die x64 CONTEXT integriert werden können, nicht verwendet werden dürfen, da ihre Werte jederzeit verloren gehen können, wenn ein Vorgang verwendet CONTEXT wird (und einige können asynchron und unerwartet sein, z. B. der Garbage Collection-Vorgang einer verwalteten Sprachlaufzeit oder ein APC).

Die Zuordnungsregeln zwischen Arm64EC- und x64-Registern werden durch die Struktur in den ARM64EC_NT_CONTEXT Windows-Headern dargestellt, die im SDK vorhanden sind. 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.

Beispiel: RCX Zuordnungen zu X0, RDX zu X1, RSP zu SP, RIP zu PC, zu , usw. Wir können auch sehen, wie die Register x13, , x14, x24x23, x28keine v16-v31 Vertretung haben und somit nicht in Arm64EC verwendet werden können.

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 Code mit der Option /guard:cf kompiliert wird, generiert der Compiler einen zusätzlichen Aufruf der Checker-Funktion direkt vor jedem indirekten Aufruf/Sprung. Die Prüffunktion selbst wird von Windows bereitgestellt, und für CFG führt sie eine Gültigkeitsprüfung für die bekannten Aufrufziele durch. Diese Informationen sind auch in Binärdateien enthalten, die mit /guard:cfkompiliert wurden.

Dies ist ein Beispiel für die Verwendung einer Anrufprüfer in Classic 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

Im CFG-Fall gibt die Anrufprüfer einfach zurück, wenn das Ziel gültig ist, oder wenn dies nicht der Fall 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 Code 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 (aka varargs, aka functions with the ellipsis (. . .) parameter Schlüsselwort (keyword)).

Für den variadischen Spezifischen Fall folgt Arm64EC einer Aufrufkonvention, die mit nur wenigen Unterschieden sehr ähnlich wie x64 variadic ist. Im Folgenden finden Sie die wichtigsten Regeln für arm64EC variadic:

  • Nur die ersten 4 Register werden für die Parameterübergabe verwendet: x0, x1, , x2. x3 Re Standard ingparameter werden auf den Stapel übergelaufen. Dies folgt genau der x64 variadischen Aufrufkonvention und unterscheidet sich von Arm64 Classic, wo Register x0>x7 verwendet werden.
  • Gleitkomma-/SIMD-Parameter, die von register übergeben werden, verwenden ein General-Purpose-Register, kein SIMD-Parameter. Dies ähnelt Arm64 Classic und unterscheidet sich von x64, wobei FP/SIMD-Parameter sowohl in einem General-Purpose- als auch im SIMD-Register übergeben werden. For example, for a function f1(int, …) being called as f1(int, double), on x64, the second parameter will be assigned to both RDX and XMM1. 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 werden direkt in das Register für allgemeine Zwecke geladen. Strukturen mit anderen Größen werden auf den Stapel übergelaufen, und dem Register wird ein Zeiger auf die übergelaufene Position zugewiesen. Dies herabgestuft im Wesentlichen den Nachwert in einen Nachverweis auf niedriger Ebene. Auf dem klassischen Arm64 ABI werden Strukturen beliebiger Größe bis zu 16 Byte direkt den General-Purposed-Registern zugewiesen.
  • X4-Register wird mit einem Zeiger auf den ersten Parameter geladen, der über stapel übergeben wird (der 5. Parameter). Dies schließt keine Strukturen ein, die aufgrund der oben beschriebenen Größenbeschränkungen übergelaufen sind.
  • Das X5-Register wird in Bytes mit der Größe aller Parameter geladen, die vom Stapel übergeben werden (Größe aller Parameter beginnend mit dem 5. ). Dies schließt keine Strukturen ein, die aufgrund der oben beschriebenen Größenbeschränkungen übergeben werden.

Im folgenden Beispiel: pt_nova_function Unten werden Parameter in einer nicht variadischen Form verwendet, daher nach der klassischen Arm64-Aufrufkonvention. 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-Anrufkonventionsregeln zugewiesen werden:

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

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

  • 'f' ist ein Double. Sie wird x0 zugewiesen.
  • "tc" ist eine Struktur mit einer Größe von 3 Bytes. Es wird auf den Stapel übergelaufen und seine Position in x1 geladen.
  • ull1 ist eine ganze Zahl mit 8 Byte. Sie wird x2 zugewiesen.
  • ull2 ist eine ganze Zahl von 8 Byte. Sie wird x3 zugewiesen.
  • ull3 ist eine ganze Zahl von 8 Byte. Sie wird direkt dem Stapel zugewiesen.
  • x4 wird mit der Position von "ull3" im Stapel geladen.
  • x5 wird mit der Größe von "ull3" geladen.

Im Folgenden finden Sie eine mögliche Kompilierungsausgabe, pt_nova_functiondie die oben 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, wurden viele Ergänzungen an der klassischen Arm64 ABI vorgenommen. Sie behandeln die Unterschiede zwischen Arm64EC und x64.

Die folgende Liste enthält diese Ergänzungen:

Einreise und Ausgang Thunks

Entry and Exit Thunks kümmern sich um die Übersetzung der Arm64EC-Anrufkonvention (meist identisch mit Classic Arm64) in die x64-Anrufkonvention und umgekehrt.

Ein häufiges Missverständnis besteht darin, dass Aufrufkonventionen durch Ausführen einer einzelnen Regel konvertiert werden können, 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 die Typen der vorherigen Parameter in der Liste sind abhängig. Dieses Detail wird durch den 3. Parameter veranschaulicht. In beiden Funktionen ist der Parametertyp "int", die resultierende Übersetzung ist jedoch unterschiedlich.

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

Beide Arten von Thunks sind selbst Funktionen. Entry Thunks werden automatisch vom Emulator aufgerufen, wenn x64-Funktionen Arm64EC-Funktionen aufrufen (Ausführung Enters Arm64EC). Exit Thunks werden automatisch von den Anrufprüfern aufgerufen, wenn Arm64EC-Funktionen x64-Funktionen aufrufen (Ausführung Exits Arm64EC).

Beim Kompilieren von Arm64EC-Code generiert der Compiler für jede Arm64EC-Funktion einen Eintrags-Thunk, der seine Signatur abgleicht. Der Compiler generiert für jede Funktion auch einen Exit Thunk für jede Funktion, die eine Arm64EC-Funktionsaufrufe 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 obigen Codes für Arm64EC generiert der Compiler Folgendes:

  • Code für 'fA'.
  • Eintrag Thunk für 'fA'
  • Exit Thunk für 'fB'
  • Exit Thunk für 'fC'

Der fA Eintrag Thunk wird für den Fall fA generiert und aus x64-Code aufgerufen. Exit Thunks for fB and fC are generated in case fB and/or fC and turn out to be x64 code.

Dasselbe Exit Thunk kann mehrmals generiert werden, da der Compiler sie an der Aufrufwebsite generiert, anstatt die Funktion selbst. Dies kann zu einer beträchtlichen Menge redundanter Thunks führen, sodass der Compiler in Wirklichkeit triviale Optimierungsregeln anwendet, um sicherzustellen, dass nur die erforderlichen Thunks es in die endgültige Binärdatei umwandeln.

In einer Binärdatei, in der die Arm64EC-Funktion A arm64EC aufruft, B wird die Arm64EC-Funktion Bnicht exportiert, und ihre Adresse ist außerhalb von A. Es ist sicher, den Exit Thunk A von zu Bbeseitigen, zusammen mit der Entry 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.

Exit Thunks

Mit den Beispielfunktionen fAfB und fC oben wird der Compiler sowohl als fC auch fB Exit Thunks generieren:

Exit Thunk nach int fB(int a, double b, int i1, int i2, int i3);

$iexit_thunk$cdecl$i8$i8di8i8i8:
    stp         fp,lr,[sp,#-0x10]!
    mov         fp,sp
    sub         sp,sp,#0x30
    adrp        x8,__os_arm64x_dispatch_call_no_redirect
    ldr         xip0,[x8]
    str         x3,[sp,#0x20]  ; Spill 5th param (i3) into the stack
    fmov        d1,d0          ; Move 2nd param (b) from d0 to XMM1 (x1)
    mov         x3,x2          ; Move 4th param (i2) from x2 to R9 (x3)
    mov         x2,x1          ; Move 3rd param (i1) from x1 to R8 (x2)
    blr         xip0           ; Call the emulator
    mov         x0,x8          ; Move return from RAX (x8) to x0
    add         sp,sp,#0x30
    ldp         fp,lr,[sp],#0x10
    ret

Exit Thunk nach 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 können wir sehen, wie das Vorhandensein eines "double"-Parameters dazu führt, dass die Zuordnung des GP-Registers neu Standard wird, um die Zuweisung neu zu überschreiben, ein Ergebnis der verschiedenen Zuordnungsregeln von Arm64 und x64. Wir können auch sehen, dass x64 nur vier Parameter zu Registern zuweist, sodass der 5. Parameter auf den Stapel übergelaufen 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 dies dann vom Register auf den Stapel übertragen struct und stattdessen einen Zeiger auf das Register zuweisen. Dies verwendet weiterhin ein Register (um den Zeiger zu tragen), sodass keine Zuordnungen für die neu Standard ing-Register geändert werden: Für den 3. und 4. Parameter erfolgt keine Registeränderung. Genau wie für den fB Fall muss der 5. Parameter auf den Stapel übergelaufen werden.

Zusätzliche Überlegungen für Exit Thunks:

  • Der Compiler trägt sie nicht nach dem Funktionsnamen, von> dem sie übersetzen, sondern der Signatur, die sie adressieren. Dies erleichtert das Auffinden von Redundanzen.
  • Die Exit Thunk wird mit dem Register x9 aufgerufen, das die Adresse der Zielfunktion (x64) trägt. Dies wird von der Anrufprüfer festgelegt und durchläuft die Exit Thunk, unbestreitet, in den Emulator.

Nach dem Neuanordnen der Parameter ruft die Exit Thunk dann über den Emulator __os_arm64x_dispatch_call_no_redirectauf.

An diesem Punkt lohnt es sich, die Funktion der Anrufprüfer zu überprüfen und details zu seiner eigenen benutzerdefinierten ABI. So würde ein indirekter Aufruf fB aussehen:

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). Es ist möglicherweise nicht bekannt, zu diesem Zeitpunkt, wenn 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 Aufrufprüfer zurückgegebenen Daten hängen von der Zielfunktion ab, die Arm64EC oder x64 ist.

Wenn das Ziel Arm64EC lautet:

  • x11 gibt die Adresse des Arm64EC-Codes zurück, der aufgerufen werden soll. Dies kann oder nicht derselbe Wert sein, in dem angegeben wurde.

Wenn das Ziel x64-Code ist:

  • x11 gibt die Adresse des Exit Thunk zurück. Dies wird aus der Eingabe kopiert, die in x10.
  • x10 gibt die Adresse des Exit Thunk zurück, die nicht von der Eingabe entfernt wurde.
  • x9 gibt die x64-Zielfunktion zurück. Dies kann oder nicht derselbe Wert sein, über den sie bereitgestellt x11wurde.

Anrufprüfer lassen immer die Aufrufkonventionsparameter register unbeachtet, sodass der aufrufende Code dem Aufruf der Anrufprüfer sofort folgen blr x11 sollte (oder br x11 bei einem Tail-Call). Hierbei handelt es sich um die Registrierten Anrufprüfer. Sie werden immer über standardmäßige nicht veränderliche Register hinaus beibehalten: 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. Dies ist im Wesentlichen die Umgekehrte von Exit Thunks, aber es gibt ein paar weitere Aspekte zu berücksichtigen.

Sehen Sie sich das vorherige Beispiel für die Kompilierung fAan, ein Entry Thunk wird generiert, fA sodass dieser durch x64-Code aufgerufen werden 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

Die Adresse der Zielfunktion wird vom Emulator in x9bereitgestellt.

Vor dem Aufrufen des Eintrags Thunk füllt der x64-Emulator die Absenderadresse aus dem Stapel in das LR Register ein. Es wird dann erwartet, dass LR beim Übertragen der Steuerung auf den Entry Thunk auf x64 Code verweist.

Der Emulator kann auch eine weitere Anpassung am Stapel vornehmen, je nach folgendem: Sowohl Arm64- als auch x64-ABIs definieren eine Stapelausrichtungsanforderung, bei der der Stapel an dem Punkt, an dem eine Funktion ausgerichtet werden muss, ausgerichtet werden muss. Wenn Arm64-Code ausgeführt wird, erzwingt Hardware diese Regel, aber es gibt keine Hardwareerzwingung für x64. Beim Ausführen von x64-Code gehen fehlerhafte Aufrufe von Funktionen mit einem nicht ausgerichteten Stapel möglicherweise unbemerkt unbemerkt, bis einige 16-Byte-Ausrichtungsanweisung verwendet wird (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 Byte aus und speichert seinen ursprünglichen Wert im x4 Register. Auf diese Weise beginnen Entry Thunks immer mit der Ausführung eines ausgerichteten Stapels, kann aber trotzdem korrekt auf die parameter verweisen, die am Stapel übergeben werden, via x4.

Bei nicht volatilen SIMD-Registern besteht ein erheblicher 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 XMM7 nicht veränderliche Register vorhanden, XMM6 während D6 und D7 (die entsprechenden Arm64-Register) veränderlich sind.

Um diese SIMD Register Manipulation asymmetrien zu adressieren, muss Entry Thunks explizit alle SIMD-Register speichern, die in x64 als nicht veränderlich angesehen werden. Dies ist nur bei Entry Thunks (nicht Exit Thunks) erforderlich, da x64 strenger als Arm64 ist. Mit anderen Worten, register saving/preservation rules in x64 überschreiten die Arm64-Anforderungen in allen Fällen.

Um die richtige Wiederherstellung dieser Registerwerte beim Abwickeln des Stapels zu beheben (z. B. setjmp + longjmp, oder throw + catch), wurde ein neuer Abspann 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 oben genannten Registerüberlauf-/Ausfüllvorgänge verwendet. save_any_reg ist kompatibel mit save_next_pair (0xE6).

Im Folgenden finden Sie die entsprechenden Entspanninformationen, die dem oben vorgestellten Eintrag Thunk gehören:

   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ückgegeben wurde, wird die __os_arm64x_dispatch_ret Routine verwendet, um den Emulator wieder einzugeben, zurück zum x64-Code (auf den verwiesen von LR).

Arm64EC-Funktionen verfügen über die 4 Byte vor der ersten Anweisung in der Funktion, die zum Speichern von Informationen reserviert ist, die zur Laufzeit verwendet werden sollen. In diesen 4 Byte ist die relative Adresse des Eintrags Thunk für die Funktion zu finden. Beim Ausführen eines Aufrufs von einer x64-Funktion an eine Arm64EC-Funktion liest der Emulator die vier Bytes vor dem Start der Funktion, maskieren Sie die unteren beiden Bits, und fügen Sie diesen Betrag der Adresse der Funktion hinzu. Dies wird die Adresse des Eintrags Thunk liefern, der angerufen wird.

Adjustor Thunks

Adjustor Thunks sind Signaturenlose Funktionen, die einfach die Steuerung an eine andere Funktion übertragen (Tail-Call), nachdem sie eine Transformation zu einem der Parameter durchgeführt haben. Der Typ der zu transformierenden Parameter ist bekannt, aber alle neu Standard ing-Parameter können alles sein und in einer beliebigen Zahl – Adjustor Thunks berührt kein Register, das potenziell einen Parameter enthält und den Stapel nicht berührt. Dies macht Adjustor Thunks signaturlose Funktionen.

Adjustor Thunks können automatisch vom Compiler generiert werden. Dies ist z. B. bei C++-Vererbung mit mehreren Vererbungen üblich, wobei jede virtuelle Methode an die übergeordnete Klasse delegiert werden kann, die nicht geändert wird, abgesehen von einer Anpassung an den this Zeiger.

Im Folgenden finden Sie ein Beispiel für eine praxisnahe Anwendung:

[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, sondern von – häufig Betriebssystem-Binärdateien – abhängig sind, um die Ausführung zu umleiten, wenn die Funktion aufgerufen wird. Dies wird auch als Hooking bezeichnet.

Auf hoher Ebene ist der Hakenprozess 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 Folgendes:

  • 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 1. Anweisung: Es empfiehlt sich, sie durch einen JMP zu ersetzen, der die gleiche Größe oder kleiner ist, um zu vermeiden, dass der obere Rand der Funktion ersetzt wird, während ein anderer Thread sie im Test-Flight ausführt.
  • Der Typ der ersten Anweisung: Wenn die erste Anweisung über eine relative PC-Art verfügt, müssen Sie sie möglicherweise ändern, z. B. die Verdrängungsfelder. Da sie wahrscheinlich überlaufen werden, wenn eine Anweisung an einen entfernten Ort verschoben wird, kann dies eine gleichwertige Logik mit unterschiedlichen Anweisungen insgesamt erfordern.

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, auf die die Anwendung in den spezifischen APIs stoßen wird, an denen sie interessiert ist. 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 auf Arm64-Code stoßen würden? Sie würden sicherlich scheitern.

Fast-Forward Sequence (FFS)-Funktionen erfüllen diese Kompatibilitätsanforderung in Arm64EC.

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

In diesen Fällen enthält der resultierende Code x64-Code, wenn code einen Zeiger auf eine bestimmte Funktion abruft, entweder durch GetProcAddress den Exportfall oder durch &function den __declspec(hybrid_patchable) Fall. Dieser x64-Code wird für eine legitime x64-Funktion übergeben und erfüllt die meisten der derzeit verfügbaren Hookinglogik.

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 Wert des Funktionszeigers in der pgma Variablen enthält die Adresse des GetMachineTypeAttributesFFS.

Dies ist ein Beispiel für eine Fast-Forward-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, 5 emulierte x64-Anweisungen zwischen zwei Arm64EC-Funktionen auszuführen. FFS-Funktionen sind besonders. FFS-Funktionen werden nicht wirklich ausgeführt, wenn sie unverändert sind Standard. Die Anrufprüferhilfe überprüft effizient, ob das FFS nicht geändert wurde. Wenn dies der Fall ist, wird der Anruf direkt an das eigentliche Ziel weitergeleitet. Wenn der FFS auf eine mögliche Weise geändert wurde, ist es kein FFS mehr. Die Ausführung wird an den geänderten FFS übertragen und den Code ausführen, der den Umweg und jede Hooking-Logik emuliert.

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

Erstellen von Arm64EC in Assembly

Windows SDK-Header und der C-Compiler können die Erstellung der Arm64EC-Assembly vereinfachen. Beispielsweise kann der C-Compiler verwendet werden, um Entry and Exit Thunks für Funktionen zu generieren, die nicht aus C-Code kompiliert wurden.

Betrachten Sie das Beispiel einer Entsprechung mit der folgenden Funktion fD , die in Assembly (ASM) erstellt werden muss. Diese Funktion kann sowohl von Arm64EC- als auch von x64-Code aufgerufen werden, und der pfE Funktionszeiger kann auch auf Arm64EC- oder x64-Code zeigen.

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 würde etwa wie folgt aussehen:

#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

Auf das obige Beispiel trifft Folgendes zu:

  • Arm64EC verwendet dieselbe Prozedurdeklaration und Prolog-/Epilog-Makros wie Arm64.
  • Funktionsnamen sollten durch das A64NAME Makro umbrochen werden. Beim Kompilieren von C/C++-Code als Arm64EC markiert der Compiler den OBJ Code " ARM64EC Arm64EC". Dies geschieht nicht mit ARMASM. Beim Kompilieren von ASM-Code gibt es eine alternative Möglichkeit, den Linker darüber zu informieren, dass der erstellte Code Arm64EC ist. Dies ist das Präfix des Funktionsnamens mit #dem Präfix . 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. Dies ermöglicht das Freigeben von Quellcode zwischen Arm64 und Arm64EC.
  • Der pfE Funktionszeiger muss zuerst über die EC-Anrufprüfer ausgeführt werden, zusammen mit dem entsprechenden Exit Thunk, falls die Zielfunktion x64 ist.

Ein- und Ausstieg Thunks

Der nächste Schritt besteht darin, die Entry Thunk für fD und die Exit Thunk für pfE. Der C-Compiler kann diese Aufgabe mit minimalem Aufwand ausführen, indem der _Arm64XGenerateThunk Compiler Schlüsselwort (keyword) 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;
}

Der _Arm64XGenerateThunk Schlüsselwort (keyword) 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 Entry Thunk (wenn der Parameter 2 ist) zu generieren.

Es wird empfohlen, die Thunk-Generation in einer eigenen C-Datei zu platzieren. 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.

Benutzerdefinierter Eintrag Thunks

Makros wurden dem SDK hinzugefügt, um die Erstellung von benutzerdefinierten, handcodierten, Entry Thunks zu unterstützen. Ein Fall, in dem dies verwendet werden kann, ist die Erstellung von benutzerdefinierten Adjustor Thunks.

Die meisten Adjustor Thunks werden vom C++-Compiler generiert, können aber auch manuell generiert werden. Dies kann in Fällen gefunden werden, in denen ein generisches Rückrufsteuerelement an den tatsächlichen Rückruf überträgt, der durch einen der Parameter identifiziert wird.

Im Folgenden finden Sie ein Beispiel in 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 wird die Zielfunktionsadresse aus dem Element einer Struktur abgerufen, die durch Verweis über den 1. Parameter bereitgestellt wird. Da die Struktur schreibbar ist, muss die Zieladresse über Control Flow Guard (CFG) überprüft werden.

Im folgenden Beispiel wird veranschaulicht, wie der entsprechende Adjustor Thunk aussehen würde, wenn er nach Arm64EC portiert wurde:

    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 obige Code liefert keinen Exit Thunk (im Register x10). Dies ist nicht möglich, da der Code für viele verschiedene Signaturen ausgeführt werden kann. Dieser Code nutzt den Aufrufer, der x10 auf "Exit Thunk" festgelegt hat. Der Anrufer hätte den Anruf auf eine explizite Signatur ausgerichtet.

Der obige Code benötigt einen Eintrag Thunk, um den Fall zu beheben, wenn der Aufrufer x64-Code ist. So erstellen Sie den entsprechenden Eintrag Thunk unter Verwendung des Makros für benutzerdefinierte Entry Thunks:

    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 Eintrags-Thunk schließlich keine Steuerung an die zugeordnete Funktion (der Adjustor Thunk). In diesem Fall wird die Funktionalität selbst (die Parameteranpassung durchführen) in den Entry Thunk eingebettet und die Steuerung direkt an das Endziel über den __os_arm64x_x64_jump Helfer übertragen.

Arm64EC-Code dynamisch generieren (JIT Compiling)

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 enthalten eine Bereichstabelle für Arm64EC vs x64 Code pages.

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

In der Regel bedeutet dies:

  • Zuordnen des schreibbaren Speichers (VirtualAlloc).
  • Der Code wird im zugewiesenen Speicher erstellt.
  • Erneutes Schützen des Speichers vor Lese-/Schreibzugriff auf "Read-Execute" (VirtualProtect).
  • Fügen Sie Entspannfunktionseinträge für alle nicht trivialen (nicht blattfreien) generierten Funktionen (RtlAddFunctionTable oder RtlAddGrowableFunctionTable) hinzu.

Aus trivialen Kompatibilitätsgründen führt jede Anwendung, die diese Schritte in einem Arm64EC-Prozess ausführt, dazu, dass der Code als x64-Code betrachtet wird. Dies geschieht für jeden Prozess, der die nicht geänderte x64-Java-Runtime, .NET-Runtime, javaScript-Engine usw. verwendet.

Zum Generieren von dynamischem Arm64EC-Code ist der Prozess meist mit nur zwei Unterschieden identisch:

  • Verwenden Sie beim Zuweisen des Speichers neuere VirtualAlloc2 (anstelle oder VirtualAllocVirtualAllocEx) und geben Sie das Attribut an MEM_EXTENDED_PARAMETER_EC_CODE .
  • 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 stattdessen immer die neuere RtlAddGrowableFunctionTable API.

Nachfolgend sehen Sie ein Beispiel für 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);

Und ein Beispiel für das Hinzufügen eines Abspannfunktionseintrags:

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