Qemu.js s podporou JIT: mletí masa se přece jen dá otočit zpět

Před několika lety napsal Fabrice Bellard jslinux — emulátor PC napsaný v JavaScriptu. Poté byl ještě minimálně Virtual x86. Ale všechny, co vím, byly interprety, zatímco mnohem dříve napsaný tím samým Fabricem Bellardem Qemu, a také pravděpodobně jakýkoli respektovaný moderní emulátor, využívá JIT kompilaci hostovaného kódu do kódu hostitelské systému. Přišlo mi, že je nejvyšší čas realizovat opačný úkol, než který řeší prohlížeče: JIT kompilaci strojového kódu do JavaScriptu, proto mě napadlo, že je logičtější portovat Qemu. Může se zdát, proč právě Qemu, existují přece jednodušší a uživatelsky přívětivější emulátory — například VirtualBox — nainstalujete a funguje. Ale Qemu má několik zajímavých vlastností,

  • otevřené zdrojové kódy
  • možnost fungovat bez ovladače jádra
  • možnost pracovat v režimu interpreteru
  • podpora velkého množství jak hostitelských, tak hostovaných architektur.

K třetímu bodu nyní už mohu vysvětlit, že ve skutečnosti v režimu TCI se neinterpretují samotné hostované strojové instrukce, ale získaný bajtkód, ale na podstatě to nic nemění — abyste sestavili a spustili Qemu na nové architektuře, pokud budete mít štěstí, stačí C kompilátor — psaní kódu generátoru můžete odložit.

A tak po dvou letech pomalého bádání v čase, kdy jsem měl volno, se objevil funkční prototyp, ve kterém už lze spustit například Kolibri OS.

Co je Emscripten

V dnešní době se objevilo mnoho kompilátorů, jejichž konečným produktem je JavaScript. Některé, jako Type Script, byly od začátku zamýšleny jako nejlepší způsob, jak psát pro web. Na druhou stranu, Emscripten je způsob, jak vzít existující kód v C nebo C++ a zkompilovat jej do podoby, kterou prohlížeč zvládne. Na tato stránka bylo nasbíráno nemálo portů známých programů: zde, například si můžete prohlédnout PyPy — mimochodem, jak se tvrdí, už mají JIT. Ve skutečnosti ne každý program lze jednoduše zkompilovat a spustit v prohlížeči — existuje řada vlastnosti, se kterými se musíme smířit, jak však praví nápis na stejné stránce "Emscripten lze použít k zkompilování téměř jakéhokoliv portable Kód C/C++ do JavaScriptu. Existuje řada operací, které představují nedefinované chování podle standardu, ale obvykle fungují na x86 — například nevyrovnaný přístup k proměnným, který je na některých architekturách zcela zakázán. Obecně je Qemu multiplatformní program a doufejme, že neobsahuje velké množství nedefinovaného chování — vezmi a kompiluj, pak se trochu pošťouchej s JIT — a hotovo! Ale to ještě nebylo to pravé…

První pokus

Obecně řečeno, nejsem první, koho napadlo portovat Qemu na JavaScript. Na fóru ReactOS se objevila otázka, zda je to možné udělat pomocí Emscripten. Už dříve se spekulovalo, že to osobně udělal Fabrice Bellard, ale mluvilo se o jslinux, což je, pokud vím, pokus o ruční dosažení dostatečného výkonu na JS, který je napsán od základů. Později byl napsán Virtual x86 — k němu byly publikovány neobfuskované zdrojové kódy, a jak se tvrdilo, větší "realističnost" emulace umožnila použít SeaBIOS jako firmware. Kromě toho byla alespoň jedna pokus o portování Qemu pomocí Emscripten — to se snažil udělat socketpair, ale vývoj, jak jsem pochopil, byl pozastaven.

