Qemu.js z obsługą JIT: mimo wszystko można to odwrócić

Kilka lat temu Fabrice Bellard napisał jslinux — emulator PC, napisany w JavaScript. Po tym był jeszcze przynajmniej Virtual x86. Ale wszystkie one, o ile mi wiadomo, były interpreterami, podczas gdy znacznie wcześniej stworzony przez tego samego Fabrice'a Bellarda Qemu, a może każdy szanujący się nowoczesny emulator, korzysta z JIT-kompilacji kodu gościa do kodu systemu hosta. Pomyślałem, że to właściwy moment, aby zaimplementować odwrotną zadanie w stosunku do tego, co rozwiązują przeglądarki: JIT-kompilację kodu maszynowego do JavaScript, dlatego logicznie wydawało się portować Qemu. Można by pomyśleć, dlaczego akurat Qemu, są przecież prostsze i bardziej przyjazne użytkownikom emulatory — taki VirtualBox, na przykład — zainstalowałeś i działa. Ale Qemu ma kilka interesujących cech

  • otwarte źródła
  • możliwość pracy bez sterownika jądra
  • możliwość pracy w trybie interpretacji
  • wsparcie dla dużej liczby architektur zarówno hostów, jak i gości

Co do trzeciego punktu, teraz mogę wyjaśnić, że w rzeczywistości w trybie TCI nie są interpretowane same instrukcje maszynowe gościa, a bajtowy kod, który z nich powstaje, ale to nie zmienia sedna — aby skompilować i uruchomić Qemu na nowej architekturze, jeśli się uda, wystarczy kompilator C — tworzenie generatora kodu można odłożyć na później.

I oto, po dwóch latach wolnego grzebania w źródłach Qemu, powstał działający prototyp, w którym już można uruchomić na przykład Kolibri OS.

Czym jest Emscripten

W naszych czasach pojawiło się wiele kompilatorów, których wynikiem końcowym jest JavaScript. Niektóre z nich, takie jak Type Script, pierwotnie były pomyślane jako lepszy sposób pisania dla webu. Jednocześnie Emscripten to sposób na wzięcie istniejącego kodu w C lub C++ i skompilowanie go do formy zrozumiałej przez przeglądarkę. Na tej stronie zebrano sporo portów znanych programów: tutaj, na przykład można spojrzeć na PyPy — zresztą, jak się twierdzi, mają już JIT. W rzeczywistości nie każdy program można po prostu skompilować i uruchomić w przeglądarce — istnieje szereg cech, z którymi trzeba się pogodzić, zresztą, jak głosi napis na tej samej stronie "Emscripten can be used to compile almost any portable Kod C/C++ do JavaScript. Istnieje szereg operacji, które są nieokreślonym zachowaniem zgodnie ze standardem, ale zazwyczaj działają na x86 — na przykład, niepoprawny dostęp do zmiennych, który w niektórych architekturach jest wręcz zabroniony. Generalnie, Qemu to program wieloplatformowy i, chciałoby się wierzyć, nie zawiera dużej ilości nieokreślonego zachowania — weź i skompiluj, potem trochę pobaw się z JIT — i gotowe! Ale nie tak prosto…

Pierwsza próba

Ogólnie mówiąc, nie jestem pierwszym, komu przyszło do głowy, by przenieść Qemu na JavaScript. Na forum ReactOS zadawano pytanie, czy jest to możliwe za pomocą Emscripten. Jeszcze wcześniej krążyły plotki, że zrobił to osobiście Fabrice Bellard, ale mowa była o jslinux, który, o ile mi wiadomo, jest próbą ręcznego osiągnięcia wystarczającej wydajności w JS i napisano go od podstaw. Później powstał Virtual x86 — udostępniono do niego nieobfuskowany kod źródłowy, a jak twierdzono, większa "realistyczność" emulacji pozwoliła na użycie SeaBIOS jako firmware. Ponadto była co najmniej jedna próba przeniesienia Qemu za pomocą Emscripten — to próbował zrobić socketpair, ale rozwój, jak zrozumiałem, został wstrzymany.

