Qemu.js com suporte a JIT: ainda dá para reverter a mistura

Há alguns anos, Fabrice Bellard escreveu jslinux — um emulador de PC escrito em JavaScript. Após isso, houve pelo menos Virtual x86. Mas todos eles, pelo que sei, eram interpretadores, enquanto o Qemu, escrito muito antes pelo mesmo Fabrice Bellard, e, provavelmente, qualquer emulador moderno respeitável, utiliza a compilação JIT do código convidado no código do sistema host. Achei que era hora de implementar a tarefa inversa daquela que os navegadores resolvem: a compilação JIT de código de máquina em JavaScript, para a qual fazia mais sentido portá-lo para o Qemu. Pode parecer que, por que o Qemu, existem emuladores mais simples e fáceis de usar — como o VirtualBox, por exemplo — que você apenas instala e funciona. Mas o Qemu tem várias características interessantes

  • código-fonte aberto
  • capacidade de operar sem driver de núcleo
  • capacidade de funcionar em modo de intérprete
  • suporte para uma ampla gama de arquiteturas, tanto host quanto convidadas

Quanto ao terceiro ponto, agora posso explicar que, na verdade, em modo TCI não são as instruções de máquina do convidado que são interpretadas, mas o bytecode gerado a partir delas, mas isso não altera a essência — para compilar e executar o Qemu em uma nova arquitetura, se tudo der certo, é suficiente um compilador C — a escrita de um gerador de código pode ser postergada.

E assim, após dois anos de trabalho tranquilo no código-fonte do Qemu, surgiu um protótipo funcional, no qual já é possível iniciar, por exemplo, o Kolibri OS.

O que é Emscripten

Hoje em dia, surgiram muitos compiladores, cujo resultado final é JavaScript. Alguns, como o TypeScript, foram inicialmente concebidos como a melhor maneira de programar para a web. Ao mesmo tempo, o Emscripten é um método para pegar código existente em C ou C++ e compilá-lo em um formato compreensível pelo navegador. No nesta página foram feitos muitos ports de programas famosos: aqui, por exemplo, pode-se olhar para o PyPy — a propósito, como afirmam, já possuem JIT. Na verdade, nem todo programa pode ser simplesmente compilado e executado no navegador — há uma série de particularidades, com as quais é necessário lidar, no entanto, como diz a inscrição nesta mesma página "O Emscripten pode ser usado para compilar quase qualquer portable Código C/C++ para JavaScript. Ou seja, existem várias operações que são considerados comportamentos indefinidos conforme o padrão, mas normalmente funcionam em x86 — por exemplo, o acesso não alinhado a variáveis, que em algumas arquiteturas é totalmente proibido. Em geral, o Qemu é um programa multiplataforma e, espera-se, também não contém uma grande quantidade de comportamentos indefinidos — basta compilar e depois brincar um pouco com o JIT — e está pronto! Mas não foi bem assim...

Primeira tentativa

De modo geral, não sou o primeiro a ter a ideia de portar o Qemu para JavaScript. No fórum do ReactOS, foi levantada a questão de ser possível fazer isso com o Emscripten. Antes, havia rumores de que isso foi feito pessoalmente por Fabrice Bellard, mas falava-se do jslinux, que, pelo que sei, é uma tentativa de obter um desempenho satisfatório em JS de forma manual, e foi escrito do zero. Mais tarde, o Virtual x86 foi criado — com seus códigos-fonte não ofuscados disponíveis, e, conforme afirmado, a maior "realidade" da emulação permitiu usar o SeaBIOS como firmware. Além disso, houve pelo menos uma tentativa de portar o Qemu usando o Emscripten — isso foi tentado fazer socketpair, mas o desenvolvimento, pelo que entendi, foi congelado.

