- Elevator übersetzt vollständige x86-64-Executable-Dateien statisch nach AArch64 – ohne Debug-Informationen, Quellcode oder Annahmen über das Binärlayout
- Statt Heuristiken zur Unterscheidung von Code und Daten erzeugt es einen Superset-CFG, der alle möglichen Interpretationen jedes Bytes enthält, und entfernt nur Pfade, die in Programmabbruch enden
- Es bildet den x64-Zustand eins zu eins auf AArch64-Register ab und verarbeitet indirekte Sprünge über eine Lookup-Tabelle, die von Originaladressen zu übersetztem Code führt
- Eine Offline-Tile-Bank beschreibt die Semantik von x64-Instruktionen als C-Templates und kompiliert sie anschließend mit LLVM 20 zu AArch64-Byte-Sequenzen
- Das Ergebnis ist ein eigenständiges AArch64-Binary ohne Laufzeitübersetzung, das bei SPECint 2006 eine Leistung auf oder über dem Niveau des QEMU-User-Mode-JIT erreicht
Ziel von Elevator
- Elevator ist ein vollständig statischer Binärübersetzer, der komplette x86-64-Executable-Dateien nach AArch64 überführt
- Er verwendet weder Debug-Informationen noch Quellcode, Code-Muster des Original-Binaries oder Annahmen über das Layout des Binaries
- Bestehende statische Übersetzer verlassen sich zur Unterscheidung von Code und Daten auf Heuristiken oder Laufzeit-Fallbacks, während Elevator jedes Byte der Original-Executable vorab für alle möglichen Interpretationen übersetzt
- Da jedes Byte Daten, Teil eines Opcodes oder Teil eines Opcode-Arguments sein kann, erzeugt er einen Superset-CFG, der alle möglichen Kontrollflüsse enthält, und entfernt nur Pfade, die zu einem Ausnahme-abbruch des Programms führen
- Die Ausgabe besteht aus einem eigenständigen AArch64-Binary, das den übersetzten Code, das originale x64-Binary, eine Adress-Lookup-Tabelle und einen Laufzeit-Treiber enthält
- Nach Abschluss der Übersetzung kann es ohne JIT oder Unterstützung für Laufzeitübersetzung ausgeführt werden
- Wird dasselbe Eingabebinary zweimal übersetzt, entsteht dieselbe Ausgabebitfolge; Test, Verifikation, Zertifizierung und kryptografische Signatur beziehen sich damit auf denselben tatsächlich ausgelieferten Code
- Der Hauptpreis ist größeres Codevolumen; im Gegenzug lässt sich das Ergebnis vor der Auslieferung besser verifizieren als bei Emulatoren oder JIT-Compilern
- Die Auswertung umfasst die vollständige SPECint-2006-Benchmark-Suite sowie handgefertigte Binaries; die Leistung liegt auf dem Niveau der QEMU-User-Mode-Emulation mit JIT-Beschleunigung oder darüber
- Die Forschenden kündigen an, das gesamte Projekt nach Abschluss als Open Source zu veröffentlichen
Warum statische Übersetzung nötig ist und wo bisherige Grenzen liegen
- Wenn Hardware von einer ISA auf eine andere wechselt, muss bestehende Software auf die neue Plattform gebracht werden; das bloße Neukompilieren noch vorhandenen Quellcodes reicht dafür womöglich nicht aus
- Bei verifizierter oder zertifizierter Legacy-Software ist oft nicht der Quellcode, sondern ein gut getestetes spezifisches maßgebliches Binary Gegenstand der Zertifizierung
- Um später aus dem Quellcode exakt dasselbe Binary bitgenau zu reproduzieren, könnten die damaligen Versionen von Compiler, Linker und Build-System nötig sein, was praktisch oft schwierig ist
- Wenn Hersteller Patches direkt am Binary vorgenommen haben, ohne den Quellcode zu ändern, kann ein erneuter Build aus archiviertem Quellcode bereits behobene Fehler wieder einführen
- Bestehende Ansätze zur direkten Verarbeitung von Binaries kombinieren Emulation, statische Übersetzung und dynamische Übersetzung, doch zusätzliche Systemkomponenten, die zusammen mit dem übersetzten Programm laufen, werden Teil der Trusted Code Base
- Dynamisches Verhalten kann sich je nach Testreihenfolge oder Eingaben unterscheiden, was die Bewertung der Gesamtzuverlässigkeit erschwert
- Horspool und Marovac zeigten 1980, dass für die Rückgewinnung einer Executable Code und Daten zuverlässig unterschieden werden müssten und dies auf den meisten Architekturen dem Halteproblem entspricht, also im Allgemeinen unlösbar ist
- Bestehende statische Binary-Lifter approximieren die Unterscheidung zwischen Code und Daten heuristisch; besonders problematisch ist das bei der Vorhersage von Zielen indirekter Kontrollflussübertragungen
- LLBT hebt ARM-Instruktionen in LLVM IR an und kompiliert sie für die Zielarchitektur neu, verwendet aber Heuristiken zur Erkennung indirekter Sprungziele und trifft mehrere Annahmen über das Eingabebinary
- Selbst gute Heuristiken scheitern bei manchen Eingaben; um ein komplettes Binary korrekt anzuheben, müssen alle Entscheidungen zu Code und Daten stimmen, weshalb die Ausfallwahrscheinlichkeit mit der Größe des Binaries steigt
- Dynamische Verfahren folgen dem tatsächlich ausgeführten Instruktionsfluss und können daher Instruktionsrekonstruktion und indirekten Kontrollfluss behandeln, aber sie liften keine Instruktionen, die in der konkreten Ausführung nicht erreicht wurden
- Bei ISAs wie x64 mit variabler Instruktionslänge können andere Instruktionssequenzen innerhalb einer Instruktionssequenz verschachtelt sein; springt man in die Mitte einer Mehrbyte-Instruktion, können frühere Operanden als eigene Instruktionen dekodiert werden
- ROP-Angriffe und Code-Obfuskation können sich diese Eigenschaft zunutze machen
- Apples Rosetta II und Microsoft Prism kombinieren Vorabübersetzung mit dynamischen Übersetzungskomponenten
- WYTIWYG und Polynima liften statisch entlang von durch dynamisches Profiling identifizierten Kontrollflusspfaden und nutzen einen dynamischen Fallback, der Kontrollflussinformationen sammelt, wenn bisher unbekannte Zieladressen erreicht werden
- Elevator entscheidet nicht, welches Byte Code oder Daten ist oder ob es zu einem Instruktionswort oder Argument gehört, sondern nimmt jedes Byte der Executable in allen möglichen Interpretationen als eigenen Kontrollflusspfad auf
- Dieser Ansatz wendet Superset-Disassembly auf statische Rekompilierung und Cross-ISA-Kompilierung an und tauscht Dekodiergenauigkeit gegen Codewachstum ein
Kontrollfluss und Zustandserhaltung
- Elevator arbeitet innerhalb des übersetzten AArch64-Codes nach dem Prinzip der vollständigen Erhaltung des x64-Zustands
- x64-Register und AArch64-Register werden eins zu eins abgebildet, sodass jeder x64-Registerzustand im entsprechenden AArch64-Register emuliert wird
- Der x64-Stack wird direkt auf dem AArch64-Stack emuliert; normales Stack-Wachstum während der Ausführung übernimmt das Betriebssystem
- Ohne die ABI des Eingabe-x64-Binaries zu analysieren, führt das System ABI-Übersetzung nur an Stellen aus, an denen die Ausführung in externen Code wechselt oder von dort zurückkehrt, gemäß x64 System V ABI und AArch64 Procedure Call Standard
- Dank vollständiger Zustandserhaltung und der Eins-zu-eins-Abbildung der Register kann jede x64-Instruktion unabhängig übersetzt werden, ohne Kenntnis über vorherige oder nachfolgende Instruktionen
- Jeder ausführbare Byte-Offset des Original-Binaries wird zugleich als Daten und als potenzieller Startpunkt einer Instruktionssequenz interpretiert
- Für alle potenziellen Ziele, die sich statisch nicht analysieren lassen – etwa indirekte Sprünge, Callbacks oder Laufzeit-Dispatch –, entsteht im umgeschriebenen Binary ein entsprechender Landepunkt
- Zur Laufzeit wird das Ziel über eine in das finale Binary eingebettete Lookup-Tabelle aufgelöst, die von Original-Instruktionsadressen auf die Adressen des übersetzten Codes abbildet
-
Beispiel überlappender Instruktionen
Listing 1zeigt eine Struktur, in der bei Beginn der Dekodierung an.byte 0xB0zunächstMOV AL, 0xC3und danachRETerscheint, während bei Beginn ein Byte später anReturnC2nurRETdekodiert wird- Beide Dekodierungen sind vom vorangehenden
jzaus erreichbar; würde der Übersetzer für diese beiden Bytes nur eine Interpretation wählen, ginge ein Pfad verloren
-
Beispiel für berechneten indirekten Sprung
Listing 2zeigt, wiecall Labeleine tabellenbezogene Basisadresse erzeugt,pop rsisie wieder lädt, dann ein eingabeabhängiger Offset addiert wird und so das Ziel vonjmp rsientsteht- Der Sprung kann auf eine von vier
inc eax-Instruktionen landen, die im Kodierungsstrom im Abstand von 2 Byte liegen - Ein Übersetzer, der nur statisch interpretierbare Sprungziele umschreibt, hätte keinen Ort, an dem ein solcher Sprung landen könnte
-
Aufrufe, Rücksprünge und Verzweigungen
call-,return- undbranch-Instruktionen können nicht als C-Tiles ausgedrückt werden, weil sich Position der Rücksprungadresse, Program Counter und Layout der Condition Flags zwischen x64 und AArch64 unterscheiden- Direkte Aufrufe legen die originale x64-Rücksprungadresse auf den emulierten Stack und verzweigen zum übersetzten Tile des Callee
- Indirekte Aufrufe prüfen, ob das Ziel innerhalb des übersetzten Binaries oder in einer externen Bibliothek liegt; interne Ziele werden über eine x64-Offset-zu-Tile-Tabelle übersetzt und zu dem entsprechenden Tile verzweigt
- Bei externen Zielen wird in
X30, wohin die AArch64-Bibliothek zurückkehren wird, die Adresse eines Reverse-ABI-Übersetzungs-Gadgets gelegt, dann die Exit-ABI-Übersetzung ausgeführt und anschließend zum externen Ziel verzweigt - Rücksprünge nehmen eine 8-Byte-Rücksprungadresse vom emulierten Stack, vergleichen sie mit dem Adressbereich des eingebetteten x64-Binaries und übersetzen bei internem Rücksprung die Adresse per Lookup-Tabelle, bevor sie zum entsprechenden Tile verzweigen
- Direkte Verzweigungen haben ihr Ziel schon zur Übersetzungszeit bekannt; bedingte Verzweigungen werden in AArch64-Conditional-Branches übersetzt, die die in
X14gespeicherten x64-Flag-Bits prüfen - Indirekte Verzweigungen emittieren dieselben Bounds-Checks wie indirekte Aufrufe und Rücksprünge; liegt das Ziel extern, wird die Exit-ABI-Übersetzung ausgeführt
Tile-basierte Übersetzungspipeline
- Die Übersetzung in Elevator ist in drei Phasen aufgeteilt: Offline-Erzeugung der Tile-Bank, binärspezifisches Umschreiben und abschließendes Packaging
- Die Offline-Phase beschreibt die Semantik von x64-Instruktionen als C-Funktionen, spezialisiert sie unter einer festen x64-zu-AArch64-Registerabbildung für Operandenkombinationen und kompiliert sie dann mit einem modifizierten LLVM 20 zu wiederverwendbaren AArch64-Byte-Sequenzen
- Die binärspezifische Phase führt eine Superset-Disassembly durch und hängt für jede gefundene Kandidateninstruktion anhand ihres Namens die passende AArch64-Byte-Sequenz aus der Tile-Bank an
- Instruktionsklassen, die sich schwer als C-Tiles ausdrücken lassen – etwa Kontrollflussübertragungen und ABI-Grenzen –, werden mit kleinen handgeschriebenen Templates behandelt
- In der Packaging-Phase werden übersetzter Code, originales x64-Binary, Adress-Lookup-Tabelle und Laufzeit-Treiber zu einem eigenständig ausführbaren AArch64-Binary kombiniert
-
Offline-Tile-Bank
- Für jede x64-Instruktion von Hand eine äquivalente AArch64-Instruktionssequenz zu schreiben, ist nicht praktikabel
- Schon ein einzelnes Template wie
ADD Reg8, Reg8expandiert zu 256 konkreten Registerkombinationen; der gesamte x64-Instruktionssatz enthält zudem viele Varianten für Register, Speicheroperanden und Immediate-Adressierung - Elevator schreibt die Semantik jeder x64-Instruktion als kleine C-Funktion, spezialisiert sie für konkrete Operandenkombinationen und lässt LLVM sie nach AArch64 kompilieren
- Im Beispiel
ADD Reg8, Reg8aktualisiert das Template die unteren 8 Bit des Zielregisters mit der 8-Bit-Summe und behält die oberen 56 Bit bei, um die Semantik partieller Registerschreibvorgänge von x64 korrekt nachzubilden - Da x64
ADD Reg8, Reg8auch die Flags Carry, Parity, Auxiliary Carry, Zero, Sign und Overflow inRFLAGSändert, werden Flag-Aktualisierungen wegen der Einschränkung von C-Funktionen auf einen einzelnen Rückgabewert in separaten Flag-Tiles erfasst - Eine einzelne x64-Instruktion kann einem oder mehreren Tiles entsprechen; bei der Emission werden diese wieder aneinandergereiht, um die vollständige Semantik zu rekonstruieren
- Das Attribut
aarch64_custom_regdeklariert, in welche AArch64-Register LLVM den Rückgabewert und die einzelnen Argumente legen soll - Die feste Abbildung wurde so gewählt, dass die callee-saved-/caller-saved-Eigenschaften von x64 System V und AAPCS64 zueinander passen, Umordnungen der Integer-Argumentregister reduziert werden und freie AArch64-callee-saved-Register für künftigen Schattenzustand verfügbar bleiben
- Auch die
RFLAGS-Bits und dieXMM-Registerdatei von x64 werden nach demselben Eins-zu-eins-Prinzip in dedizierten AArch64-Registern gehalten - Das modifizierte LLVM 20 verarbeitet das funktionsspezifische Attribut
aarch64_custom_regund klassifiziert die AArch64-Register, die den emulierten x64-Zustand halten, im Registerallokator als callee-saved um TileGendurchläuft die C-Templates, erzeugt für jede zulässige Operandenkombination spezialisierte Kopien und synthetisiert die Attribute anhand der Parameterpositionen und Registerabbildungen mechanisch
-
Binärspezifisches Umschreiben
- Liegt ein Eingabe-x64-Binary vor, führt die per-Binary-Phase eine Superset-Disassembly durch und traversiert den resultierenden CFG
- An jedem Knoten bildet der Formatter aus Opcode und Operanden der dekodierten Instruktion den Tile-Namen; bei Instruktionen mit mehreren nötigen Tiles werden mehrere Namen kombiniert
- x64 kennt keine Einschränkung bei der Stack-Pointer-Ausrichtung, AArch64 verlangt aber bei Verwendung des Stack-Pointers in Speicheroperanden 16-Byte-Ausrichtung
- Würde
RSPdirekt aufSPgemappt, könnten gängige x64-Code-Muster wie aufeinanderfolgendePUSH-Instruktionen im Funktionsprolog auf AArch64 Ausrichtungsfehler auslösen - Elevator lässt Tiles deshalb über ein separates Register
X25auf den Stack zugreifen und materialisiertSPnur dann darin, wenn das Tile ihn tatsächlich braucht - Mit LLVM kompilierte Tiles erwarten beim Eintritt 16-Byte-ausgerichtetes
SP; daher wird vor der Ausführung von Tiles, bei denen Spill-Speicher erkannt wurde,SPnach unten ausgerichtet und danach wiederhergestellt - Da Tiles zur Flag-Berechnung relativ teuer sind, entfernt das System die Flag-Berechnung am aktuellen Knoten, wenn die Flags überschrieben werden, bevor sie in einer späteren post-dominierenden Instruktion gelesen werden
- Gegenwärtig nicht unterstützte Instruktionen sind vor allem die AVX2- und späteren breiten Vektorerweiterungen von x64; an solchen Stellen wird statt eines Tiles eine Interrupt-Instruktion eingefügt
- Für die vollständige Auswertung mit SPECint 2006 reichte der gesamte x86-64-Integer-ISA-Umfang plus die von SPECint genutzte SSE-Teilmenge aus, um alle Benchmarks auszuführen
- Zusätzliche Instruktionsunterstützung lässt sich durch neue Tiles erweitern, allerdings erwarten die Forschenden davon eher zusätzlichen Engineering-Aufwand als neue wissenschaftliche Erkenntnisse
Behandlung von ABI-Grenzen
- Elevator unterstützt nur dynamisch gelinkte Binaries
- Statisch gelinkte Binaries können architekturspezifische Instruktionen wie
CPUIDdirekt enthalten, während dynamisch gelinkte Binaries diese anlibcdelegieren und so weniger Übersetzungsbedarf entsteht - Für die Interaktion mit dynamisch gelinkten Bibliotheken unterstützt das System Übergänge zwischen der emulierten x64-Umgebung und nativer AArch64-Bibliothekssoftware, also zwischen x64-Linux-ABI und AArch64-Linux-ABI
- Die wichtigsten Elemente, die ABI-Übersetzung benötigen, sind Argumentanordnung und die Position der Rücksprungadresse
- Das System-V-x64-ABI verwendet die sechs Register
RDI,RSI,RDX,RCX,R8,R9als Argumentregister; weitere Argumente werden ab[RSP+8]auf dem Stack übergeben - x64-
CALLspeichert die Rücksprungadresse in[RSP] - Der AArch64 Procedure Call Standard verwendet acht Argumentregister
X0-X7, legt weitere Argumente ab[SP]auf den Stack und speichert die Rücksprungadresse inX30 -
Aufrufe externer Bibliotheken
- Zielt ein übersetzter x64-Aufruf auf eine externe Bibliothek, muss das Argumentlayout an die AArch64-Calling-Convention angepasst werden
- Zunächst wird von
SPder Wert 8 subtrahiert, um ihn wieder auf eine 16-Byte-Grenze auszurichten, und die bereits auf dem Stack liegende x64-Rücksprungadresse wird bei[SP+0x8]platziert - Die Werte an
[SP+0x10]und[SP+0x18]werden inX6undX7geladen, damit eine AArch64-Bibliothek potenzielle siebte und achte Argumente sehen kann, die der x64-Code auf dem Stack abgelegt hat - Verbleibende potenzielle Stack-Argumente bleiben ab
[SP+0x20]erhalten und liegen damit nicht an den Positionen, die AArch64 erwartet - Würde man die x64-Rücksprungadresse sowie die nach
X6undX7verschobenen Werte einfach vom Stack entfernen, wäre das unsicher, da es sich dabei auch um Caller-Spill-Space oder Teile einer vom Caller auf dem Stack allokierten Struktur handeln könnte - Deshalb verändert Elevator das Stack-Layout des Callers nicht, sondern allokiert zusätzlich
n×8Byte Stack-Speicher und kopiert von der aktuellen Position ausnpotenzielle 8-Byte-Argumente dorthin - Standardmäßig ist
ngleich 10; wenn das Eingabebinary an externe Bibliotheksfunktionen insgesamt mehr als 16 Argumente übergibt, kann dieser Wert per Konfiguration erhöht werden - Abschließend wird in
X30die Adresse des Gadgets abgelegt, zu dem die externe Bibliothek zurückkehren soll
-
Rückkehr aus externen Bibliotheken
- Wenn die Kontrolle über das vor dem Bibliotheksaufruf in
X30gespeicherte Gadget zurückkehrt, addiert das Systemn×8zum Stack-Pointer, um die zuvor kopierten Stack-Argumente aufzuräumen - Der Rückgabewert der externen Bibliothek wird aus
X0an die Stelle verschoben, an der der emulierte x64-CodeRAXerwartet, also inX9 - Danach werden die originale x64-Rücksprungadresse und das zugehörige Padding vom Stack genommen, die Adresse übersetzt und dorthin verzweigt, um die Ausführung nach dem ursprünglichen
CALLfortzusetzen
- Wenn die Kontrolle über das vor dem Bibliotheksaufruf in
-
Callbacks in übersetzten Code hinein
- Wenn nativer AArch64-Code das übersetzte Binary aufruft, muss die AArch64-Calling-Convention in die x64-Calling-Convention umgewandelt werden
- Da der emulierte x64-Code das siebte und achte Argument auf dem Stack und nicht in
X6undX7erwartet, wird zuerstX7und danachX6auf den Stack gelegt, sodass sie an den von x64 erwarteten Positionen liegen - Erwartet der Callee tatsächlich kein siebtes oder achtes Argument, haben diese gepushten Werte keine Auswirkungen
- Die Rücksprungadresse, die eine AArch64-
branch-and-link-Instruktion einer externen Bibliothek inX30abgelegt hat, wird an die Stack-Position gepusht, an der eine x64-return-Instruktion sie erwartet
-
Rückkehr aus einem Callback in eine externe Bibliothek
- Wenn der übersetzte Code aus einem Callback in eine externe Bibliothek zurückkehrt, wird der Eintrittsprozess in umgekehrter Reihenfolge ausgeführt
- Die Rücksprungadresse wird vom Stack genommen,
X6undX7werden gepusht, und der dafür allokierte Stack-Speicher wird durch Addition von0x10zum Stack-Pointer wieder freigegeben
1 Kommentare
Hacker-News-Kommentare
Ich weiß nicht genau, was der User-Mode-JIT von QEMU eigentlich macht, aber es sieht so aus, als gäbe es noch ziemlich viel Luft nach oben.
2013 habe ich eine JIT-Engine zur Übersetzung von x86-64 nach aarch64 gebaut und konnte damals Fedora-Beta-aarch64-Binaries ausführen sowie den Großteil des aarch64-Ports von Fedora unter x86_64 Linux neu bauen.
Ich habe auch einen JIT in die Gegenrichtung, also aarch64 → x86-64, gebaut und zum Spaß sogar gezeigt, wie sich in demselben Prozess beide JITs in einer Art Loopback gegenseitig ausführen: x86-64 → aarch64 → x86_64.
Mein JIT hat Instruktionen und CPU-Zustand als 1:n-Mapping abgebildet und war gegenüber nativ recompiliertem Code ungefähr 2- bis 5-mal langsamer.
Später habe ich ihn mit dem QEMU-JIT verglichen, und QEMU schien eher im Bereich von 10- bis 50-mal langsamer zu liegen.
Leider war die Lizenz nicht Open-Source-tauglich, daher kann ich den Code als Beleg nicht veröffentlichen.
Vor allem, wenn man das Design gezielt auf „nur x86 auf aarch64“ und „nur User Mode“ zuschneiden darf, gibt es viel Performance zu holen.
QEMUs User-Mode-Unterstützung ist eher ein „funktioniert halt irgendwie“-Anhang an die Systememulation, und die gesamte JIT-Struktur ist nach dem Muster „Gast → Zwischendarstellung → Host“ aufgebaut. Das ist gut, um viele Gast- und viele Host-Architekturen zu unterstützen, aber schlecht geeignet, um Eigenschaften einer konkreten Gast/Host-Kombination auszunutzen, etwa „x86 hat nur wenige Integer-Register, also kann man hart zuweisen“ oder „wenn man die aarch64-CPU im richtigen Modus hält, stimmen die komplexen Fließkomma-Semantiken immer“.
Außerdem fließt in der QEMU-Entwicklung mehr Zeit in „neues Architektur-Feature X emulieren“ als in das Finden von Performance-Optimierungsmöglichkeiten, weil die Geldgeber das für wichtiger halten.
Dass der Abschnitt
.text50-mal größer wird, ist enorm, wirkt aber als Preis für eine vollständig deterministische Übersetzung noch vertretbar.In vielen Fällen dürfte der Performance-Unterschied gegenüber Emulation stärker ins Gewicht fallen als die Unannehmlichkeit der Größensteigerung.
Interessant ist auch, dass Multithreading und Exception-Handling nicht unmöglich sind, sondern nur außerhalb des Projektumfangs liegen.
Ich frage mich, ob der nächste Schritt darin besteht, den Möglichkeitsraum mit Heuristiken zu beschneiden, um die Binärgröße zu reduzieren.
Damit ginge zwar die Übersetzungsgarantie verloren, aber die praktische Portabilität von Binaries könnte steigen.
Dieser Übersetzer ist viel langsamer als Box64 oder FEX und, solange man aus irgendeinem Grund nicht auf JIT verzichten muss, einfach die schlechtere Wahl.
Ich habe mich immer gefragt, wie ein Übersetzer indirekte Sprünge behandelt.
Bei der Analyse eines Binaries kann man schließlich nur Codebereiche finden, die über direkte Sprünge mit bekannten Zieladressen verbunden sind.
Das würde bedeuten, dass man bei jedem indirekten Sprung die Ziel-Funktion suchen, sie bei Bedarf übersetzen und dann in den übersetzten Code zurückkehren muss — ist das nicht langsam?
Ich frage mich, ob es eine schnellere Methode gibt, ob man die Adresse der übersetzten Funktion an die ursprüngliche Funktionsadresse anpassen kann oder ob man an der ursprünglichen Adresse einen Sprung in den übersetzten Code einfügt.
jmpgesprungen wird, liegt der entsprechende Block an Position Y.“Das ist langsamer als ein direkter
jmpohne Tabelle, aber indirekte Sprünge waren schon im Originalprogramm langsamer und kommen normalerweise in performancekritischen Schleifen nicht so häufig vor.Die Idee eines Superset-Kontrollflussgraphen gefällt mir wirklich, aber wer den Artikel lesen will, sollte Folgendes im Hinterkopf behalten:
Die Laufzeit wird ungefähr 4,75-mal schneller (also schneller als QEMU, aber deutlich langsamer als Box64), die Anzahl ausgeführter Instruktionen steigt um den Faktor 7, und die Binärgröße wächst um den Faktor 50.
Bis zu externen Aufrufen wird die x86-ABI emuliert.
Ein großer Teil des x86-CPU-Zustands wie EFLAGS muss emuliert werden, und auch komplexe
mov-Operationen müssen einzeln berechnet werden.Unterstützt werden nur Single-Thread-Binaries.
Es gibt weder Exception-Handling noch Stack-Unwinding.
Der vollständige Instruktionssatz wird nicht unterstützt.
Interessante Arbeit.
Ich habe es mir nicht im Detail angesehen, aber relative Offsets könnten weiterhin ein Problem sein.
Da die Größe des Codegenerierungs-Ergebnisses ohnehin anders ausfällt, braucht man vermutlich irgendeine Form von Übersetzungsschicht oder MMU, was vor allem Sprungtabellen und interne Verzweigungen betreffen dürfte.
Ich arbeite hauptsächlich mit Dingen aus den 90ern, und Disassembler treffen viele Annahmen über Anfang und Ende von Code.
Manchmal lässt sich aber ein Binary-Blob ohne Vorwissen — etwa einen Entry-Point-Zeiger an einer festen Position — gar nicht entdecken.
Nach ein paar Durchläufen könnte man das Binary vermutlich auf Bereiche verfeinern, die „mit Sicherheit Code“ sind.
Wenn „Elevator alle möglichen Interpretationen jedes Bytes berücksichtigt, für jede mögliche Interpretation eine eigene Übersetzung im Voraus erzeugt und [...] nur die Fälle wegschneidet, die zu einem Crash führen“, werden dann alle realen Programme mit möglichen Kollisionen vollständig weggefiltert?
Dann gäbe es zwar weiterhin eine Kollision, aber sie entspräche nicht demselben Absturz wie beim direkten Ausführen des falschen Codes.
Der für mich interessanteste Aspekt ist die Perspektive der Zertifizierung.
In regulierten Branchen wie Luftfahrt oder Medizintechnik darf oft nur zertifizierter Code ausgeführt werden, weshalb man aus genau diesem Grund häufig kein JIT verwenden kann.
Eine statische Übersetzung, die signierbare Binaries erzeugt, könnte trotz Codeaufblähung ein echter Durchbruch sein.
Vermutlich lässt sich dort auch kein LLM im großen Stil einsetzen, aber in den großen Debatten über „KI in der Arbeit“ kommt so etwas fast nie vor.
50-mal ist nicht vernünftig, das ist eine Cache-Katastrophe.
Der Performance-Gewinn durch das Vermeiden von JIT könnte dadurch komplett aufgefressen werden.
Wenn man heißen Code an einer Stelle bündelt, kann man dafür sorgen, dass ungenutzter Code überhaupt nie geladen wird.
Instruktionen sind ohnehin nicht so groß, und die CPU optimiert während der Ausführung zusätzlich.
Kann es selbstmodifizierenden Code verarbeiten?
Ich frage mich auch, warum nur x86_64.
Gerade 32-Bit-Programme wie alte Spiele zu übersetzen, erscheint sinnvoller.
„Selbstmodifizierender und JIT-kompilierter Code. Elevator unterstützt wie alle vollständig statischen Binary-Rewriter weder selbstmodifizierenden Code noch JIT-kompilierten Code.“
Der Abschnitt
.textist heute meist schreibgeschützt, und es ist nicht zu erwarten, dass die Sicherheitsanforderungen sinken.Das ist im Kern ein Widerspruch.
Er ruiniert die Performance von Cache-Lines und Branch-Prediction in der Pipeline.
Außerdem verstößt er gegen W^X und sollte daher in der Regel nur auf JIT-kompatiblen Speicherseiten verwendet werden.
Deshalb sollte man ihn fast immer vermeiden.
Zu Zeiten des 486 oder P5 wurde er noch teilweise eingesetzt, etwa indem Immediate-Werte wie innere Schleifenvariablen verwendet wurden, aber heute ist das kaum noch üblich.
Um eine nahezu perfekte Emulation oder Übersetzung zu erreichen, muss man viele schmutzige Sonderfälle von x86 behandeln.
Wo ist der Quellcode?