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

Vor einigen Jahren schrieb Fabrice Bellard jslinux — einen PC-Emulator, der in JavaScript geschrieben wurde. Danach gab es noch mindestens Virtual x86. Aber alle waren, soweit ich weiß, Interpreter, während Qemu, das viel früher von demselben Fabrice Bellard geschrieben wurde, und wahrscheinlich jeder respektable moderne Emulator, JIT-Kompilierung von Gastcode in Host-Systemcode verwendet. Mir schien, dass es an der Zeit war, die umgekehrte Aufgabe zu realisieren, die Browser lösen: JIT-Kompilierung von Maschinencode in JavaScript, weshalb ich es für am sinnvollsten hielt, Qemu zu portieren. Es scheint, als wäre Qemu der Grund, da es einfachere und benutzerfreundlichere Emulatoren gibt — zum Beispiel VirtualBox — einfach installieren und es funktioniert. Aber Qemu hat einige interessante Eigenschaften

  • Open-Source-Code
  • die Möglichkeit, ohne Kernel-Treiber zu arbeiten
  • die Möglichkeit, im Interpreter-Modus zu arbeiten
  • unterstützt viele sowohl Host- als auch Gastarchitekturen

Was den dritten Punkt betrifft, so kann ich inzwischen erklären, dass im TCI-Modus nicht die Gastmaschineninstruktionen selbst interpretiert werden, sondern der aus ihnen generierte Bytecode, aber das ändert nichts an der Sache — um Qemu auf einer neuen Architektur zu kompilieren und auszuführen, reicht im besten Fall ein C-Compiler — die Implementierung eines Codegenerators kann verschoben werden.

Und so, nach zwei Jahren gemächlichen Herumwerkens an den Qemu-Quellen, gab es einen funktionierenden Prototyp, auf dem bereits zum Beispiel Kolibri OS gestartet werden kann.

Was ist Emscripten

In unserer Zeit sind viele Compiler entstanden, deren Endergebnis JavaScript ist. Einige, wie TypeScript, wurden ursprünglich als die beste Möglichkeit zum Schreiben für das Web gedacht. Gleichzeitig ist Emscripten eine Möglichkeit, bestehenden Code in C oder C++ zu nehmen und ihn in eine Form zu kompilieren, die der Browser versteht. Es dieser Seite wurden zahlreiche Ports bekannter Programme erstellt: hier, zum Beispiel kann man sich PyPy ansehen — übrigens, wie behauptet wird, haben sie bereits JIT. In Wirklichkeit kann man nicht jedes Programm einfach kompilieren und im Browser ausführen — es gibt eine Reihe von Besonderheiten, mit denen man sich arrangieren muss, wie die Aufschrift auf dieser Seite besagt: "Emscripten kann verwendet werden, um fast jedes zu kompilieren. portable C/C++-Code in JavaScript. Es gibt eine Reihe von Operationen, die im Standard als undefiniertes Verhalten gelten, aber normalerweise auf x86 funktionieren — zum Beispiel unaligned access auf Variablen, das in einigen Architekturen überhaupt verboten ist. Im Allgemeinen ist Qemu ein plattformübergreifendes Programm und ich wollte glauben, dass es nicht viel undefiniertes Verhalten enthält — einfach kompilieren und dann ein wenig mit JIT herumspielen — und schon ist es bereit! Aber es kam anders...

Erster Versuch

Im Allgemeinen bin ich nicht der Erste, dem die Idee kam, Qemu nach JavaScript zu portieren. Im Forum von ReactOS wurde die Frage aufgeworfen, ob dies mit Emscripten möglich ist. Noch früher gab es Gerüchte, dass Fabrice Bellard das persönlich gemacht hat, aber es ging um jslinux, das, soweit ich weiß, gerade einen Versuch darstellt, manuell eine ausreichende Leistung in JS zu erreichen und von Grund auf neu geschrieben wurde. Später wurde Virtual x86 geschrieben — dazu wurden die unobfuskierten Quelltexte bereitgestellt, und es wurde behauptet, dass die größere "Realitätsnähe" der Emulation es ermöglichte, SeaBIOS als Firmware zu verwenden. Außerdem gab es mindestens einen Versuch, Qemu mit Emscripten zu portieren — das wollte man machen socketpair, aber die Entwicklung, soweit ich das verstanden habe, wurde eingestellt.

