Qemu.js mit JIT-Unterstützung: Sie können das Hackfleisch immer noch rückwärts drehen

Vor ein paar Jahren Fabrice Bellard geschrieben von jslinux ist ein in JavaScript geschriebener PC-Emulator. Danach gab es zumindest noch mehr Virtuelles x86. Aber soweit ich weiß, waren sie alle Interpreter, während Qemu, das viel früher von demselben Fabrice Bellard geschrieben wurde, und wahrscheinlich jeder moderne Emulator mit Selbstachtung, die JIT-Kompilierung von Gastcode in Hostsystemcode verwendet. Es schien mir, dass es an der Zeit war, die entgegengesetzte Aufgabe in Bezug auf die zu implementieren, die Browser lösen: JIT-Kompilierung von Maschinencode in JavaScript, wofür es am logischsten erschien, Qemu zu portieren. Es scheint, dass es für Qemu einfachere und benutzerfreundlichere Emulatoren gibt – beispielsweise dieselbe VirtualBox – die installiert ist und funktioniert. Aber Qemu hat mehrere interessante Funktionen

  • Open Source
  • Möglichkeit, ohne Kernel-Treiber zu arbeiten
  • Fähigkeit, im Dolmetschermodus zu arbeiten
  • Unterstützung für eine große Anzahl von Host- und Gastarchitekturen

Was den dritten Punkt betrifft, kann ich nun erklären, dass im TCI-Modus tatsächlich nicht die Anweisungen der Gastmaschine selbst interpretiert werden, sondern der daraus gewonnene Bytecode, was jedoch nichts an der Essenz ändert – zum Erstellen und Ausführen Qemu auf einer neuen Architektur, wenn Sie Glück haben, reicht ein C-Compiler – das Schreiben eines Codegenerators kann verschoben werden.

Und nun, nach zwei Jahren gemächlichen Herumbastelns am Qemu-Quellcode in meiner Freizeit, ist ein funktionierender Prototyp aufgetaucht, in dem man beispielsweise bereits Kolibri OS ausführen kann.

Was ist Emscripten

Heutzutage sind viele Compiler erschienen, deren Endergebnis JavaScript ist. Einige, wie Type Script, waren ursprünglich als beste Möglichkeit zum Schreiben für das Web gedacht. Gleichzeitig ist Emscripten eine Möglichkeit, vorhandenen C- oder C++-Code in eine browserlesbare Form zu kompilieren. An Diese Seite Wir haben viele Portierungen bekannter Programme zusammengestellt: hierSie können sich zum Beispiel PyPy ansehen – sie behaupten übrigens, bereits über JIT zu verfügen. Tatsächlich lässt sich nicht jedes Programm einfach kompilieren und in einem Browser ausführen – es gibt eine ganze Reihe Eigenschaften, was man allerdings in Kauf nehmen muss, denn auf der gleichen Seite heißt es: „Mit Emscripten lässt sich fast alles kompilieren.“ tragbar C/C++-Code zu JavaScript“. Das heißt, es gibt eine Reihe von Operationen, die laut Standard undefiniertes Verhalten darstellen, aber normalerweise auf x86 funktionieren – zum Beispiel der unausgerichtete Zugriff auf Variablen, der auf einigen Architekturen generell verboten ist. Im Allgemeinen , Qemu ist ein plattformübergreifendes Programm und , das wollte ich glauben, und es enthält nicht schon viel undefiniertes Verhalten – nehmen Sie es und kompilieren Sie es, basteln Sie dann ein wenig an JIT herum – und fertig! Aber das ist nicht der Fall Fall...

Versuchen Sie es zuerst

Generell bin ich nicht der Erste, der auf die Idee kommt, Qemu auf JavaScript zu portieren. Im ReactOS-Forum wurde gefragt, ob dies mit Emscripten möglich sei. Schon früher gab es Gerüchte, dass Fabrice Bellard dies persönlich getan hätte, aber wir sprachen über jslinux, das meines Wissens nur ein Versuch ist, manuell eine ausreichende Leistung in JS zu erreichen, und von Grund auf neu geschrieben wurde. Später wurde Virtual x86 geschrieben – es wurden unverschlüsselte Quellen dafür veröffentlicht, und wie bereits erwähnt, ermöglichte der größere „Realismus“ der Emulation die Verwendung von SeaBIOS als Firmware. Darüber hinaus gab es mindestens einen Versuch, Qemu mithilfe von Emscripten zu portieren – ich habe dies versucht Steckdosenpaar, aber die Entwicklung war, soweit ich weiß, eingefroren.