Tak więc, wydawałoby się, oto kod źródłowy, oto Emscripten — weź i skompiluj. Ale są także biblioteki, od których zależy Qemu, oraz biblioteki, od których zależą te biblioteki itd., przy czym jedna z nich — libffi, od której zależy glib. W Internecie krążyły plotki, że w dużej kolekcji portów bibliotek pod Emscripten znajduje się i ona, ale wierzyło się w to z trudem: po pierwsze, nowy kompilator jej nie obsługiwał, po drugie, to zbyt niskopoziomowa biblioteka, aby po prostu wziąć i skompilować w JS. I chodzi tu nie tylko o wstawki assemblerowe — być może, jeśli się wysilić, dla niektórych konwencji wywołań można bez nich utworzyć odpowiednie argumenty na stosie i wywołać funkcję. Tylko że Emscripten to skomplikowana rzecz: aby wygenerowany kod wyglądał znajomo dla optymalizatora silnika JS w przeglądarki, używane są pewne sztuczki. W szczególności tzw. relooping — generator kodu przy wykorzystaniu otrzymanego LLVM IR z pewnymi abstrakcyjnymi instrukcjami przejść stara się odtworzyć wiarygodne ify, pętle itd. A jak przekazuje się argumenty do funkcji? Oczywiście, jak argumenty funkcji JS, to znaczy, jeśli to możliwe, nie przez stos.

Na początku myślałem po prostu napisać zamiennik libffi w JS i przeprowadzić standardowe testy, ale w końcu zagubiłem się w tym, jak zrobić swoje pliki nagłówkowe, aby działały z istniejącym kodem — co tu poradzić, jak mówią, "Tyle trudnych zadań, tyle my jesteśmy głupi". Musiałem zportować libffi na jeszcze jedną architekturę, jeśli można tak to ująć — na szczęście w Emscripten są zarówno makra do inline assembly (w JavaScripcie, tak — cóż, jaka architektura, taki i assembler), jak i możliwość uruchomienia wygenerowanego kodu na bieżąco. W ogólnym zarysie, po pobawieniu się przez jakiś czas z platformowo-zależnymi fragmentami libffi, otrzymałem pewien kompilujący się kod i uruchomiłem go na pierwszym lepszym teście. Ku mojemu zaskoczeniu test przeszedł pomyślnie. Zszokowany swoją geniuszem — żartując, działało od pierwszego uruchomienia — ja, wciąż nie wierząc własnym oczom, zajrzałem jeszcze raz, aby przyjrzeć się otrzymanemu kodowi, ocenić, dokąd iść dalej. Wtedy po raz drugi byłem zszokowany — jedyne, co robiła moja funkcja ffi_call — to raportować o udanym wywołaniu. Samego wywołania nie było. Tak więc wysłałem mój pierwszy pull request, który naprawiał błąd jasny dla każdego olimpijczyka w teście — liczby zmiennoprzecinkowe nie powinny być porównywane jak a == b a nawet jak a - b < EPS — trzeba jeszcze pamiętać o module, bo inaczej 0 okaże się bardzo równo 1/3… W każdym razie powstał mi jakiś port libffi, który przechodzi najprostsze testy i z którym kompiluje się glib — pomyślałem, że w razie potrzeby później dopiszę. Mówiąc z góry, mogę powiedzieć, że, jak się okazało, kompilator nawet nie uwzględnił w finalnym kodzie funkcji libffi.

Ale, jak już mówiłem, są pewne ograniczenia, a wśród swobodnego użycia różnorodnego nieokreślonego zachowania znalazła się cecha nieprzyjemniejsza — JavaScript z założenia nie wspiera wielowątkowości z wspólną pamięcią. W zasadzie, zazwyczaj można by to nawet nazwać niezłym pomysłem, ale nie przy portowaniu kodu, którego architektura opiera się na wątkach C. Generalnie w Firefoxie prowadzone są eksperymenty nad wsparciem shared workers, a implementacja pthread dla nich w Emscripten jest dostępna, ale nie chciałem się na tym opierać. Musiałem powoli wyrywać wielowątkowość z kodu Qemu — to znaczy szukać, gdzie uruchamiane są wątki, przenosić ciało pętli wykonywanej w tym wątku do osobnej funkcji i na przemian wywoływać takie funkcje z głównej pętli.

Druga próba