Es scheint also, dass hier die Quelltexte sind, hier Emscripten — einfach kompilieren. Aber es gibt auch Bibliotheken, von denen Qemu abhängt, und Bibliotheken, von denen diese Bibliotheken abhängen usw., wobei eine davon — libffi, von der glib abhängt. Im Internet gab es Gerüchte, dass es in der großen Sammlung von Ports für Bibliotheken unter Emscripten auch eine gibt, aber ich hielt es irgendwie für schwer vorstellbar: Erstens ließ sich damit kein neuer Compiler erstellen, und zweitens ist es eine zu niedrigstufige Bibliothek, um einfach so zu nehmen und in JS zu kompilieren. Dabei geht es nicht nur um die Assembler-Einfügungen – wahrscheinlich könnte man für einige Calling Conventions die erforderlichen Argumente auch ohne sie auf dem Stack bilden und die Funktion aufrufen. Aber Emscripten ist ein kniffliges Zeug: Um sicherzustellen, dass der generierte Code für den Optimierer der JS-Engine des Browsers vertraut aussieht, werden einige Tricks verwendet. Insbesondere das sogenannte Relooping – der Code-Generator versucht, basierend auf dem erhaltenen LLVM IR mit einigen abstrakten Steueranweisungen, glaubwürdige if-Anweisungen, Schleifen usw. zu rekonstruieren. Und wie werden die Argumente in die Funktion übergeben? Natürlich wie die Argumente von JS-Funktionen, also möglichst nicht über den Stack.

Zunächst hatte ich die Idee, einfach eine Ersatzbibliothek für libffi in JS zu schreiben und die Standardtests durchzuführen, aber schließlich verlor ich den Überblick darüber, wie ich meine Header-Dateien erstellen sollte, damit sie mit dem bestehenden Code funktionieren – was soll man machen, wie man sagt, 'Entweder sind die Aufgaben so kompliziert, oder wir sind so dumm'. Ich musste libffi auf eine weitere Architektur portieren, wenn man das so ausdrücken kann – zum Glück gibt es in Emscripten sowohl Makros für Inline-Assembler (in JavaScript, ja – nun, welche Architektur, so auch der Assembler), als auch die Möglichkeit, generierten On-the-Fly-Code auszuführen. Insgesamt habe ich eine Weile mit den plattformabhängigen Fragmenten von libffi herumgefuhrwerkt und einen kompilierbaren Code erhalten, den ich beim ersten besten Test ausprobierte. Zu meiner Überraschung bestand der Test erfolgreich. Beeindruckt von meiner Genialität – es ist kein Scherz, es funktionierte beim ersten Versuch – schaute ich, immer noch meinen Augen nicht glaubend, erneut auf den erhaltenen Code, um zu beurteilen, wo ich weiter graben sollte. Hier war ich erneut perplex – das Einzige, was meine Funktion ffi_call tat, war, den erfolgreichen Aufruf zu melden. Der Aufruf selbst fand nicht statt. So schickte ich meine erste Pull-Request, die einen für jeden Olympioniken verständlichen Fehler im Test korrigierte – reelle Zahlen sollten nicht verglichen werden wie a == b und nicht einmal wie a - b < EPS — Man darf das Modul nicht vergessen, sonst wird 0 schnell gleich 1/3… Auf jeden Fall habe ich eine gewisse libffi-Portierung erstellt, die die einfachsten Tests besteht und mit der glib kompiliert wird — ich denke, dass ich später noch etwas hinzufügen werde. Um vorzugreifen, sage ich, dass der Compiler den finalen Code der Funktion libffi nicht einmal einbezogen hat.

Aber wie ich schon sagte, gibt es einige Einschränkungen, und unter der freizügigen Nutzung verschiedener unbestimmter Verhaltensweisen gibt es ein unangenehmes Merkmal — JavaScript unterstützt von sich aus kein Multithreading mit gemeinsamem Speicher. Prinzipiell könnte das sogar eine gute Idee sein, aber nicht für die Portierung von Code, dessen Architektur auf C-Threads basiert. Im Allgemeinen experimentiert Firefox mit der Unterstützung von Shared Workers, und die pthread-Implementierung ist in Emscripten vorhanden, aber ich wollte mich nicht darauf verlassen. Ich musste langsam das Multithreading aus dem Qemu-Code herausarbeiten — das heißt, ich musste herausfinden, wo Threads gestartet werden, den Körper der Schleife, die in diesem Thread ausgeführt wird, in eine separate Funktion verschieben und diese Funktionen dann abwechselnd aus der Hauptschleife aufrufen.

