КЕМУ.јс: сада озбиљан и са ВАСМ-ом

Једном давно сам се одлучио за забаву доказати реверзибилност процеса и научите како да генеришете ЈаваСцрипт (тачније, Асм.јс) из машинског кода. За експеримент је изабран КЕМУ, а нешто касније написан је чланак на Хабру. У коментарима су ми саветовали да преправим пројекат у ВебАссембли-у, па чак и да одустанем скоро готово Некако нисам желео пројекат... Рад се одвијао, али веома споро, а сада се недавно у том чланку појавио коментар на тему „Па како се све завршило?“ Као одговор на мој детаљан одговор, чуо сам „Ово звучи као чланак.“ Па, ако можете, биће чланак. Можда ће некоме бити од користи. Из њега ће читалац сазнати неке чињенице о дизајну позадинских делова за генерисање КЕМУ кода, као и о томе како да напише компајлер Јуст-ин-Тиме за веб апликацију.

задаци

Пошто сам већ научио како да „некако” портујем КЕМУ у ЈаваСцрипт, овог пута је одлучено да то урадим мудро и да не понављам старе грешке.

Грешка број један: гранање од тачке ослобађања

Моја прва грешка је била да одвојим своју верзију од претходне верзије 2.4.1. Тада ми се учинило добром идејом: ако постоји тачка ослобађања, онда је вероватно стабилнија од обичне 2.4, а још више од гране master. А пошто сам планирао да додам приличну количину сопствених грешака, уопште ми није требао нико други. Тако је вероватно испало. Али ево ствари: КЕМУ не стоји мирно, а у неком тренутку су чак најавили оптимизацију генерисаног кода за 10 одсто. „Да, сад ћу да се смрзнем“, помислио сам и прекинуо. Овде треба да направимо дигресију: због једнонитне природе КЕМУ.јс и чињенице да оригинални КЕМУ не подразумева одсуство вишенитног рада (тј. могућност истовременог рада са неколико неповезаних путања кода, и не само „користи сва језгра“) је критична за њега, главне функције нити које сам морао да „искључим“ да бих могао да позовем споља. Ово је створило неке природне проблеме током спајања. Међутим, чињеница да су неке од промена из бранше master, са којим сам покушао да спојим свој код, такође су изабрани у тачки издања (а самим тим и у мојој грани) такође вероватно не би имали додатну погодност.

Генерално, одлучио сам да и даље има смисла избацити прототип, раставити га на делове и направити нову верзију од нуле на основу нечег новијег и сада од master.

Грешка број два: ТЛП методологија

У суштини, ово није грешка, уопштено, то је само карактеристика креирања пројекта у условима потпуног неразумевања и „куда и како да се крећемо?“ и уопште „хоћемо ли стићи тамо?“ У овим условима неспретно програмирање била оправдана опција, али, наравно, нисам желео да је понављам без потребе. Овог пута сам желео да то урадим мудро: атомско урезивање, свесне промене кода (а не „низање насумичних знакова док се не компајлира (са упозорењима)“, како је једном рекао Линус Торвалдс о некоме, према Викицитату), итд.

Грешка број три: улазак у воду без познавања брода

Још увек се нисам у потпуности решио овога, али сам сада одлучио да уопште не идем путем мањег отпора, и да то урадим „као одрасла особа“, наиме, напишем свој ТЦГ бацкенд од нуле, да не да касније морам да кажем: „Да, ово је, наравно, полако, али не могу све да контролишем – тако се пише ТЦИ...“ Штавише, ово је у почетку изгледало као очигледно решење, пошто Ја генеришем бинарни код. Како кажу, „Гент се окупиоу, али не и тај”: код је, наравно, бинарни, али контрола се не може једноставно пренети на њега – мора се експлицитно угурати у претраживач ради компилације, што резултира одређеним објектом из ЈС света, који тек треба да се бити сачуван негде. Међутим, на нормалним РИСЦ архитектурама, колико разумем, типична ситуација је потреба да се експлицитно ресетује кеш инструкција за регенерисани код - ако ово није оно што нам треба, онда је, у сваком случају, близу. Поред тога, из мог последњег покушаја, сазнао сам да се контрола не преноси на средину блока за превођење, тако да нам заправо не треба бајт код интерпретиран из било ког офсета, и можемо га једноставно генерисати из функције на ТБ .

