Несколько лет назад Фабрис Беллар написал jslinux — эмулятор ПК, написанный на JavaScript. После этого был ещё как минимум Virtual x86. Но все они, насколько мне известно, являлись интерпретаторами, в то время как написанный значительно раньше тем же Фабрисом Белларом Qemu, да и, наверное, любой уважающий себя современный эмулятор, использует JIT-компиляцию гостевого кода в код хостовой системы. Мне показалось, что самое время реализовать обратную задачу по отношению к той, которую решают браузеры: JIT-компиляцию машинного кода в JavaScript, для чего логичнее всего виделось портировать Qemu. Казалось бы, почему именно Qemu, есть же более простые и user-friendly эмуляторы — тот же VirtualBox, например — поставил и работает. Но у Qemu есть несколько интересных особенностей На счёт третьего пункта теперь-то я уже могу пояснить, что на самом деле в режиме TCI интерпретируются не сами гостевые машинные инструкции, а полученный из них байткод, но сути это не меняет — чтобы собрать и запустить Qemu на новой архитектуре, если повезёт, достаточно компилятора C — написание кодогенератора можно отложить. И вот, после двух лет неспешного ковыряния в свободное время исходников Qemu появился работающий прототип, в котором уже можно запустить, например, Kolibri OS. В наше время появилось много компиляторов, конечным результатом работы которых является JavaScript. Некоторые, такие как Type Script, изначально задумывались как лучший способ писать для веба. В то же время, Emscripten — это способ взять существующий код на C или C++, и скомпилировать его в вид, понятный браузеру. На этой странице собрано немало портов известных программ: здесь, например, можно посмотреть на PyPy — кстати, как утверждается, у них уже есть JIT. На самом деле, не любую программу можно просто скомпилировать и запустить в браузере — есть ряд особенностей, с которыми приходится мириться, впрочем, как гласит надпись на этой же странице "Emscripten can be used to compile almost any portable C/C++ code to JavaScript". То есть существует ряд операций, которые являются неопределённым поведением по стандарту, но обычно работают на x86 — к примеру, невыровненный доступ к переменным, который на некоторых архитектурах вообще запрещён. В общем, Qemu — программа кроссплатформенная и, хотелось верить, и так не содержит большого количества неопредлённого поведения — бери и компилируй, потом немного повозиться с JIT — и говото! Но не тут-то было... Вообще говоря, я не первый, кому пришла в голову идея портировать Qemu на JavaScript. На форуме ReactOS задавался вопрос, возможно ли это с помощью Emscripten. Ещё раньше ходили слухи, что это сделал лично Фабрис Беллар, но речь шла о jslinux, который, насколько мне известно, является как раз попыткой вручную добиться на JS достаточной производительности, и написан с нуля. Позднее был написан Virtual x86 — к нему были выложены необфусцированные исходники, и, как утверждалась, большая "реалистичность" эмуляции позволила использовать SeaBIOS в качестве firmware. Кроме того, была как минимум одна попытка портировать Qemu с помощью Emscripten — это пытался сделать socketpair, но разработка, насколько я понял, была заморожена. Итак, казалось бы, вот исходники, вот Emscripten — бери и компилируй. Но есть ещё и библиотеки, от которых Qemu зависит, и библиотеки от которых зависят те библиотеки и т.д., причём одна из них — libffi, от которой зависит glib. В интернете находились слухи, что в большой коллекции портов библиотек под Emscripten есть и она, но верилось как-то с трудом: во-первых, новым компилятором она не собиралась, во-вторых, это слишком низкоуровневая библиотека, чтобы просто так взять, и скомпилироваться в JS. И дело даже не только в ассемблерных вставках — наверное, если извратиться, то для некоторых calling conventions можно и без них сформировать нужные аргументы на стеке и вызвать функцию. Вот только Emscripten — штуковина хитрая: для того, чтобы сгенерированный код выглядел привычно для оптимизатора JS-движка браузера, используются некоторые трюки. В частности, так называемый relooping — кодогенератор по полученному LLVM IR с некими абстрактными инструкциями переходов пытается воссоздать правдоподобные if-ы, циклы и т.д. Ну а аргументы в функции передаются как? Естественно, как аргументы JS-функций, то есть по возможности не через стек. В начале была мысль просто написать замену libffi на JS и прогнать штатные тесты, но в конце концов я запутался в том, как сделать свои заголовочные файлы, чтобы они работали с существующим кодом — что уж поделать, как говорится, "То ли задачи такие сложные, то ли мы такие тупые". Пришлось портировать libffi на ещё одну архитектуру, если можно так выразиться — к счастью, в Emscripten есть как макросы для inline assembly (на джаваскрипте, ага — ну, какая архитектура, такой и ассемблер), так и возможность запустить сгенерированный на ходу код. В общем, повозившись некоторое время с платформенно-зависимыми фрагментами libffi, я получил некий компилирующийся код, и прогнал его на первом попавшемся тесте. К моему удивлению, тест прошёл успешно. Офигев от своей гениальности — шутка ли, заработало с первого запуска — я, всё ещё не веря своим глазам, полез ещё раз посмотреть на получившийся код, оценить, куда копать дальше. Тут я офигел вторично — единственное, что делала моя функция ffi_call — это рапортовала об успешном вызове. Самого вызова не было. Так я отправил свой первый pull request, исправлявший понятную любому олимпиаднику ошибку в тесте — вещественные числа не следует сравнивать как a == b и даже как a - b < EPS — надо ещё модуль не забыть, а то 0 окажется очень даже равен 1/3… В общем, у меня получился некий порт libffi, который проходит самые простые тесты, и с которым компилируется glib — решил, надо будет, потом допишу. Забегая вперёд скажу, что, как оказалось, в финальный код функции libffi компилятор даже не включил. Но, как я уже говорил, есть некоторые ограничения, и среди вольного использования разнообразного неопределённого поведения затесалась особенность понеприятнее — JavaScript by design не поддерживает многопоточность с общей памятью. В принципе, это обычно можно даже назвать неплохой идеей, но не для портирования кода, чья архитектура завязана на сишные потоки. Вообще говоря, в Firefox идут эксперименты по поддержке shared workers, и реализация pthread для них в Emscripten присутствует, но зависеть от этого не хотелось. Пришлось потихоньку выкорчёвывать многопоточность из кода Qemu — то есть выискивать, где запускаются потоки, выносить тело цикла, выполняющегося в этом потоке в отдельную функцию, и поочерёдно вызывать такие функции из основного цикла. В какой-то момент стало понятно, что воз и ныне там, и что бессистемное распихивание костылей по коду до добра не доведёт. Вывод: надо как-то систематизировать процесс добавления костылей. Поэтому была взята свежая на тот момент версия 2.4.1 (не 2.5.0, потому что, мало ли, там окажутся ещё не отловленные баги новой версии, а мне и своих багов хватит), и первым делом был безопасным образом переписан thread-posix.c. Ну то есть, как безопасным: если кто-то пытался выполнить операцию, приводящую к блокировке, тут же вызывалась функция abort() — конечно, это не решало сразу всех проблем, но как минимум, это как-то приятнее, чем тихо получать неконсистентность данных. Вообще, в портировании кода на JS очень помогают опции Emscripten -s ASSERTIONS=1 -s SAFE_HEAP=1 — они отлавливают некоторые виды undefined behavior вроде обращений по не выровненному адресу (что совсем не согласуется с кодом для typed arrays вроде HEAP32[addr >> 2] = 1) или вызов функции с неправильным количеством аргументов. Кстати, ошибки выравнивания — отдельная тема. Как я уже говорил, в Qemu есть "вырожденный" интерпретирующий бэкенд кодогенерации TCI (tiny code interpreter), и чтобы собрать и запустить Qemu на новой архитектуре, если повезёт, достаточно компилятора C. Ключевые слова "если повезёт". Мне вот не повезло, и оказалось, что TCI при разборе своего байт-кода использует не выровненный доступ. То есть на всяких там ARM и прочих архитектурах с обязательно выровненным доступом Qemu компилируется потому, что для них есть нормальный TCG-бэкенд, генерирующий нативный код, а заработает ли на них TCI — это ещё вопрос. Впрочем, как оказалось, в документации на TCI что-то подобное явно указывалось. В итоге в код были добавлены вызовы функций для не выровненного чтения, которые обнаружились в другой части Qemu.  В итоге, не выровненный доступ в TCI был исправлен, сделан главный цикл, по очереди вызывавший процессор, RCU и что-то по мелочи. И вот я запускаю Qemu с опцией -d exec,in_asm,out_asm, означающей, что нужно говорить, какие блоки кода выполняются, а также в момент трансляции писать, какой гостевой код был, какой хостовый код стал (в данном случае, байткод). Оно запускается, выполняет несколько блоков трансляции, пишет оставленное мной отладочное сообщение, что сейчас запустится RCU и… падает по abort() внутри функции free(). Путём ковыряния функции free() удалось выяснить, что в заголовке блока кучи, который лежит в восьми байтах, предшествующих выделенной памяти, вместо размера блока или чего-то подобного оказался мусор. Разрушение кучи — как мило… В подобном случае есть полезное средство — из (по возможности) тех же исходников собрать нативный бинарник и прогнать под Valgrind. Через некоторое время бинарник был готов. Запускаю с теми же опциями — падает ещё на инициализации, не дойдя до, собственно, выполнения. Неприятно, конечно — видать, исходники были не совсем те же, что не удивительно, ведь configure разведал несколько другие опции, но у меня же есть Valgrind — сначала эту багу починю, а потом, если повезёт, и исходная проявится. Запускаю всё то же самое под Valgrind… Ы-ы-ы, у-у-у, э-э-э, оно запустилось, нормально прошло инициализацию и пошло дальше мимо исходного бага без единого предупреждения о неправильном доступе к памяти, не говоря уж о падениях. К такому жизнь меня, как говорится, не готовила — падающая программа перестаёт падать при запуске под валгриндом. Что это было — загадка. Моя гипотеза, что раз в окрестностях текущей инструкции после падения при инициализации gdb показывал работу memset-а с валидным указателем с использованием то ли mmx, то ли xmm регистров, то, возможно, это была какая-то ошибка выравнивания, хотя всё равно верится слабо. О-кей, Valgrind здесь, похоже, не помощник. И вот тут началось самое противное — всё, вроде, даже запускается, но падает по абсолютно неведомым причинам из-за события, которое могло произойти миллионы инструкций назад. Долгое время даже подступиться было непонятно как. В конце концов пришлось всё-таки сесть и отлаживать. Печать того, чем был переписан заголовок, показала, что это похоже не на число, а, скорее, на какие-то бинарные данные. И, о чудо, эта бинарная строка нашлась в файле с биосом — то есть теперь можно было с достаточной уверенностью сказать, что это было переполнение буфера, и даже понятно, что в этот буфер записывалось. Ну а дальше как-то так — в Emscripten, к счастью, рандомизации адресного пространства нет, дыр в нём тоже нет, поэтому можно написать где нибудь в середине кода вывод данных по указателю с прошлого запуска, посмотреть на данные, посмотреть на указатель, и, если тот не изменился, получить информацию к размышлению. Правда, на линковку после любого изменения тратится пара минут, но что поделаешь. В результате была найдена конкретная строка, копирующая BIOS из временного буфера в гостевую память — и, действительно, в буфере не оказалось достаточного места. Поиск источника того странного адреса буфера привел в функцию qemu_anon_ram_alloc в файле oslib-posix.c — логика там была такая: иногда может быть полезно выровнять адрес по huge page размером 2 Мб, для этого попросим у mmap сначала чуть больше, а потом лишнее вернём с помощью munmap. А если такое выравнивание не требуется, то укажем вместо 2 Мб результат getpagesize() — mmap же всё равно выдаст выровненный адрес… Так вот в Emscripten mmap просто вызывает malloc, а тот, естественно, по странице не выравнивает. В общем, бага, расстраивавшая меня пару месяцев, исправилась изменением в двух строчках. И вот уже процессор что-то считает, Qemu не падает, но экран не включается, и процессор быстро зацикливается, судя по выводу -d exec,in_asm,out_asm. Появилась гипотеза: не приходят прерывания таймера (ну или вообще все прерывания). И действительно, если от нативной сборки, которая почему-то работала, открутить прерывания, получается похожая картина. Но разгадка оказалась совсем не в этом: сравнение трассировок, выданных с указанной выше опцией, показало, что траектории выполнения расходятся очень рано. Тут надо сказать, что сравнение записанного с помощью запускатора emrun отладочного вывода с выводом нативной сборки — не совсем механический процесс. Не знаю точно, как запущенная в браузере программа соединяется с emrun, но некоторые строчки в выводе оказываются переставленные местами, поэтому различие в диффе — это ещё не повод считать, что траектории разошлись. В общем, стало понятно, что по инструкции ljmpl происходит переход по разным адресам, да и байткод генерируется принципиально разный: в одном есть инструкция вызова сишной функции-хелпера, в другом — нет. После гугления инструкции и изучения кода, который эти инструкции транслирует, стало понятно, что, во-первых, непосредственно до неё в регистр cr0 производилась запись — тоже с помощью хелпера ---, переводящая процессор в защищённый режим, а во-вторых, что js-версия в защищённый режим так и не перешла. А дело в том, что ещё одной особенностью Emscripten является нежелание терпеть код вроде реализации инструкции call в TCI, которая любой указатель на функцию приводит к типу long long f(int arg0, .. int arg9) — функции должны вызываться с правильным количеством аргументов. При нарушении этого правила в зависимости от отладочных настроек программа либо упадёт (что хорошо), либо вызовет вовсе не ту функцию (что печально будет отлаживать). Есть ещё и третий вариант — включить генерацию обёрток, добавляющих / выкидывающих аргументы, но суммарно эти обёртки занимают очень немало места, при том, что фактически мне нужно всего лишь чуть больше сотни обёрток. Одно только это весьма печально, но оказалась более серьёзная проблема: в сгенерированном коде функций-обёрток аргументы конвертировались-конвертировались, только вот функция со сгенерированными аргументами иногда не вызывалась — ну прямо как в моей реализации libffi. То есть некоторые хелперы просто не выполнялись. К счастью, в Qemu есть машиночитаемые списки хелперов в виде заголовочного файла вроде Используются они довольно забавно: сначала самым причудливым образом переопределяются макросы DEF_HELPER_n, а потом инклудится helper.h. Вплоть до того, что макрос раскрывается в инициализатор структуры и запятую, а потом определяется массив, а вместо элементов — #include <helper.h> В результате, наконец подвернулся повод попробовать в работе библиотеку pyparsing, и был написан скрипт генерирующий обёртки ровно те и ровно для тех функций, для которых нужно. И вот, после этого процессор вроде бы заработал. Вроде бы, потому что экран так и не инициализировался, хотя в нативной сборке удалось запустить memtest86+. Тут нужно уточнить, что код блочного ввода-вывода Qemu написан на корутинах. В Emscripten есть своя весьма заковыристая реализация, но её ещё нужно было поддержать в коде Qemu, а отлаживать процессор можно уже сейчас: Qemu поддерживает опции -kernel, -initrd, -append, с помощью которых можно загрузить Linux или, например, memtest86+, вообще не используя блочные устройства. Но вот незадача: в нативной сборке можно было наблюдать вывод Linux kernel на консоль с опцией -nographic, а из браузера никакого вывода в терминал, откуда был запущен emrun, не приходило. То есть непонятно: процессор не работает или вывод графики. А потом мне пришло в голову немного подождать. Оказалось, что "процессор не спит, а просто медленно моргает", и минут через пять ядро выкинуло на консоль пачку сообщений и пошло виснуть дальше. Стало понятно, что процессор, в целом, работает, и копать нужно в коде работы с SDL2. Пользоваться этой библиотекой я, к сожалению, не умею, поэтому местами пришлось действовать наугад. В какой-то момент на экране промелькнула строчка parallel0 на синем фоне, что наводило на некие мысли. В итоге оказалось, что дело было в том, что Qemu открывает несколько виртуальных окон в одном физическом окне, между которыми можно переключаться по Ctrl-Alt-n: в нативной сборке оно работает, в Emscripten — нет. После избавления от лишних окон с помощью опций -monitor none -parallel none -serial none и указания принудительно перерисовывать весь экран на каждом кадре всё внезапно заработало. Итак, эмуляция в браузере работает, но ничего интересного однодискетного в ней не запустить, потому что нет блочного ввода-вывода — нужно реализовывать поддержку корутин. В Qemu уже есть несколько coroutine backend-ов, но в силу особенностей JavaScript и кодогенератора Emscripten нельзя просто взять и начать жонглировать стеками. Казалось бы, "усё пропало, гипс снимают", но разработчики Emscripten уже обо всём позаботились. Реализовано это довольно забавно: а давайте назовём подозрительным вызов функции вроде emscripten_sleep и нескольких других, использующих механизм Asyncify, а также вызовы по указателю и вызовы любой функции, где ниже по стеку может произойти один из предыдущих двух случаев. А теперь перед каждым подозрительным вызовом выделим async context, а сразу после вызова — проверим, не произошёл ли асинхронный вызов, и если произошёл, то сохраним все локальные переменные в этот async context, укажем, на какую функцию передавать управление, когда нужно будет продолжить выполнение, и выйдем из текущей функции. Вот уж где простор для изучения эффекта растаращивания — для нужд продолжения выполнения кода после возврата из асинхронного вызова компилятор генерирует "обрубки" функции, начинающиеся после подозрительного вызова — вот так: если есть n подозрительных вызовов, то функция будет растаращена где-то в n/2 раз — это ещё, если не учитывать, что в исходную функцию нужно после каждого потенциально асинхронного вызова добавить сохранение части локальных переменных. Впоследствии даже пришлось писать нехитрый скрипт на Питоне, который по заданному множеству особо растаращенных функций, которые, предположительно, "не пропускают асинхронность сквозь себя" (то есть в них не срабатывает раскрутка стека и всё то, что я только что описал), указывает, вызовы через указатели в каких функциях нужно игнорировать компилятору, чтобы данные функции не рассматривались как асинхронные. А то JS-файлы под 60 Мб — это уже явно перебор — пусть уж хотя бы 30. Хотя, как-то раз я настраивал сборочный скрипт, и случайно выкинул опции линковщика, среди которых была и -O3. Запускаю сгенерированный код, и Chromium выжирает память и падает. Я потом случайно посмотрел на то, что он пытался загрузить… Ну, что я могу сказать, я бы тоже завис, если бы меня попросили вдумчиво изучить и оптимизировать джаваскрипт на 500+ Мб. К сожалению, проверки в коде библиотеки поддержки Asyncify не совсем дружили с longjmp-ами, которые используются в коде виртуального процессора, но после небольшого патча, отключающего эти проверки и принудительно восстанавливающего контексты так, как будто всё хорошо, код заработал. И тут началось странное: иногда срабатывали проверки в коде синхронизации — те самые, которые аварийно завершают код, если по логике выполнения он должен заблокироваться — кто-то пытался захватить уже захваченный мьютекс. К счастью, это оказалась не логическая проблема в сериализованном коде — просто я использовал штатную функциональность main loop, предоставляемую Emscripten, но иногда асинхронный вызов полностью разворачивал стек, а в этот момент срабатывал setTimeout от main loop — таким образом, код заходил в итерацию главного цикла, не выйдя из предыдущей итерации. Переписал на бесконечном цикле и emscripten_sleep, и проблемы с мьютексами прекратились. Код даже логичнее стал — ведь, по сути, у меня нет некоего кода, который подготавливает очередной кадр анимации — просто процессор что-то считает и экран периодически обновляется. Впрочем, проблемы на этом не прекратились: иногда выполнение Qemu просто тихо завершалось без каких бы то ни было исключений и ошибок. В тот момент я на это забил, но, забегая вперёд скажу, что проблема была вот в чём: код корутин, на самом деле, вообще не использует setTimeout (ну или, по крайней мере, не так часто, как можно подумать): функция emscripten_yield просто выставляет флажок асинхронного вызова. Вся соль в том, что emscripten_coroutine_next не является асинхронной функцией: внутри себя она проверяет флажок, сбрасывает его и передаёт управление куда надо. То есть на ней раскрутка стека заканчивается. Проблема была в том, что из-за use-after-free, который проявлялся при отключённом пуле корутин из-за того, что я не докопировал важную строчку кода из существующего coroutine backend, функция qemu_in_coroutine возвращала true, когда на самом деле должна была вернуть false. Это приводило к вызову emscripten_yield, выше которого по стеку не было emscripten_coroutine_next, стек разворачивался до самого верха, но никаких setTimeout, как я уже говорил, не выставлялось. А вот, собственно, и обещанное "проворачивание фарша назад". На самом деле нет. Конечно, если запустить в браузере Qemu, а в нём — Node.js, то, естественно, после кодогенерации в Qemu мы получим совершенно не тот JavaScript. Но всё-таки, какое-никакое, а обратное преобразование. Для начала немного о том, как работает Qemu. Сразу прошу меня простить: я не являюсь профессиональным разработчиком Qemu и мои выводы могут быть местами ошибочны. Как говорится, "мнение студента не обязано совпадать с мнением преподавателя, аксиоматикой Пеано и здравым смыслом". У Qemu есть некое количество поддерживаемых гостевых архитектур и для каждой есть каталог вроде target-i386. При сборке можно указать поддержку нескольких гостевых архитектур, но в результате получится просто несколько бинарников. Код для поддержки гостевой архитектуры, в свою очередь, генерирует некие внутренние операции Qemu, которые TCG (Tiny Code Generator) уже превращает в машинный код хостовой архитектуры. Как утверждается в readme-файле, лежащем в каталоге tcg, изначально это была часть обычного компилятора C, которую потом приспособили под JIT. Поэтому, например, target architecture в терминах этого документа — это уже не гостевая, а хостовая архитектура. В какой-то момент появился ещё один компонент — Tiny Code Interpreter (TCI), который должен выполнять код (практически те же внутренние операции) в отсутствии кодогенератора под конкретную хостовую архитектуру. На самом деле, как говорится в его документации, этот интерпретатор может не всегда работать так же хорошо, как JIT-кодогенератор не только количественно в плане скорости, но и качественно. Хотя не уверен, что его описание полностью актуально. Поначалу я пытался сделать полноценный TCG backend, но быстро запутался в исходниках и не вполне понятном описании инструкций байткода, поэтому решил обернуть интерпретатор TCI. Это дало сразу несколько преимуществ: На счёт третьего пункта не уверен, что патчинг возможен после того, как код будет в первый раз исполнен, но и первых двух пунктов достаточно. Изначально код генерировался в виде большого switch по адресу исходной инструкции байткода, но потом, вспомнив статью про Emscripten, оптимизацию генерируемого JS и relooping, решил генерировать более человеческий код, тем более, что эмпирически получалось, что единственная точка входа в блок трансляции — это его начало. Сказано — сделано, через некоторое время получился кодогенератор, генерирующий код с if-ами (хотя и без циклов). Но вот незадача, он падал, выдавая сообщение о том, что инструкция оказалась какой-то неправильной длины. При этом последняя инструкция на этом уровне рекурсии была brcond. Хорошо, добавлю идентичную проверку в генерацию этой инстукции до рекурсивного вызова и после и… не одна из них не выполнилась, но после свича по assert-у всё же упали. В конце концов, изучив сгенерированный код, я понял, что после switch-а указатель на текущую инструкцию перезагружается со стека и, вероятно, перетирается генерируемым JavaScript-кодом. Так оно и оказалось. Увеличение буфера с одного мегабайта до десяти ни к чему не привело, и стало понятно, что кодогенератор бегает по кругу. Пришлось проверять, что мы не вышли за границы текущего TB, и если вышли, то выдавать адрес следующего TB со знаком минус, чтобы можно было продолжить выполнение. К тому же это решает проблему "какие сгенерированные функции инвалидировать, если поменялся вот этот кусочек байткода?" — инвалидировать нужно только ту функцию, которая соответствует этому блоку трансляции. Кстати, хотя отлаживал я всё в Chromium (поскольку пользуюсь Firefox и мне проще использовать отдельный браузер для экспериментов), но Firefox мне помог исправить несовместимости со стандартом asm.js, после чего код стал шустрее работать в Хромиуме. Итак, работа всё ещё не завершена, но в тайне доводить до совершенства этот долгострой мне поднадоело. Поэтому решил опубликовать пока то, что есть. Код местами страшноватый, поскольку это эксперимент, и не понятно заранее, что нужно делать. Наверное, потом стоит оформить нормальные атомарные коммиты поверх какой-нибудь более современной версии Qemu. Пока же есть ветка в гите в формате блога: к каждому хоть как-то пройденному "уровню" добавлен развёрнутый комментарий на русском языке. Собственно, эта статья в немалой степени — пересказ вывода git log. Попробовать это всё можно здесь (осторожно, трафик). Что уже сейчас работает: Что можно ещё сделать Программист