Zweiter Versuch

Irgendwann wurde klar, dass es nicht funktioniert, und dass das unorganisierte Verstecken von Notlösungen den Code nicht besser macht. Fazit: Der Prozess zum Hinzufügen von Notlösungen muss irgendwie systematisiert werden. Deshalb wurde die zu dem Zeitpunkt aktuelle Version 2.4.1 (nicht 2.5.0, denn wer weiß, ob dort noch nicht gefixte Bugs der neuen Version sind, und ich habe genug eigene Bugs) genommen, und als erstes wurde sicherheitsbewusst umgeschrieben thread-posix.c. Also gut, sicherheitsbewusst: Wenn jemand versuchte, eine Operation auszuführen, die zu einer Blockierung führt, wurde sofort die Funktion abort() aufgerufen — natürlich löste das nicht sofort alle Probleme, aber es war zumindest schöner, als still und leise inkonsistente Daten zu erhalten.

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

Übrigens sind Ausrichtungsfehler ein eigenes Thema. Wie ich bereits erwähnt habe, gibt es in Qemu einen "degenerierten" interpretierenden TCI (tiny code interpreter) Codegenerierungs-Backend, und um Qemu auf einer neuen Architektur zu bauen und zu starten, braucht man, wenn man Glück hat, nur einen C-Compiler. Schlüsselwörter "wenn man Glück hat". Ich hatte kein Glück, und es stellte sich heraus, dass TCI beim Parsen seines Bytecodes keinen ausgerichteten Zugang verwendet. Das bedeutet, dass Qemu auf verschiedenen Architekturen wie ARM und anderen, die einen zwingend ausgerichteten Zugang benötigen, kompilierbar ist, weil es für sie ein normales TCG-Backend gibt, das nativen Code generiert, aber ob TCI darauf laufen wird, ist eine andere Frage. Wie sich herausstellte, wurde in der TCI-Dokumentation etwas Ähnliches deutlich angegeben. Schließlich wurden im Code Funktionsaufrufe zum nicht ausgerichteten Lesen hinzugefügt, die in einem anderen Teil von Qemu gefunden wurden.

Heapzerstörung

Am Ende wurde der nicht ausgerichtete Zugang in TCI behoben, eine Hauptschleife erstellt, die nacheinander den Prozessor, RCU und noch ein paar Kleinigkeiten aufrief. Und jetzt starte ich Qemu mit der Option -d exec,in_asm,out_asm, was bedeutet, dass man sagen muss, welche Codeblöcke ausgeführt werden, und auch während der Übersetzung anzugeben, welcher Gastcode war und welcher Hostcode geworden ist (in diesem Fall Bytecode). Es startet, führt einige Blöcke der Übersetzung aus, schreibt die Debugging-Nachricht, die ich hinterlassen habe, dass jetzt RCU gestartet wird, und… stürzt bei abort() innerhalb der Funktion free(). Durch das Aufschlüsseln der Funktion free() wurde festgestellt, dass im Header des Heapblocks, der acht Bytes vor dem zugewiesenen Speicher liegt, anstelle der Blockgröße oder etwas Ähnlichem Müll vorhanden war.

Das Zerstören eines Haufens - wie nett... In einem solchen Fall gibt es ein nützliches Mittel - aus (wenn möglich) denselben Quellcodes ein natives Binary zu erstellen und dies mit Valgrind zu testen. Nach einiger Zeit war das Binary fertig. Ich starte mit den gleichen Optionen - es stürzt noch beim Initialisieren ab, bevor es zu der eigentlichen Ausführung kommt. Unangenehm, natürlich - anscheinend waren die Quellcodes nicht ganz dieselben, was nicht überrascht, denn configure hat einige andere Optionen gefunden, aber ich habe ja Valgrind - zuerst repariere ich diesen Bug, und dann, wenn ich Glück habe, zeigt sich auch der ursprüngliche. Ich starte dasselbe unter Valgrind... Uuuh, es hat gestartet, die Initialisierung lief normal und es ging weiter vorbei am ursprünglichen Bug ohne eine einzige Warnung über unzulässigen Speicherzugriff, ganz zu schweigen von Abstürzen. Auf so etwas hat mich das Leben, wie man so sagt, nicht vorbereitet - ein abstürzendes Programm hört auf zu crasht, wenn ich es unter Valgrind starte. Was das war - ein Rätsel. Meine Hypothese ist, dass in der Umgebung des aktuellen Befehls nach dem Absturz während der Initialisierung gdb eine Arbeit angezeigt hat. memset- mit einem gültigen Zeiger unter Verwendung von entweder mmx, oder xmm Registern, könnte es möglicherweise ein Problem mit der Ausrichtung gewesen sein, obwohl ich das trotzdem kaum glaube.

