Qemu.js mit JIT-Unterstützung: Man kann das Hackfleisch doch zurückdrehen

Vor einigen Jahren hat Fabrice Bellard jslinux geschrieben, einen PC-Emulator, der in JavaScript geschrieben wurde. Danach gab es mindestens noch Virtual x86. Soweit ich weiß, waren sie jedoch alle Interpreter, während Qemu, das viel früher von Fabrice Bellard geschrieben wurde, und wahrscheinlich jeder respektable moderne Emulator, JIT-Kompilierung von Gastcode in den Code des Hosts verwendet. Ich dachte, es sei an der Zeit, das Gegenteil zu realisieren, was Browser tun: Maschinencode in JavaScript zu JIT-kompilieren, wofür es am logischsten erschien, Qemu zu portieren. Man könnte fragen, warum gerade Qemu, es gibt einfachere und benutzerfreundlichere Emulatoren – derselbe VirtualBox zum Beispiel – installiert und funktioniert. Aber Qemu hat einige interessante Besonderheiten:

  • Open Source
  • die Möglichkeit, ohne Kernel-Treiber zu arbeiten
  • die Möglichkeit, im Interpreter-Modus zu arbeiten
  • Unterstützung einer großen Anzahl von sowohl Host- als auch Gastarchitekturen

Bezüglich des dritten Punkts kann ich nun erläutern, dass im TCI-Modus nicht die Anweisungen der virtuellen Maschinen selbst interpretiert werden, sondern der daraus resultierende Bytecode. Das ändert jedoch nichts am Wesentlichen: Um Qemu auf einer neuen Architektur zu kompilieren und auszuführen, benötigt man im besten Fall nur einen C-Compiler – die Entwicklung eines Codegenerators kann warten.

Nach zwei Jahren gemütlicher Beschäftigung mit dem Qemu-Quellcode ist nun ein funktionierender Prototyp entstanden, in dem man unter anderem Kolibri OS starten kann.

Was ist Emscripten?

Heutzutage gibt es viele Compiler, deren Endergebnis JavaScript ist. Einige, wie TypeScript, wurden ursprünglich als bessere Möglichkeit für die Webentwicklung konzipiert. Emscripten hingegen ist ein Weg, bestehenden C- oder C++-Code zu nehmen und ihn in ein Format zu kompilieren, das vom Browser verstanden wird. Auf auf dieser Seite gibt es bereits zahlreiche Ports bekannter Programme: hier, zum Beispiel kann man PyPy betrachten – übrigens, wie behauptet wird, haben sie schon einen JIT. Tatsächlich kann nicht jedes Programm einfach kompiliert und im Browser ausgeführt werden – es gibt eine Reihe von Besonderheiten., mit denen man sich arrangieren muss, wie die Inschrift auf dieser Seite besagt: "Emscripten kann verwendet werden, um nahezu jeden portablen C/C++-Code in JavaScript". Das bedeutet, dass es eine Reihe von Operationen gibt, die im Standard als undefiniertes Verhalten gelten, aber normalerweise auf x86 funktionieren – zum Beispiel unaligned access zu Variablen, der auf einigen Architekturen überhaupt verboten ist. Im Allgemeinen ist Qemu ein plattformübergreifendes Programm und man hoffte, es würde nicht viele undefinierte Verhaltensweisen enthalten – einfach kompilieren und dann ein wenig mit JIT experimentieren – und schon fertig! Aber dem war nicht so…

Erster Versuch

In der Tat bin ich nicht der Erste, dem die Idee kam, Qemu nach JavaScript zu portieren. Im Forum von ReactOS wurde die Frage aufgeworfen, ob dies mithilfe von Emscripten möglich sei. Zuvor gab es Gerüchte, dass Fabrice Bellard dies persönlich getan hat, jedoch handelte es sich dabei um jslinux, das, soweit ich weiß, ein Versuch ist, manuell ausreichende Leistung in JS zu erreichen und von Grund auf neu geschrieben wurde. Später wurde Virtual x86 entwickelt – für das wurden unobfuscated Quellcodes veröffentlicht und angeblich ermöglichte eine größere "Realitätsnähe" der Emulation die Verwendung von SeaBIOS als Firmware. Zudem gab es mindestens einen Versuch, Qemu mithilfe von Emscripten zu portieren – das versuchte zu erreichen socketpair, aber die Entwicklung wurde, soweit ich verstanden habe, eingestellt.

Es schien also, die Quellcodes sind da, Emscripten ebenfalls – nimm und kompiliere. Aber es gibt auch Bibliotheken, von denen Qemu abhängt, sowie Bibliotheken, von denen diese Bibliotheken wieder abhängen usw., wobei eine davon – libffi, auf die glib angewiesen ist. Im Internet kursierten Gerüchte, dass es auch in der umfangreichen Sammlung von Emscripten-Bibliotheken vorhanden sei, aber das schien irgendwie unglaubwürdig: Erstens ließ es sich mit dem neuen Compiler nicht kompilieren, und zweitens ist es eine zu niedrigstufige Bibliothek, um einfach so in JS kompiliert zu werden. Und es geht dabei nicht nur um assemblerbasierte Einfügungen – wahrscheinlich könnte man, wenn man es wirklich darauf anlegt, für einige Calling Conventions auch ohne sie die benötigten Argumente auf dem Stack bilden und die Funktion aufrufen. Nur ist Emscripten ein raffiniertes Teil: Damit der generierte Code für den Optimierer der JS-Engine des Browsers vertraut aussieht, werden einige Tricks eingesetzt. Insbesondere das sogenannte Relooping – der Codegenerator versucht, basierend auf dem erhaltenen LLVM IR mit einigen abstrakten Sprunganweisungen, glaubwürdige If-Anweisungen, Schleifen usw. zu rekonstruieren. Und wie werden die Argumente in die Funktionen übergeben? Natürlich wie die Argumente von JS-Funktionen, das heißt, wenn möglich, nicht über den Stack.