Дошли су и шутирали

Иако сам почео да преписујем код још у јулу, магични ударац се појавио непримећено: обично писма са ГитХуб-а стижу као обавештења о одговорима на проблеме и захтеве за повлачење, али овде, изненада поменути у нити Бинариен као кему бацкенд у контексту, „Учинио је тако нешто, можда ће нешто и рећи“. Разговарали смо о коришћењу Емсцриптен повезане библиотеке Бинариен да креирате ВАСМ ЈИТ. Па, рекао сам да тамо имате Апацхе 2.0 лиценцу, а КЕМУ се као целина дистрибуира под ГПЛв2, и нису баш компатибилни. Одједном се показало да лиценца може бити поправи то некако (Не знам: можда промените, можда дупло лиценцирање, можда нешто друго...). То ме је, наравно, обрадовало, јер сам до тада већ добро погледао бинарни формат ВебАссембли, а ја сам био некако тужан и несхватљив. Постојала је и библиотека која би прогутала основне блокове са графом прелаза, произвела бајткод, па чак и покренула у самом интерпретатору, ако је потребно.

Онда је било више писмо на КЕМУ маилинг листи, али ово се више односи на питање „Коме је то уопште потребно?“ И то је изненада, показало се да је неопходно. У најмању руку, можете састругати следеће могућности употребе, ако ради мање или више брзо:

  • покретање нечег едукативног без икакве инсталације
  • виртуелизација на иОС-у, где је, према гласинама, једина апликација која има право да генерише код у ходу ЈС мотор (да ли је то тачно?)
  • демонстрација мини-ОС-а - сингле-флоппи, уграђени, све врсте фирмвера, итд...

Функције времена рада претраживача

Као што сам већ рекао, КЕМУ је везан за мултитхреадинг, али претраживач га нема. Па, то јест, не... У почетку уопште није постојао, а онда се појавио ВебВоркерс - колико сам разумео, ово је вишенитно засновано на преношењу порука без заједничких променљивих. Наравно, ово ствара значајне проблеме приликом преноса постојећег кода заснованог на моделу заједничке меморије. Затим је под притиском јавности спроведена под именом SharedArrayBuffers. Постепено је уводио, славили су његово лансирање у различитим претраживачима, затим су славили Нову годину, па Мелтдовн... Након чега су дошли до закључка да је мерење времена грубо или грубо, али уз помоћ заједничке меморије и нит повећава бројач, све је исто то ће радити прилично тачно. Тако смо онемогућили вишенитност са дељеном меморијом. Чини се да су га касније поново укључили, али, као што је постало јасно из првог експеримента, постоји живот и без тога, а ако јесте, покушаћемо да то урадимо без ослањања на мултитхреадинг.

Друга карактеристика је немогућност манипулација ниског нивоа са стеком: не можете једноставно узети, сачувати тренутни контекст и прећи на нови са новим стеком. Стеком позива управља ЈС виртуелна машина. Чини се, у чему је проблем, пошто смо ипак одлучили да бившим токовима управљамо потпуно ручно? Чињеница је да се блок И/О у КЕМУ имплементира кроз корутине, и ту би манипулације стеком ниског нивоа добро дошле. На срећу, Емсциптен већ садржи механизам за асинхроне операције, чак два: Асинцифи и Емтерпретер. Први функционише кроз значајно надувавање у генерисаном ЈаваСцрипт коду и више није подржан. Други је тренутни "тачан начин" и ради кроз генерисање бајткода за изворни тумач. Ради, наравно, споро, али не надувава код. Истина, подршка за корутине за овај механизам је морала да се допринесе независно (већ су постојале корутине написане за Асинцифи и постојала је имплементација приближно истог АПИ-ја за Емтерпретер, само је требало да их повежете).