Takže se zdá, že tady jsou zdrojové kódy, tady je Emscripten — vezmi a kompiluj. Ale jsou tu také knihovny, na kterých Qemu závisí, a knihovny, na kterých závisí tyto knihovny, apod., přičemž jedna z nich je — libffi, na němž závisí glib. Na internetu kolovaly zvěsti, že v rozsáhlé kolekci portů knihoven pro Emscripten je také, ale moc tomu nevěřilo: jednak se s novým kompilátorem nesestavovala, jednak je to příliš nízkoúrovňová knihovna, aby se mohla jen tak vzít a zkompilovat do JS. A o komplikovaných assemblerových vložkách ani nemluvě — pravděpodobně, když se s tím nějakým způsobem vyhrajete, lze pro některé calling conventions argumenty na zásobníku vytvořit i bez nich a vyvolat funkci. Jenže Emscripten je složitá záležitost: abych generovaný kód udělal přehledný pro optimalizátor JS-enginu prohlížeče, používají se určité triky. Konkrétně takzvaný relooping — generátor kódu se pokouší na základě získaného LLVM IR s jakýmsi abstraktními instrukcemi skoků rekonstruovat věrohodné podmínky, cykly atd. A jak se předávají argumenty do funkcí? Samozřejmě, jako argumenty JS-funkcí, tedy pokud možno ne přes zásobník.

Na začátku jsem měl myšlenku prostě napsat náhradu za libffi na JS a provést standardní testy, ale nakonec jsem se ztratil v tom, jak udělat své hlavičkové soubory, aby pracovaly se stávajícím kódem — co se dá dělat, jak se říká, "Buď jsou úkoly tak složité, nebo jsme tak hloupí". Musel jsem portovat libffi na ještě jednu architekturu, pokud to tak můžu říct — naštěstí, Emscripten má jak makra pro inline assembly (na JavaScriptu, no, jaká architektura, takový i assembler), tak možnost spustit generovaný kód za chodu. Po nějakém čase tráveném s platformně závislými fragmenty libffi jsem dostal nějaký kód, který se dal kompilovat, a provedl ho na prvním nalezeném testu. K mému překvapení test úspěšně prošel. Ohromený svou genialitou — vtip to snad není, fungovalo to na první pokus — jsem, stále ještě nevěřil svým očím, šel znovu podívat na vzniklý kód, abych zjistil, kam se posunout dál. Zde jsem byl podruhé ohromen — jediná věc, kterou moje funkce ffi_call — hlásila úspěšné vyvolání. Samotné vyvolání neproběhlo. Tak jsem poslal svou první pull request, která opravila každému olympijskému účastníkovi jasnou chybu v testu — reálná čísla by se neměla porovnávat jako a == b a dokonce ani jako a - b < EPS — nesmíte zapomenout ani na modul, jinak se 0 ukáže být velmi rovno 1/3... V každém případě jsem vytvořil jakýsi port libffi, který prochází nejjednoduššími testy, a se kterým se kompiluje glib — rozhodl jsem se, že to pak dopíšu. Ujistím vás, že, jak se ukázalo, do finálního kódu funkce libffi kompilátor dokonce nezahrnul.

Ale jak už jsem říkal, existují určitá omezení, a mezi volným používáním různých neurčitých chování se objevila vlastnost, která je poněkud nepříjemná — JavaScript záměrně nepodporuje multithreading se sdílenou pamětí. V zásadě to lze obvykle považovat za docela dobrý nápad, ale ne pro portování kódu, jehož architektura je založena na C vláknech. Obecně řečeno, ve Firefoxu probíhají experimenty s podporou sdílených pracovníků, a implementace pthread pro ně v Emscriptenu existuje, ale nechtěl jsem na tom záviset. Musel jsem pomalu vytrhávat multithreading z kódu Qemu — to znamená hledat, kde se spouštějí vlákna, přesunout tělo smyčky, která se provádí v tomto vláknu, do samostatné funkce a postupně vyvolávat takové funkce z hlavní smyčky.