Zunächst hatte ich die Idee, einfach eine Ersetzung von libffi durch JS zu schreiben und die Standardtests durchzuführen. Aber schließlich war ich verwirrt darüber, wie ich meine Header-Dateien erstellen sollte, damit sie mit dem bestehenden Code funktionieren — wie sagt man so schön: "Entweder sind die Aufgaben zu kompliziert, oder wir sind zu dumm." Also musste ich libffi auf eine weitere Architektur portieren. Glücklicherweise gibt es in Emscripten sowohl Makros für Inline-Assembly (ja, in JavaScript — nun, wie die Architektur, so der Assembler) als auch die Möglichkeit, den dynamisch generierten Code auszuführen. Nachdem ich eine Zeit lang mit den plattformabhängigen Fragmenten von libffi experimentiert hatte, erhielt ich einen kompilierten Code, den ich bei dem ersten Test, der mir in die Hände fiel, ausprobierte. Zu meinem Erstaunen bestand der Test erfolgreich. Erstaunt über meine Genialität — keine Scherz, es hat beim ersten Versuch funktioniert — schaute ich noch einmal auf den entstandenen Code, um zu bewerten, wohin ich als Nächstes graben sollte. Da war ich erneut verblüfft — das Einzige, was meine Funktion tat, war... ffi_call — berichtete von einem erfolgreichen Aufruf. Der Aufruf selbst fand jedoch nicht statt. So habe ich meinen ersten Pull Request gesendet, der einen Fehler im Test behebt, den jeder Wettbewerbsprogrammierer versteht – Fließkommazahlen sollten nicht verglichen werden wie a == b und sogar wie a - b < EPS — man darf auch den Betrag nicht vergessen, sonst wird 0 durchaus gleich 1/3… Insgesamt habe ich eine Art libffi-Port erstellt, der die einfachsten Tests besteht und mit dem glib kompiliert wird – ich habe beschlossen, später weiterzumachen. Vorausblickend sage ich, dass der Compiler den endgültigen Code der Funktion libffi sogar nicht einbezogen hat.

Wie bereits erwähnt, gibt es einige Einschränkungen, und unter der Vielzahl unbestimmter Verhaltensweisen versteckt sich eine unangenehme Besonderheit — JavaScript unterstützt von Design her keine nebenläufige Programmierung mit gemeinsamem Speicher. Prinzipiell könnte man das sogar als eine ganz gute Idee ansehen, jedoch nicht, wenn es um das Portieren von Code geht, der auf C-Threads angewiesen ist. Allgemein gesagt, gibt es in Firefox Experimente zur Unterstützung von Shared Workers, und die Implementierung von pthread in Emscripten ist vorhanden, aber darauf wollte ich mich nicht verlassen. Daher musste ich nach und nach die Multithread-Funktionalität aus dem Qemu-Code entfernen — das heißt, herauszufinden, wo Threads gestartet werden, den Körper der Schleife, die in diesem Thread ausgeführt wird, in eine separate Funktion auszulagern und solche Funktionen der Reihe nach aus der Hauptschleife aufzurufen.

Ein zweiter Versuch

Es wurde klar, dass sich nichts geändert hat und dass das willkürliche Verteilen von Notlösungen im Code nicht zum Erfolg führt. Fazit: Der Prozess des Hinzufügens von Notlösungen muss systematisiert werden. Deshalb wurde die damals aktuelle Version 2.4.1 (nicht 2.5.0, da eventuell noch nicht gefundene Bugs in der neuen Version vorhanden sind und ich bereits mit meinen eigenen Bugs beschäftigt bin) und zuerst wurde sicher umgeschrieben. thread-posix.c. Das heißt, sicher im Sinne von: Wenn jemand versuchte, eine operation auszuführen, die zu einer Blockade führen könnte, wurde sofort die Funktion abort() aufgerufen – das löste natürlich nicht sofort alle Probleme, aber es war zumindest angenehmer, als stillschweigend inkonsistente Daten zu erhalten.

Im Allgemeinen helfen die Emscripten-Optionen sehr beim Portieren von Code auf JS. -s ASSERTIONS=1 -s SAFE_HEAP=1 – sie fangen einige Arten von undefined behavior wie Zugriffe auf nicht ausgerichtete Adressen (was überhaupt nicht mit dem Code für typed arrays übereinstimmt, wie HEAP32[addr >> 2] = 1) oder den Aufruf einer Funktion mit einer falschen Anzahl von Argumenten.