W pewnym momencie stało się jasne, że nic się nie zmienia i że bezsystematyczne rozrzucanie tymczasowych rozwiązań po kodzie nie doprowadzi do niczego dobrego. Wniosek: trzeba jakoś usystematyzować proces dodawania takich rozwiązań. Dlatego wzięto świeżą wersję 2.4.1 (nie 2.5.0, bo kto wie, czy nie znajdą się jeszcze nieodkryte błędy nowej wersji, a mi wystarczy moich błędów), i najpierw została w bezpieczny sposób przepisana thread-posix.c. To znaczy, jak w bezpieczny sposób: jeśli ktoś próbował wykonać operację prowadzącą do zablokowania, natychmiast wywoływana była funkcja abort() — oczywiście, to nie rozwiązywało od razu wszystkich problemów, ale przynajmniej w jakiś sposób było to przyjemniejsze niż cichym przyjmowaniem niespójności danych.

W ogóle, przy portowaniu kodu do JS bardzo pomagają opcje Emscripten -s ASSERTIONS=1 -s SAFE_HEAP=1 — wychwytują one niektóre rodzaje nieokreślonego zachowania, takie jak dostęp do nie Wyrównanego adresu (co całkowicie nie zgadza się z kodem dla typowanych tablic, jak HEAP32[addr >> 2] = 1) lub wywołanie funkcji z nieprawidłową liczbą argumentów.

Zresztą, błędy wyrównania to osobny temat. Jak już wspominałem, w Qemu istnieje "degeneracyjny" backend interpretatora dla generacji kodu TCI (tiny code interpreter), i aby skompilować i uruchomić Qemu na nowej architekturze, wystarczy kompilator C, jeśli masz szczęście. Słowa kluczowe "jeśli masz szczęście". Mnie się nie udało, okazało się, że TCI, podczas przetwarzania swojego kodu bajtowego, używa dostępu niewyrównanego. To znaczy, na różnych architekturach ARM i innych, wymagających koniecznego dostępu wyrównanego, Qemu jest kompilowane dlatego, że dla nich istnieje normalny backend TCG, generujący kod natywny, a to, czy TCI będzie działać na tych architekturach, to jeszcze pytanie. Jak się okazało, w dokumentacji TCI coś podobnego zostało wyraźnie wskazane. W efekcie do kodu dodano wywołania funkcji do niewyrównanego odczytu, które znaleziono w innej części Qemu.

Zniszczenie sterty

W rezultacie, dostęp niewyrównany w TCI został naprawiony, stworzono główną pętlę, która kolejno wywoływała procesor, RCU i kilka drobnych rzeczy. I tak uruchamiam Qemu z opcją -d exec,in_asm,out_asm, co oznacza, że trzeba podać, jakie bloki kodu są wykonywane, a także podczas translacji zapisywać, jaki był kod gościa, a jaki stał się kod gospodarza (w tym przypadku, kod bajtowy). Uruchamia się, wykonuje kilka bloków translacji, zapisuje moją zamieszczoną wiadomość debugowania, że teraz uruchomi się RCU i… pada w abort() wewnętrznej funkcji free(). Poprzez zgłębianie funkcji free() udało się ustalić, że w nagłówku bloku sterty, który leży w ośmiu bajtach poprzedzających przydzieloną pamięć, zamiast rozmiaru bloku lub czegoś podobnego znajdował się śmieć.

Destrukcja stosu — jak miło… W takim przypadku istnieje przydatne narzędzie — z (jeśli to możliwe) tych samych źródeł zbudować natywny binarny plik i uruchomić go pod Valgrind. Po chwili binarny plik był gotowy. Uruchamiam z tymi samymi opcjami — upada jeszcze przy inicjowaniu, nie dochodząc do samego wykonania. Niefajnie, oczywiście — widać, że źródła nie były do końca takie same, co nie dziwi, bo configure wykrył kilka innych opcji, ale mam Valgrind — najpierw naprawię ten błąd, a potem, jeśli szczęście dopisze, to i pierwotny się objawi. Uruchamiam to samo pod Valgrind… O-o-o, u-u-u, e-e-e, uruchomiło się, normalnie przeszło inicjację i poszło dalej, mijając pierwotny błąd bez jednego ostrzeżenia o nieprawidłowym dostępie do pamięci, nie mówiąc już o awariach. Na to życie mnie, jak się mówi, nie przygotowało — program, który się psuje, przestaje padać przy uruchomieniu pod Valgrindem. Co to było — zagadka. Moja hipoteza jest taka, że skoro w otoczeniu bieżącej instrukcji po awarii przy inicjacji gdb pokazywał działanie memset-a z ważnym wskaźnikiem z użyciem albo mmx, albo xmm rejestrów, to być może była to jakaś błąd wyrównania, chociaż wciąż trudno w to uwierzyć.