Druhá pokus

V jednu chvíli bylo jasné, že je to stále tam, a že bezsystematické rozšiřování oprav v kódu k ničemu dobrému nevede. Závěr: musí se nějak systematizovat proces přidávání oprav. Proto byla použita čerstvá verze 2.4.1 (ne 2.5.0, protože, ať se děje cokoli, tam mohou být ještě neodhalené chyby nové verze, a mám svých chyb dost), a jako první krok byl bezpečně přepsán thread-posix.c. No tak jako bezpečně: pokud se někdo pokusil provést operaci vedoucí k zablokování, okamžitě byla vyvolána funkce abort() — samozřejmě to nevyřešilo všechny problémy, ale minimálně to bylo příjemnější než tiše získávat nekonzistenci dat.

Obecně platí, že při portování kódu do JS velice pomáhají možnosti Emscripten -s ASSERTIONS=1 -s SAFE_HEAP=1 — zachycují některé druhy neurčitého chování, jako je například přístup na nevyrovnanou adresu (což vůbec nesouhlasí s kódem pro typed arrays, jako je HEAP32[addr >> 2] = 1) nebo vyvolání funkce s nesprávným počtem argumentů.

Mimochodem, chyby zarovnání jsou samostatné téma. Jak jsem již zmínil, v Qemu existuje "degenerovaný" interpretující backend kódování TCI (tiny code interpreter), a pro sestavení a spuštění Qemu na nové architektuře, pokud bude štěstí, stačí C kompilátor. Klíčová slova "pokud bude štěstí". Mně se štěstí nedostalo a ukázalo se, že TCI při zpracování svého bajtkódu používá nezarovnaný přístup. Jinými slovy, na různých ARM a dalších architekturách, které vyžadují zarovnaný přístup, se Qemu kompiluje, protože pro ně existuje normální TCG backend, generující nativní kód, a zda TCI na nich funguje, to je ještě otázka. Nicméně se ukázalo, že v dokumentaci k TCI bylo něco podobného jasně uvedeno. Nakonec byly do kódu přidány výzvy funkcí pro nezarovnané čtení, které byly nalezeny v jiné části Qemu.

Destrukce haldy

Výsledkem je, že nezarovnaný přístup v TCI byl opraven a byl vytvořen hlavní cyklus, který po sobě volal procesor, RCU a něco malého. A tak spouštím Qemu s volbou -d exec,in_asm,out_asm, což znamená, že je třeba říci, jaké bloky kódu se vykonávají, a také při překladu uvést, jaký byl hostitelský kód a jaký se stal (v tomto případě bajtkód). Spouští se, vykonává několik bloků překladu, zapíše podmíněnou zprávu pro ladění, že nyní se spustí RCU a… padá po abort() vnitřní funkci free(). Průzkumem funkce free() se mi podařilo zjistit, že v hlavičce bloku haldy, která leží v osmi bajtech před přidělenou pamětí, místo velikosti bloku nebo něčeho podobného je jen odpad.

Rušení haldy — jak pěkné... V takovém případě je užitečným prostředkem sestavit nativní binární soubor z (pokud možno) stejných zdrojových kódů a spustit ho pod Valgrindem. Po nějaké době byl binární soubor hotový. Spouštím s těmi stejnými přepínači — zhroutí se ještě při inicializaci, aniž by se dostal k vlastnímu provedení. Není to příjemné, samozřejmě — zřejmě zdrojové kódy vůbec nebyly ty samé, což není překvapující, protože configure zjistil několik jiných přepínačů, ale já mám Valgrind — nejprve tuto chybu opravím a pak, pokud budu mít štěstí, se objeví i původní chyba. Spouštím všechno to samé pod Valgrindem… Ach, to je zvláštní, ono se to spustilo, normálně proběhlo inicializací a pokračovalo dál mimo původní chybu bez jediného varování o nesprávném přístupu k paměti, nemluvě o pádech. Takovému vývoji mě život, jak se říká, nepřipravil — program, který se zhroutí, přestává padat při spuštění pod Valgrindem. Co to bylo — záhada. Moje hypotéza je, že v okolí aktuální instrukce po pádu při inicializaci gdb ukazoval činnost memset-a s platným ukazatelem s použitím buď mmx, nebo xmm registrů, tak to možná byla nějaká chyba zarovnání, ačkoli tomu stejně moc nevěřím.