Übrigens sind Alignment-Fehler ein eigenes Thema. Wie ich bereits erwähnt habe, gibt es in Qemu den "degenerierten" Interpreter-Backend zur Code-Generierung TCI (Tiny Code Interpreter), und um Qemu auf einer neuen Architektur zu kompilieren und zu starten, reicht manchmal ein C-Compiler. Schlüsselwörter "manchmal". Ich hatte dabei kein Glück und stellte fest, dass TCI beim Parsen seines Bytecodes nicht ausgerichteten Zugriff verwendet. Das bedeutet, dass Qemu auf Architekturen wie ARM und anderen, die einen ausgerichteten Zugriff erfordern, kompiliert wird, weil es für sie ein funktionierendes TCG-Backend gibt, das nativen Code generiert, ob TCI jedoch auf diesen Architekturen funktioniert, bleibt fraglich. In der Dokumentation zu TCI wurde allerdings auf etwas Ähnliches hingewiesen. Letztendlich wurden Funktionsaufrufe für nicht ausgerichteten Zugriff im Code hinzugefügt, die in einem anderen Teil von Qemu entdeckt wurden.

Heap-Zerstörung

Schließlich wurde der nicht ausgerichtete Zugriff in TCI behoben, und eine Hauptschleife wurde erstellt, die der Reihe nach den Prozessor, RCU und einige Kleinigkeiten aufrief. Und nun starte ich Qemu mit der Option -d exec,in_asm,out_asm, was bedeutet, dass Sie angeben müssen, welche Codeblöcke ausgeführt werden und auch während der Übersetzung angeben, welcher Guest-Code war, welcher Host-Code wurde (in diesem Fall Bytecode). Es wird gestartet, führt mehrere Übersetzungsblöcke aus, schreibt meine hinterlassene Debug-Nachricht, dass jetzt RCU gestartet wird und… dann fällt es auf abort() innerhalb der Funktion free(). Durch das Untersuchen der Funktion free() konnte festgestellt werden, dass im Header des Heap-Blocks, der acht Bytes vor dem zugewiesenen Speicher liegt, anstelle der Blockgröße oder etwas Ähnlichem Müll vorhanden war.

Der Stapelabbau – wie angenehm... In solchen Fällen gibt es ein nützliches Mittel: aus (wenn möglich) denselben Quellcodes eine native Binärdatei zu erstellen und sie unter Valgrind auszuführen. Nach einiger Zeit war die Binärdatei fertig. Ich starte sie mit denselben Optionen – sie stürzt bereits während der Initialisierung ab, bevor sie tatsächlich ausgeführt wird. Das ist natürlich unangenehm – offensichtlich waren die Quellcodes nicht ganz die gleichen, was nicht überrascht, denn configure hat einige andere Optionen untersucht. Aber ich habe ja Valgrind – zuerst werde ich diesen Fehler beheben und dann, wenn ich Glück habe, wird auch der ursprüngliche sichtbar. Ich starte alles unter Valgrind... Hmm, hach, es läuft, hat die Initialisierung normal durchlaufen und läuft weiter, ohne eine einzige Warnung über ungültigen Speicherzugriff, ganz zu schweigen von Abstürzen. Darauf war ich im Leben nicht gefasst – ein abstürzendes Programm hört auf abzustürzen, wenn es unter Valgrind läuft. Was das war – ein Rätsel. Meine Hypothese ist, dass bei der aktuellen Anweisung nach dem Absturz während der Initialisierung gdb die Ausführung anzeigte. memset- mit einem gültigen Zeiger unter Verwendung von entweder mmx, oder xmm Wenn Sie registriert sind, könnte es sich um ein Fehler bei der Ausrichtung handeln, auch wenn man es nicht wirklich glaubt.

Okay, Valgrind scheint hier nicht zu helfen. Und hier beginnt das Unangenehmste – alles scheint zwar zu starten, aber es bricht aus völlig unbekannten Gründen aufgrund eines Ereignisses zusammen, das vor Millionen von Instruktionen stattgefunden haben könnte. Längere Zeit war es unklar, wie man überhaupt an das Problem herankommt. Schließlich blieb nichts anderes übrig, als sich hinzusetzen und das Ganze zu debuggen. Die Ausgabe des überarbeiteten Headers zeigte, dass es eher wie binäre Daten und nicht wie eine Zahl aussah. Und, oh Wunder, dieser binäre String fand sich in der BIOS-Datei – das heißt, jetzt konnte man mit ausreichender Sicherheit sagen, dass es sich um einen Buffer-Overflow handelte, und es war sogar nachvollziehbar, was in diesen Buffer geschrieben wurde. Nun, von da an war es so – glücklicherweise gibt es in Emscripten keine Adressraumrandomisierung und auch keine Lücken, also kann man irgendwo mitten im Code Daten über einen Pointer aus einem früheren Run ausgeben, sich die Daten anschauen, den Pointer überprüfen und, wenn dieser unverändert ist, die Informationen zur Reflexion gewinnen. Allerdings dauert das Linken nach jeder Änderung einige Minuten, aber was will man machen? Schließlich wurde eine spezifische Zeile gefunden, die das BIOS aus dem temporären Buffer in den Gast-Speicher kopierte – und tatsächlich war im Buffer nicht genug Platz vorhanden. Die Suche nach der Quelle dieser seltsamen Bufferadresse führte zur Funktion. qemu_anon_ram_alloc in der Datei oslib-posix.c Die Logik war wie folgt: Manchmal kann es nützlich sein, die Adresse auf eine Huge Page von 2 MB auszurichten, dafür fordern wir bei mmap zuerst etwas mehr an und geben dann den Rest mit munmapzurück. Wenn eine solche Ausrichtung nicht erforderlich ist, geben wir anstelle von 2 MB das Ergebnis an getpagesize()mmap das dennoch eine ausgerichtete Adresse zurückgibt... So wird in Emscripten mmap einfach mallocaufgerufen, und das richtet natürlich nicht seitenweise aus. Insgesamt wurde ein Fehler, der mich ein paar Monate genervt hat, durch eine Änderung in zwei den Zeilen behoben.

