Como escrever uma biblioteca C para o Wasm

Às vezes, você quer usar uma biblioteca disponível apenas como código C ou C++. Tradicionalmente, é aqui que você desiste. Bem, não mais, porque agora temos Emscripten e WebAssembly (ou Wasm)!

O conjunto de ferramentas

Estabeleci meu objetivo de descobrir como compilar um código C existente para Wasm. Havia algum ruído ao redor do back-end Wasm do LLVM. Comecei a investigar isso. você pode compilar programas simples, dessa forma, no segundo em que você quer usar a biblioteca padrão de C ou até mesmo compilar vários arquivos, você provavelmente terá problemas. Isso me levou lição que aprendi:

Embora o Emscripten tenha usado ser um compilador C-to-asm.js, ele amadureceu para Wasm e é no processo de troca para o back-end oficial do LLVM internamente. O Emscripten também oferece Implementação compatível com Wasm da biblioteca padrão C. Use o Emscripten. Ela tem muito trabalho oculto, emula um sistema de arquivos, fornece gerenciamento de memória, une o OpenGL com o WebGL — uma coisas que você não precisa experimentar no desenvolvimento.

Embora isso possa parecer que você precisa se preocupar com a sobrecarga, eu certamente me preocupei — o compilador Emscripten remove tudo o que não é necessário. No meu nos experimentos, os módulos Wasm resultantes são dimensionados adequadamente de acordo com a lógica que eles contêm, e as equipes Emscripten e WebAssembly estão trabalhando para com um número ainda menor no futuro.

Para ter o Emscripten, siga as instruções na do site ou usando o Homebrew. Se você é fã de comandos dockerizados como eu e não quer instalar nada no sistema, para brincar com o WebAssembly, há um imagem Docker que pode ser usada como alternativa:

    $ docker pull trzeci/emscripten
    $ docker run --rm -v $(pwd):/src trzeci/emscripten emcc <emcc options here>

Compilação de algo simples

Vamos pegar o exemplo quase canônico de escrever uma função em C que calcula o no número de fibonacci:

    #include <emscripten.h>

    EMSCRIPTEN_KEEPALIVE
    int fib(int n) {
      if(n <= 0){
        return 0;
      }
      int i, t, a = 0, b = 1;
      for (i = 1; i < n; i++) {
        t = a + b;
        a = b;
        b = t;
      }
      return b;
    }

Se você conhece a linguagem C, a função em si não surpreenderá muito. Mesmo se você não conhece C, mas sabe JavaScript, espera-se que seja capaz de entender o que está acontecendo aqui.

emscripten.h é um arquivo principal fornecido pela Emscripten. Nós só precisamos dele, então têm acesso à macro EMSCRIPTEN_KEEPALIVE, mas ela oferece muito mais funcionalidades. Essa macro diz ao compilador para não remover uma função, mesmo que ela apareça o que não é usado. Se omitíssemos essa macro, o compilador otimizaria a função Afinal, ninguém usa.

Vamos salvar tudo isso em um arquivo chamado fib.c. Para transformá-lo em um arquivo .wasm, precisa usar o comando emcc do compilador do Emscripten:

    $ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' fib.c

Vamos analisar esse comando. emcc é o compilador do Emscripten. fib.c é nosso C . Até aqui, tudo bem. O -s WASM=1 pede para o Emscripten fornecer um arquivo Wasm. em vez de um arquivo asm.js. -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' instrui o compilador a sair do A função cwrap() está disponível no arquivo JavaScript. Saiba mais sobre ela mais tarde. -O3 instrui o compilador a otimizar de forma agressiva. Você pode escolher para reduzir o tempo de build, mas isso também tornará os pacotes resultantes maior, já que o compilador pode não remover código não utilizado.

Depois de executar o comando, você terá um arquivo JavaScript chamado a.out.js e um arquivo WebAssembly chamado a.out.wasm. O arquivo Wasm (ou "module") contém nosso código C compilado e deve ser bem pequeno. A JavaScript faz o carregamento e a inicialização do módulo Wasm e fornecendo uma API melhor. Se necessário, ele também cuidará da configuração do pilha, a pilha e outras funcionalidades que normalmente se espera que sejam fornecidas pela no sistema operacional ao escrever o código C. Dessa forma, o arquivo JavaScript maior, pesando-se a 19 KB (~5 KB em gzip).

Executando algo simples

A maneira mais fácil de carregar e executar seu módulo é usar o JavaScript gerado . Depois de carregar esse arquivo, você terá uma Module global à sua disposição. Usar cwrap para criar uma função nativa de JavaScript que cuida da conversão de parâmetros a algo compatível com C e invocar a função encapsulada. cwrap pega nome da função, tipo de retorno e tipos de argumento como argumentos, nesta ordem:

    <script src="a.out.js"></script>
    <script>
      Module.onRuntimeInitialized = _ => {
        const fib = Module.cwrap('fib', 'number', ['number']);
        console.log(fib(12));
      };
    </script>