Es scheint also, hier sind die Quellen, hier ist Emscripten – nehmen Sie es und kompilieren Sie es. Aber es gibt auch Bibliotheken, von denen Qemu abhängt, und Bibliotheken, von denen diese Bibliotheken abhängig sind usw., und eine davon ist es libffi, von der Glib abhängt. Im Internet gab es Gerüchte, dass es einen in der großen Sammlung von Portierungen von Bibliotheken für Emscripten gäbe, aber irgendwie war es kaum zu glauben: Erstens war es nicht als neuer Compiler gedacht, zweitens war er ein zu Low-Level-Compiler Bibliothek zum einfachen Abholen und Kompilieren in JS. Und es geht nicht nur um Assembly-Einfügungen – wahrscheinlich können Sie, wenn Sie es verdrehen, für einige Aufrufkonventionen die erforderlichen Argumente auf dem Stapel generieren und die Funktion ohne sie aufrufen. Doch Emscripten ist eine knifflige Sache: Damit der generierte Code dem JS-Engine-Optimierer des Browsers bekannt vorkommt, werden einige Tricks angewendet. Insbesondere das sogenannte Relooping – ein Codegenerator, der die empfangene LLVM-IR mit einigen abstrakten Übergangsanweisungen verwendet, versucht, plausible Ifs, Schleifen usw. wiederherzustellen. Wie werden die Argumente an die Funktion übergeben? Natürlich als Argumente für JS-Funktionen, also möglichst nicht über den Stack.

Am Anfang gab es die Idee, einfach einen Ersatz für libffi mit JS zu schreiben und Standardtests auszuführen, aber am Ende war ich verwirrt, wie ich meine Header-Dateien so gestalten sollte, dass sie mit dem vorhandenen Code funktionieren – was kann ich tun? Wie sie sagen: „Sind die Aufgaben so komplex? „Sind wir so dumm?“ Ich musste libffi sozusagen auf eine andere Architektur portieren – glücklicherweise verfügt Emscripten sowohl über Makros für die Inline-Assemblierung (in Javascript, ja – nun ja, egal welche Architektur, also den Assembler), als auch über die Möglichkeit, im laufenden Betrieb generierten Code auszuführen. Nachdem ich einige Zeit mit plattformabhängigen libffi-Fragmenten herumgebastelt hatte, bekam ich im Allgemeinen kompilierbaren Code und führte ihn beim ersten Test aus, auf den ich stieß. Zu meiner Überraschung war der Test erfolgreich. Verblüfft von meiner Genialität – kein Scherz, es funktionierte vom ersten Start an – schaute ich mir den resultierenden Code noch einmal an und traute meinen Augen immer noch nicht, um zu überlegen, wo ich als nächstes graben sollte. Hier bin ich zum zweiten Mal verrückt geworden – das einzige, was meine Funktion getan hat, war ffi_call - Hier wurde ein erfolgreicher Anruf gemeldet. Zu einem Anruf selbst kam es nicht. Also schickte ich meine erste Pull-Anfrage, die einen Fehler im Test korrigierte, der jedem Olympia-Studenten klar ist – echte Zahlen sollten nicht als verglichen werden a == b und sogar wie a - b < EPS - Sie müssen sich auch das Modul merken, sonst entspricht 0 sehr genau 1/3... Im Allgemeinen habe ich mir einen bestimmten Port von libffi ausgedacht, der die einfachsten Tests besteht und mit dem Glib kompatibel ist kompiliert - ich habe beschlossen, dass es notwendig sein würde, ich werde es später hinzufügen. Mit Blick auf die Zukunft möchte ich sagen, dass der Compiler, wie sich herausstellte, nicht einmal die libffi-Funktion in den endgültigen Code aufgenommen hat.

Aber wie ich bereits sagte, gibt es einige Einschränkungen, und unter der kostenlosen Nutzung verschiedener undefinierter Verhaltensweisen wurde eine unangenehmere Funktion ausgeblendet – JavaScript unterstützt von Natur aus kein Multithreading mit gemeinsam genutztem Speicher. Im Prinzip kann man das meist sogar als gute Idee bezeichnen, allerdings nicht für die Portierung von Code, dessen Architektur an C-Threads gebunden ist. Im Allgemeinen experimentiert Firefox mit der Unterstützung gemeinsam genutzter Worker, und Emscripten hat eine Pthread-Implementierung dafür, aber ich wollte mich nicht darauf verlassen. Ich musste Multithreading langsam aus dem Qemu-Code ausmerzen – das heißt herausfinden, wo die Threads laufen, den Rumpf der Schleife, die in diesem Thread läuft, in eine separate Funktion verschieben und solche Funktionen eine nach der anderen aus der Hauptschleife aufrufen.

Zweiter Versuch