O-Kay, Valgrind scheint hier nicht hilfreich zu sein. Und hier begann das wirklich Unangenehme — alles startet zwar, aber es stürzt aus völlig unbekannten Gründen ab, aufgrund eines Ereignisses, das Millionen von Anweisungen zuvor passiert sein könnte. Lange Zeit war es unklar, wie man überhaupt herankommt. Schließlich musste ich mich doch hinsetzen und debuggen. Der Ausdruck dessen, was den Header überschrieben hat, zeigte, dass es eher wie binäre Daten denn wie eine Zahl aussieht. Und, oh Wunder, diese binäre Zeichenkette fand sich in der BIOS-Datei — das heißt, nun konnte man mit ausreichender Sicherheit sagen, dass es sich um einen Buffer Overflow handelte, und es war sogar klar, was in diesen Buffer geschrieben wurde. Nun, und dann ist es so gelaufen — in Emscripten gibt es glücklicherweise keine Adressraumrandomisierung, auch keine Lücken, deshalb kann man irgendwo in der Mitte des Codes Daten über einen Zeiger vom letzten Start ausgeben, sich die Daten ansehen, den Zeiger überprüfen, und wenn dieser sich nicht verändert hat, Informationen zur weiteren Überlegung erhalten. Es dauert zwar ein paar Minuten, um nach jeder Änderung zu linken, aber was soll man machen. Letztendlich wurde eine bestimmte Zeile gefunden, die das BIOS vom temporären Buffer in den Gast-Speicher kopiert — und tatsächlich war im Buffer nicht genügend Platz. 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 so: manchmal kann es nützlich sein, die Adresse auf eine große Seite von 2 MB auszurichten, dafür bitten wir um mmap zuerst etwas mehr, und später geben wir das Überflüssige mit Hilfe von munmapzurück. Wenn eine solche Ausrichtung nicht erforderlich ist, geben wir anstelle von 2 MB das Ergebnis an getpagesize()mmap gibt uns dennoch die ausgerichtete Adresse zurück... Also in Emscripten mmap ruft man einfach malloc, und dieser richtet, wie zu erwarten, nicht nach Seite aus. Im Großen und Ganzen wurde der Fehler, der mich ein paar Monate lang geärgert hat, durch eine Änderung in zwei den Zeilen behoben.

Besonderheiten beim Funktionsaufruf