Se você executar o código, vai aparecer "144" no console, que é o 12o número de Fibonacci.

O Santo Graal: compilação de uma biblioteca C

Até agora, o código C que escrevemos foi escrito com o Wasm em mente. Um núcleo de uso do WebAssembly, no entanto, é pegar o ecossistema existente de e permitir que os desenvolvedores as usem na Web. Essas bibliotecas geralmente dependem da biblioteca padrão de C, um sistema operacional, um sistema de arquivos e outros coisas. A Emscripten fornece a maioria desses recursos, embora existam limitações.

Vamos voltar ao meu objetivo original: compilar um codificador para WebP para Wasm. A para o codec WebP é escrita em C e disponível em GitHub, assim como alguns exemplos Documentação da API. Esse é um ótimo ponto de partida.

    $ git clone https://github.com/webmproject/libwebp

Para começar de forma simples, vamos tentar expor o WebPGetEncoderVersion() da encode.h em JavaScript gravando um arquivo C chamado webp.c:

    #include "emscripten.h"
    #include "src/webp/encode.h"

    EMSCRIPTEN_KEEPALIVE
    int version() {
      return WebPGetEncoderVersion();
    }

Esse é um bom programa simples para testar se conseguimos o código-fonte do libwebp para compilar, pois não precisamos de parâmetros ou estruturas de dados complexas para invoque essa função.

Para compilar esse programa, precisamos informar ao compilador onde ele pode encontrar arquivos de cabeçalho do libwebp usando a sinalização -I e também transmiti-la a todos os arquivos C do libwebp necessário. Para ser honesta: eu só dei todo o Dó dos arquivos que encontramos e dependia do compilador para remover tudo o que desnecessária. Ele funcionou muito bem!

    $ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' \
        -I libwebp \
        webp.c \
        libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c

Agora só precisamos de HTML e JavaScript para carregar nosso novo módulo:

<script src="/a.out.js"></script>
<script>
  Module.onRuntimeInitialized = async (_) => {
    const api = {
      version: Module.cwrap('version', 'number', []),
    };
    console.log(api.version());
  };
</script>

E veremos o número da versão de correção na seção output:

Captura de tela do console do DevTools mostrando a versão correta
número

Transferir uma imagem do JavaScript para o Wasm

Obter o número da versão do codificador é ótimo, mas codificar um seria mais impressionante, certo? Vamos fazer isso, então.

A primeira pergunta que precisamos responder é: como colocamos a imagem na terra do Wasm? Analisando API de codificação do libwebp, espera-se uma matriz de bytes em RGB, RGBA, BGR ou BGRA. Felizmente, a API Canvas tem getImageData(), que nos dá Uint8ClampedArray contendo os dados da imagem em RGBA:

async function loadImage(src) {
  // Load image
  const imgBlob = await fetch(src).then((resp) => resp.blob());
  const img = await createImageBitmap(imgBlob);
  // Make canvas same size as image
  const canvas = document.createElement('canvas');
  canvas.width = img.width;
  canvas.height = img.height;
  // Draw image onto canvas
  const ctx = canvas.getContext('2d');
  ctx.drawImage(img, 0, 0);
  return ctx.getImageData(0, 0, img.width, img.height);
}

Agora é "apenas" uma questão de copiar os dados do JavaScript para o Wasm terra. Para isso, precisamos expor mais duas funções. Um que aloca para a imagem dentro do Wasm e outra que a libere de novo:

    EMSCRIPTEN_KEEPALIVE
    uint8_t* create_buffer(int width, int height) {
      return malloc(width * height * 4 * sizeof(uint8_t));
    }

    EMSCRIPTEN_KEEPALIVE
    void destroy_buffer(uint8_t* p) {
      free(p);
    }

create_buffer aloca um buffer para a imagem RGBA. Portanto, 4 bytes por pixel. O ponteiro retornado por malloc() é o endereço da primeira célula de memória do desse buffer. Quando o ponteiro é retornado para JavaScript, ele é tratado como apenas um número. Depois de expor a função ao JavaScript usando cwrap, podemos usar esse número para encontrar o início do nosso buffer e copiar os dados da imagem.

const api = {
  version: Module.cwrap('version', 'number', []),
  create_buffer: Module.cwrap('create_buffer', 'number', ['number', 'number']),
  destroy_buffer: Module.cwrap('destroy_buffer', '', ['number']),
};
const image = await loadImage('/image.jpg');
const p = api.create_buffer(image.width, image.height);
Module.HEAP8.set(image.data, p);
// ... call encoder ...
api.destroy_buffer(p);

Grand Finale: codificar a imagem

A imagem agora está disponível em Wasm. É hora de chamar o codificador WebP para o que faz o trabalho dela. Analisando Documentação do WebP, WebPEncodeRGBA parece ser a opção perfeita. A função leva um ponteiro para a imagem de entrada e suas dimensões e uma opção de qualidade entre 0 e 100. Ele também aloca um buffer de saída, que precisamos liberar usando WebPFree() quando não tenha mais a imagem WebP.

