Компиляция библиотеки C в Wasm с помощью Emscripten

Иногда вам может понадобиться библиотека, доступная только в виде кода C или C++. Обычно на этом этапе все сдаются. Но теперь у нас есть Emscripten и WebAssembly (или Wasm).

Набор инструментов

Я поставил перед собой цель выяснить, как скомпилировать существующий код C в Wasm. В последнее время много говорят о бэкенде Wasm в LLVM, поэтому я решил изучить его. Хотя таким образом можно компилировать простые программы, как только вы захотите использовать стандартную библиотеку C или даже скомпилировать несколько файлов, вы, скорее всего, столкнетесь с проблемами. Это привело меня к главному уроку, который я усвоил:

Раньше Emscripten был компилятором C-to-asm.js, но теперь он поддерживает Wasm и переходит на официальный бэкенд LLVM. Emscripten также предоставляет совместимую с Wasm реализацию стандартной библиотеки C. Используйте Emscripten. Он выполняет много скрытой работы, эмулирует файловую систему, обеспечивает управление памятью, оборачивает OpenGL в WebGL – много вещей, с которыми вам не нужно сталкиваться при разработке.

Это может показаться избыточным, но компилятор Emscripten удаляет все ненужное. В моих экспериментах результирующие модули Wasm имеют размер, соответствующий содержащейся в них логике, и команды Emscripten и WebAssembly работают над тем, чтобы в будущем сделать их ещё меньше.

Вы можете скачать Emscripten, следуя инструкциям на сайте, или использовать Homebrew. Если вы, как и я, любите команды Docker и не хотите устанавливать на компьютер лишние программы, чтобы просто попробовать WebAssembly, воспользуйтесь образом Docker.

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

Компиляция простого кода

Давайте возьмем почти канонический пример написания функции на C, которая вычисляет n-еth число Фибоначчи:

    #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;
    }

Если вы знаете C, сама функция не должна вас удивить. Даже если вы не знаете C, но знаете JavaScript, вы, скорее всего, поймете, что здесь происходит.

emscripten.h – заголовочный файл, предоставляемый Emscripten. Нам нужен только макрос EMSCRIPTEN_KEEPALIVE, но у тега есть и другие функции. Этот макрос сообщает компилятору, что не нужно удалять функцию, даже если она не используется. Если бы мы опустили этот макрос, компилятор оптимизировал бы функцию, поскольку ее никто не использует.

Сохраним все это в файле с названием fib.c. Чтобы преобразовать его в файл .wasm, нужно использовать команду компилятора Emscripten emcc:

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

Давайте разберём эту команду. emcc – компилятор Emscripten. fib.c – это файл C. Пока что неплохо. -s WASM=1 указывает Emscripten, что нужно создать файл Wasm, а не asm.js. -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' указывает компилятору, что функция cwrap() должна быть доступна в файле JavaScript. Подробнее об этой функции мы расскажем ниже. -O3 указывает компилятору на необходимость агрессивной оптимизации. Вы можете выбрать меньшее число, чтобы сократить время сборки, но это также увеличит размер пакетов, поскольку компилятор может не удалить неиспользуемый код.

После выполнения команды вы получите файл JavaScript с названием a.out.js и файл WebAssembly с названием a.out.wasm. Файл Wasm (или "модуль") содержит скомпилированный код C и должен быть довольно небольшим. Файл JavaScript отвечает за загрузку и инициализацию модуля Wasm и предоставляет более удобный API. При необходимости он также позаботится о настройке стека, кучи и других функций, которые обычно предоставляются операционной системой при написании кода на C. Поэтому файл JavaScript немного больше и весит 19 КБ (около 5 КБ в формате gzip).

Простой запрос

Проще всего загрузить и запустить модуль с помощью сгенерированного файла JavaScript. После загрузки этого файла вам будет доступен глобальный объект Module. Используйте cwrap, чтобы создать нативную функцию JavaScript, которая преобразует параметры в формат, совместимый с C, и вызывает упакованную функцию. Функция cwrap принимает в качестве аргументов название функции, тип возвращаемого значения и типы аргументов в указанном порядке:

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

Если вы выполните этот код, то в консоли должно появиться число 144, которое является 12-м числом Фибоначчи.

Святой Грааль: компиляция библиотеки C

До сих пор мы писали код на C с учетом Wasm. Однако основная цель WebAssembly – дать разработчикам возможность использовать существующую экосистему библиотек C в интернете. Такие библиотеки часто зависят от стандартной библиотеки C, операционной системы, файловой системы и других компонентов. Emscripten предоставляет большинство этих функций, хотя есть некоторые ограничения.

Вернемся к моей первоначальной цели: компиляции кодировщика WebP в Wasm. Исходный код кодека WebP написан на языке C и доступен на GitHub. Также вы можете ознакомиться с документацией по API. Это довольно хорошая отправная точка.

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

Для начала попробуем экспортировать WebPGetEncoderVersion() из encode.h в JavaScript, написав файл C с именем webp.c:

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

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

Это хорошая простая программа для проверки того, можем ли мы получить исходный код libwebp для компиляции, поскольку для вызова этой функции нам не требуются никакие параметры или сложные структуры данных.

Чтобы скомпилировать эту программу, нам нужно указать компилятору, где он может найти заголовочные файлы libwebp, используя флаг -I, а также передать ему все необходимые C-файлы libwebp. Буду честен: я просто скормил ему все файлы C, которые смог найти, и положился на компилятор, который должен был убрать все лишнее. И это сработало!

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