Assim, aparentemente, aqui estão os códigos-fonte, aqui está o Emscripten — basta compilar. Mas também existem bibliotecas das quais o Qemu depende, e bibliotecas das quais essas bibliotecas dependem, etc., e uma delas é libffi, do qual depende o glib. Na internet, havia rumores de que na grande coleção de portas da biblioteca para Emscripten, havia uma, mas era meio difícil de acreditar: em primeiro lugar, ela não compilava com o novo compilador; em segundo, é uma biblioteca muito de baixo nível para simplesmente ser compilada em JS. E a questão não está apenas nas inserções de assembly — provavelmente, se forçar, é possível formular os argumentos necessários na pilha e chamar a função para algumas convenções de chamada sem elas. O problema é que Emscripten é uma coisa complicada: para que o código gerado parecesse familiar para o otimizador do motor JS do navegador, são usados alguns truques. Em particular, o chamado relooping — o gerador de código, a partir do LLVM IR recebido com algumas instruções abstratas de salto, tenta recriar ifs e loops plausíveis, etc. E como os argumentos na função são passados? Naturalmente, como argumentos de funções JS, ou seja, sempre que possível, não através da pilha.

No começo, pensei em simplesmente escrever uma substituição do libffi em JS e executar os testes padrão, mas acabei me confundindo sobre como criar meus arquivos de cabeçalho para que funcionassem com o código existente — como diz o ditado, "Seja lá se as tarefas são tão difíceis ou se somos nós que somos tão estúpidos". Tive que portar o libffi para mais uma arquitetura, se é que pode ser assim expresso — felizmente, o Emscripten possui macros para assembly inline (em JavaScript, sim — bem, qual arquitetura, tal assembly), e também a possibilidade de executar código gerado em tempo real. Em suma, depois de brincar um pouco com os fragmentos dependentes da plataforma do libffi, consegui um código que compilava e o executei no primeiro teste que encontrei. Para minha surpresa, o teste foi bem-sucedido. Estupefato com minha genialidade — não é brincadeira, funcionou na primeira execução — eu, ainda não acreditando nos meus olhos, fui dar uma olhada no código gerado, para avaliar para onde eu poderia cavar a seguir. Aqui fiquei novamente chocado — a única coisa que minha função ffi_call — foi relatar a chamada bem-sucedida. A própria chamada não ocorreu. Assim, enviei meu primeiro pull request, corrigindo um erro que qualquer olimpíade entenderia no teste — números reais não devem ser comparados como a == b nem mesmo como a - b < EPS — ainda preciso me lembrar de não esquecer o módulo, senão 0 vai acabar se tornando 1/3... No geral, fiz uma espécie de port do libffi, que passa nos testes mais simples, e com o qual o glib compila — decidi que, se necessário, vou continuar a escrever mais tarde. Antecipando um pouco, devo dizer que, como se revelou, o compilador não incluiu o código final da função libffi.

Mas, como já mencionei, existem algumas limitações, e entre o uso livre de diversos comportamentos indefinidos, surgiu uma característica não muito agradável — o JavaScript, por design, não suporta múltiplos threads com memória compartilhada. Na verdade, isso pode até ser considerado uma boa ideia, mas não para a porta de um código cuja arquitetura é baseada em threads do C. De modo geral, no Firefox, estão sendo feitos experimentos com o suporte a shared workers, e a implementação de pthread para eles está presente no Emscripten, mas não queria depender disso. Tive que ir lentamente eliminando a multithreading do código do Qemu — ou seja, procurando onde as threads são iniciadas, movendo o corpo do loop que executa nessa thread para uma função separada e chamando essas funções sequencialmente a partir do loop principal.

Segunda tentativa

Em algum momento, ficou claro que a situação continuava a mesma, e que a distribuição aleatória de gambiarras pelo código não levaria a nada bom. Conclusão: é necessário de alguma forma sistematizar o processo de adição de gambiarras. Por isso, peguei a versão 2.4.1 que estava atual na época (não 2.5.0, porque, quem sabe, pode haver bugs não corrigidos na nova versão, e eu já tenho bug suficiente), e a primeira coisa foi reescrever de maneira segura thread-posix.c. Ou seja, de forma segura: se alguém tentasse executar uma operação que levaria a um bloqueio, a função abort() — claro, isso não resolvia imediatamente todos os problemas, mas pelo menos era mais agradável do que receber inconsistências nos dados silenciosamente.

De modo geral, ao portar código para JS, as opções do Emscripten ajudam muito -s ASSERTIONS=1 -s SAFE_HEAP=1 — elas capturam alguns tipos de comportamento indefinido como acessos a endereços desalinhados (o que não é compatível com o código para typed arrays como HEAP32[addr >> 2] = 1) ou chamadas de funções com um número errado de argumentos.