Ok, Valgrind zdaje się nie być pomocny. I tutaj zaczyna się to, co najbardziej irytujące - wszystko jakby się uruchamia, ale pada z całkowicie nieznanych powodów z powodu zdarzenia, które mogło się wydarzyć miliony instrukcji temu. Przez długi czas nawet nie było wiadomo, jak do tego podejść. W końcu musiałem usiąść i debugować. Drukowanie tego, co zostało zapisane w nagłówku, pokazało, że to wygląda bardziej jak dane binarne niż liczba. I, o cudzie, ten ciąg binarny znalazł się w pliku BIOS - co oznacza, że teraz można było z wystarczającą pewnością powiedzieć, że to było przepełnienie bufora, i nawet wiadomo, co było zapisywane w tym buforze. No a potem jakoś tak — w Emscripten, na szczęście, nie ma randomizacji przestrzeni adresowej, nie ma też dziur, więc można napisać gdzieś w środku kodu wyjście danych przez wskaźnik z poprzedniego uruchomienia, spojrzeć na dane, spojrzeć na wskaźnik i, jeśli ten się nie zmienił, uzyskać informacje do przemyśleń. Prawda, na linkowanie po każdej zmianie mija kilka minut, ale co zrobić. W rezultacie znaleziono konkretny ciąg, który kopiuje BIOS z bufora tymczasowego do pamięci gościa - i rzeczywiście, w buforze nie było wystarczająco dużo miejsca. Poszukiwanie źródła tego dziwnego adresu bufora prowadziło do funkcji qemu_anon_ram_alloc w pliku oslib-posix.c — logika tam była taka: czasem może być przydatne wyrównanie adresu do huge page o rozmiarze 2 MB, w tym celu poprosimy o mmap najpierw trochę więcej, a potem oddamy nadmiar za pomocą munmap. A jeśli takie wyrównanie nie jest wymagane, to zamiast 2 MB wskaźmy wynik getpagesize()mmap to i tak wydaje wyrównany adres… Tak więc w Emscripten mmap po prostu wywołuje malloc, a ten, naturalnie, nie wyrównuje według stron. W ogóle, błąd, który mnie frustrował przez kilka miesięcy, został naprawiony zmianą w dwóch linie.

Cechy wywołania funkcji