Funktionaufruf-Details

Und jetzt rechnet die CPU etwas, Qemu stürzt nicht ab, aber der Bildschirm bleibt dunkel, und die CPU gerät schnell in eine Schleife, wie man aus der Ausgabe ableiten kann -d exec,in_asm,out_asm. Es tauchte die Hypothese auf: Die Timer-Interrupts kommen nicht (oder überhaupt alle Interrupts). Und tatsächlich, wenn alle Interrupts von der nativen Version, die aus irgendeinem Grund funktionierte, abgezogen werden, sieht es ähnlich aus. Doch die Lösung lag ganz woanders: Ein Vergleich der Trace-Daten, die mit der oben genannten Option ausgegeben wurden, zeigte, dass die Ausführungspfade sehr früh divergieren. Hier muss erwähnt werden, dass der Vergleich, der mit dem Launcher emrun Die Fehlersuche mit der nativen Ausgabe ist kein ganz mechanischer Prozess. Ich weiß nicht genau, wie das im Browser laufende Programm sich verbindet mit emrun, aber einige Zeilen in der Ausgabe erscheinen vertauscht, daher ist eine Abweichung im Diff noch kein Grund zu glauben, dass die Pfade auseinandergegangen sind. Es wurde klar, dass laut Anleitung ljmpl der Übergang zu verschiedenen Adressen erfolgt und der Bytecode prinzipiell unterschiedlich generiert wird: In einem gibt es einen Aufrufbefehl der C-Hilfsfunktion, im anderen nicht. Nach dem Googeln der Anweisungen und dem Studium des Codes, der diese Anweisungen übersetzt, wurde klar, dass erstens direkt vor ihr in das Register cr0 geschrieben wurde – ebenfalls mit Hilfe des Helpers – was den Prozessor in den geschützten Modus versetzt hat, und zweitens, dass die JS-Version den geschützten Modus nie erreicht hat. Der Grund ist, dass eine weitere Besonderheit von Emscripten darin besteht, dass es keinen Code toleriert, der die Implementierung der Anweisung call im TCI betrifft, wobei jeder Funktionszeiger in den Typ long long f(int arg0, .. int arg9) geführt wird. Die Funktionen müssen mit der richtigen Anzahl von Argumenten aufgerufen werden. Wenn diese Regel nicht beachtet wird, stürzt das Programm je nach Debugging-Einstellungen entweder ab (was gut ist) oder ruft die falsche Funktion auf (was beim Debuggen problematisch sein wird). Es gibt auch eine dritte Möglichkeit: die Generierung von Wrappers einzuschalten, die Argumente hinzufügen/entfernen, aber insgesamt nehmen diese Wrappers ziemlich viel Platz ein, wobei ich eigentlich nur etwas mehr als einhundert Wrappers benötige. Schon dies ist recht bedauerlich, aber es stellt sich ein noch ernsteres Problem heraus: Im generierten Code der Wrapper-Funktionen wurden die Argumente immer wieder konvertiert, aber die Funktion mit den generierten Argumenten wurde manchmal nicht aufgerufen – genau wie in meiner libffi-Implementierung. Das heißt, einige Helfer wurden einfach nicht ausgeführt.

Glücklicherweise gibt es in Qemu maschinenlesbare Listen von Helfern in Form einer Header-Datei, die wie folgt aussieht:

DEF_HELPER_0(lock, void)
DEF_HELPER_0(unlock, void)
DEF_HELPER_3(write_eflags, void, env, tl, i32)

Sie werden auf eine ziemlich amüsante Weise verwendet: Zuerst werden die Makros DEF_HELPER_n, auf die seltsamste Art und Weise überschrieben und dann wird helper.h. Es kam sogar so weit, dass das Makro im Initialisierer der Struktur entfaltet wird und ein Komma folgt, gefolgt von der Definition eines Arrays, wobei anstelle von Elementen - #include <helper.h> Schließlich gab es einen Grund, die Bibliothek pyparsing, auszuprobieren, und es wurde ein Skript geschrieben, das genau die Wrapper generiert, die benötigt werden.