Тренутно још нисам успео да поделим код на један компајлиран у ВАСМ-у и интерпретиран помоћу Емтерпретер-а, тако да блок уређаји још не раде (погледајте у следећој серији, како кажу...). То јест, на крају би требало да добијете нешто попут ове смешне слојевите ствари:

  • интерпретирани блок И/О. Па, да ли сте заиста очекивали емулирани НВМе са изворним перформансама? 🙂
  • статички компајлиран главни КЕМУ код (преводилац, други емулисани уређаји, итд.)
  • динамички компајлиран гостујући код у ВАСМ

Карактеристике КЕМУ извора

Као што сте вероватно већ претпоставили, код за емулацију гостујуће архитектуре и код за генерисање инструкција хост машине су раздвојени у КЕМУ. У ствари, то је чак и мало теже:

  • постоје архитектуре гостију
  • ту је акцелератори, наиме, КВМ за виртуелизацију хардвера на Линук-у (за гостујуће и хост системе који су међусобно компатибилни), ТЦГ за генерисање ЈИТ кода било где. Почевши од КЕМУ 2.9, појавила се подршка за стандард виртуелизације хардвера ХАКСМ на Виндовс-у (детаље)
  • ако се користи ТЦГ а не хардверска виртуелизација, онда има посебну подршку за генерисање кода за сваку архитектуру хоста, као и за универзални тумач
  • ... и око свега овога - емулиране периферије, кориснички интерфејс, миграција, репродукција снимања итд.

Успут, да ли сте знали: КЕМУ може емулирати не само цео рачунар, већ и процесор за посебан кориснички процес у језгру домаћина, који користи, на пример, АФЛ фуззер за бинарне инструменте. Можда би неко желео да овај начин рада КЕМУ-а пренесе на ЈС? 😉

Као и већина дуготрајног бесплатног софтвера, КЕМУ се гради путем позива configure и make. Рецимо да сте одлучили да додате нешто: ТЦГ бацкенд, имплементацију нити, нешто друго. Немојте журити да будете срећни/ужаснути (подвуците по потреби) због могућности да комуницирате са Аутоцонф – у ствари, configure КЕМУ је очигледно сам написан и није генерисан ни из чега.

ВебАссембли

Дакле, шта је ово што се зове ВебАссембли (ака ВАСМ)? Ово је замена за Асм.јс, која се више не претвара да је важећи ЈаваСцрипт код. Напротив, он је чисто бинарни и оптимизован, па чак ни једноставно уписивање целог броја у њега није баш једноставно: ради компактности, чува се у формату ЛЕБ128.

Можда сте чули за алгоритам поновног покретања за Асм.јс – ово је обнављање инструкција за контролу тока „високог нивоа“ (тј. иф-тхен-елсе, петље, итд.), за које су дизајнирани ЈС мотори, од ЛЛВМ ИР ниског нивоа, ближе машинском коду који извршава процесор. Наравно, средњи приказ КЕМУ је ближи другом. Рекло би се да је ту, бајткод, крај мукама... А онда су блокови, ако-онда-друго и петље!..

И ово је још један разлог зашто је Бинариен користан: може природно да прихвати блокове високог нивоа близу онога што би било ускладиштено у ВАСМ-у. Али такође може да произведе код из графа основних блокова и прелаза између њих. Па, већ сам рекао да скрива ВебАссембли формат складиштења иза практичног Ц/Ц++ АПИ-ја.

ТЦГ (Мали генератор кодова)

ГТК првобитно је био бацкенд за компајлер Ц. Тада, очигледно, није могао да издржи конкуренцију са ГЦЦ-ом, али је на крају нашао своје место у КЕМУ-у као механизам за генерисање кода за хост платформу. Постоји и ТЦГ бацкенд који генерише неки апстрактни бајт код, који тумач одмах извршава, али сам одлучио да избегнем овај пут да га користим. Међутим, чињеница да је у КЕМУ већ могуће омогућити прелазак на генерисану ТБ кроз функцију tcg_qemu_tb_exec, испоставило се да је веома корисно за мене.