I już procesor coś przelicza, Qemu się nie zawiesza, ale ekran się nie włącza, a procesor szybko się zapętla, sądząc po wyjściu -d exec,in_asm,out_asm. Pojawiła się hipoteza: nie przychodzą przerwania timera (ani w ogóle żadna przerwana). I rzeczywiście, jeśli od natywnej wersji, która z jakiegoś powodu działała, odkręce przerwania, otrzymuje się podobny obraz. Ale rozwiązanie okazało się zupełnie inne: porównanie śladów wykonania, wydanych z wymienioną powyżej opcją, pokazało, że trajektorie wykonania rozchodzą się bardzo wcześnie. Tutaj należy powiedzieć, że porównanie zapisanego za pomocą uruchamiacza emrun debugowania z wyjściem natywnej wersji — nie jest to do końca mechaniczny proces. Nie wiem dokładnie, jak uruchomiony w przeglądarce program łączy się z emrun, ale niektóre wiersze w wyjściu okazują się zamienione miejscami, dlatego różnica w różnicach — to jeszcze nie powód, by sądzić, że trajektorie się rozeszły. W ogóle stało się jasne, że zgodnie z instrukcją ljmpl następuje przejście do różnych adresów, a kod bajtowy generowany jest zasadniczo różny: w jednym jest instrukcja wywołania pomocniczej funkcji C, w drugim - nie. Po googlowaniu instrukcji i zapoznaniu się z kodem, który te instrukcje transponuje, stało się jasne, że, po pierwsze, bezpośrednio przed nią do rejestru cr0 była zapisywana — również za pomocą pomocnika — konwertująca procesor w tryb chroniony, a po drugie, że wersja js nigdy nie przeszła w tryb chroniony. A chodzi o to, że jeszcze jedną cechą Emscripten jest niechęć do akceptowania kodu w stylu realizacji instrukcji call w TCI, która każdy wskaźnik na funkcję przekształca w typ long long f(int arg0, .. int arg9) — funkcje powinny być wywoływane z odpowiednią liczbą argumentów. Przy naruszeniu tej zasady, w zależności od ustawień debugowania, program albo się zawiesi (co jest dobre), albo wywoła nie tą funkcję (co będzie smutne do debugowania). Jest jeszcze trzeci wariant — włączyć generację opakowań, które dodają/usuwają argumenty, ale łącznie te opakowania zajmują bardzo dużo miejsca, przy czym tak naprawdę potrzebuję tylko nieco ponad setki opakowań. Tylko to jest dość smutne, ale okazał się bardziej poważny problem: w generowanym kodzie funkcji-opakowań argumenty były konwertowane, konwertowane, tylko ta funkcja z wygenerowanymi argumentami czasami nie była wywoływana — no dokładnie tak, jak w mojej realizacji libffi. Czyli niektóre pomocniki po prostu nie były wykonywane.

Na szczęście Qemu ma czytelne dla maszyny listy pomocników w postaci pliku nagłówkowego, jak na przykład

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

Używane są one dość zabawnie: najpierw w najbardziej dziwaczny sposób są redefiniowane makra DEF_HELPER_n, a potem jest włączany helper.h. Aż do tego, że makro rozwija się w inicjalizatorze struktury i przecinek, a następnie definiowana jest tablica, a zamiast elementów — #include <helper.h> W rezultacie w końcu pojawiła się okazja, aby spróbować w praktyce biblioteki pyparsing, i napisano skrypt generujący „owijki” dokładnie dla tych funkcji, które są potrzebne.

I oto, po tym procesor wydawał się działać. Wydawał się, ponieważ ekran wciąż nie zainicjował, chociaż w natywnej wersji udało się uruchomić memtest86+. Tutaj należy wyjaśnić, że kod blokowego wejścia/wyjścia Qemu jest napisany w korutynach. W Emscripten istnieje jego dość zawiła implementacja, ale wymaga jeszcze wsparcia w kodzie Qemu, a procesor można debugować już teraz: Qemu obsługuje opcje -kernel, -initrd, -append, za pomocą których można załadować Linuxa lub na przykład memtest86+, w ogóle nie używając urządzeń blokowych. Ale oto problem: w natywnej wersji można było zobaczyć wyjście kernela Linuxa na konsolę z opcją -nographic, a z przeglądarki nie było żadnego wyjścia do terminala, z którego została uruchomiona emrun, co nie było jasne. Czy procesor nie działa, czy wyjście graficzne. A potem przyszło mi do głowy poczekać trochę. Okazało się, że "procesor nie śpi, a tylko powoli miga", i po pięciu minutach jądro wypisało na konsolę paczkę komunikatów i zaczęło się dalej zawieszać. Stało się jasne, że procesor ogólnie działa, a trzeba będzie szukać w kodzie pracy z SDL2. Niestety, nie umiem korzystać z tej biblioteki, więc miejscami musiałem działać na ślepo. W pewnym momencie na ekranie mignęła linijka parallel0 na niebieskim tle, co nasuwało pewne myśli. Ostatecznie okazało się, że problem polegał na tym, że Qemu otwiera kilka wirtualnych okien w jednym fizycznym oknie, między którymi można przełączać się za pomocą Ctrl-Alt-n: w natywnej wersji to działa, w Emscripten — nie. Po pozbyciu się zbędnych okien za pomocą opcji -monitor none -parallel none -serial none i wymuszeniu ponownego rysowania całego ekranu przy każdym kadrze wszystko nagle zaczęło działać.