Und nun rechnet der Prozessor irgendetwas, Qemu stürzt nicht ab, aber der Bildschirm bleibt schwarz, und der Prozessor läuft schnell in eine Schleife, judging by the output. -d exec,in_asm,out_asm. Es gibt eine Hypothese: Es kommen keine Interrupts des Timers an (oder überhaupt keine Interrupts). Und tatsächlich, wenn man die Interrupts von der nativen Build, die aus unerklärlichen Gründen funktionierte, abzieht, ergibt sich ein ähnliches Bild. Doch das Rätsel lag ganz woanders: Der Vergleich der Traces, die mit der oben genannten Option ausgegeben wurden, zeigte, dass sich die Ausführungswege sehr früh unterscheiden. Hier muss gesagt werden, dass der Vergleich der Aufzeichnungen, die mit dem Launcher emrun erzeugt wurden, nicht ganz ein mechanischer Prozess ist. Ich weiß nicht genau, wie das im Browser laufende Programm verbunden ist mit emrun, aber einige Zeilen in der Ausgabe scheinen vertauscht zu sein, daher ist der Unterschied im Diff noch kein Grund zu glauben, dass sich die Ausführungswege getrennt haben. Insgesamt wurde klar, dass gemäß der Anleitung ljmpl der Übergang zu unterschiedlichen Adressen stattfindet, und der Bytecode wird prinzipiell unterschiedlich generiert: In einer Version gibt es eine Anweisung zum Aufruf einer C-Helper-Funktion, in der anderen nicht. Nach dem Googeln der Anweisungen und dem Studium des Codes, der diese Anweisungen überträgt, wurde klar, dass erstens direkt davor in das Register cr0 ein Wert geschrieben wurde — ebenfalls mit Hilfe des Helpers —, der den Prozessor in den geschützten Modus versetzt, und zweitens, dass die JS-Version in den geschützten Modus nicht übergegangen ist. Der Grund dafür ist, dass eine weitere Besonderheit von Emscripten das Unvermögen ist, Code wie die Implementierung der Anweisung call in TCI zu ertragen, das jeden Funktionszeiger in den Typ long long f(int arg0, .. int arg9) überführt — Funktionen müssen mit der richtigen Anzahl von Argumenten aufgerufen werden. Bei Missachtung dieser Regel stürzt das Programm je nach Debugging-Einstellungen entweder ab (was gut ist) oder es ruft eine falsche Funktion auf (was beim Debuggen traurig sein wird). Es gibt auch eine dritte Möglichkeit — die Generierung von Wrappers zu aktivieren, die Argumente hinzufügen/entfernen, aber insgesamt nehmen diese Wrappers sehr viel Platz in Anspruch, wobei ich tatsächlich nur etwas mehr als hundert Wrappers brauche. Allein das ist schon ziemlich enttäuschend, aber es stellte sich heraus, dass es ein ernsthafteres Problem gab: In dem generierten Code der Wrapper-Funktionen wurden die Argumente zwar konvertiert, aber die Funktion mit den generierten Argumenten wurde manchmal nicht aufgerufen — genau wie in meiner Implementierung von libffi. Das bedeutet, dass einige Helpers einfach nicht ausgeführt wurden.

Glücklicherweise gibt es in Qemu maschinenlesbare Helferlisten in Form einer Header-Datei wie

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

Diese werden auf recht amüsante Weise verwendet: zuerst werden die Makros auf die skurrilste Weise überschrieben DEF_HELPER_n, und dann wird inkludiert helper.h. Bis hin zu dem Punkt, dass das Makro in den Initialisierer der Struktur und ein Komma ausgeweitet wird, gefolgt von der Definition eines Arrays, und anstelle von Elementen — #include <helper.h> Schließlich ergab sich die Gelegenheit, die Bibliothek pyparsing, auszuprobieren, und es wurde ein Skript geschrieben, das genau die Wrapper für die Funktionen generiert, die benötigt werden.

Und so scheint der Prozessor danach zu funktionieren. Zumindest scheint es so, denn der Bildschirm wurde nicht initialisiert, obwohl es in der nativen Version gelang, memtest86+ zu starten. Hier sollte klargestellt werden, dass der Code für den blockweisen Input/Output in Qemu mit Koroutinen geschrieben ist. In Emscripten gibt es eine recht verworrene Umsetzung, aber diese musste noch im Qemu-Code unterstützt werden, und den Prozessor kann man bereits jetzt debuggen: Qemu unterstützt die Optionen -kernel, -initrd, -append, mit denen man Linux oder beispielsweise memtest86+ laden kann, ohne blockierende Geräte zu verwenden. Aber hier liegt das Problem: In der nativen Version konnte man die Ausgabe des Linux-Kernels auf der Konsole mit der Option -nographic, aber aus dem Browser kam keine Ausgabe im Terminal, von dem aus gestartet wurde, emrun, es war unklar, ob der Prozessor nicht funktioniert oder die Grafikausgabe fehlerhaft ist. Und dann kam mir die Idee, ein wenig zu warten. Es stellte sich heraus, dass "der Prozessor schläft nicht, sondern blinkt einfach langsam", und nach etwa fünf Minuten warf der Kernel eine Reihe von Nachrichten auf die Konsole und begann weiter zu hängen. Es wurde klar, dass der Prozessor im Allgemeinen funktioniert, und man sollte den Code für die Arbeit mit SDL2 näher untersuchen. Leider kann ich diese Bibliothek nicht benutzen, also musste ich teilweise auf gut Glück vorgehen. Irgendwann blitzte die Zeile parallel0 auf blauem Hintergrund auf, was zum Nachdenken anregte. Schließlich 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 überflüssige Fenster mithilfe der Optionen -monitor none -parallel none -serial none entfernt wurden und die Anweisung gegeben wurde, den gesamten Bildschirm in jedem Frame neu zu zeichnen, funktionierte plötzlich alles.

