Site menu Emscripten: rode código C em ambiente Javascript

Emscripten: rode código C em ambiente Javascript

Goste ou não, Javascript é o "esperanto", a linguagem universal. Praticamente todas as plataformas podem embutir um interpretador JS, fora que ele já é a linguagem principal de plataformas importantes como Web, Electron, Node, etc. Se você vai escrever hoje uma biblioteca de uso geral, deveria escrevê-la em JS.

Mas isto é um fenômeno recente. Muito código em C e C++ foi escrito antes disso acontecer. Reescrever esse código em JS custaria tempo e dinheiro para entregar algo que você já tinha. Joel Spolsky ensinou há 20 anos: nunca reescreva código!

Pelo menos em se tratando de Node.js/Electron, uma forma sensata de reutilizar código de legado é escrever um módulo nativo. Mas isso implica em aprender a API C++, escrever o código-cola em C++ e lidar com compilação nativa em pelo menos três plataformas: Windows, Linux e Mac. Sim, existem ferramentas como node-gyp para ajudar, mas continua sendo uma tarefa não-trivial.

Existe outra opção: compilar código C para "binário" Javascript usando o Emscripten. Soa extremamente subversivo, mas funciona.

O Emscripten é um projeto vasto, e possui um grande número de bibliotecas pré-portadas. Por exemplo, um programa C baseado na biblioteca gráfica SDL (muito usada em jogos) compila e roda sem maiores esforços, pois o Enscriptem embute a adaptação do SDL para a plataforma Web!

Meu objetivo ao experimentar o Emscripten era bem mais modesto: portar uma biblioteca C "surda" e "muda", análoga a projetos tipo libpng ou libjpeg. Tais bibliotecas só fazem uma tarefa específica e não interagem com a plataforma, e evitam ao máximo usar qualquer API dependente de plataforma. Isso é de propósito, pois facilita o porte — inclusive para JS.

Uma parte do Emscripten é bem documentada no próprio site e.g. instalação e uso. (A propósito, o Homebrew e distribuições Linux empacotam o Emscripten, tornando a instalação ainda mais simples.) Também está documentado como invocar funções JS a partir do C e vice-versa, passando e retornando parâmetros numéricos.

O bicho pega quando se trata de passar ou retornar objetos como strings ou arrays. A documentação é esparsa e taciturna. Precisei fuçar em inúmeros blogs e códigos de exemplo para descobrir como proceder. (Curiosamente, a documentação do interfaceamento com outras linguagens é um problema crônico em todos os interpretadores JS embutíveis: Rhino, UIWebView do iOS, WebView do Android...)

O resultado dos meus esforços estão condensados neste projeto do GitHub. Se você é safo e está com pressa, não precisa nem continuar lendo este artigo. Vá direto ao GitHub.

Os exemplos do GitHub são voltados para o ambiente Node.js, e escolhi o "formato binário" asm.js (que é Javascript puro, embora escrito com dicas de otimização). O padrão seria binário WebAssembly, cujo suporte é relativamente recente no Node.js. É interessante olhar os flags do meu Makefile, e estudar a documentação do Emscripten para identificar quais flags são ideais para seu projeto.

Interfacear duas linguagens diferentes parece fácil, pois há apenas dois casos: linguagem A chamando função da linguagem B, e vice-versa. O problema é que cada função envia e retorna dados, e o tratamento em cada direção é diferente. Há três tipos principais de dados para tratar: números, strings e buffers, e é preciso definir quem vai chamar free() para os dois últimos. Os 2 casos viraram 20, e nem falamos ainda de como passar closures ou ponteiros de função.

Um módulo Emscripten, quando compilado em asm.js, carrega assincronamente. Precisei apelar para o escopo global para detectar quando o módulo está pronto para uso. Se alguém tiver uma ideia melhor, aceito pull requests.

O heap do C compilado pelo Emscripten é simplesmente um array de bytes diretamente acessível via m.HEAPU8.buffer onde m é o módulo. Ponteiros são simplesmente offsets dentro desse array, e podem ser passados e retornados como números. Neste ponto, esta "plataforma" C é VAXocentrista, vide itens 3 e 5.

Por outro lado, o Emscripten é anti-VAX no item 6. Ele bronqueia com algo que é "comportamento indefinido" mas geralmente funciona nas demais plataformas: acesso desalinhado. A linha abaixo falha no Emscripten quando buffer_position é ímpar:

// errado, mas funciona na maioria das plataformas
*((uint16_t *) buffer_position) = value;

Estes "comportamentos indefinidos mas tolerados" são as maiores pedras-no-sapato ao portar código C. É importante usar tantos flags de checagem quanto for possível para pegar esse tipo de erro na compilação. O código acima falha silenciosamente quando compilado com flags de otimização, portanto é bom não habilitar otimizações quando não são absolutamente necessárias.

Uma vez identificado o problema, é fácil fazer do jeito certo:

// errado
// *((uint16_t *) buffer_position) = value;
// certo
memcpy(buffer_position, &data, 2);

Segue uma descrição sucinta das técnicas usadas em cada fonte do exemplo.

Referências