Korutyny

Emulacja w przeglądarce działa, ale nic ciekawego z dyskietki nie można uruchomić, ponieważ brakuje blokowego wejścia-wyjścia — trzeba wprowadzić wsparcie dla korutyn. W Qemu istnieje już kilka backendów korutyn, ale z powodu specyfiki JavaScript i generatora kodu Emscripten nie można po prostu wziąć i zacząć żonglować stosami. Można by powiedzieć, że "wszystko stracone, gips jest ściągany", ale twórcy Emscripten już o wszystko zadbali. Zrealizowano to w dość zabawny sposób: a co powiesz na nazwanie podejrzanego wywołania funkcji takiej jak emscripten_sleep i kilku innych, które używają mechanizmu Asyncify, oraz wywołań z wskaźników i wywołań dowolnej funkcji, w której poniżej stosu może wystąpić jeden z wcześniej wymienionych przypadków. A teraz przed każdym podejrzanym wywołaniem przeznaczmy kontekst asynchroniczny, a tuż po wywołaniu sprawdźmy, czy nie miało miejsca wywołanie asynchroniczne, a jeśli miało miejsce, to zapiszmy wszystkie zmienne lokalne w tym kontekście asynchronicznym, wskaźmy, do której funkcji przekazać kontrolę, gdy będziemy musieli wznowić wykonanie, i wyjdźmy z bieżącej funkcji. To dopiero pole do badania efektu rozbicia — dla potrzeb kontynuacji wykonania kodu po powrocie z wywołania asynchronicznego kompilator generuje "fragmenty" funkcji, zaczynające się po podejrzanym wywołaniu — tak: jeśli jest n podejrzanych wywołań, funkcja będzie rozbita gdzieś w n/2 — to jeszcze nie licząc tego, że do oryginalnej funkcji trzeba po każdym potencjalnie asynchronicznym wywołaniu dodać zapis części zmiennych lokalnych. W późniejszym czasie musiałem nawet napisać skrypt w Pythonie, który, biorąc pod uwagę zadany zbiór szczególnie rozbitych funkcji, które przypuszczalnie "nie przepuszczają asynchroniczności" (to znaczy w nich nie występuje odwijanie stosu i wszystko to, co właśnie opisałem), wskazuje, które wywołania przez wskaźniki w jakich funkcjach należy zignorować przez kompilator, aby te funkcje nie były traktowane jako asynchroniczne. W końcu pliki JS o rozmiarze 60 MB to już wyraźna przesada — niech będą chociaż 30. Chociaż raz ustawiałem skrypt budujący i przypadkowo usunąłem opcje linkera, wśród których była -O3Uruchamiam wygenerowany kod, a Chromium zużywa pamięć i pada. Później przypadkowo spojrzałem na to, co próbował załadować… Co mogę powiedzieć, też bym się zawiesił, gdyby kazano mi dokładnie przeanalizować i zoptymalizować JavaScript o wielkości 500+ MB.