Aliás, erros de alinhamento são um tópico à parte. Como já mencionei, no Qemu existe um backend de interpretação "degenerado" de geração de código TCI (tiny code interpreter), e para compilar e executar o Qemu em uma nova arquitetura, se tudo der certo, basta um compilador C. Palavras-chave "se tudo der certo". Eu, infelizmente, não tive essa sorte, e descobri que o TCI, ao interpretar seu bytecode, utiliza acesso não alinhado. Ou seja, em várias arquiteturas como ARM e outras onde o acesso alinhado é obrigatório, o Qemu é compilado porque existe um backend TCG normal que gera código nativo, mas se o TCI vai funcionar nessas arquiteturas é uma questão em aberto. No entanto, como se revelou, a documentação do TCI mencionava algo parecido de forma explícita. No final, foram adicionadas chamadas de função para leitura não alinhada que foram encontradas em outra parte do Qemu.

Destruição de heap

No final, o acesso não alinhado no TCI foi corrigido, foi feito um loop principal que chamava, por sua vez, o processador, RCU e algumas outras pequenas coisas. E aqui estou eu, executando o Qemu com a opção -d exec,in_asm,out_asm, que significa que precisamos informar quais blocos de código estão sendo executados e, ao mesmo tempo, durante a tradução, anotar qual era o código do convidado e qual se tornou o código do host (neste caso, bytecode). Ele inicia, executa vários blocos de tradução, registra a mensagem de depuração que deixei, indicando que agora o RCU será iniciado e… falha ao acessar abort() dentro da função free(). Por meio de uma investigação na função free() consegui descobrir que no cabeçalho do bloco de heap, que está nos oito bytes precedentes à memória alocada, ao invés do tamanho do bloco ou algo parecido, havia lixo.

A destruição de uma pilha - que fofo... Em tal caso, há uma ferramenta útil - a partir (se possível) dos mesmos códigos-fonte, compilar um binário nativo e executá-lo com o Valgrind. Após algum tempo, o binário estava pronto. Executei com as mesmas opções - caiu ainda na inicialização, sem chegar, propriamente dito, à execução. Desagradável, claro - parece que os códigos-fonte não eram exatamente os mesmos, o que não é surpreendente, já que o configure detectou algumas opções diferentes, mas eu tenho o Valgrind - primeiro vou corrigir esse bug, e depois, se tiver sorte, o original aparecerá. Executei tudo sob o Valgrind... Uhhh, uhhh, ehhh, ele foi executado, passou normalmente pela inicialização e seguiu adiante, ignorando o bug original sem um único aviso de acesso incorreto à memória, sem falar em quedas. A vida me preparou para algo assim, como se costuma dizer - um programa que falha deixa de falhar ao ser executado sob o Valgrind. O que foi isso - um mistério. Minha hipótese é que, se na vizinhança da instrução atual, após a falha na inicialização, o gdb mostrava atividade memset-a com um ponteiro válido usando ou mmx, ou xmm registros, então, talvez, tenha sido algum erro de alinhamento, embora ainda assim seja difícil de acreditar.

Ok, o Valgrind aqui parece não ser uma ajuda. E aqui começou a parte mais desagradável - tudo parece até estar iniciando, mas falha por razões absolutamente desconhecidas devido a um evento que poderia ter ocorrido milhões de instruções atrás. Por muito tempo, não era nem claro como abordar isso. No final das contas, tive que sentar e fazer a depuração. A impressão do que foi reescrito no cabeçalho mostrou que isso não parecia um número, mas, em vez disso, parecia ser algum dado binário. E, oh maravilha, essa string binária foi encontrada em um arquivo de BIOS - ou seja, agora era possível afirmar com segurança que se tratava de um estouro de buffer, e ainda era claro o que estava sendo gravado nesse buffer. E, a partir daí, como em Emscripten, felizmente, não há aleatorização do espaço de endereçamento, também não há buracos nele, então você pode escrever em algum lugar no meio do código a saída de dados através do ponteiro da execução anterior, verificar os dados, olhar para o ponteiro e, se não tiver mudado, obter informações para reflexão. Claro, o link após qualquer alteração leva alguns minutos, mas o que se pode fazer? Como resultado, uma linha específica foi encontrada, copiando a BIOS do buffer temporário para a memória convidada - e, realmente, não havia espaço suficiente no buffer. A busca pela origem daquele estranho endereço de buffer levou à função qemu_anon_ram_alloc no arquivo oslib-posix.c — a lógica era a seguinte: às vezes pode ser útil alinhar o endereço com uma página grande de 2 MB, para isso pediremos a mmap um pouco mais no início, e depois devolveremos o excesso com a ajuda de munmap. E se esse alinhamento não for necessário, então indicamos, em vez de 2 MB, o resultado getpagesize() — mmap que ainda assim irá fornecer o endereço alinhado... Então, em Emscripten mmap basta chamar malloc, e este, naturalmente, não faz o alinhamento por página. Em geral, o bug que me incomodou por alguns meses foi corrigido com uma alteração nas dois linhas.