Koroutinen

Die Emulation im Browser funktioniert also, aber es gibt nichts Interessantes, das von einer Diskette ausgeführt werden könnte, weil es kein Block-I/O gibt – Unterstützung für Koroutinen muss implementiert werden. In Qemu gibt es bereits mehrere Coroutine-Backends, aber aufgrund der Besonderheiten von JavaScript und des Emscripten-Codegenerators kann man nicht einfach anfangen, mit Stacks zu jonglieren. Es schien, dass "alles verloren ist, der Gips wird abgenommen", aber die Entwickler von Emscripten haben sich bereits um alles gekümmert. Das ist ziemlich unterhaltsam umgesetzt: Lassen Sie uns verdächtige Funktionsaufrufe wie emscripten_sleep und mehrere andere, die den Asyncify-Mechanismus verwenden, sowie Aufrufe über Zeiger und Aufrufe beliebiger Funktionen, bei denen unten im Stack einer der beiden vorherigen Fälle eintreten könnte. Lassen Sie uns vor jedem verdächtigen Aufruf einen asynchronen Kontext anlegen und sofort nach dem Aufruf überprüfen, ob ein asynchroner Aufruf stattgefunden hat, und wenn ja, speichern wir alle lokalen Variablen in diesem asynchronen Kontext, geben an, welche Funktion die Kontrolle übertragen bekommen soll, wenn die Ausführung fortgesetzt werden muss, und verlassen die aktuelle Funktion. Hier gibt es viel zu erforschen über den Effekt des Zerlegens – für die Fortsetzung der Ausführung von Code nach der Rückkehr aus einem asynchronen Aufruf generiert der Compiler "Schnipsel" der Funktion, die nach dem verdächtigen Aufruf beginnen – so: wenn es n verdächtige Aufrufe gibt, wird die Funktion etwa n/2-mal zerlegt – das ist noch nicht einmal berücksichtigt, dass in die ursprüngliche Funktion nach jedem potenziell asynchronen Aufruf das Speichern eines Teils der lokalen Variablen hinzugefügt werden muss. Später musste ich sogar ein einfaches Skript in Python schreiben, das aufgrund einer bestimmten Menge von besonders zerlegten Funktionen, die möglicherweise "Asynchronität nicht durch sich hindurchlassen" (das heißt, in denen das Stack-Winden nicht funktioniert und all das, was ich gerade beschrieben habe), angibt, in welchen Funktionen Aufrufe über Zeiger vom Compiler ignoriert werden sollen, damit diese Funktionen nicht als asynchron betrachtet werden. Denn JS-Dateien unter 60 MB – das ist bereits deutlich übertrieben – lassen wir es zumindest bei 30 MB. Obwohl ich einmal ein Build-Skript konfiguriert habe und versehentlich die Linker-Optionen entfernt habe, unter denen auch -O3. Ich starte den generierten Code und Chromium frisst den Speicher und stürzt ab. Später habe ich zufällig darauf geschaut, was er zu laden versuchte… Nun, was soll ich sagen, ich würde auch hängen bleiben, wenn ich aufgefordert werden würde, JavaScript mit über 500 MB sorgfältig zu durchsuchen und zu optimieren.

Leider hatten die Überprüfungen im Code der Asyncify-Unterstützungsbibliothek nicht wirklich gut funktioniert mit longjmp-s, die im Code des virtuellen Prozessors verwendet werden, aber 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 traten die Überprüfungen im Synchronisierungscode auf – die, die den Code sofort stoppen, wenn er laut Ausführungslogik blockieren sollte – jemand versuchte, ein bereits erlangtes Mutex zu erfassen. Zum Glück handelte es sich dabei nicht um ein logisches Problem im serialisierten Code – ich hatte einfach die Standardfunktionalität der Hauptschleife verwendet, die von Emscripten bereitgestellt wird, aber manchmal entfaltete der asynchrone Aufruf den Stack vollständig, und in diesem Moment wurde setTimeout von der Hauptschleife aufgerufen – so gelangte der Code in eine Iteration der Hauptschleife, ohne die vorherige Iteration zu beenden. Ich habe es auf eine Endlosschleife umgeschrieben, und emscripten_sleep, und die Probleme mit den Mutexen hörten auf. Der Code wurde sogar logischer – schließlich habe ich im Grunde keinen speziellen Code, der einen weiteren Animationsframe vorbereitet – sondern der Prozessor berechnet etwas und der Bildschirm wird regelmäßig aktualisiert. Dennoch hörten die Probleme damit nicht auf: Manchmal wurde die Ausführung von Qemu einfach still beendet, ohne dass irgendwelche Ausnahmen oder Fehler auftraten. In diesem Moment habe ich es ignoriert, aber um vorzugreifen, das Problem lag daran, dass der Coroutine-Code tatsächlich nicht verwendet wurde setTimeout (oder zumindest nicht so oft, wie man denken könnte): Die Funktion emscripten_yield setzt einfach ein Flag für einen asynchronen Aufruf. Das Hauptproblem ist, dass emscripten_coroutine_next keine asynchrone Funktion ist: Sie überprüft ihr Flag, setzt es zurück und übergibt die Kontrolle, wo immer nötig. Das heißt, dort endet der Stack-Entrollvorgang. Das Problem war, dass aufgrund eines use-after-free, das auftrat, wenn der Coroutine-Pool deaktiviert war, weil ich eine wichtige Zeile Code aus dem existierenden Coroutine-Backend nicht kopiert hatte, die Funktion qemu_in_coroutine gibt true zurück, wenn sie tatsächlich false zurückgeben sollte. Das führte zu einem Aufruf emscripten_yield, über dem im Stack nichts war emscripten_coroutine_next, der Stack entspannte sich bis nach oben, aber keine setTimeout, wie ich bereits sagte, wurde nicht angezeigt.

Codegenerierung JavaScript

Und hier ist es, das versprochene "Zurückdrehen des Hackfleischs". Tatsächlich nicht. Natürlich, wenn man Qemu im Browser startet und darin Node.js ausführt, dann erhält man nach der Codegenerierung in Qemu ein ganz anderes JavaScript. Aber trotzdem, eine Art Rücktransformation.

Zunächst ein wenig darüber, wie Qemu funktioniert. Ich bitte Sie sofort um Verzeihung: Ich bin kein professioneller Qemu-Entwickler, und meine Schlussfolgerungen können an einigen Stellen fehlerhaft sein. Wie man sagt: "Die Meinung eines Studenten muss nicht mit der Meinung des Lehrers, der Peano-Axiome und dem gesunden Menschenverstand übereinstimmen". Qemu hat eine bestimmte Anzahl unterstützter Gastarchitekturen, und für jede gibt es ein Verzeichnis wie target-i386. Bei der Erstellung kann man die Unterstützung mehrerer Gastarchitekturen angeben, aber dabei erhält man einfach mehrere Binärdateien. Der Code zur Unterstützung der Gastarchitektur generiert seinerseits bestimmte interne Operationen von Qemu, die der TCG (Tiny Code Generator) bereits in Maschinencode der Hostarchitektur umwandelt. Wie in der Readme-Datei im Verzeichnis tcg angegeben, war das ursprünglich Teil eines normalen C-Compilers, der später für JIT angepasst wurde. Daher ist die Zielarchitektur in den Begriffen dieses Dokuments nicht mehr die Gastarchitektur, sondern die Hostarchitektur. Irgendwann erschien eine weitere Komponente – der Tiny Code Interpreter (TCI), der den Code (praktisch dieselben internen Operationen) ohne einen Codegenerator für eine spezifische Hostarchitektur ausführen sollte. 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 seine Beschreibung vollständig aktuell ist.

Anfangs versuchte ich, ein vollständiges TCG-Backend zu erstellen, verlor jedoch schnell den Überblick über den Quellcode und die wenig verständliche Beschreibung der Bytecode-Anweisungen, weshalb ich beschloss, den TCI-Interpreter zu umschließen. Das brachte gleich mehrere Vorteile:

  • bei der Implementierung des Codegenerators konnte ich nicht in die Beschreibung der Anweisungen schauen, sondern in den Code des Interpreters
  • Man kann Funktionen nicht für jeden angesprochenen Übersetzungsblock generieren, sondern beispielsweise nur nach der hundertsten Ausführung.
  • Im Falle einer Änderung des generierten Codes (was offenbar möglich ist, judging by the functions with names containing the word patch) muss ich den generierten JS-Code invalidieren, aber ich werde zumindest etwas haben, aus dem ich ihn regenerieren kann.

Was den dritten Punkt betrifft, bin ich mir nicht sicher, ob Patche nach der ersten Ausführung des Codes möglich sind, aber die ersten beiden Punkte sind ausreichend.

Ursprünglich wurde der Code in Form eines großen Switchs für die Adresse der ursprünglichen Bytecode-Anweisung generiert, aber nachdem ich den Artikel über Emscripten, die Optimierung des generierten JS und Re-Looping erinnert hatte, beschloss ich, menschenlesbareren Code zu generieren, vor allem, weil empirisch herauskam, dass der einzige Einstiegspunkt in den Übersetzungsblock dessen Anfang ist. Gesagt, getan, nach einiger Zeit entstand ein Codegenerator, der Code mit If-Anweisungen generiert (wenn auch ohne Schleifen). Aber das Problem war, dass er abstürzte und eine Meldung ausgegeben hat, dass die Anweisung eine falsche Länge hatte. Dabei war die letzte Anweisung auf dieser Rekursionsebene. brcond. Gut, ich werde eine identische Überprüfung bei der Generierung dieser Anweisung vor dem rekursiven Aufruf und danach hinzufügen, und... keine von ihnen wurde ausgeführt, aber nach dem Switch über den Assert stürzten sie doch ab. Schließlich, nachdem ich den generierten Code untersucht hatte, verstand ich, dass nach dem Switch der Zeiger auf die aktuelle Anweisung vom Stack neu geladen wird und wahrscheinlich durch den generierten JavaScript-Code überschrieben wird. So war es. Die Erhöhung des Puffers von einem Megabyte auf zehn führte zu nichts, und es wurde klar, dass der Codegenerator im Kreis lief. Es war notwendig zu überprüfen, dass wir die Grenzen des aktuellen TB nicht überschritten haben, 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 zu invalidieren sind, wenn dieser Teil des Bytecodes geändert wurde?“ — nur die Funktion zu invalidieren, die diesem Übersetzungsblock entspricht. Übrigens habe ich alles in Chromium debuggt (da ich Firefox benutze und es mir einfacher ist, einen separaten Browser für Experimente zu verwenden), aber Firefox half mir, die Inkonsistenzen mit dem Standard asm.js zu beheben, nach dem der Code schneller in Chromium 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 es hat mir genug, heimlich dieses lange Projekt zu perfektionieren. Daher habe ich beschlossen, vorerst das zu veröffentlichen, was ich habe. Der Code ist stellenweise etwas furchterregend, da dies ein Experiment ist und nicht im Voraus klar ist, was zu tun ist. Wahrscheinlich sollte ich später normale atomare Commits auf einer moderneren Version von Qemu machen. Bis dahin gibt es einen Branch im Git im Blog-Format: zu jedem "Level", das irgendwie durchlaufen wurde, wurde ein ausführlicher Kommentar in russischer Sprache hinzugefügt. Diese Artikel sind zum großen Teil eine Zusammenfassung des Ergebnisses. git log.

Man kann all dies ausprobieren. hier (Vorsicht, Traffic).

Was bereits jetzt funktioniert:

  • Ein virtueller x86-Prozessor funktioniert.
  • Es gibt einen funktionierenden Prototyp eines JIT-Code-Generators, der aus Maschinencode JavaScript generiert.
  • Es gibt eine Vorlage zur Erstellung anderer 32-Bit-Gastarchitekturen: Sie können sich jetzt Linux für die MIPS-Architektur im Browser ansehen, das während des Ladevorgangs hängt.

Was noch gemacht werden kann:

  • Die Emulation beschleunigen. Selbst im JIT-Modus scheint es langsamer zu laufen als Virtual x86 (aber es gibt potenziell ein ganzes Qemu mit einer größeren Anzahl emulierter Hardware und Architekturen).
  • Eine normale Schnittstelle erstellen – ich bin, um es einfach zu sagen, kein guter Webentwickler, daher habe ich vorerst das Standard-Interface von Emscripten umgebaut, so gut ich konnte.
  • Versuchen, komplexere Funktionen von Qemu auszuführen – Netzwerk, VM-Migration usw.
  • UPD: Ich werde meine wenigen Entwicklungen und Bug-Reports an Emscripten zurückgeben müssen, wie es die vorherigen Portierer von Qemu und anderen Projekten getan haben. Danke ihnen, dass ich unauffällig von ihrem Beitrag zu Emscripten im Rahmen meiner Aufgabe profitieren konnte.

Quelle: habr.com

60GB SSD 8Gb DDR4