Irgendwann wurde klar, dass das Problem immer noch da war und dass das willkürliche Herumschieben des Codes mit Krücken nichts Gutes bringen würde. Fazit: Wir müssen den Prozess des Hinzufügens von Krücken irgendwie systematisieren. Deshalb wurde die damals frische Version 2.4.1 genommen (nicht 2.5.0, denn wer weiß, es wird in der neuen Version Fehler geben, die noch nicht behoben wurden, und ich habe genug von meinen eigenen Fehlern ), und das erste war, es sicher umzuschreiben thread-posix.c. Nun, das ist so sicher: Wenn jemand versuchte, eine Operation auszuführen, die zur Blockierung führte, wurde die Funktion sofort aufgerufen abort() - Das hat natürlich nicht alle Probleme auf einmal gelöst, aber es war zumindest irgendwie angenehmer, als stillschweigend inkonsistente Daten zu erhalten.

Im Allgemeinen sind Emscripten-Optionen sehr hilfreich bei der Portierung von Code nach JS -s ASSERTIONS=1 -s SAFE_HEAP=1 - Sie fangen einige Arten von undefiniertem Verhalten ab, wie z. B. Aufrufe an eine nicht ausgerichtete Adresse (was überhaupt nicht mit dem Code für typisierte Arrays wie … übereinstimmt). HEAP32[addr >> 2] = 1) oder den Aufruf einer Funktion mit der falschen Anzahl von Argumenten.

Ausrichtungsfehler sind übrigens ein separates Thema. Wie ich bereits sagte, verfügt Qemu über ein „entartetes“ interpretierendes Backend für die Codegenerierung, TCI (Tiny Code Interpreter), und um Qemu auf einer neuen Architektur zu erstellen und auszuführen, reicht mit etwas Glück ein C-Compiler aus. Schlüsselwörter "Wenn du Glück hast". Ich hatte Pech und es stellte sich heraus, dass TCI beim Parsen seines Bytecodes einen nicht ausgerichteten Zugriff verwendet. Das heißt, auf allen Arten von ARM und anderen Architekturen mit notwendigerweise abgestuftem Zugriff kompiliert Qemu, weil sie über ein normales TCG-Backend verfügen, das nativen Code generiert, aber ob TCI auf ihnen funktioniert, ist eine andere Frage. Wie sich jedoch herausstellte, deutete die TCI-Dokumentation eindeutig auf etwas Ähnliches hin. Infolgedessen wurden dem Code Funktionsaufrufe für unausgerichtetes Lesen hinzugefügt, die in einem anderen Teil von Qemu entdeckt wurden.

Haufenzerstörung

Dadurch wurde der unausgerichtete Zugriff auf TCI korrigiert, eine Hauptschleife entstand, die wiederum den Prozessor, die RCU und einige andere Kleinigkeiten aufrief. Und so starte ich Qemu mit der Option -d exec,in_asm,out_asm, was bedeutet, dass Sie sagen müssen, welche Codeblöcke ausgeführt werden, und zum Zeitpunkt der Übertragung auch schreiben müssen, welcher Gastcode war und welcher Hostcode wurde (in diesem Fall Bytecode). Es startet, führt mehrere Übersetzungsblöcke aus, schreibt die Debugging-Meldung, die ich hinterlassen habe, dass RCU jetzt startet und ... stürzt ab abort() innerhalb einer Funktion free(). Durch Herumbasteln an der Funktion free() Wir haben herausgefunden, dass im Header des Heap-Blocks, der in den acht Bytes vor dem zugewiesenen Speicher liegt, statt der Blockgröße oder ähnlichem Müll war.

Zerstörung des Heaps – wie süß ... In einem solchen Fall gibt es eine nützliche Abhilfe: Stellen Sie aus (wenn möglich) denselben Quellen eine native Binärdatei zusammen und führen Sie sie unter Valgrind aus. Nach einiger Zeit war die Binärdatei fertig. Ich starte es mit den gleichen Optionen – es stürzt sogar während der Initialisierung ab, bevor es tatsächlich zur Ausführung kommt. Es ist natürlich unangenehm – anscheinend waren die Quellen nicht genau dieselben, was nicht verwunderlich ist, da configure leicht unterschiedliche Optionen ausgekundschaftet hat, aber ich habe Valgrind – zuerst werde ich diesen Fehler beheben und dann, wenn ich Glück habe , das Original wird angezeigt. Ich verwende das Gleiche unter Valgrind ... Y-y-y, y-y-y, uh-uh, es startete, durchlief die Initialisierung normal und überwand den ursprünglichen Fehler ohne eine einzige Warnung über fehlerhaften Speicherzugriff, ganz zu schweigen von Stürzen. Das Leben hat mich, wie man sagt, nicht darauf vorbereitet – ein abstürzendes Programm stürzt nicht mehr ab, wenn es unter Walgrind gestartet wird. Was es war, ist ein Rätsel. Meine Hypothese ist, dass gdb nach einem Absturz während der Initialisierung in der Nähe der aktuellen Anweisung funktionierte memset-a mit einem gültigen Zeiger unter Verwendung von beidem mmx, ob xmm Register, dann war es vielleicht eine Art Ausrichtungsfehler, obwohl es immer noch schwer zu glauben ist.