O-kay, Valgrind zde vypadá, že není pomocníkem. A tady začalo to nejhorší — všechno jako by se spouštělo, ale padalo z naprosto neznámých důvodů kvůli události, která se mohla stát miliony instrukcí zpět. Dlouho bylo nejasné, jak se k tomu přiblížit. Nakonec jsem se musel posadit a ladit. Tisk toho, co bylo zapsáno v hlavičce, ukázal, že se to spíše podobá binárním datům než číslu. A, ó zázraku, tato binární řetězec byla nalezena v souboru s BIOSem — tedy nyní bylo možné s dostatečnou jistotou říci, že došlo k přeplnění vyrovnávací paměti, a dokonce bylo jasné, co se do této vyrovnávací paměti zapisovalo. A pak to šlo — v Emscripten, naštěstí, není randomizace adresního prostoru, také tam nejsou žádné díry, takže můžete někde uprostřed kódu napsat výstup dat podle ukazatele z minulého spuštění, podívat se na data, podívat se na ukazatel, a pokud se ten nezměnil, získat informace k zamyšlení. Pravda, na linkování po jakékoliv změně to trvá pár minut, ale co se dá dělat. V důsledku toho byla nalezena konkrétní řetězec, který kopíruje BIOS z dočasné paměti do hostitelské paměti — a ve vyrovnávací paměti skutečně nebylo dostatek místa. Hledání zdroje toho podivného adresy vyrovnávací paměti vedlo do funkce qemu_anon_ram_alloc v souboru oslib-posix.c — logika tam byla taková: někdy může být užitečné vyrovnat adresu podle huge page o velikosti 2 MB, k tomu požádáme o mmap nejprve o něco víc, a pak to přebytečné vrátíme pomocí munmap. A pokud takové vyrovnání není nutné, pak uvedeme místo 2 MB výsledek getpagesize() — mmap stejně tak vyrobí vyrovnanou adresu… Takže v Emscripten mmap prostě zavolá malloc, a ten samozřejmě nevyrovnává podle stránky. Celkově se chyba, která mě trápila pár měsíců, napravila změnou v dvou řádcích.

Specifika volání funkcí

