Hinweis
Für den Zugriff auf diese Seite ist eine Autorisierung erforderlich. Sie können versuchen, sich anzumelden oder das Verzeichnis zu wechseln.
Für den Zugriff auf diese Seite ist eine Autorisierung erforderlich. Sie können versuchen, das Verzeichnis zu wechseln.
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.
- Registrieren von Zuordnungen und blockierten Registern
- Anrufprüfer
- Stapelprüfer
- Variadische Anrufkonvention
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
x11anstelle vonx15. - 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.x3Die übrigen Parameter werden auf den Stack übertragen. Diese Regel folgt genau der x64 variadischen Aufrufkonvention und unterscheidet sich von Arm64 Classic, bei der Registerx0x7verwendet 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 alsf1(int, double)aufgerufen wird, der zweite Parameter sowohlRDXals auchXMM1zugewiesen, wenn x64 verwendet wird. Auf Arm64EC wird der zweite Parameter nurx1zugewiesen. - 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
x4Register 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
d0zugewiesen. - "tc" ist eine Struktur mit einer Größe von 3 Bytes. Es wird
x0zugewiesen. -
ull1ist eine ganze Zahl mit 8 Byte. Es weistx1zu. -
ull2ist eine ganze Zahl mit 8 Byte. Es wird zux2zugewiesen. -
ull3ist eine ganze Zahl mit 8 Byte. Es wird zux3zugewiesen.
pt_va_function ist eine variadische Funktion, sodass sie den zuvor beschriebenen Variadischen Regeln von Arm64EC folgt:
- 'f' ist ein Double. Es wird auf
x0zugewiesen. - "tc" ist eine Struktur mit einer Größe von 3 Bytes. Es fließt in den Stack über und wird in
x1geladen. -
ull1ist eine ganze Zahl mit 8 Byte. Es wirdx2zugewiesen. -
ull2ist eine ganze Zahl mit 8 Byte. Es wirdx3zugewiesen. -
ull3ist eine ganze Zahl mit 8 Byte. Es weist direkt dem Stack zu. -
x4lädt die Adresse vonull3im Stapel. -
x5lädt die Größe vonull3.
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:
- Einreise und Ausgang Thunks
- Exit-Schleifen
- Eintrag Thunks
- Adjustor Thunks (Anpassungssprünge)
- Schnelle Vorwärtssequenzen
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 übergibtx9ohne Ä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:
-
x11stellt die Adresse der Zielfunktion bereit, die aufgerufen werden soll (fBin diesem Fall). An diesem Punkt weiß die Anrufprüfer möglicherweise nicht, ob die Zielfunktion Arm64EC oder x64 ist. -
x10liefert einen Exit Thunk, der der Signatur der aufgerufenen Funktion entspricht (fBin 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:
-
x11gibt 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:
-
x11gibt die Adresse des Exit Thunks zurück. Diese Adresse wird aus der Eingabex10kopiert. -
x10gibt die Adresse des Exit Thunk zurück, unverändert durch die Eingabe. -
x9gibt die x64-Zielfunktion zurück. Dieser Wert kann mit dem in der Dateix11bereitgestellten 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
A64NAMEMakro umschließen. Wenn Sie C- oder C++-Code als Arm64EC kompilieren, kennzeichnet der Compiler denOBJalsARM64EC, der Arm64EC-Code enthält. Diese Markierung geschieht nicht mitARMASM. 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.#DasA64NAMEMakro 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
pfEFunktionszeiger 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 (
RtlAddFunctionTableoderRtlAddGrowableFunctionTable).
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 vonVirtualAllocoderVirtualAllocEx) und geben Sie das AttributMEM_EXTENDED_PARAMETER_EC_CODEan. - Beim Hinzufügen von Funktionseinträgen:
- Sie müssen im Arm64-Format vorliegen. Beim Kompilieren von Arm64EC-Code entspricht der
RUNTIME_FUNCTIONTyp dem x64-Format. Verwenden Sie für das Arm64-Format beim Kompilieren von Arm64EC stattdessen denARM64_RUNTIME_FUNCTIONTyp. - Verwenden Sie nicht die ältere
RtlAddFunctionTableAPI. Verwenden Sie immer die neuereRtlAddGrowableFunctionTableAPI.
- Sie müssen im Arm64-Format vorliegen. Beim Kompilieren von Arm64EC-Code entspricht der
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)
);
Windows on Arm