Okay, Valgrind scheint hier nicht zu helfen. Und hier begann das Ekelhafteste – alles scheint überhaupt zu starten, stürzt aber aus völlig unbekannten Gründen aufgrund eines Ereignisses ab, das vor Millionen von Anweisungen hätte passieren können. Lange Zeit war nicht einmal klar, wie man vorgehen sollte. Am Ende musste ich mich immer noch hinsetzen und debuggen. Der Ausdruck, womit der Header umgeschrieben wurde, zeigte, dass es nicht wie eine Zahl aussah, sondern eher wie eine Art Binärdaten. Und siehe da, diese Binärzeichenfolge wurde in der BIOS-Datei gefunden – das heißt, man konnte nun mit einiger Sicherheit sagen, dass es sich um einen Pufferüberlauf handelte, und es war sogar klar, dass sie in diesen Puffer geschrieben wurde. Nun, dann so etwas in der Art: In Emscripten gibt es glücklicherweise keine Randomisierung des Adressraums, es gibt auch keine Lücken darin, sodass Sie irgendwo in die Mitte des Codes schreiben können, um Daten vom letzten Start per Zeiger auszugeben. Schauen Sie sich die Daten an, schauen Sie sich den Zeiger an und holen Sie sich Denkanstöße, wenn er sich nicht geändert hat. Zwar dauert die Verknüpfung nach jeder Änderung ein paar Minuten, aber was können Sie tun? Als Ergebnis wurde eine bestimmte Zeile gefunden, die das BIOS vom temporären Puffer in den Gastspeicher kopierte – und tatsächlich war nicht genügend Platz im Puffer vorhanden. Das Finden der Quelle dieser seltsamen Pufferadresse führte zu einer Funktion qemu_anon_ram_alloc im Ordner oslib-posix.c - Die Logik dort war folgende: Manchmal kann es nützlich sein, die Adresse auf eine riesige Seite mit einer Größe von 2 MB auszurichten, danach werden wir fragen mmap Zuerst noch ein bisschen, und dann geben wir den Überschuss mit der Hilfe zurück munmap. Und wenn eine solche Ausrichtung nicht erforderlich ist, geben wir das Ergebnis anstelle von 2 MB an getpagesize() - mmap Es wird immer noch eine ausgerichtete Adresse ausgegeben ... Also in Emscripten mmap ruft einfach an malloc, aber natürlich passt es nicht auf die Seite. Im Allgemeinen wurde ein Fehler, der mich einige Monate lang frustriert hatte, durch eine Änderung behoben zwei Linien.

Merkmale des Aufrufs von Funktionen