A již procesor něco počítá, Qemu nepadá, ale obrazovka se nezapíná a procesor se rychle cyklí, podle výstupu. -d exec,in_asm,out_asmObjevila se hypotéza: buď nepřicházejí přerušení časovače (nebo vůbec všechna přerušení). A skutečně, pokud od nativní sestavy, která z nějakého důvodu fungovala, odeberete přerušení, dostanete podobný obraz. Ale rozluštění nebylo v tom: porovnání trasování s uvedenou možností ukázalo, že trajektorie vykonávání se rozcházejí velmi brzy. Zde je třeba říci, že porovnání zápisu pomocí spouštěče emrun s výstupem nativní sestavy — není to zcela mechanický proces. Přesně nevím, jak program spuštěný v prohlížeči komunikuje s emrun, ale některé řádky ve výstupu se ukazují být prohozené, tudíž rozdíl v diffu — to ještě není důvod považovat trajektorie za rozcházející se. Obecně bylo jasné, že podle instrukcí ljmpl dochází k přechodu na různé adresy, a také, že bajtkód se generuje principiálně jinak: v jednom případě je pokyn pro volání pomocné C funkce, v druhém — není. Po googlení pokynů a studiu kódu, který tyto pokyny převádí, se ukázalo, že zaprvé se přímo do registru cr0 provádí zápis — také pomocí pomocníka — přepínajícího procesor do chráněného režimu, a zadruhé, že js-verze se do chráněného režimu tak nikdy nepřepnula. A problém je v tom, že dalším rysem Emscripten je neochota př tolerate kód jako implementace pokynu call v TCI, který jakýkoli ukazatel na funkci převádí na typ long long f(int arg0, .. int arg9) — funkce musí být volány s správným počtem argumentů. Při porušení této pravidla, v závislosti na nastavení ladění, program buď spadne (to je dobré), nebo zavolá zcela jinou funkci (což bude smutné ladit). Existuje i třetí možnost — zapnout generaci obalů, které přidávají/vyhazují argumenty, ale celkově tyto obaly zabírají velmi mnoho místa, přičemž ve skutečnosti potřebuji jen něco přes sto obalů. Jen tohle je dost smutné, ale ukázal se vážnější problém: v generovaném kódu funkce-obalu byly argumenty konvertovány-konvertovány, ale funkce se generovanými argumenty se někdy nezavolala — přímo jako v mé implementaci libffi. Tedy někteří pomocníci se prostě neprováděli.

Naštěstí má Qemu strojově čitelné seznamy pomocníků ve formě hlavičkového souboru, jako je

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

Používají se docela zábavně: nejprve se nejpodivnějším způsobem předefinují makra DEF_HELPER_n, a poté se zahrne helper.h. Až do toho, že makro se vyexpaduje v inicializátoru struktury a čárce, a poté se definuje pole, přičemž místo prvků — #include <helper.h> Nakonec se naskytla příležitost vyzkoušet knihovnu pyparsing, a byl napsán skript, který generuje obaly přesně pro ty funkce, které je třeba.

A tak, po tomto, procesor zdánlivě začal fungovat. Zdánlivě, protože obrazovka se nikdy neinitialized, přestože v nativním sestavení se podařilo spustit memtest86+. Je třeba upřesnit, že kód blokového vstupu a výstupu Qemu je napsán na korutinách. V Emscripten existuje svou dost složitá implementace, ale tu bylo potřeba podpořit v kódu Qemu a procesor lze ladit již teď: Qemu podporuje možnosti -kernel, -initrd, -append, pomocí kterých lze načíst Linux nebo například memtest86+ bez použití blokových zařízení. Ale pak se stalo něco nečekaného: v nativním sestavení bylo možné sledovat výstup jádra Linuxu na konzoli s možností -nographic, avšak z prohlížeče žádný výstup do terminálu, odkud byl spuštěn emrun, nepřišel. To znamená, není jasné: procesor nefunguje, nebo se neprovádí výstup grafiky. A pak mě napadlo chvilku počkat. Ukázalo se, že "procesor nespí, ale prostě pomalu bliká", a asi za pět minut jádro vyhodilo na konzoli dávku zpráv a začalo se zase zasekávat. Stalo se jasné, že procesor v zásadě funguje a je potřeba zkoumat kód, který pracuje se SDL2. Používat tuto knihovnu bohužel neumím, takže jsem občas musel jednat náhodně. V jednom okamžiku na obrazovce zazářil řádek parallel0 na modrém pozadí, což vzbudilo určité myšlenky. Nakonec se ukázalo, že problém spočíval v tom, že Qemu otevírá několik virtuálních oken v jednom fyzickém okně, mezi nimiž lze přepínat pomocí Ctrl-Alt-n: v nativním sestavení to funguje, v Emscripten — ne. Po odstranění zbytečných oken pomocí možností -monitor none -parallel none -serial none a určení, aby se celý obrazovka znovu vykreslovala v každém snímku, všechno najednou začalo fungovat.

Korutiny