Niestety, kontrole w kodzie biblioteki wsparcia Asyncify nie współpracowały z longjmp-ami, które są używane w kodzie wirtualnego procesora, ale po niewielkiej poprawce, która wyłącza te kontrole i wymusza przywracanie kontekstów tak, jakby wszystko było w porządku, kod zaczął działać. I wtedy zaczęły się dziwne rzeczy: czasami uruchamiały się kontrole w kodzie synchronizacji - te, które awaryjnie kończą kod, jeśli według logiki wykonania powinien się zablokować - ktoś próbował przejąć już zajęty mutex. Na szczęście nie była to problem logiczny w serializowanym kodzie - po prostu używałem standardowej funkcjonalności pętli głównej dostarczanej przez Emscripten, ale czasami asynchroniczne wywołanie całkowicie rozwijało stos, a w tym momencie uruchamiał się setTimeout z pętli głównej - w ten sposób kod wchodził w iterację pętli głównej, nie wychodząc z poprzedniej iteracji. Przepisałem na nieskończonej pętli i emscripten_sleep, i problemy z mutexami zniknęły. Kod stał się nawet bardziej logiczny - w końcu nie mam jakiegoś kodu, który przygotowuje kolejny klatkę animacji - po prostu procesor coś oblicza, a ekran okresowo się aktualizuje. Niemniej jednak, problemy na tym się nie skończyły: czasami wykonanie Qemu po cichu kończyło się bez jakichkolwiek wyjątków i błędów. W tym momencie się tym nie przejmowałem, ale wyprzedzając fakty, powiem, że problem tkwił w tym: kod coroutine tak naprawdę w ogóle nie wykorzystuje setTimeout (no albo przynajmniej nie tak często, jak można by pomyśleć): funkcja emscripten_yield po prostu ustawia znacznik wywołania asynchronicznego. Cała sól w tym, że emscripten_coroutine_next nie jest funkcją asynchroniczną: sprawdza wewnątrz znacznika, resetuje go i przekazuje kontrolę tam, gdzie to potrzebne. To znaczy, na niej kończy się rozwijanie stosu. Problem polegał na tym, że z powodu use-after-free, które objawiało się przy wyłączonym puli korutyn, z powodu tego, że nie skopiowałem ważnej linii kodu z istniejącego backendu coroutine, funkcja qemu_in_coroutine zwracała true, kiedy w rzeczywistości powinna zwrócić false. Prowadziło to do wywołania emscripten_yield, powyżej którego na stosie nie było emscripten_coroutine_next, stos rozwijał się do samej góry, ale żadnych setTimeout, jak już wspomniałem, nie było ustawione.

Generowanie kodu JavaScript

A oto obiecane "przewracanie mielonego mięsa z powrotem". Tak naprawdę nie. Oczywiście, jeśli uruchomimy Qemu w przeglądarce, a w nim — Node.js, to naturalnie, po generowaniu kodu w Qemu otrzymamy zupełnie inny JavaScript. Ale jednak, jakieś tam odwrotne przekształcenie.

Na początek trochę o tym, jak działa Qemu. Proszę mnie wybaczyć: nie jestem profesjonalnym deweloperem Qemu i moje wnioski mogą być czasami błędne. Jak to się mówi, "zdanie studenta nie musi pokrywać się ze zdaniem wykładowcy, aksjomatyką Peano i zdrowym rozsądkiem". Qemu ma określoną liczbę wspieranych architektur gości i dla każdej z nich jest katalog o nazwie target-i386. Przy kompilacji można określić wsparcie dla kilku architektur gości, ale w efekcie otrzymamy po prostu kilka binariów. Kod do obsługi architektury gościa, z kolei, generuje pewne wewnętrzne operacje Qemu, które TCG (Tiny Code Generator) przekształca już w kod maszynowy architektury gospodarza. Jak stwierdza plik readme znajdujący się w katalogu tcg, początkowo była to część zwykłego kompilatora C, którą potem dostosowano do JIT. Dlatego, na przykład, architektura docelowa w terminach tego dokumentu — to już nie architektura gościa, lecz architektura gospodarza. W pewnym momencie pojawił się jeszcze jeden komponent — Tiny Code Interpreter (TCI), który ma za zadanie wykonywać kod (praktycznie te same wewnętrzne operacje) w braku generatora kodu pod konkretną architekturę gospodarza. Tak naprawdę, jak mówi jego dokumentacja, ten interpreter może nie zawsze działać tak dobrze, jak generator kodu JIT, nie tylko pod względem szybkości, ale także jakości. Choć nie jestem pewien, czy jego opis jest całkowicie aktualny.

Początkowo próbowałem stworzyć pełnoprawny backend TCG, ale szybko zgubiłem się w źródłach i mało zrozumiałym opisie instrukcji bajtowych, więc postanowiłem owinąć interpreter TCI. To natychmiast przyniosło kilka korzyści:

  • przy realizacji generatora kodu można było patrzeć nie w opis instrukcji, ale w kod interpretera
  • można generować funkcje nie dla każdego napotkanego bloku transformacji, a na przykład tylko po setnym wykonaniu
  • w przypadku zmiany wygenerowanego kodu (co wydaje się możliwe, sądząc po funkcjach z nazwami zawierającymi słowo patch) będę musiał unieważnić wygenerowany kod JS, ale przynajmniej będę miał, z czego go ponownie wygenerować