Particularidades da chamada de funções

E agora o processador está calculando algo, o Qemu não falha, mas a tela não liga, e o processador rapidamente entra em um loop infinito, a julgar pela saída. -d exec,in_asm,out_asm. Surgiu a hipótese: as interrupções do timer não estão chegando (ou, na verdade, nenhuma interrupção está chegando). E de fato, se desconectarmos as interrupções da compilação nativa, que aparentemente funcionava, obtemos uma situação semelhante. Mas a solução não estava exatamente nisso: a comparação das rastreadas, emitidas com a opção mencionada acima, mostrou que as trajetórias de execução divergem muito cedo. Aqui é preciso dizer que a comparação do que foi gravado usando o lançador emrun com a saída da compilação nativa — não é um processo totalmente mecânico. Não sei exatamente como o programa executado no navegador se conecta a emrun, mas algumas linhas na saída acabam sendo rearranjadas, portanto, a diferença no diff — isso ainda não é um motivo para considerar que as trajetórias divergiram. Em geral, ficou claro que, conforme as instruções ljmpl diferentes endereços estão sendo acessados, e o bytecode gerado é fundamentalmente diferente: em um existe uma instrução para chamar uma função auxiliar em C, enquanto no outro — não. Após pesquisar a instrução e estudar o código que a transfere, tornou-se claro que, em primeiro lugar, era feita uma gravação diretamente nesse registro cr0 , também usando o auxiliar — que trazia o processador para o modo protegido, e em segundo lugar, que a versão em js nunca entrou no modo protegido. O fato é que outra peculiaridade do Emscripten é a relutância em aceitar códigos como a implementação da instrução call em TCI, que qualquer ponteiro para uma função leva ao tipo long long f(int arg0, .. int arg9) — as funções devem ser chamadas com o número correto de argumentos. Ao violar esta regra, dependendo das configurações de depuração, o programa pode falhar (o que é bom) ou chamar a função errada (o que será triste para depurar). Há uma terceira opção — ativar a geração de wrappers que adicionam/removem argumentos, mas no total, esses wrappers ocupam um espaço considerável, sendo que na verdade eu só preciso de pouco mais de uma centena de wrappers. Apenas isso é bastante triste, mas apareceu um problema mais sério: no código gerado das funções-wrapper, os argumentos estavam sendo convertidos, mas a função com os argumentos gerados às vezes não era chamada — bem como na minha implementação do libffi. Ou seja, alguns auxiliares simplesmente não eram executados.

Felizmente, no Qemu existem listas legíveis por máquina de helpers na forma de um arquivo de cabeçalho como

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

Eles são usados de maneira bastante engraçada: primeiro, os macros são redefinidos de forma bastante peculiare DEF_HELPER_n, e então inclui-se helper.h. Até o ponto em que o macro se expande em um inicializador de estrutura e uma vírgula, e então um array é definido, em vez de elementos — #include <helper.h> Como resultado, finalmente surgiu a oportunidade de tentar usar a biblioteca pyparsing, e foi escrito um script que gera wrappers exatamente para as funções que são necessárias.