Und so schien der Prozessor nach diesem Punkt zu funktionieren. Ich sage 'schien', weil der Bildschirm sich nicht initialisierte, obwohl es in der nativen Version gelang, memtest86+ zu starten. Hier ist zu beachten, dass der Code für den blockorientierten Input/Output von Qemu in Koroutinen geschrieben ist. Emscripten hat eine recht knifflige Implementierung, aber diese musste auch im Qemu-Code unterstützt werden, während man den Prozessor schon jetzt debuggen kann: Qemu unterstützt die Optionen -kernel, -initrd, -append, mit denen man Linux oder beispielsweise memtest86+ laden kann, ohne blockorientierte Geräte zu verwenden. Aber das ist das Problem: In der nativen Version konnte man die Ausgabe des Linux-Kernels auf der Konsole mit der Option -nographic, beobachten, aber aus dem Browser gab es keine Ausgabe im Terminal, von dem aus es gestartet wurde. emrun, es kam nichts an. Das heißt, es ist unklar: funktioniert der Prozessor nicht oder gibt es ein Problem mit der Grafikausgabe. Dann kam mir der Gedanke, ein wenig zu warten. Es stellte sich heraus, dass "der Prozessor nicht schläft, sondern einfach langsam blinkt", und nach etwa fünf Minuten spuckte der Kern eine Reihe von Nachrichten auf die Konsole aus und hing weiter. Es wurde klar, dass der Prozessor im Großen und Ganzen funktioniert, und ich musste im Code mit SDL2 nachforschen. Leider kenne ich mich mit dieser Bibliothek nicht aus, daher musste ich manchmal auf gut Glück vorgehen. Irgendwann tauchte auf dem Bildschirm die Zeile parallel0 auf einem blauen Hintergrund auf, was einige Gedanken anregte. Am Ende stellte sich heraus, dass es daran lag, dass Qemu mehrere virtuelle Fenster in einem physischen Fenster öffnet, zwischen denen man mit Ctrl-Alt-n wechseln kann: in der nativen Version funktioniert es, in Emscripten jedoch nicht. Nachdem ich überflüssige Fenster mit Optionen entfernt hatte -monitor none -parallel none -serial none , und der Vorgabe, den gesamten Bildschirm bei jedem Frame neu zu zeichnen, hat plötzlich alles funktioniert.

Coroutines

Die Browser-Emulation funktioniert also, aber es gibt nichts Interessantes für eine ein-Disketten-Anwendung, da kein blockorientierter I/O vorhanden ist – die Unterstützung für Koroutinen muss implementiert werden. In Qemu gibt es bereits mehrere Coroutine-Backends, aber aufgrund der Eigenheiten von JavaScript und dem Emscripten-Codegenerator kann man nicht einfach anfangen, mit Stacks zu jonglieren. Man könnte denken, "alles ist verloren, der Gips wird entfernt", aber die Emscripten-Entwickler haben bereits an alles gedacht. Es ist auf eine ziemlich amüsante Weise gelöst: Lassen Sie uns verdächtige Funktionsaufrufe wie emscripten_sleep und einige andere, die den Asyncify-Mechanismus verwenden, sowie Zeigeraufrufe und Aufrufe beliebiger Funktionen, bei denen unter dem Stack einer der vorherigen beiden Fälle eintreten kann, benennen. Nun vergeben wir vor jedem verdächtigen Aufruf einen async context, und direkt nach dem Aufruf überprüfen wir, ob ein asynchroner Aufruf stattgefunden hat, und wenn ja, speichern wir alle lokalen Variablen in diesem async context, geben an, zu welcher Funktion die Kontrolle übergeben werden soll, wenn die Ausführung fortgesetzt werden muss, und verlassen die aktuelle Funktion. Hier gibt es viel Raum für das Studium des Effekts der Zerstreuung — für die Fortsetzung der Code-Ausführung nach der Rückkehr aus einem asynchronen Aufruf generiert der Compiler "Schnipsel" von Funktionen, die nach dem verdächtigen Aufruf beginnen — so: Wenn es n verdächtige Aufrufe gibt, wird die Funktion etwa um den Faktor n/2 zerschlagen — und das ist noch nicht alles, denn nach jedem potenziell asynchronen Aufruf muss ein Teil der lokalen Variablen gespeichert werden. Später musste ich sogar ein einfaches Skript in Python schreiben, das für eine bestimmte Menge besonders stark zerschliffener Funktionen, von denen angenommen wird, dass sie "die Asynchronität nicht durchlassen" (das heißt, in ihnen funktioniert das Stapel-Scrolling nicht und alles, was ich gerade beschrieben habe), angibt, bei welchen Aufrufen über Zeiger der Compiler ignorieren soll, damit diese Funktionen nicht als asynchron betrachtet werden. Am Ende sind JS-Dateien von 60 MB einfach zu viel — lassen wir es bis wenigstens 30. Obwohl ich einmal ein Build-Skript eingerichtet habe und versehentlich die Optionen des Linkers gelöscht habe, darunter auch die -O3. Ich starte den generierten Code, und Chromium frisst den Speicher und stürzt ab. Ich habe später zufällig darauf geschaut, was er zu laden versuchte… Nun, was kann ich sagen, ich würde auch hängen bleiben, wenn man mich bitten würde, JavaScript auf mehr als 500 MB gründlich zu analysieren und zu optimieren.