Takže emulace v prohlížeči funguje, ale není možné spustit nic zajímavého s jednou disketou, protože chybí blokový vstup a výstup – je nutné implementovat podporu pro korutiny. V Qemu už existuje několik coroutine backendů, ale díky specifikům JavaScriptu a generátoru kódu Emscripten nelze prostě vzít a začít žonglovat se zásobníky. Zdálo by se, že „všechno je ztraceno“, ale vývojáři Emscripten už se o všechno postarali. Realizovali to dost vtipně: co kdybychom nazvali podezřelým volání funkce jako emscripten_sleep a několik dalších, které používají mechanismus Asyncify, a také volání pomocí ukazatele a volání jakékoli funkce, kde může níže ve zásobníku nastat jeden z předchozích dvou případů. A nyní před každým podezřelým voláním alokujeme async context a ihned po volání zkontrolujeme, zda nedošlo k asynchronnímu volání, a pokud ano, uložíme všechny lokální proměnné do tohoto async contextu, určíme, kterou funkci předat řízení, když bude potřeba pokračovat v provádění, a vystoupíme z aktuální funkce. Tady je prostor pro prozkoumání účinku roztržení – pro potřeby pokračování v provádění kódu po návratu z asynchronního volání generuje kompilátor "úseky" funkce, které začínají po podezřelém volání – takto: pokud existuje n podezřelých volání, funkce bude roztrhnuta někde v n/2. pokud nepočítáme, že do původní funkce je třeba po každém potenciálně asynchronním volání přidat uložení části lokálních proměnných. Následně jsem dokonce musel napsat jednoduchý skript na Pythonu, který pro danou množinu zvlášť roztržených funkcí, které pravděpodobně "nepouští asynchronnost skrz sebe" (tj. vyvolávání zásobníku a vše, co jsem právě popsal, se v nich neprovádí), uvádí, které volání pomocí ukazatelů v jakých funkcích by měl kompilátor ignorovat, aby nebyly považovány za asynchronní. Protože JS soubory o velikosti 60 MB – to už je jasně moc – ať je to raději alespoň 30. I když jsem jednou nastavil skript pro sestavení a náhodou jsem vyhodil možnosti linkeru, mezi nimiž byla i -O3. Spouštím vygenerovaný kód a Chromium zkonzumuje paměť a spadne. Potom jsem náhodou koukal na to, co se snažil načíst… No, co mohu říct, také bych se zasekl, kdybych byl požádán o pečlivé prozkoumání a optimalizaci JavaScriptu na více než 500 MB.

Bohužel, kontroly v kódu knihovny pro podporu Asyncify nebyly zcela kompatibilní s longjmp-y, které se používají v kódu virtuálního procesoru, ale po drobném patche, který tyto kontroly vypnul a obnovil kontexty, jako by bylo vše v pořádku, kód začal fungovat. A tady začaly podivnosti: občas se spustily kontroly v kódu synchronizace — ty, které násilně ukončují kód, pokud by se podle logiky měl zablokovat — někdo se pokoušel získat již získaný zámek. Naštěstí to nebyl logický problém v serializovaném kódu — prostě jsem použil standardní funkčnost hlavní smyčky, poskytovanou Emscripten, ale občas asynchronní volání plně roztočilo zásobník, a v tu chvíli se spustil setTimeout z hlavní smyčky — tak se kód dostával do iterace hlavní smyčky, aniž by opustil předchozí iteraci. Přepsal jsem to na nekonečné smyčky a emscripten_sleep, a problémy se zámky přestaly. Kód se stal dokonce logičtější — protože vlastně nemám žádný kód, který by připravoval další snímek animace — prostě procesor něco počítá a obrazovka se periodicky aktualizuje. Nicméně, problémy tímto nekončily: občas se Qemu prostě tiše ukončil bez jakýchkoli výjimek nebo chyb. V tu chvíli jsem na to kašlal, ale abych to zkrátil, problém byl v tom, že kód korutin ve skutečnosti vůbec nepoužíval setTimeout (no nebo, alespoň, ne tak často, jak by se mohlo zdát): funkce emscripten_yield prostě nastaví příznak asynchronního volání. Celá pointa spočívá v tom, že emscripten_coroutine_next není asynchronní funkcí: uvnitř sebe kontroluje příznak, resetuje ho a předává kontrolu tam, kam je potřeba. To znamená, že na ní rotace zásobníku končí. Problém byl v tom, že kvůli use-after-free, který se projevil při vypnutém poolu korutin, protože jsem nezkopíroval důležitý řádek kódu z existujícího backendu korutin, funkce qemu_in_coroutine vracela true, když ve skutečnosti měla vracet false. To vedlo k volání emscripten_yield, nad kterým se stack nezvedl emscripten_coroutine_next, stack se rozvinul až nahoru, ale žádné setTimeout, jak jsem již řekl, nebylo vystaveno.

Generování kódu v JavaScriptu

A tady je slíbené "otáčení mletého masa zpět". Ve skutečnosti ne. Samozřejmě, pokud běžíte Qemu v prohlížeči a v něm - Node.js, pak samozřejmě po generování kódu v Qemu dostaneme úplně jiný JavaScript. Ale přesto, jakési zpětné zpracování.

Nejprve trochu o tom, jak funguje Qemu. Hned se omlouvám: nejsem profesionální vývojář Qemu a moje závěry mohou být místy chybný. Jak se říká, "názor studenta nemusí být v souladu s názorem učitele, Peanovskou axiomatickou a zdravým rozumem". Qemu podporuje určité množství hostitelských architektur a pro každou existuje katalog jako target-i386. Při sestavování můžete uvést podporu několika hostitelských architektur, ale nakonec dostanete prostě několik binárních souborů. Kód pro podporu hostitelské architektury generuje určité vnitřní operace Qemu, které TCG (Tiny Code Generator) pak převádí na strojový kód hostitelské architektury. Jak je uvedeno v souboru readme, který se nachází v adresáři tcg, původně to byla část běžného C kompilátoru, kterou později přizpůsobili pro JIT. Proto například hostitelská architektura v termínech tohoto dokumentu již není hostitelská, ale hostitelská architektura. V určitém okamžiku se objevil další komponent - Tiny Code Interpreter (TCI), který má vykonávat kód (prakticky stejné vnitřní operace) v nepřítomnosti generátoru kódu pro konkrétní hostitelskou architekturu. Ve skutečnosti, jak se uvádí v jeho dokumentaci, tento interpretr nemusí vždy fungovat stejně dobře jako JIT generátor kódu nejen kvantitativně z pohledu rychlosti, ale také kvalitativně. Ačkoli si nejsem jistý, že jeho popis je zcela aktuální.

Zpočátku jsem se snažil vytvořit plnohodnotný TCG backend, ale rychle jsem se ztratil v zdrojových kódech a ne zcela srozumitelném popisu instrukcí bytecode, proto jsem se rozhodl obalit interpretr TCI. To přineslo hned několik výhod:

  • při realizaci generátoru kódu bylo možné se dívat ne na popis instrukcí, ale na kód interpretru
  • Funkce lze generovat nejen pro každý nalezený blok překladu, ale například pouze po sto provedeních.
  • V případě změny vygenerovaného kódu (což se zřejmě může stát, soudě podle funkcí s názvy obsahujícími slovo patch) budu muset invalidovat vygenerovaný JS kód, ale aspoň budu mít z čeho ho znovu vygenerovat.

K třetímu bodu si nejsem jistý, zda je patchování možné po prvním provedení kódu, ale na prvních dvou bodech je to dostatečné.

Původně byl kód generován ve formě velkého switch podle adresy výchozí instrukce bytecode, ale pak, vzpomínajíc na článek o Emscriptenu, optimalizaci generovaného JS a reloopingu, jsem se rozhodl generovat více srozumitelný kód, zejména proto, že empiricky bylo zkoumáno, že jediný vstup do bloku překladu je jeho začátek. Bylo řečeno — uděláno, po nějaké době se objevil kódový generátor, který generoval kód s ify (i když bez cyklů). Ale ouha, spadl a vyhodil zprávu, že instrukce měla nějakou nesprávnou délku. Přitom poslední instrukce na této úrovni rekurze byla. brcond. Dobře, přidám identickou kontrolu do generace této instrukce před rekurzivním voláním a po něm a… ani jedna z nich se neprošla, ale po switchi na assertu jsme stejně spadli. Nakonec, po prostudování vygenerovaného kódu, jsem pochopil, že po switchi se ukazatel na aktuální instrukci přetíží ze zásobníku a pravděpodobně se přepíše generovaným JavaScript kódem. A bylo to tak. Zvýšení bufferu z jednoho megabajtu na deset ničemu nepomohlo a stalo se jasným, že kódový generátor se točí v kruhu. Musel jsem zkontrolovat, zda jsme nevyšli za hranice aktuálního TB, a pokud ano, tak vyhodit adresu následujícího TB se znaménkem mínus, aby bylo možné pokračovat ve vykonávání. Kromě toho to vyřešilo problém "které vygenerované funkce invalidovat, pokud se tento kousek bytecode změnil?" — invalidovat je třeba pouze tu funkci, která odpovídá tomuto bloku překladu. Mimochodem, ačkoli jsem vše ladil v Chromu (protože používám Firefox a je pro mě snazší používat samostatný prohlížeč pro experimenty), Firefox mi pomohl opravit nekompatibility se standardem asm.js, po čemž kód začal lépe fungovat v Chromu.

Příklad generovaného kódu

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

Závěr

Takže práce stále není dokončena, ale už mě nebaví tajně zdokonalovat tento dlouhý projekt. Rozhodl jsem se tedy zveřejnit to, co mám. Kód je místy hrozný, protože se jedná o experiment a předem není jasné, co dělat. Určitě by stálo za to později provést normální atomové commity na nějaké modernější verzi Qemu. Zatím je k dispozici větev v gitu ve formátu blogu: k každému "úrovni", kterou jsem nějakým způsobem dosáhl, je přidán podrobný komentář v češtině. Tato článek je do značné míry přečtením výstupu. git log.

Je možné to všechno vyzkoušet zde (opatrně, provoz).

Co už teď funguje:

  • Funguje virtuální procesor x86
  • Existuje funkční prototyp JIT-kodovače pro strojový kód do JavaScriptu
  • Existuje základna pro sestavení dalších 32-bitových hostitelských architektur: můžete se právě teď podívat na Linux pro architekturu MIPS, který se zasekne v prohlížeči během načítání.

Co ještě můžeme udělat

  • Zrychlit emulaci. I v režimu JIT to vypadá, že je pomalejší než Virtual x86 (ale potenciálně je tu celý Qemu s mnoha emulovanými zařízeními a architekturami).
  • Udělat normální rozhraní - webový vývojář ze mě, řekněme, není zrovna skvělý, takže jsem zatím přepracoval standardní shell Emscripten, jak jsem uměl.
  • Zkusit spustit složitější funkce Qemu - síť, migraci VM atd.
  • UPD: Budete muset poslat své málo početné příspěvky a chyby do upstreamu Emscripten, jak to dělali předchozí portéři Qemu a dalších projektů. Děkuji jim za to, že byla možnost neformálně využívat jejich příspěvek k Emscripten v rámci svého úkolu.

Zdroj: habr.com

Koupit spolehlivý hosting pro webové stránky s ochranou proti DDoS, VPS VDS servery 🔥 Koupit spolehlivý hosting pro webové stránky s ochranou proti DDoS, VPS VDS servery | ProHoster