E assim, depois disso, o processador parece ter funcionado. Parece, porque a tela não inicializou, embora na compilação nativa tenha sido possível rodar o memtest86+. Aqui precisa-se esclarecer que o código de E/S em bloco do Qemu é escrito em corrotinas. No Emscripten, existe uma implementação bastante elaborada, mas ainda era necessário suportá-la no código do Qemu, e o depurador do processador pode ser usado agora: o Qemu suporta as opções -kernel, -initrd, -append, com as quais é possível carregar o Linux ou, por exemplo, o memtest86+, sem usar dispositivos de bloco. Mas, aqui está o problema: na compilação nativa era possível ver a saída do kernel Linux no console com a opção -nographic, mas do navegador não veio nenhuma saída para o terminal de onde foi iniciado emrun, não estava vindo. Ou seja, não está claro: o processador não funciona ou a saída gráfica. Então, eu pensei em esperar um pouco. Aconteceu que "o processador não está dormindo, só está piscando lentamente", e em cerca de cinco minutos o núcleo atirou um monte de mensagens no console e continuou travando. Ficou claro que o processador, em geral, está funcionando, e a investigação deve focar no código que trabalha com SDL2. Infelizmente, eu não sei usar essa biblioteca, por isso, em alguns pontos, tive que agir por tentativa e erro. Em algum momento, apareceu na tela a linha parallel0 em um fundo azul, o que trouxe algumas reflexões. No fim, descobri que o problema era que o Qemu abre várias janelas virtuais em uma única janela física, entre as quais pode-se alternar com Ctrl-Alt-n: na compilação nativa funciona, no Emscripten — não. Após se livrar das janelas extras com as opções -monitor none -parallel none -serial none e forçar a redraw de toda a tela em cada quadro, tudo de repente funcionou.

Corrotinas

Portanto, a emulação no navegador está funcionando, mas não é possível executar nada interessante de um disco único, porque não há entrada/saída em blocos — é necessário implementar suporte para corrotinas. O Qemu já possui alguns backends de corrotina, mas devido às particularidades do JavaScript e do gerador de código Emscripten, não dá para simplesmente pegar e começar a manipular pilhas. Pareceria que "tudo está perdido", mas os desenvolvedores do Emscripten já cuidaram de tudo. Isso foi implementado de maneira bem interessante: vamos chamar de suspeito a chamada de uma função como emscripten_sleep e várias outras que utilizam o mecanismo Asyncify, além de chamadas por ponteiro e qualquer função em que a seguir na pilha possa ocorrer um dos dois casos anteriores. Agora, antes de cada chamada suspeita, vamos alocar um contexto assíncrono, e logo após a chamada vamos verificar se ocorreu uma chamada assíncrona; se ocorreu, salvaremos todas as variáveis locais nesse contexto assíncrono, indicaremos para qual função passar o controle quando for necessário continuar a execução e sairemos da função atual. Aqui é onde há espaço para estudar o efeito de fragmentação — para as necessidades de continuar a execução do código após o retorno de uma chamada assíncrona, o compilador gera "fragmentos" da função que começam após a chamada suspeita — assim: se houver n chamadas suspeitas, a função será fragmentada em aproximadamente n/2 vezes — isso sem contar que na função original é necessário adicionar, após cada chamada potencialmente assíncrona, a salvaguarda de parte das variáveis locais. Mais tarde, até foi necessário escrever um simples script em Python que, dado um conjunto de funções especialmente fragmentadas que, presumivelmente, "não permitem a assíncronia passar por elas" (ou seja, nelas a exploração da pilha e tudo mais que acabei de descrever não ocorre), indica quais chamadas por ponteiro em quais funções devem ser ignoradas pelo compilador, para que essas funções não sejam consideradas assíncronas. Afinal, arquivos JS de quase 60 MB já é um exagero — que pelo menos sejam 30. Embora, certa vez eu tenha configurado um script de build e acidentalmente excluído as opções do linkador, dentre as quais estava -O3. Executei o código gerado, e o Chromium estava consumindo memória e travando. Depois, vi acidentalmente o que ele estava tentando carregar... Bem, o que posso dizer, eu também travaria se me pedissem para analisar e otimizar um JavaScript de mais de 500 MB.

Infelizmente, as verificações no código da biblioteca de suporte Asyncify não eram muito compatíveis com longjmp-s usados no código do processador virtual, mas após um pequeno patch que desativava essas verificações e forçava a recuperação de contextos como se tudo estivesse bem, o código funcionou. E então começou o estranho: às vezes, as verificações no código de sincronização eram ativadas — aquelas que encerram o código de forma abrupta se, logicamente, ele deveria estar bloqueado — alguém estava tentando capturar um mutex já capturado. Felizmente, isso não era um problema lógico no código serializado — eu só estava utilizando a funcionalidade padrão do loop principal fornecida pelo Emscripten, mas às vezes a chamada assíncrona reverteria completamente a pilha, e nesse momento a setTimeout do loop principal era ativada — assim, o código entrava em uma iteração do loop principal sem sair da iteração anterior. Reescrevi em um loop infinito e emscripten_sleep, e os problemas com mutexes cessaram. O código até se tornou mais lógico — afinal, eu não tinha um código específico que preparava um novo quadro de animação — apenas o processador calcula algo e a tela é atualizada periodicamente. No entanto, os problemas não pararam: às vezes, a execução do Qemu simplesmente terminava silenciosamente sem nenhuma exceção ou erro. Naquele momento, ignorei isso, mas, antecipando, posso dizer que o problema estava no seguinte: o código das corrotinas realmente não usa setTimeout (ou, pelo menos, não tão frequentemente quanto se poderia pensar): a função emscripten_yield simplesmente configura uma flag de chamada assíncrona. A questão é que emscripten_coroutine_next não é uma função assíncrona: ela verifica a flag, a redefine e passa o controle para onde for necessário. Ou seja, é o fim da desenrolamento da pilha. O problema era que, devido ao uso após liberação, que se manifestava com o pool de corrotinas desativado porque eu não copie corretamente uma linha importante do código do backend de corrotinas existente, a função qemu_in_coroutine retornava true, quando na verdade deveria retornar false. Isso levava à chamada emscripten_yield, acima do qual não havia mais na pilha emscripten_coroutine_next, a pilha se estendia até o topo, mas nenhuma setTimeout, como já mencionei, não foi definido.

Geração de Código JavaScript

Aqui está, na verdade, a "reversão do moedor de carne" prometida. Na verdade, não. Claro, se você executar o Qemu no navegador, e nele — Node.js, então, após a geração de código no Qemu, obteremos um JavaScript totalmente diferente. Mas, mesmo assim, haverá alguma forma de conversão reversa.

Para começar, um pouco sobre como o Qemu funciona. Peço desculpas desde já: não sou um desenvolvedor profissional de Qemu e minhas conclusões podem ser equivocadas em alguns lugares. Como se costuma dizer, "a opinião de um estudante não precisa coincidir com a opinião do professor, a axiomatização de Peano e o bom senso". O Qemu possui um certo número de arquiteturas de convidados suportadas e para cada uma há um catálogo como target-i386. Durante a construção, é possível especificar suporte para várias arquiteturas de convidados, mas o resultado será apenas vários binários. O código para suportar a arquitetura de convidados, por sua vez, gera algumas operações internas do Qemu, que o TCG (Tiny Code Generator) já transforma em código de máquina da arquitetura do host. Como afirmado no arquivo readme, que está no diretório tcg, isso era originalmente parte de um compilador C comum, que depois foi adaptado para JIT. Portanto, por exemplo, a arquitetura alvo em termos deste documento — já não é arquitetura de convidado, mas sim a arquitetura do host. Em algum momento, surgiu mais um componente — o Tiny Code Interpreter (TCI), que deve executar o código (praticamente as mesmas operações internas) na ausência de um gerador de código para uma arquitetura específica do host. Na verdade, como diz a documentação dele, esse interpretador pode não funcionar sempre tão bem quanto o gerador de código JIT, não apenas quantitativamente em termos de velocidade, mas também qualitativamente. Embora eu não tenha certeza de que sua descrição seja completamente atual.

No início, eu tentei fazer um backend TCG completo, mas rapidamente fiquei confuso com os códigos-fonte e a descrição não muito clara das instruções do bytecode, então decidi envolver o interpretador TCI. Isso trouxe várias vantagens de imediato:

  • na implementação do gerador de código, é possível olhar não para a descrição das instruções, mas para o código do interpretador.
  • é possível gerar funções não para cada bloco de tradução encontrado, mas, por exemplo, apenas após a centésima execução
  • no caso de alteração do código gerado (e isso, aparentemente, é possível, segundo as funções com nomes que contêm a palavra patch), eu precisarei invalidar o código JS gerado, mas pelo menos terei o que reutilizar para regenerá-lo

Quanto ao terceiro ponto, não tenho certeza se o patching é possível após o código ser executado pela primeira vez, mas os dois primeiros pontos já são suficientes.

Inicialmente, o código era gerado na forma de um grande switch pelo endereço da instrução de bytecode, mas depois, lembrando de um artigo sobre Emscripten, otimizando o JS gerado e relooping, decidi gerar um código mais legível, especialmente porque empiricamente percebi que o único ponto de entrada no bloco de tradução é o seu início. Dito e feito, depois de um tempo, o gerador de código estava gerando código com ifs (embora sem loops). Mas, eis o problema, ele falhava, apresentando uma mensagem de que a instrução tinha um comprimento incorreto. Com isso, a última instrução neste nível de recursão era brcond. Certo, vou adicionar uma verificação idêntica na geração dessa instrução antes da chamada recursiva e depois e... nenhuma delas foi executada, mas após o switch no assert, realmente falhamos. No final, após analisar o código gerado, percebi que após o switch o ponteiro na instrução atual é recarregado da pilha e, provavelmente, é sobreescrito pelo código JavaScript gerado. E assim foi. Aumentar o buffer de um megabyte para dez não resolveu nada, e ficou claro que o gerador de código estava rodando em um loop. Foi necessário verificar se não saímos dos limites do TB atual e, se saímos, entregar o endereço do próximo TB com sinal negativo, para que fosse possível continuar a execução. Além disso, isso resolve o problema de "quais funções geradas invalidar, se essa parte do bytecode tiver mudado?" — é necessário invalidar apenas a função que corresponde a esse bloco de tradução. A propósito, embora eu tenha depurado tudo no Chromium (já que uso o Firefox e é mais fácil usar um navegador separado para experimentos), o Firefox me ajudou a corrigir incompatibilidades com o padrão asm.js, após o que o código começou a funcionar mais rápido no Chromium.

Exemplo de código gerado

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

Conclusão

Assim, o trabalho ainda não está concluído, mas estou cansado de aprimorar este projeto interminável em segredo. Portanto, decidi publicar o que já está pronto. O código é, em alguns pontos, assustador, pois é um experimento e não está claro de antemão o que deve ser feito. Provavelmente, vale a pena fazer commits atômicos normais em cima de alguma versão mais moderna do Qemu. Por enquanto, há uma branch no Git em formato de blog: a cada "nível" avançado, foi adicionado um comentário detalhado em russo. Na verdade, este artigo é, em grande parte, uma reinterpretação das conclusões. git log.

Você pode experimentar tudo isso aqui (cuidado com o tráfego).

O que já funciona agora:

  • Processador virtual x86 funcionando
  • Há um protótipo funcional de um gerador de código JIT que converte código de máquina em JavaScript
  • Existe uma base para compilar outras arquiteturas de convidados de 32 bits: você pode admirar a versão do Linux para a arquitetura MIPS que está travando no seu navegador durante o carregamento

O que mais pode ser feito

  • Acelerar a emulação. Mesmo no modo JIT, parece funcionar mais devagar que o Virtual x86 (mas potencialmente há todo o Qemu com uma quantidade maior de hardware e arquiteturas emulado)
  • Criar uma interface adequada - sou, convenhamos, um desenvolvedor web bastante medíocre, então, por enquanto, modifiquei a interface padrão do Emscripten, na medida do possível
  • Tentar executar funções mais complexas do Qemu – como rede, migração de VMs, etc.
  • UPD: Será necessário enviar minhas poucas contribuições e relatórios de bugs para o upstream do Emscripten, como fizeram os portadores anteriores do Qemu e de outros projetos. Agradeço a eles pela possibilidade de utilizar implicitamente sua contribuição no Emscripten em minha tarefa.

Fonte: habr.com

Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS 🔥 Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS | ProHoster