Leider harmonierten die Prüfungen im Code der Asyncify-Unterstützungsbibliothek nicht ganz mit longjmp-s, die im Code des virtuellen Prozessors verwendet werden. Nach einem kleinen Patch, der diese Überprüfungen deaktiviert und die Kontexte so wiederherstellt, als ob alles in Ordnung wäre, funktionierte der Code. Und dann begann das Seltsame: Manchmal wurden die Prüfungen im Synchronisierungscode aktiviert — die, die den Code abbricht, wenn er logisch gesehen blockiert sein sollte — jemand versuchte, ein bereits erfasstes Mutex zu übernehmen. Zum Glück war das kein logisches Problem im serialisierten Code — ich verwendete einfach die Standardfunktionalität des Hauptloops, die von Emscripten bereitgestellt wird, aber manchmal wurde der asynchrone Aufruf vollständig entfaltet und in diesem Moment wurde setTimeout aus der main loop — so kam der Code in eine Iteration der Hauptschleife, ohne die vorherige Iteration zu verlassen. Ich habe es in einer Endlosschleife umgeschrieben und emscripten_sleep, und die Probleme mit den Mutexen hörten auf. Der Code wurde sogar logischer — denn eigentlich habe ich keinen speziellen Code, der den nächsten Animationsrahmen vorbereitet — der Prozessor berechnet einfach etwas und der Bildschirm wird regelmäßig aktualisiert. Die Probleme hörten jedoch damit nicht auf: Manchmal beendete Qemu einfach stillschweigend die Ausführung, ohne irgendwelche Ausnahmen oder Fehler. In diesem Moment habe ich es ignoriert, aber ich sage voraus, dass das Problem Folgendes war: Der Coroutine-Code verwendet tatsächlich setTimeout (oder zumindest nicht so oft, wie man denken könnte): die Funktion emscripten_yield setzt einfach ein Flag für den asynchronen Aufruf. Der ganze Clou ist, dass emscripten_coroutine_next ist keine asynchrone Funktion: sie überprüft ein Flag, setzt es zurück und übergibt die Kontrolle an die nächste Stelle. Das heißt, hier endet das Entrollen des Stacks. Das Problem war, dass aufgrund eines use-after-free, das auftrat, als der Coroutine-Pool deaktiviert war, weil ich eine wichtige Zeile Code aus dem bestehenden Coroutine-Backend nicht kopiert hatte, die Funktion qemu_in_coroutine true zurückgab, obwohl sie eigentlich false zurückgeben sollte. Dies führte zu einem Aufruf emscripten_yield, über dem kein emscripten_coroutine_nextim Stack war, der Stack wurde bis ganz oben entfaltet, aber es gab keine setTimeout, wie ich bereits gesagt habe, die gesetzt wurden.

JavaScript-Codegenerierung

Und hier ist das versprochene "Zurückdrehen des Fleisches". Eigentlich nicht. Natürlich, wenn man Qemu im Browser ausführt und darin — Node.js, dann erhalten wir nach der Codegenerierung in Qemu sicherlich nicht das gleiche JavaScript. Aber dennoch, eine Art Rücktransformation gibt es.

Zunächst ein wenig darüber, wie Qemu funktioniert. Ich bitte Sie um Nachsicht: Ich bin kein professioneller Qemu-Entwickler, und meine Schlussfolgerungen können an einigen Stellen fehlerhaft sein. Wie man so sagt: "Die Meinung eines Studenten muss nicht mit der Meinung des Lehrers, der Peano-Axiome und dem gesunden Menschenverstand übereinstimmen." Qemu unterstützt eine Reihe von Gastarchitekturen, und für jede gibt es ein Verzeichnis wie target-i386. Bei der Erstellung kann die Unterstützung mehrerer Gastarchitekturen angegeben werden, jedoch resultiert dies lediglich in mehreren Binärdateien. Der Code zur Unterstützung der Gastarchitektur generiert seinerseits einige interne Operationen von Qemu, die der TCG (Tiny Code Generator) dann in Maschinencode der Hostarchitektur umwandelt. Wie im README-Dokument im Ordner TCG angegeben, war dies ursprünglich ein Teil des gängigen C-Compilers, der später für JIT angepasst wurde. Daher bezieht sich die Zielarchitektur in den Begriffen dieses Dokuments nicht mehr auf die Gast-, sondern auf die Hostarchitektur. Irgendwann kam ein weiterer Bestandteil hinzu – der Tiny Code Interpreter (TCI), der den Code (praktisch dieselben internen Operationen) in Abwesenheit eines Codegenerators für eine spezifische Hostarchitektur ausführen soll. Tatsächlich, wie in seiner Dokumentation gesagt wird, kann dieser Interpreter nicht immer so gut funktionieren wie der JIT-Codegenerator, nicht nur quantitativ in Bezug auf die Geschwindigkeit, sondern auch qualitativ. Obwohl ich mir nicht sicher bin, ob diese Beschreibung vollständig aktuell ist.

Zunächst versuchte ich, ein vollständiges TCG-Backend zu erstellen, aber ich verlor schnell den Überblick über den Quellcode und die nicht ganz klaren Anweisungen zum Bytecode. Daher entschloss ich mich, den TCI-Interpreter zu kapseln. Das brachte mehrere Vorteile mit sich:

  • Bei der Implementierung des Code-Generators konnte ich nicht in die Beschreibung der Anweisungen schauen, sondern direkt in den Code des Interpreters.
  • Ich kann Funktionen nicht für jeden einzelnen Translationsblock generieren, sondern beispielsweise erst nach der hundertsten Ausführung.
  • Im Falle einer Änderung des generierten Codes (was anscheinend möglich ist, judging by functions with names containing the word patch) müsste ich den generierten JS-Code ungültig machen, aber ich hätte wenigstens eine Basis, um ihn neu zu generieren.

Beim dritten Punkt bin ich mir nicht sicher, ob Patching möglich ist, nachdem der Code zum ersten Mal ausgeführt wurde, aber die ersten beiden Punkte reichen aus.