Да бисте додали нову ТЦГ позадину у КЕМУ, потребно је да креирате поддиректоријум tcg/<имя архитектуры> (у овом случају, tcg/binaryen), и садржи две датотеке: tcg-target.h и tcg-target.inc.c и propisati то је све о configure. Тамо можете ставити друге датотеке, али, као што можете да претпоставите из имена ове две, обе ће бити укључене негде: једна као обична датотека заглавља (укључена је у tcg/tcg.h, а тај се већ налази у другим датотекама у директоријумима tcg, accel и не само), други - само као исечак кода у tcg/tcg.c, али има приступ својим статичким функцијама.

Одлучивши да ћу потрошити превише времена на детаљна истраживања о томе како то функционише, једноставно сам копирао „скелете“ ове две датотеке из друге позадинске имплементације, искрено наводећи ово у заглављу лиценце.

фајл tcg-target.h садржи углавном подешавања у форми #define-с:

  • колико регистара и која ширина има на циљној архитектури (имамо колико хоћемо, колико хоћемо - питање је више у томе шта ће претраживач генерисати у ефикаснији код на „потпуно циљаној“ архитектури ...)
  • поравнање инструкција хоста: на к86, па чак и у ТЦИ-у, инструкције уопште нису поравнате, али ћу ставити у бафер кода не инструкције уопште, већ показиваче на структуре библиотеке Бинариен, па ћу рећи: 4 бајтова
  • које опционе инструкције бацкенд може да генерише - укључујемо све што пронађемо у Бинариен-у, нека акцелератор сам разбије остало на једноставније
  • Која је приближна величина ТЛБ кеша коју захтева бацкенд. Чињеница је да је у КЕМУ све озбиљно: иако постоје помоћне функције које обављају учитавање/складиштење узимајући у обзир гостујући ММУ (где бисмо сада били без њега?), они чувају свој преводни кеш у облику структуре, тј. чију обраду је погодно уградити директно у блокове емитовања. Питање је, који офсет у овој структури се најефикасније обрађује малим и брзим низом команди?
  • овде можете подесити сврху једног или два резервисана регистра, омогућити позивање ТБ преко функције и опционо описати неколико малих inline-функционише као flush_icache_range (али ово није наш случај)

фајл tcg-target.inc.c, наравно, обично је много веће величине и садржи неколико обавезних функција:

  • иницијализација, укључујући ограничења о томе које инструкције могу да раде на којим операндима. Ја сам очигледно копирао са другог бекенда
  • функција која узима једну интерну инструкцију бајткода
  • Овде можете ставити и помоћне функције, а можете користити и статичке функције из tcg/tcg.c

За себе сам одабрао следећу стратегију: у првим речима следећег преводног блока записао сам четири показивача: знак почетка (одређена вредност у близини 0xFFFFFFFF, који је одредио тренутно стање ТБ), контекст, генерисани модул и магични број за отклањање грешака. У почетку је ознака постављена 0xFFFFFFFF - nГде n - мали позитиван број, и сваки пут када је извршен преко тумача повећавао се за 1. Када је достигао 0xFFFFFFFE, компилација је обављена, модул је сачуван у функцијској табели, увезен у мали „лаунцхер“, у који је ишло извршење tcg_qemu_tb_exec, а модул је уклоњен из КЕМУ меморије.

Да парафразирамо класике, „Штака, колико је у овом звуку испреплетено за срце прогера...“. Међутим, негде је цурило памћење. Штавише, меморијом је управљао КЕМУ! Имао сам код који је приликом писања следеће инструкције (па, односно показивача) избрисао онај чији је линк раније био на овом месту, али ово није помогло. У ствари, у најједноставнијем случају, КЕМУ додељује меморију при покретању и тамо уписује генерисани код. Када се бафер потроши, код се избацује и на његово место почиње да се пише следећи.