Теперь нам нужно только немного HTML и JavaScript, чтобы загрузить наш новый модуль:

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

И мы увидим номер версии исправления в выходных данных:

Скриншот консоли DevTools с правильным номером версии.

Как передать изображение из JavaScript в Wasm

Получить номер версии кодировщика – это, конечно, здорово, но закодировать настоящее изображение было бы ещё лучше, не так ли? Давайте так и сделаем.

Первый вопрос, на который нам нужно ответить: как нам перенести изображение в страну Wasm? Если посмотреть на API кодирования libwebp, то он ожидает массив байтов в RGB, RGBA, BGR или BGRA. К счастью, в Canvas API есть метод getImageData(), который возвращает массив Uint8ClampedArray с данными изображения в формате 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);
}

Теперь осталось "всего лишь" скопировать данные из JavaScript в Wasm. Для этого нам нужно добавить две дополнительные функции. Одна выделяет память для изображения в Wasm, а другая освобождает ее:

    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 выделяет буфер для изображения RGBA – по 4 байта на пиксель. Указатель, возвращаемый malloc(), – это адрес первой ячейки памяти этого буфера. Когда указатель возвращается в JavaScript, он обрабатывается как обычное число. После того как мы предоставим доступ к функции для JavaScript с помощью cwrap, мы сможем использовать это число, чтобы найти начало буфера и скопировать данные изображения.

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);

Грандиозный финал: закодируйте изображение

Теперь изображение доступно в Wasm. Пришло время вызвать кодировщик WebP, чтобы он выполнил свою работу. Судя по документации WebP, WebPEncodeRGBA – идеальный вариант. Функция принимает указатель на входное изображение и его размеры, а также параметр качества от 0 до 100. Она также выделяет для нас выходной буфер, который мы должны освободить с помощью WebPFree(), как только закончим работу с изображением WebP.

Результатом операции кодирования является выходной буфер и его длина. Поскольку функции в C не могут иметь массивы в качестве типов возвращаемых значений (если мы не выделяем память динамически), я прибег к статическому глобальному массиву. Я знаю, что это не чистый C (на самом деле он полагается на то, что указатели Wasm имеют ширину 32 бита), но для простоты я считаю, что это справедливый ярлык.

    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];
    }

Теперь, когда все это на месте, мы можем вызвать функцию кодирования, получить указатель и размер изображения, поместить их в наш собственный буфер JavaScript-land и освободить все буферы Wasm-land, которые мы выделили в процессе.

    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);

В зависимости от размера изображения вы можете столкнуться с ошибкой, когда Wasm не может увеличить объем памяти настолько, чтобы вместить как входное, так и выходное изображение:

Скриншот консоли инструментов разработчика с ошибкой.

К счастью, решение этой проблемы есть в сообщении об ошибке! Нам просто нужно добавить -s ALLOW_MEMORY_GROWTH=1 в команду компиляции.

Вот и всё! Мы скомпилировали кодировщик WebP и перекодировали изображение JPEG в WebP. Чтобы убедиться, что все работает, мы можем преобразовать буфер результатов в blob-объект и использовать его в элементе <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);

Посмотрите, как выглядит новое изображение WebP.

Панель &quot;Сеть&quot; в Инструментах разработчика и сгенерированное изображение.

Заключение

Заставить библиотеку C работать в браузере непросто, но как только вы поймете общий процесс и то, как работает поток данных, это станет проще, а результаты могут быть ошеломляющими.

WebAssembly открывает множество новых возможностей в интернете для обработки данных, вычислений и игр. Помните, что Wasm – это не панацея, которую следует применять ко всему, но когда вы сталкиваетесь с одним из этих узких мест, Wasm может оказаться невероятно полезным инструментом.

Бонусный контент: как запустить что-то простое сложным способом

Если вы хотите избежать создания файла JavaScript, это можно сделать. Вернемся к примеру с числами Фибоначчи. Чтобы загрузить и запустить его самостоятельно, мы можем сделать следующее:

<!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>

Модули WebAssembly, созданные Emscripten, не имеют памяти для работы, если вы не предоставите им ее. Чтобы предоставить модулю Wasm что-либо, используйте объект imports – второй параметр функции instantiateStreaming. Модуль Wasm может получить доступ ко всему, что находится внутри объекта imports, но не за его пределами. По соглашению модули, скомпилированные Emscripten, ожидают от загружаемой среды JavaScript несколько вещей:

  • Во-первых, это env.memory. Модуль Wasm не знает о внешнем мире, поэтому ему нужно получить память для работы. Введите WebAssembly.Memory. Она представляет собой фрагмент линейной памяти, который при необходимости можно расширить. Параметры размера указываются в единицах страниц WebAssembly. Это означает, что приведенный выше код выделяет одну страницу памяти, размер которой составляет 64 KiB. Если не указать параметр maximum, объем памяти теоретически может расти бесконечно (в настоящее время в Chrome установлено жесткое ограничение в 2 ГБ). Для большинства модулей WebAssembly не нужно задавать максимальный размер.
  • env.STACKTOP определяет, где должен начинаться стек. Стек нужен для вызова функций и выделения памяти для локальных переменных. Поскольку в нашей небольшой программе для вычисления чисел Фибоначчи мы не используем динамическое управление памятью, мы можем использовать всю память в качестве стека, поэтому STACKTOP = 0.