Und jetzt zählt der Prozessor etwas, Qemu stürzt nicht ab, aber der Bildschirm schaltet sich nicht ein und der Prozessor geht, der Ausgabe nach zu urteilen, schnell in Schleifen -d exec,in_asm,out_asm. Es wurde eine Hypothese aufgestellt: Timer-Interrupts (oder allgemein alle Interrupts) kommen nicht an. Und tatsächlich, wenn man die Interrupts aus der nativen Baugruppe herausschraubt, die aus irgendeinem Grund funktioniert haben, erhält man ein ähnliches Bild. Dies war jedoch keineswegs die Antwort: Ein Vergleich der mit der oben genannten Option ausgegebenen Spuren zeigte, dass die Ausführungsbahnen sehr früh auseinander gingen. Hier muss gesagt werden, dass der Vergleich dessen ist, was mit dem Launcher aufgezeichnet wurde emrun Das Debuggen der Ausgabe mit der Ausgabe der nativen Assembly ist kein vollständig mechanischer Prozess. Ich weiß nicht genau, wie ein Programm, das in einem Browser ausgeführt wird, eine Verbindung herstellt emrun, aber es stellt sich heraus, dass einige Zeilen in der Ausgabe neu angeordnet sind, sodass der Unterschied im Diff noch kein Grund für die Annahme ist, dass die Trajektorien auseinandergegangen sind. Im Allgemeinen wurde klar, dass dies gemäß den Anweisungen der Fall war ljmpl Es erfolgt ein Übergang zu unterschiedlichen Adressen, und der generierte Bytecode unterscheidet sich grundlegend: Der eine enthält eine Anweisung zum Aufruf einer Hilfsfunktion, der andere nicht. Nachdem ich die Anweisungen gegoogelt und den Code studiert hatte, der diese Anweisungen übersetzt, wurde klar, dass dies zunächst unmittelbar davor im Register der Fall war cr0 es wurde eine Aufnahme gemacht – ebenfalls mit Hilfe eines Helfers – die den Prozessor in den geschützten Modus schaltete, und zweitens, dass die js-Version nie in den geschützten Modus wechselte. Tatsache ist jedoch, dass ein weiteres Merkmal von Emscripten darin besteht, dass es Code wie die Implementierung von Anweisungen nicht toleriert call in TCI, was jeder Funktionszeiger zum Typ führt long long f(int arg0, .. int arg9) - Funktionen müssen mit der richtigen Anzahl an Argumenten aufgerufen werden. Wenn diese Regel verletzt wird, stürzt das Programm abhängig von den Debugging-Einstellungen entweder ab (was gut ist) oder ruft überhaupt die falsche Funktion auf (was beim Debuggen traurig sein wird). Es gibt auch eine dritte Option: Aktivieren Sie die Generierung von Wrappern, die Argumente hinzufügen/entfernen, aber insgesamt beanspruchen diese Wrapper viel Platz, obwohl ich tatsächlich nur etwas mehr als hundert Wrapper benötige. Das allein ist sehr traurig, aber es stellte sich heraus, dass es ein schwerwiegenderes Problem gab: Im generierten Code der Wrapper-Funktionen wurden die Argumente konvertiert und konvertiert, aber manchmal wurde die Funktion mit den generierten Argumenten nicht aufgerufen – nun ja, genau wie in meine libffi-Implementierung. Das heißt, einige Helfer wurden einfach nicht hingerichtet.

Glücklicherweise verfügt Qemu über maschinenlesbare Listen von Helfern 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)

Die Verwendung ist ziemlich lustig: Zunächst werden Makros auf die bizarrste Art und Weise neu definiert DEF_HELPER_n, und schaltet sich dann ein helper.h. In dem Maße, in dem das Makro zu einem Strukturinitialisierer und einem Komma erweitert wird und dann ein Array definiert wird und anstelle von Elementen - #include <helper.h> Dadurch hatte ich endlich die Gelegenheit, die Bibliothek bei der Arbeit auszuprobieren Analyse, und es wurde ein Skript geschrieben, das genau die Wrapper für genau die Funktionen generiert, für die sie benötigt werden.

Und so schien der Prozessor danach zu funktionieren. Es scheint daran zu liegen, dass der Bildschirm nie initialisiert wurde, obwohl memtest86+ in der nativen Assembly ausgeführt werden konnte. Hier muss klargestellt werden, dass der Qemu-Block-I/O-Code in Coroutinen geschrieben ist. Emscripten verfügt über eine eigene, sehr knifflige Implementierung, die jedoch noch im Qemu-Code unterstützt werden musste, und Sie können den Prozessor jetzt debuggen: Qemu unterstützt Optionen -kernel, -initrd, -append, mit dem Sie Linux oder beispielsweise memtest86+ booten können, ohne Blockgeräte zu verwenden. Aber hier liegt das Problem: In der nativen Assembly konnte man mit der Option die Ausgabe des Linux-Kernels auf der Konsole sehen -nographicund keine Ausgabe vom Browser an das Terminal, von dem aus es gestartet wurde emrun, kam nicht. Das heißt, es ist nicht klar: Der Prozessor funktioniert nicht oder die Grafikausgabe funktioniert nicht. Und dann kam mir der Gedanke, noch ein wenig zu warten. Es stellte sich heraus, dass „der Prozessor nicht schläft, sondern nur langsam blinkt“, und nach etwa fünf Minuten warf der Kernel eine Reihe von Nachrichten auf die Konsole und blieb weiterhin hängen. Es wurde klar, dass der Prozessor im Allgemeinen funktioniert, und wir müssen uns mit dem Code für die Arbeit mit SDL2 befassen. Leider weiß ich nicht, wie ich diese Bibliothek nutzen soll, daher musste ich an manchen Stellen willkürlich vorgehen. Irgendwann blinkte auf dem Bildschirm die Linie parallel0 auf blauem Hintergrund, was auf einige Gedanken schließen ließ. Am Ende stellte sich heraus, dass das Problem darin bestand, dass Qemu mehrere virtuelle Fenster in einem physischen Fenster öffnet, zwischen denen man mit Strg-Alt-n wechseln kann: Im nativen Build funktioniert es, in Emscripten jedoch nicht. Nachdem Sie unnötige Fenster mithilfe von Optionen entfernt haben -monitor none -parallel none -serial none und Anweisungen, den gesamten Bildschirm bei jedem Frame zwangsweise neu zu zeichnen, funktionierte plötzlich alles.

Koroutinen

Die Emulation im Browser funktioniert also, aber Sie können darin nichts Interessantes auf einer einzelnen Diskette ausführen, da es keine Block-E/A gibt – Sie müssen die Unterstützung für Coroutinen implementieren. Qemu verfügt bereits über mehrere Coroutine-Backends, aber aufgrund der Natur von JavaScript und des Emscripten-Codegenerators können Sie nicht einfach mit dem Stapeln jonglieren. Es scheint, dass „alles weg ist, der Putz entfernt wird“, aber die Emscripten-Entwickler haben sich bereits um alles gekümmert. Das ist ziemlich lustig umgesetzt: Nennen wir einen solchen Funktionsaufruf verdächtig emscripten_sleep und mehrere andere, die den Asyncify-Mechanismus verwenden, sowie Zeigeraufrufe und Aufrufe jeder Funktion, bei denen einer der beiden vorherigen Fälle weiter unten im Stapel auftreten kann. Und jetzt wählen wir vor jedem verdächtigen Aufruf einen asynchronen Kontext aus und prüfen unmittelbar nach dem Aufruf, ob ein asynchroner Aufruf stattgefunden hat. Wenn ja, speichern wir alle lokalen Variablen in diesem asynchronen Kontext und geben an, welche Funktion um die Kontrolle zu übertragen, wenn wir die Ausführung fortsetzen und die aktuelle Funktion verlassen müssen. Hier besteht die Möglichkeit, den Effekt zu untersuchen Vergeudung – Um die Codeausführung nach der Rückkehr von einem asynchronen Aufruf fortzusetzen, generiert der Compiler „Stubs“ der Funktion, beginnend nach einem verdächtigen Aufruf – etwa so: Wenn es n verdächtige Aufrufe gibt, wird die Funktion irgendwo um n/2 erweitert mal – das ist immer noch so, wenn nicht. Beachten Sie, dass Sie nach jedem potenziell asynchronen Aufruf das Speichern einiger lokaler Variablen zur ursprünglichen Funktion hinzufügen müssen. Anschließend musste ich sogar ein einfaches Skript in Python schreiben, das auf einem bestimmten Satz besonders überbeanspruchter Funktionen basiert, die angeblich „nicht zulassen, dass Asynchronität durch sich hindurchgeht“ (das heißt, Stack-Promotion und alles, was ich gerade beschrieben habe, tun dies nicht). Arbeit in ihnen), zeigt Aufrufe durch Zeiger an, in denen Funktionen vom Compiler ignoriert werden sollten, damit diese Funktionen nicht als asynchron betrachtet werden. Und dann sind JS-Dateien unter 60 MB eindeutig zu viel – sagen wir mindestens 30. Allerdings habe ich einmal beim Einrichten eines Assembly-Skripts versehentlich die Linker-Optionen weggeworfen, darunter auch -O3. Ich führe den generierten Code aus und Chromium verbraucht Speicher und stürzt ab. Ich habe dann versehentlich geschaut, was er herunterladen wollte ... Naja, was soll ich sagen, ich wäre auch erstarrt, wenn man mich gebeten hätte, ein über 500 MB großes Javascript sorgfältig zu studieren und zu optimieren.

Leider waren die Überprüfungen im Code der Asyncify-Unterstützungsbibliothek nicht ganz freundlich longjmp-s, die im Code des virtuellen Prozessors verwendet werden, aber nach einem kleinen Patch, der diese Prüfungen deaktiviert und Kontexte zwangsweise wiederherstellt, als ob alles in Ordnung wäre, funktionierte der Code. Und dann begann etwas Seltsames: Manchmal wurden Überprüfungen im Synchronisierungscode ausgelöst – dieselben, die den Code zum Absturz bringen, wenn er gemäß der Ausführungslogik blockiert werden sollte – jemand versuchte, einen bereits erfassten Mutex zu ergreifen. Glücklicherweise stellte sich heraus, dass dies im serialisierten Code kein logisches Problem war – ich nutzte lediglich die von Emscripten bereitgestellte Standard-Hauptschleifenfunktionalität, aber manchmal entpackte der asynchrone Aufruf den Stapel vollständig und schlug in diesem Moment fehl setTimeout aus der Hauptschleife – somit trat der Code in die Hauptschleifeniteration ein, ohne die vorherige Iteration zu verlassen. In einer Endlosschleife umgeschrieben und emscripten_sleep, und die Probleme mit Mutexes hörten auf. Der Code ist sogar noch logischer geworden – schließlich habe ich keinen Code, der den nächsten Animationsframe vorbereitet – der Prozessor berechnet einfach etwas und der Bildschirm wird regelmäßig aktualisiert. Damit hörten die Probleme jedoch nicht auf: Manchmal wurde die Qemu-Ausführung einfach stillschweigend ohne Ausnahmen oder Fehler beendet. In diesem Moment habe ich es aufgegeben, aber mit Blick auf die Zukunft werde ich sagen, dass das Problem folgendes war: Der Coroutine-Code wird tatsächlich nicht verwendet setTimeout (oder zumindest nicht so oft, wie Sie vielleicht denken): Funktion emscripten_yield Setzt einfach das Flag für asynchrone Aufrufe. Der springende Punkt ist das emscripten_coroutine_next ist keine asynchrone Funktion: Intern prüft sie das Flag, setzt es zurück und überträgt die Kontrolle dorthin, wo sie benötigt wird. Das heißt, die Förderung des Stapels endet dort. Das Problem bestand darin, dass die Funktion aufgrund von „Use-after-free“ auftrat, als der Coroutine-Pool deaktiviert wurde, weil ich keine wichtige Codezeile aus dem vorhandenen Coroutine-Backend kopiert hatte qemu_in_coroutine hat „true“ zurückgegeben, obwohl es eigentlich „false“ hätte zurückgeben sollen. Dies führte zu einem Anruf emscripten_yield, oberhalb dessen sich niemand auf dem Stapel befand emscripten_coroutine_next, der Stapel entfaltete sich bis ganz nach oben, aber nein setTimeout, wie ich bereits sagte, wurde nicht ausgestellt.

JavaScript-Codegenerierung

Und hier ist tatsächlich das versprochene „Umdrehen des Hackfleisches“ der Fall. Nicht wirklich. Wenn wir Qemu im Browser und Node.js darin ausführen, erhalten wir natürlich nach der Codegenerierung in Qemu völlig falsches JavaScript. Aber dennoch eine Art umgekehrte Transformation.

Zunächst ein wenig darüber, wie Qemu funktioniert. Bitte verzeihen Sie mir sofort: Ich bin kein professioneller Qemu-Entwickler und meine Schlussfolgerungen können an einigen Stellen falsch sein. Sie sagen: „Die Meinung des Schülers muss nicht mit der Meinung des Lehrers, Peanos Axiomatik und seinem gesunden Menschenverstand übereinstimmen.“ Qemu verfügt über eine bestimmte Anzahl unterstützter Gastarchitekturen und für jede gibt es ein Verzeichnis wie target-i386. Beim Erstellen können Sie die Unterstützung mehrerer Gastarchitekturen angeben, das Ergebnis sind jedoch nur mehrere Binärdateien. Der Code zur Unterstützung der Gastarchitektur generiert wiederum einige interne Qemu-Operationen, die der TCG (Tiny Code Generator) bereits in Maschinencode für die Host-Architektur umwandelt. Wie in der Readme-Datei im tcg-Verzeichnis angegeben, war dies ursprünglich Teil eines regulären C-Compilers, der später für JIT angepasst wurde. Daher handelt es sich beispielsweise bei der Zielarchitektur im Sinne dieses Dokuments nicht mehr um eine Gastarchitektur, sondern um eine Hostarchitektur. Irgendwann erschien eine weitere Komponente – Tiny Code Interpreter (TCI), die Code (fast die gleichen internen Vorgänge) ausführen sollte, wenn kein Codegenerator für eine bestimmte Host-Architektur vorhanden ist. In der Tat heißt es in der Dokumentation, dass dieser Interpreter möglicherweise nicht immer so gut funktioniert wie ein JIT-Codegenerator, nicht nur quantitativ, sondern auch qualitativ. Obwohl ich nicht sicher bin, ob seine Beschreibung vollständig relevant ist.

Zuerst habe ich versucht, ein vollwertiges TCG-Backend zu erstellen, geriet aber schnell in Verwirrung im Quellcode und einer nicht ganz klaren Beschreibung der Bytecode-Anweisungen, also beschloss ich, den TCI-Interpreter zu packen. Dies brachte mehrere Vorteile:

  • Bei der Implementierung eines Codegenerators könnten Sie nicht auf die Beschreibung der Anweisungen, sondern auf den Interpretercode achten
  • Sie können Funktionen nicht für jeden gefundenen Übersetzungsblock generieren, sondern beispielsweise erst nach der hundertsten Ausführung
  • Wenn sich der generierte Code ändert (und das scheint möglich zu sein, gemessen an den Funktionen, deren Namen das Wort „Patch“ enthalten), muss ich den generierten JS-Code ungültig machen, habe aber zumindest etwas, aus dem ich ihn neu generieren kann