Након што сам проучио код, схватио сам да ми је трик са магичним бројем омогућио да не успем у уништавању гомиле тако што сам ослободио нешто погрешно на неиницијализованом баферу при првом пролазу. Али ко преписује бафер да би касније заобишао моју функцију? Као што саветују Емсцриптен програмери, када сам наишао на проблем, пренео сам резултујући код назад у матичну апликацију, поставио Мозилла Рецорд-Реплаи на њу... Генерално, на крају сам схватио једноставну ствар: за сваки блок, а struct TranslationBlock са својим описом. Погоди где... Тако је, непосредно пре блока тачно у тампону. Схвативши ово, одлучио сам да престанем да користим штаке (барем неке), и једноставно сам избацио магични број, а преостале речи пренео на struct TranslationBlock, креирајући једноструко повезану листу којом се може брзо прећи када се кеш превода ресетује и ослобађа меморију.

Остале су неке штаке: на пример, означени показивачи у баферу кода - неки од њих су једноставно BinaryenExpressionRef, односно гледају изразе које је потребно линеарно ставити у генерисани основни блок, део је услов за прелаз између ББ, део је куда треба ићи. Па, већ постоје припремљени блокови за Релоопер које треба повезати према условима. Да би се разликовали, користи се претпоставка да су сви поравнати за најмање четири бајта, тако да можете безбедно да користите два најмање значајна бита за ознаку, само треба да запамтите да је уклоните ако је потребно. Иначе, такве ознаке се већ користе у КЕМУ да назначе разлог изласка из ТЦГ петље.

Коришћење Бинаријена

Модули у ВебАссембли-у садрже функције, од којих свака садржи тело, које је израз. Изрази су унарне и бинарне операције, блокови који се састоје од листа других израза, контролни ток итд. Као што сам већ рекао, контролни ток овде је организован управо као гране високог нивоа, петље, позиви функција итд. Аргументи функцијама се не прослеђују на стек, већ експлицитно, баш као у ЈС. Постоје и глобалне варијабле, али их нисам користио, па вам нећу причати о њима.

Функције такође имају локалне променљиве, нумерисане од нуле, типа: инт32 / инт64 / флоат / доубле. У овом случају, првих н локалних променљивих су аргументи који се прослеђују функцији. Имајте на уму да иако све овде није на ниском нивоу у смислу тока контроле, цели бројеви још увек не носе атрибут „потписано/непотписано“: како се број понаша зависи од кода операције.

Уопштено говорећи, Бинаријен пружа једноставан Ц-АПИ: креирате модул, у њему креирати изразе - унарне, бинарне, блокове из других израза, контролни ток итд. Затим креирате функцију са изразом као телом. Ако и ви, као ја, имате граф прелаза ниског нивоа, компонента релоопера ће вам помоћи. Колико сам разумео, могуће је користити контролу тока извршавања на високом нивоу у блоку, све док не прелази границе блока - односно могуће је направити интерну брзу / спору путању. гранање путање унутар уграђеног кода за обраду ТЛБ кеш меморије, али да не омета „спољни“ ток контроле. Када ослободите релоопер, његови блокови се ослобађају; када ослободите модул, изрази, функције итд. који су му додељени нестају арена.

Међутим, ако желите да тумачите код у ходу без непотребног креирања и брисања инстанце интерпретатора, можда има смисла ставити ову логику у Ц++ датотеку, а одатле директно управљати целим Ц++ АПИ-јем библиотеке, заобилазећи спремно- правили омоте.

Дакле, да бисте генерисали код који вам је потребан

// настроить глобальные параметры (можно поменять потом)
BinaryenSetAPITracing(0);

BinaryenSetOptimizeLevel(3);
BinaryenSetShrinkLevel(2);

// создать модуль
BinaryenModuleRef MODULE = BinaryenModuleCreate();

// описать типы функций (как создаваемых, так и вызываемых)
helper_type  BinaryenAddFunctionType(MODULE, "helper-func", BinaryenTypeInt32(), int32_helper_args, ARRAY_SIZE(int32_helper_args));
// (int23_helper_args приоб^Wсоздаются отдельно)

// сконструировать супер-мега выражение
// ... ну тут уж вы как-нибудь сами :)