Ursprünglich wurde der Code in Form eines großen Switch-Statements generiert, entsprechend der Adresse der ursprünglichen Bytecode-Anweisung. Doch dann erinnerte ich mich an einen Artikel über Emscripten, die Optimierung des generierten JS und das Re-Looping. Ich beschloss, einen menschenfreundlicheren Code zu erzeugen, zumal ich empirisch herausfand, dass der einzige Einstiegspunkt in den Translationsblock dessen Anfang war. Gesagt, getan – nach einiger Zeit entstand ein Code-Generator, der Code mit If-Anweisungen generierte (wenn auch ohne Schleifen). Doch dummerweise stürzte er ab und gab eine Fehlermeldung aus, dass die Anweisung eine falsche Länge hatte. Die letzte Anweisung auf dieser Rekursionsebene war brcond. Gut, ich werde eine identische Überprüfung in die Generierung dieser Anweisung vor dem rekursiven Aufruf und danach hinzufügen. Keine von ihnen wurde erfüllt, doch nach dem Switch beim Assert fiel es dennoch. Letztendlich, nachdem ich den generierten Code untersucht hatte, stellte ich fest, dass nach dem Switch der Pointer auf die aktuelle Anweisung vom Stack neu geladen wird und wahrscheinlich durch den generierten JavaScript-Code überschrieben wird. So war es auch. Eine Erhöhung des Puffers von einem Megabyte auf zehn brachte nichts, und es wurde klar, dass der Code-Generator im Kreis läuft. Ich musste überprüfen, ob wir die Grenzen des aktuellen TB überschritten hatten, und wenn ja, die Adresse des nächsten TB mit einem Minuszeichen auszugeben, um die Ausführung fortzusetzen. Außerdem löst das das Problem: 'Welche generierten Funktionen sollen invalidiert werden, wenn dieser Teil des Bytecodes verändert wurde?' — Nur die Funktion muss invalidiert werden, die diesem Übersetzungsblock entspricht. Übrigens, obwohl ich alles in Chromium debuggt habe (da ich Firefox benutze und es mir einfacher ist, einen separaten Browser für Experimente zu verwenden), hat mir Firefox geholfen, Inkonsistenzen mit dem asm.js-Standard zu beheben, wonach der Code in Chromium schneller lief.

Beispiel für generierten Code

Compiling 0x15b46d0:
CompiledTB[0x015b46d0] = function(stdlib, ffi, heap) {
"use asm";
var HEAP8 = new stdlib.Int8Array(heap);
var HEAP16 = new stdlib.Int16Array(heap);
var HEAP32 = new stdlib.Int32Array(heap);
var HEAPU8 = new stdlib.Uint8Array(heap);
var HEAPU16 = new stdlib.Uint16Array(heap);
var HEAPU32 = new stdlib.Uint32Array(heap);

var dynCall_iiiiiiiiiii = ffi.dynCall_iiiiiiiiiii;
var getTempRet0 = ffi.getTempRet0;
var badAlignment = ffi.badAlignment;
var _i64Add = ffi._i64Add;
var _i64Subtract = ffi._i64Subtract;
var Math_imul = ffi.Math_imul;
var _mul_unsigned_long_long = ffi._mul_unsigned_long_long;
var execute_if_compiled = ffi.execute_if_compiled;
var getThrew = ffi.getThrew;
var abort = ffi.abort;
var qemu_ld_ub = ffi.qemu_ld_ub;
var qemu_ld_leuw = ffi.qemu_ld_leuw;
var qemu_ld_leul = ffi.qemu_ld_leul;
var qemu_ld_beuw = ffi.qemu_ld_beuw;
var qemu_ld_beul = ffi.qemu_ld_beul;
var qemu_ld_beq = ffi.qemu_ld_beq;
var qemu_ld_leq = ffi.qemu_ld_leq;
var qemu_st_b = ffi.qemu_st_b;
var qemu_st_lew = ffi.qemu_st_lew;
var qemu_st_lel = ffi.qemu_st_lel;
var qemu_st_bew = ffi.qemu_st_bew;
var qemu_st_bel = ffi.qemu_st_bel;
var qemu_st_leq = ffi.qemu_st_leq;
var qemu_st_beq = ffi.qemu_st_beq;

function tb_fun(tb_ptr, env, sp_value, depth) {
  tb_ptr = tb_ptr|0;
  env = env|0;
  sp_value = sp_value|0;
  depth = depth|0;
  var u0 = 0, u1 = 0, u2 = 0, u3 = 0, result = 0;
  var r0 = 0, r1 = 0, r2 = 0, r3 = 0, r4 = 0, r5 = 0, r6 = 0, r7 = 0, r8 = 0, r9 = 0;
  var r10 = 0, r11 = 0, r12 = 0, r13 = 0, r14 = 0, r15 = 0, r16 = 0, r17 = 0, r18 = 0, r19 = 0;
  var r20 = 0, r21 = 0, r22 = 0, r23 = 0, r24 = 0, r25 = 0, r26 = 0, r27 = 0, r28 = 0, r29 = 0;
  var r30 = 0, r31 = 0, r41 = 0, r42 = 0, r43 = 0, r44 = 0;
    r14 = env|0;
    r15 = sp_value|0;
  START: do {
    r0 = HEAPU32[((r14 + (-4))|0) >> 2] | 0;
    r42 = 0;
    result = ((r0|0) != (r42|0))|0;
    HEAPU32[1445307] = r0;
    HEAPU32[1445321] = r14;
    if(result|0) {
    HEAPU32[1445322] = r15;
    return 0x0345bf93|0;
    }
    r0 = HEAPU32[((r14 + (16))|0) >> 2] | 0;
    r42 = 8;
    r0 = ((r0|0) - (r42|0))|0;
    HEAPU32[(r14 + (16)) >> 2] = r0;
    r1 = 8;
    HEAPU32[(r14 + (44)) >> 2] = r1;
    r1 = r0|0;
    HEAPU32[(r14 + (40)) >> 2] = r1;
    r42 = 4;
    r0 = ((r0|0) + (r42|0))|0;
    r2 = HEAPU32[((r14 + (24))|0) >> 2] | 0;
    HEAPU32[1445307] = r0;
    HEAPU32[1445308] = r1;
    HEAPU32[1445309] = r2;
    HEAPU32[1445321] = r14;
    HEAPU32[1445322] = r15;
    qemu_st_lel(env|0, r0|0, r2|0, 34, 22759218);
if(getThrew() | 0) abort();
    r0 = 3241038392;
    HEAPU32[1445307] = r0;
    r0 = qemu_ld_leul(env|0, r0|0, 34, 22759233)|0;
if(getThrew() | 0) abort();
    HEAPU32[(r14 + (24)) >> 2] = r0;
    r1 = HEAPU32[((r14 + (12))|0) >> 2] | 0;
    r2 = HEAPU32[((r14 + (40))|0) >> 2] | 0;
    HEAPU32[1445307] = r0;
    HEAPU32[1445308] = r1;
    HEAPU32[1445309] = r2;
    qemu_st_lel(env|0, r2|0, r1|0, 34, 22759265);
if(getThrew() | 0) abort();
    r0 = HEAPU32[((r14 + (24))|0) >> 2] | 0;
    HEAPU32[(r14 + (40)) >> 2] = r0;
    r1 = 24;
    HEAPU32[(r14 + (52)) >> 2] = r1;
    r42 = 0;
    result = ((r0|0) == (r42|0))|0;
    if(result|0) {
    HEAPU32[1445307] = r0;
    HEAPU32[1445308] = r1;
    }
    HEAPU32[1445307] = r0;
    HEAPU32[1445308] = r1;
    return execute_if_compiled(22759392|0, env|0, sp_value|0, depth|0) | 0;
    return execute_if_compiled(23164080|0, env|0, sp_value|0, depth|0) | 0;
    break;
  } while(1); abort(); return 0|0;
}
return {tb_fun: tb_fun};
}(window, CompilerFFI, Module.buffer)["tb_fun"]