Was den dritten Punkt betrifft, bin ich mir nicht sicher, ob ein Patchen 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 Schalters an der Adresse der ursprünglichen Bytecode-Anweisung generiert, aber als ich mich dann an den Artikel über Emscripten, die Optimierung von generiertem JS und Relooping erinnerte, beschloss ich, mehr menschlichen Code zu generieren, insbesondere weil dies empirisch der Fall war Es stellte sich heraus, dass der einzige Einstiegspunkt in den Übersetzungsblock sein Start ist. Gesagt, getan, nach einer Weile hatten wir einen Codegenerator, der Code mit ifs (allerdings ohne Schleifen) generierte. Aber Pech gehabt, es stürzte ab und gab die Meldung aus, dass die Anweisungen eine falsche Länge hatten. Darüber hinaus war die letzte Anweisung auf dieser Rekursionsebene brcond. Okay, ich füge der Generierung dieser Anweisung vor und nach dem rekursiven Aufruf eine identische Prüfung hinzu und ... keine davon wurde ausgeführt, aber nach dem Assert-Schalter sind sie immer noch fehlgeschlagen. Nachdem ich den generierten Code studiert hatte, wurde mir schließlich klar, dass nach dem Wechsel der Zeiger auf die aktuelle Anweisung vom Stapel neu geladen und wahrscheinlich durch den generierten JavaScript-Code überschrieben wird. Und so kam es. Die Vergrößerung des Puffers von einem Megabyte auf zehn führte zu nichts und es wurde klar, dass der Codegenerator im Kreis lief. Wir mussten überprüfen, dass wir nicht über die Grenzen des aktuellen TB hinausgingen, und wenn ja, dann die Adresse des nächsten TB mit einem Minuszeichen versehen, damit wir mit der Ausführung fortfahren konnten. Darüber hinaus wird dadurch das Problem gelöst: „Welche generierten Funktionen sollten ungültig gemacht werden, wenn sich dieser Teil des Bytecodes geändert hat?“ — Nur die Funktion, die diesem Übersetzungsblock entspricht, muss ungültig gemacht werden. Übrigens, obwohl ich alles in Chromium debuggt habe (da ich Firefox verwende und es für mich einfacher ist, einen separaten Browser für Experimente zu verwenden), hat mir Firefox dabei geholfen, Inkompatibilitäten mit dem asm.js-Standard zu korrigieren, woraufhin der Code schneller zu funktionieren begann Chrom.

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"]

Abschluss

Die Arbeiten sind also immer noch nicht abgeschlossen, aber ich bin es leid, diesen langfristigen Bau heimlich zur Perfektion zu bringen. Deshalb habe ich beschlossen, das zu veröffentlichen, was ich vorerst habe. Der Code ist stellenweise etwas beängstigend, da es sich um ein Experiment handelt und nicht im Voraus klar ist, was getan werden muss. Dann lohnt es sich wahrscheinlich, zusätzlich zu einer moderneren Version von Qemu normale Atom-Commits auszugeben. Mittlerweile gibt es in der Gita einen Thread im Blog-Format: Zu jedem „Level“, das zumindest irgendwie bestanden wurde, wurde ein ausführlicher Kommentar auf Russisch hinzugefügt. Tatsächlich ist dieser Artikel zu einem großen Teil eine Nacherzählung der Schlussfolgerung git log.

Sie können alles ausprobieren hier (Vorsicht vor dem Verkehr).

Was funktioniert bereits:

  • Virtueller x86-Prozessor läuft
  • Es gibt einen funktionierenden Prototyp eines JIT-Codegenerators von Maschinencode zu JavaScript
  • Es gibt eine Vorlage zum Zusammenstellen anderer 32-Bit-Gastarchitekturen: Derzeit können Sie Linux für die MIPS-Architektur bewundern, die beim Laden im Browser einfriert

Was kannst du noch tun

  • Beschleunigen Sie die Emulation. Selbst im JIT-Modus scheint es langsamer zu laufen als Virtual x86 (aber es gibt möglicherweise ein ganzes Qemu mit viel emulierter Hardware und Architekturen).
  • Um eine normale Benutzeroberfläche zu erstellen – ehrlich gesagt bin ich kein guter Webentwickler, also habe ich vorerst die Standard-Emscripten-Shell so gut wie möglich neu erstellt
  • Versuchen Sie, komplexere Qemu-Funktionen zu starten – Netzwerk, VM-Migration usw.
  • UPD: Sie müssen Ihre wenigen Entwicklungen und Fehlerberichte an Emscripten im Upstream einreichen, wie es frühere Träger von Qemu und anderen Projekten getan haben. Vielen Dank an sie, dass sie ihren Beitrag zu Emscripten implizit als Teil meiner Aufgabe nutzen konnten.

Source: habr.com

Kommentar hinzufügen