// потом создать функцию
BinaryenAddFunction(MODULE, "tb_fun", tb_func_type, func_locals, FUNC_LOCALS_COUNT, expr);
BinaryenAddFunctionExport(MODULE, "tb_fun", "tb_fun");
...
BinaryenSetMemory(MODULE, (1 << 15) - 1, -1, NULL, NULL, NULL, NULL, NULL, 0, 0);
BinaryenAddMemoryImport(MODULE, NULL, "env", "memory", 0);
BinaryenAddTableImport(MODULE, NULL, "env", "tb_funcs");

// запросить валидацию и оптимизацию при желании
assert (BinaryenModuleValidate(MODULE));
BinaryenModuleOptimize(MODULE);

...ако сам нешто заборавио, извините, ово је само да представим размеру, а детаљи су у документацији.

А сада почиње црацк-фек-пек, отприлике овако:

static char buf[1 << 20];
BinaryenModuleOptimize(MODULE);
BinaryenSetMemory(MODULE, 0, -1, NULL, NULL, NULL, NULL, NULL, 0, 0);
int sz = BinaryenModuleWrite(MODULE, buf, sizeof(buf));
BinaryenModuleDispose(MODULE);
EM_ASM({
  var module = new WebAssembly.Module(new Uint8Array(wasmMemory.buffer, $0, $1));
  var fptr = $2;
  var instance = new WebAssembly.Instance(module, {
      'env': {
          'memory': wasmMemory,
          // ...
      }
  );
  // и вот уже у вас есть instance!
}, buf, sz);

Да би се некако повезали светови КЕМУ и ЈС и истовремено брзо приступили компајлираним функцијама, креиран је низ (табела функција за увоз у покретач) и ту су смештене генерисане функције. За брзо израчунавање индекса, у почетку је коришћен индекс блока превода нула речи, али је онда индекс израчунат помоћу ове формуле почео да се једноставно уклапа у поље у struct TranslationBlock.

Узгред, демо (тренутно са мутном лиценцом) ради добро само у Фирефок-у. Програмери Цхроме-а су били некако није спреман на чињеницу да би неко желео да направи више од хиљаду инстанци ВебАссембли модула, па је једноставно доделио гигабајт виртуелног адресног простора за сваку...

То је све за сада. Можда ће бити још неки чланак ако неко буде заинтересован. Наиме, остаје бар само учинити да блок уређаји раде. Такође би могло имати смисла да се компилација ВебАссембли модула учини асинхроном, као што је уобичајено у ЈС свету, пошто још увек постоји тумач који може све ово да уради док изворни модул није спреман.

Коначно загонетка: саставили сте бинарну датотеку на 32-битној архитектури, али се код, кроз меморијске операције, пење од Бинариен-а, негде на стеку, или негде другде у горња 2 ГБ 32-битног адресног простора. Проблем је што са Бинаријенове тачке гледишта ово приступа превеликој резултантној адреси. Како заобићи ово?

На админ начин

Нисам завршио ово тестирање, али моја прва помисао је била „Шта ако инсталирам 32-битни Линук?“ Тада ће горњи део адресног простора бити заузет језгром. Питање је само колико ће бити заузето: 1 или 2 Гб.

На програмерски начин (опција за практичаре)

Хајде да пустимо балон на врху адресног простора. Ни сам не разумем зашто то функционише - тамо већ мора постојати стог. Али „ми смо практичари: све нам ради, али нико не зна зашто...”

// 2gbubble.c
// Usage: LD_PRELOAD=2gbubble.so <program>

#include <sys/mman.h>
#include <assert.h>

void __attribute__((constructor)) constr(void)
{
  assert(MAP_FAILED != mmap(1u >> 31, (1u >> 31) - (1u >> 20), PROT_NONE, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0));
}

... истина је да није компатибилан са Валгриндом, али, срећом, сам Валгринд веома ефикасно гура све одатле :)

Можда ће неко дати боље објашњење како овај мој кодекс функционише...

Извор: ввв.хабр.цом

Додај коментар