Fazit

Die Arbeit ist also noch nicht abgeschlossen, aber ich habe genug davon, dieses langfristige Projekt heimlich zu perfektionieren. Daher habe ich beschlossen, zunächst das zu veröffentlichen, was bereits vorhanden ist. Der Code ist stellenweise etwas unordentlich, da es sich um ein Experiment handelt und es nicht klar ist, was zu tun ist. Vielleicht sollte ich später ordentliche atomare Commits auf einer moderneren Version von Qemu erstellen. Jetzt gibt es einen Branch im Git in Form eines Blogs: Zu jedem "Level", das ich wenigstens ansatzweise erreicht habe, gibt es einen ausführlichen Kommentar auf Russisch. Diese Artikel sind in erheblichem Maße eine Zusammenfassung der Ergebnisse. git log.

Das alles kann ausprobiert werden hier (Vorsicht, Traffic).

Was bereits funktioniert:

  • Es funktioniert ein virtueller x86-Prozessor
  • Es gibt einen funktionierenden Prototyp eines JIT-Codegenerators von Maschinencode auf JavaScript
  • Es gibt ein Gerüst für den Zusammenbau anderer 32-Bit-Gastarchitekturen: Sie können gerade jetzt den Bootprozess von Linux für die Architektur MIPS beobachten, der im Browser steckt.

Was kann noch gemacht werden

  • Die Emulation beschleunigen. Selbst im JIT-Modus scheint sie langsamer zu arbeiten als Virtual x86 (aber potenziell gibt es ein ganzes Qemu mit einer Vielzahl an emulierten Hardware und Architekturen).
  • Eine anständige Benutzeroberfläche gestalten – um ehrlich zu sein, bin ich kein großartiger Webentwickler, deshalb habe ich vorerst die Standardoberfläche von Emscripten so angepasst, wie ich es konnte.
  • Versuchen Sie, komplexere Funktionen von Qemu zu starten – Netzwerk, VM-Migration usw.
  • UPD: Ich muss meine wenigen Beiträge und Bug-Reports an den Emscripten-Upstream weitergeben, so wie es vorherige Portierer von Qemu und anderen Projekten gemacht haben. Vielen Dank an sie, dass ich ihre Beiträge zu Emscripten im Rahmen meiner Aufgaben nutzen konnte.

Quelle: habr.com

Kaufen Sie zuverlässiges Hosting für Websites mit DDoS-Schutz, VPS VDS-Server 🔥 Kaufen Sie zuverlässiges Hosting für Websites mit DDoS-Schutz, VPS VDS-Server | ProHoster