Co do punktu trzeciego, nie jestem pewien, czy patchowanie jest możliwe po pierwszym wykonaniu kodu, ale pierwsze dwa punkty są wystarczające.

Początkowo kod był generowany w postaci dużego switcha według adresu oryginalnej instrukcji bajtkodu, ale później, przypominając sobie artykuł o Emscripten, optymalizacji generowanego JS i reloopingu, postanowiłem generować bardziej czytelny kod, tym bardziej, że empiricznie okazało się, że jedynym punktem wejścia do bloku transformacji jest jego początek. Powiedziano — zrobiono, po pewnym czasie powstał generator kodu, który generował kod z if-ami (chociaż bez pętli). Ale oto niespodzianka, on się nie powiódł, zgłaszając, że instrukcja miała jakąś nieprawidłową długość. Przy tym ostatnia instrukcja na tym poziomie rekurencji była brcond. Dobrze, dodam identyczne sprawdzenie w generowanym kodzie tej instrukcji przed wywołaniem rekurencyjnym i po nim i… żadne z nich się nie wykonało, ale po switchu według asserta wciąż się zawiesiły. W końcu, analizując wygenerowany kod, zrozumiałem, że po switche wskazówka na bieżącą instrukcję jest resetowana ze stosu i prawdopodobnie nadpisywana przez generowany kod JavaScript. I tak rzeczywiście było. Zwiększenie buforu z jednego megabajta do dziesięciu nic nie dało, i stało się jasne, że generator kodu krąży w kółko. Musiałem sprawdzić, czy nie wyszliśmy poza granice bieżącego TB, a jeśli udało się to zrobić, to zgłaszać adres następnego TB z minusem, aby móc kontynuować wykonanie. Poza tym rozwiązuje to problem „które wygenerowane funkcje unieważnić, jeśli zmieniła się ta część bajtkodu?” — unieważnić trzeba tylko tę funkcję, która odpowiada temu blokowi transformacji. Przy okazji, chociaż wszystko debugowałem w Chromium (ponieważ używam Firefox i łatwiej mi korzystać z osobnej przeglądarki do eksperymentów), to Firefox pomógł mi naprawić niezgodności ze standardem asm.js, po czym kod zaczął szybciej działać w Chromie.

Przykład generowanego kodu

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

Podsumowanie

Więc praca nadal nie jest zakończona, ale w tajemnicy doprowadzanie do perfekcji tego dłuuuuuuuuuugiego projektu zaczyna mnie nudzić. Dlatego postanowiłem opublikować to, co mam na chwilę obecną. Kod jest miejscami straszny, ponieważ to eksperyment i nie wiadomo z góry, co trzeba robić. Pewnie potem warto będzie przygotować normalne atomowe commity na bazie jakiejś nowocześniejszej wersji Qemu. Na razie jest gałąź w gicie w formacie bloga: do każdego git log.

Można to wszystko wypróbować tutaj (ostrożnie, ruch).

Co już teraz działa:

  • Działa wirtualny procesor x86
  • Jest działający prototyp JIT generatora kodu z kodu maszynowego na JavaScript
  • Istnieje szkielet do budowy innych 32-bitowych architektur gości: możecie teraz nacieszyć oko wiszącym w przeglądarce podczas ładowania Linuksa dla architektury MIPS

Co jeszcze można zrobić

  • Przyspieszyć emulację. Nawet w trybie JIT działa, jak się wydaje, wolniej niż Virtual x86 (jednak potencjalnie jest cały Qemu z dużą ilością emulowanego sprzętu i architektur)
  • Zrobić normalny interfejs — z web developera jestem, delikatnie mówiąc, średni, dlatego na razie przerobiłem standardową powłokę Emscripten, jak potrafiłem
  • Spróbować uruchomić bardziej skomplikowane funkcje Qemu — sieć, migrację VM itp.
  • UPD: Będzie trzeba przekazać do upstream Emscripten swoje nieliczne osiągnięcia i raporty o błędach, tak jak to robili poprzedni portownicy Qemu i innych projektów. Dziękuję im za to, że istniała możliwość niejawnego korzystania z ich wkładu w Emscripten w ramach swoich zadań.

Źródło: habr.com

Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS 🔥 Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS | ProHoster