O resultado da operação de codificação é um buffer de saída e seu comprimento. Devido ao funções em C não podem ter matrizes como tipos de retorno (a menos que aloquemos memória de forma dinâmica), optei por uma matriz global estática. Eu sei, não limpar C (na verdade, depende do fato de que os ponteiros Wasm têm 32 bits de largura, mas, para manter simples. Acho que esse é um atalho justo.

    int result[2];
    EMSCRIPTEN_KEEPALIVE
    void encode(uint8_t* img_in, int width, int height, float quality) {
      uint8_t* img_out;
      size_t size;

      size = WebPEncodeRGBA(img_in, width, height, width * 4, quality, &img_out);

      result[0] = (int)img_out;
      result[1] = size;
    }

    EMSCRIPTEN_KEEPALIVE
    void free_result(uint8_t* result) {
      WebPFree(result);
    }

    EMSCRIPTEN_KEEPALIVE
    int get_result_pointer() {
      return result[0];
    }

    EMSCRIPTEN_KEEPALIVE
    int get_result_size() {
      return result[1];
    }

Agora que tudo isso está pronto, podemos chamar a função de codificação, e tamanho da imagem, colocá-lo em um buffer próprio de JavaScript libera todas as reservas Wasm-land que alocamos no processo.

    api.encode(p, image.width, image.height, 100);
    const resultPointer = api.get_result_pointer();
    const resultSize = api.get_result_size();
    const resultView = new Uint8Array(Module.HEAP8.buffer, resultPointer, resultSize);
    const result = new Uint8Array(resultView);
    api.free_result(resultPointer);

Dependendo do tamanho da imagem, você pode se deparar com um erro em que o Wasm não pode aumentar a memória o suficiente para acomodar a imagem de entrada e de saída:

Captura de tela do console do DevTools mostrando um erro.

Felizmente, a solução para esse problema está na mensagem de erro. Só precisamos Adicione -s ALLOW_MEMORY_GROWTH=1 ao comando de compilação.

Pronto! Compilamos um codificador WebP e transcodificamos uma imagem JPEG WebP Para provar que isso funcionou, podemos transformar nosso buffer de resultados em um blob e usar em um elemento <img>:

const blob = new Blob([result], { type: 'image/webp' });
const blobURL = URL.createObjectURL(blob);
const img = document.createElement('img');
img.src = blobURL;
document.body.appendChild(img);

Veja a glória de uma nova imagem WebP!

no painel de rede do DevTools e a imagem gerada.

Conclusão

Não é um passeio para fazer uma biblioteca C funcionar no navegador, mas uma vez você entende o processo geral e como o fluxo de dados funciona, isso se torna mais fácil, e os resultados podem ser impressionantes.

O WebAssembly abre muitas novas possibilidades na Web para processamento, número a fragmentação e os jogos. Tenha em mente que o Wasm não é uma solução perfeita ser aplicado a tudo, mas quando você encontra um desses gargalos, o Wasm pode ser é uma ferramenta incrivelmente útil.

Conteúdo bônus: executar algo simples da maneira difícil

Para evitar o arquivo JavaScript gerado, Vamos voltar ao exemplo de Fibonacci. Para carregar e executar manualmente, podemos faça o seguinte:

<!DOCTYPE html>
<script>
  (async function () {
    const imports = {
      env: {
        memory: new WebAssembly.Memory({ initial: 1 }),
        STACKTOP: 0,
      },
    };
    const { instance } = await WebAssembly.instantiateStreaming(
      fetch('/a.out.wasm'),
      imports,
    );
    console.log(instance.exports._fib(12));
  })();
</script>

Os módulos WebAssembly criados pela Emscripten não têm memória para funcionar a menos que você forneça memória. A maneira como você fornece um módulo Wasm com qualquer coisa é usar o objeto imports, o segundo parâmetro da função instantiateStreaming. O módulo Wasm pode acessar tudo que está dentro dele o objeto "imports", mas nada além dele. Por convenção, os módulos compilado pela Emscripting esperam que algumas coisas do código JavaScript de nuvem:

  • Primeiro, há env.memory. O módulo Wasm não reconhece por assim dizer, então ele precisa de memória para trabalhar. Tecla Enter WebAssembly.Memory Ele representa uma parte da memória linear (opcionalmente expansível). O dimensionamento parâmetros estão em "em unidades de páginas WebAssembly", ou seja, o código acima aloca uma página de memória, cada uma com tamanho de 64 KiB (em inglês). Sem fornecer um maximum a memória é teoricamente ilimitada em crescimento (o Chrome atualmente tem um limite absoluto de 2 GB). A maioria dos módulos WebAssembly não precisa configurar uma máximo.
  • env.STACKTOP define onde a pilha deve começar a crescer. A pilha é necessária para fazer chamadas de função e alocar memória para variáveis locais. Como não fazemos nenhum manobras de gerenciamento de memória dinâmica em nossa pequena programa Fibonacci, podemos usar toda a memória como uma pilha, ou seja, STACKTOP = 0: