Desenvolvendo uma VM WebAssembly escrita em C
(irreducible.io)- Um desenvolvedor passou 6 meses dedicando noites e fins de semana para criar a VM WebAssembly Semblance, tratando o projeto como uma iniciativa de aprendizado de longo prazo focada em estudar a especificação, em vez de um side project curto
- WebAssembly é um bytecode padrão para executar código não confiável no navegador em velocidade próxima à nativa, e seu uso fora do navegador também vem crescendo, em casos como Fastly, Shopify e Zed
- O objetivo da Semblance era aprender a WebAssembly Core Specification e se preparar para contribuir com runtimes industriais como o Wasmtime, por isso implementar todos os opcodes ou passar em toda a core test suite ficou fora do escopo
- O interpretador funciona verificando os magic bytes e a versão 1 de um arquivo
.wasm, depois segue com decodificação de seções, resolução de imports, instanciação do módulo, busca da função exportada, inicialização da pilha e do activation frame, e execução de um opcode switch loop - Como não é um runtime completo, ele ainda é lento, pode ter vazamentos de memória e continua vulnerável a módulos maliciosos, mas o objetivo de aprender a especificação foi alcançado com a execução de Hello, World!
Por que escolher WebAssembly
- WebAssembly é um conjunto padrão de instruções e um formato de bytecode para uma máquina virtual de pilha
- O ponto de partida foi o compilador de C/C++ para JavaScript do Emscripten
- O Emscripten convertia LLVM IR em JavaScript para permitir a execução de código C e C++ na web
- Desenvolvedores de navegadores e o projeto Emscripten restringiram o código gerado a um subconjunto simples de JavaScript para otimização de desempenho, e esse subconjunto foi padronizado como asm.js
- Depois, para evitar a sobrecarga de parsing do JavaScript, foi projetado o Wasm, um formato de bytecode independente
- Mais recentemente, o uso de WebAssembly fora do navegador também aumentou
- Edge Compute, da Fastly, e Functions, da Shopify, são construídos sobre motores WebAssembly
- O sistema de extensões do Zed também é baseado em Wasm
- A Bytecode Alliance lidera o esforço da indústria para apoiar o uso de WebAssembly fora do navegador, desenvolvendo o runtime Wasmtime e a padronização do WASI
- O WASI permite que módulos WebAssembly se comuniquem com o sistema operacional hospedeiro por meio de host calls semelhantes ao POSIX
- Ele fornece uma forma padrão de interagir com interfaces externas como entrada e saída padrão, sistema de arquivos e rede
De side project para projeto de aprendizado
- Havia um padrão recorrente de começar novos side projects e perder o interesse após algumas semanas, migrando para outra coisa
- Era necessário um projeto maior, que permitisse manter o foco por mais tempo e lidar com camadas mais baixas da pilha de computação do que no trabalho cotidiano
- Como muitos side projects acabavam abandonados por falta de direção, a ideia era encontrar algo com objetivo claro e resultado visível
- Depois de sentir que um perfil de engenharia generalista afetava a busca por um trabalho estável, surgiu a vontade de encontrar uma área de especialização capaz de formar um conjunto de habilidades em T
- A possibilidade de projetar um ambiente computacional sob medida em WebAssembly com syscalls customizadas pareceu atraente como ferramenta de engenharia de plataforma
Objetivos e escopo da Semblance
- O nome do projeto é Semblance, um interpretador WebAssembly escrito em C
- O principal objetivo era se familiarizar com a WebAssembly Core Specification
- No longo prazo, a meta também era acumular conhecimento sobre WebAssembly suficiente para contribuir com runtimes industriais como o Wasmtime
- Como era um projeto com foco em aprendizado, não havia intenção de implementar todos os opcode nem de passar na core test suite
- Conseguir executar “Hello, World!” já era considerado um critério de sucesso suficiente
- O autor reconhece não ter experiência profissional especializada em C, admite que a qualidade do código não é ideal e diz que revisões são bem-vindas
Fluxo de execução do Hello, World!
- O código C de exemplo declara externamente a função
putse imprime"Hello, World!\n"emhello() - O WebAssembly é gerado com Clang
--target=wasm32-nostdlib-Wl,--no-entry-Wl,--export-all-Wl,--allow-undefined-O3
- O
hello.wasmgerado é executado comsemblance hello.wasm --invoke hello - Ao iniciar o programa, são inicializadas na pilha a memória para argumentos de linha de comando, o módulo WebAssembly decodificado e a store que guarda o estado do runtime
- Os argumentos de linha de comando são analisados, e em caso de erro o programa é encerrado
Decodificação binária e estrutura do módulo
- O interpretador primeiro decodifica o formato binário do WebAssembly
- No início do arquivo, ele verifica os magic bytes
"\0asm" - Em seguida, valida se a versão do formato binário é
1 - Depois disso, decodifica as sections incluídas
- Quando a decodificação termina, a struct
WasmModuleé preenchida com as funções, tipos, imports, dados e outros elementos dehello.wasmtypesfuncstablesmemsglobalselemsdatasstartimportsexportscustomsmeta
Resolução de import e env::puts
- A única função hospedeira fornecida atualmente pela Semblance é
env::puts - O import de função hospedeira é tratado alocando um ponteiro de função nativa na store e colocando o
funcaddrgerado no arrayimportsdo módulo hostcall_putsusa como argumento o offset i32 recebido- obtém o ponteiro de dados da primeira instância de memória da store
- imprime na saída padrão a string na posição do offset com
printf("%s", ...)
- Ao percorrer a lista de imports, se encontrar uma entrada cujo module name seja
"env"e cujo item name seja"puts", o sistema registra a função hospedeira comregister_hostcall_puts
Instanciação e execução de opcodes
- Depois da resolução dos imports, o módulo é instanciado
- A instanciação inclui uma etapa de validação
- são executados testes para verificar os tipos e se o módulo tem a forma correta
- memory, global e table são inicializados
- se houver uma função
start, ela é chamada
- O resultado da instanciação é
WasmModuleInst, que contém as estruturas de tempo de execução e os exports prontos para uso - A função exportada é localizada com base no nome especificado no argumento de linha de comando
--invoke- o sistema percorre os exports e retorna o
WasmExportInstcujo nome corresponde - também verifica se o export retornado é uma function
- o sistema percorre os exports e retorna o
- A chamada de função exige inicialização da pilha e do activation frame
- O núcleo da execução é o opcode switch loop
- ele lê em sequência as instruções Wasm do corpo da função
- executa operações sobre a pilha e a store conforme o opcode
- entre os exemplos tratados estão
i32.const,i64.const,f32.const,f64.const,i32.ge_s,call,nop,unreachableeend - opcodes não tratados imprimem
"unhandled opcode [...]"e retornam trap
Instrução call e chamada de função hospedeira
- O tratamento de
WasmOpCallusa ofuncidx, valor imediato do opcodecall, para localizar a instância de função na store - Com base nas informações de tipo da função, ele faz pop dos argumentos necessários da pilha
- Depois verifica o tipo da instância de função para distinguir entre função Wasm e função hospedeira nativa
- Se for uma função hospedeira, executa por native call o ponteiro de função
hostfuncarmazenado - Se houver valor de retorno, ele é recolocado na pilha com push
- Ao chegar ao opcode
endda funçãohello, o opcode switch loop termina e o fluxo volta paramain
Resultado e limitações restantes
- O resultado da execução aparece na saída padrão da seguinte forma
Hello, World!Ok []
- O projeto consegue executar um programa simples de “Hello, World!”
- A cobertura de opcodes não é completa
- O código é desorganizado, lento e tem vazamentos de memória
- Ele pode ser vulnerável a módulos maliciosos
- Ainda assim, houve muito aprendizado sobre a WebAssembly Core Specification, e isso ajudou o autor a sair da zona técnica mais familiar
- Depois de construir o próprio interpretador, ele sente que adquiriu conhecimento sobre WebAssembly suficiente para contribuir com runtimes industriais como o Wasmtime
- Ainda pode continuar adicionando alguns opcodes, mas pretende encerrar esta fase do projeto como foco principal
1 comentários
Opiniões no Hacker News
Há algum tempo, criei um interpretador de Wasm em Scheme, então fico feliz em ver mais gente implementando por conta própria.
Não é tão difícil quanto parece; vale dar uma olhada na especificação e tentar. Nem é preciso implementar todas as instruções — basta fazer o suficiente para se divertir.
Como alguém que também implementa isso, uma dica: o spec-test tem muitos formatos Wasm em texto esquisitos cuja forma de compilação não é intuitiva, mas, usando o conversor
wast2json, dá para gerar uma descrição JSON mais simples junto com arquivos Wasm binários comuns.Quanto mais rápido você conseguir rodar essa suíte de testes, mais isso ajuda na velocidade de iteração e na precisão.
Levou um tempo para fazer tudo funcionar, mas, depois que conseguimos, chegamos muito rapidamente ao ponto de executar praticamente qualquer coisa. Mesmo que a suíte de testes não seja completa, ela cobre algo como 95%: https://github.com/WebAssembly/testsuite
Eu já tinha visto esses testes antes, mas não sabia como usá-los, então foi frustrante e confuso.
Sobre interpretar Wasm diretamente, achei este artigo bem interessante: https://arxiv.org/abs/2205.01183
Com base nele, criei https://github.com/peterseymour/winter e aprendi que Wasm não é tão simples quanto parece.
Pergunta de iniciante: em uma situação em que você não está escrevendo código diretamente para o alvo, como se faz depuração de interpretador?
Também fico curioso até que ponto fuzzing de strings de opcodes ajuda, qual é a diferença prática entre engines Wasm do lado do servidor e engines baseadas em navegador, e quanto trabalho daria trocar uma pela outra.
Abordagem interessante e excelente trabalho.
Para quem quiser ver o núcleo, ele está em grande parte neste arquivo: https://github.com/irrio/semblance/blob/main/src/wrun.c
Pensando bem, teria sido bom se o projeto seguisse a Wasm-C-API (https://github.com/WebAssembly/wasm-c-api) como interface padrão.
Ela já é uma API em C e é adotada pela maioria dos runtimes Wasm, como Wasmer, V8, wasmi etc., então desenvolvedores familiarizados com essa API poderiam experimentar com mais facilidade.
Se o autor tiver familiaridade suficiente com Wasm e quiser contribuir com o Wasmer, patches ou melhorias também são bem-vindos.
Foi dito que havia “preocupações compreensíveis com o fato de a Wasmer, financiada por VC, ter tentado registrar como marca WebAssembly, o nome de uma organização sem fins lucrativos”, o que é uma admissão de erro.
Installed-Size: 266 MBno Wasmer? Que diabos é isso?https://github.com/wasmerio/wasmer/issues/2615 ficou abandonada e acabou sendo fechada automaticamente.
Uma pergunta um pouco mais polêmica: há planos de adicionar uma instrução provisória de chamada de cauda?
No lado da especificação do Wasm, isso foi recusado por ser “alto nível” demais, mas o comitê de C também já recusou uma proposta de Dennis Ritchie.
Ainda assim, meu dinheiro continuaria em Ritchie, e acho que Rob Pike também apostou nessa direção.
Caso contrário, por que ele teria criado Golang? Chamadas de cauda só são de alto nível quando chamadas também são de alto nível.
Seria bom dar uma olhada no Orca. Acho que você poderia contribuir bem por lá.
[0] https://orca-app.dev
Eu também decidi focar em um único projeto em vez de ficar pulando entre coisas novas e brilhantes, mas, como exceção, estou colocando assistentes de IA para fazerem tarefas chatas.
Para dizer o mínimo, é bem frustrante.
E eu também ia sugerir usar uma biblioteca como
libffipara lidar com múltiplos argumentos etc. ao chamar a partir de C.Quando se usa WebAssembly como API de plugins em lugares como o zed, fico curioso sobre como os desenvolvedores de plugins depuram o código.
Por exemplo, se é possível depurar com breakpoints e se, quando o código quebra, dá para receber um stack trace.