Компиляция C в WASM без Emscripten: Учимся собирать

scale_2400.png

Когда перед вами стоит задача использовать код С/С++ в WebAssembly, то первое, что вы услышите - это Emscripten. Это не компилятор, а целый набор инструментов, который не только скомпонует вам C/C++ код, но и добавит JavaScript прослойку и даже реализацию стандартной библиотеки. Это действительно очень удобное решение. Тем более ранние LLVM версии не умели выдавать wasm сборку. Поэтому Emscripten имеет очень богатую историю своих решений. Но что, если ваш код будет сразу ориентирован на запуск и работу в браузере? Или вы действительно хотите разобраться как работает wasm+js? Поэтому отбросим весь лишний контекст и начнём с чего-то очень простого.

Установка LLVM

Для компиляции мы будем использовать LLVM (он так же используется в Emscripten под капотом). Как установить готовую сборку LLVM на разные системы вы найдёте в официальном гайде.

Достаточно просто найти на странице с официальными сборками подходящий для вашей платформы заранее собранный LLVM. Дальше скачиваем и распаковываем, вам необходимо чтобы была папка bin, а в ней хотя бы лежал clang компилятор. Я лично установил llvm 16.0.2 через homebrew на macos простой командой: brew install llvm.

Для удобства мы добавим папку "/path/to/LLVM/bin" в переменную окружения PATH, чтобы напрямую работать с компилятором. Проверим, что всё правильно:

$ clang --version

Homebrew clang version 16.0.2
Target: x86_64-apple-darwin22.4.0
Thread model: posix
InstalledDir: /usr/local/Cellar/llvm/16.0.2/bin

Пишем main.c

Для теста напишем простейшую функцию add(x, y) которая будет возвращать сумму двух чисел

double add(double x, double y) {
    return x + y;
}

Компилируем main.c

Один файл можно собрать очень быстро одной строчкой кода

clang --target=wasm32 -nostdlib -Wl,--no-entry -Wl,--export-all -o main.wasm main.c

Краткое описание используемых флагов:

Запускаем main.wasm в браузере

Для этого оформим простейший index.html, добавим загрузку и инициализацию нашего модуля

<!DOCTYPE html>
<html lang="en">
<head>
    <title></title>
</head>
<body>
    <script type="module">
        WebAssembly.instantiateStreaming(fetch("main.wasm")).then(source => {
            const add = source.instance.exports.add;
            console.info(`add(1.1, 1.9) = ${add(1.1, 1.9)}`);
        });
    </script>
</body>
</html>

Если вы запустите этот файл в браузере, то получите ошибку, мы не можем загрузить main.wasm из файловой системы, поэтому воспользуемся локальным сервером:

npx serve

Теперь когда мы откроем http://localhost:3000 - мы можем посмотреть в консоль, и увидим следующий результат:

add(1.1, 1.9) = 3

Простая оптимизация размера

У меня получился main.wasm размером 484 байт. Мы уже указали не использовать стандартную библиотеку, но не используем никаких флагов оптимизации. Дальше постараемся разобраться как оптимизировать размер нашей сборки.

Если мы просто добавим флаг -Oz в команду сборки, то файл немного уменьшается, до 367 байт.

Дальше попробуем обрезать всё лишнее из файла флагом для компановщика -Wl,--strip-all. Теперь наш файл занимает уже 272 байта.

Если мы посмотрим какие символы main.wasm доступны в JavaScript, то увидим много полезных вещей, но нами они не используются, это происходит потому что мы используем для простоты флаг --export-all.

img.png
console.table(source.instance.exports)

Заменим -Wl,--export-all на -Wl,--export-dynamic - теперь мы сами будем выбирать какие функции мы хотим экспортировать в JavaScript. Если вы пересоберете нашу сборку и запустите, то увидите ошибку, что метода add не существует. Поэтому давайте просто укажем что мы хотим его экспортировать, изменим наш main.c

__attribute__((visibility("default")))
double add(double x, double y) {
    return x + y;
}

Теперь пересобираем с флагом -Wl,--export-dynamic

clang -Oz --target=wasm32 -nostdlib -Wl,--strip-all -Wl,--no-entry -Wl,--export-dynamic -o main.wasm main.c

Размер main.wasm теперь всего 55 байт! А таблица экспортов содержит только нашу функцию.

img_1.png
Ура! Теперь мы видим только нашу Си функцию

Эту сборку 55 байт по размеру теперь можно сравнить с аналогичным примером из AssemblyScript (из-за того что они включают ссылку на sourceMap внутри wasm - их размер идентичного примера занимает аж 96 байт).

Заключение

Теперь у нас есть установленный LLVM. Мы научились компилировать наш C код в минимальную, компактную и чистую WebAssembly сборку. Можем загрузить wasm модуль и вызвать Си функцию. Познакомились с некоторыми флагами компиляции и компоновки для оптимизации размера.

В следующем уроке попробуем написать целый тест, узнаем как вызывать JavaScript функцию из C, будем использовать встроенные инструкции WebAssembly без <math.h>

Надеюсь, что у вас всё получилось и статья вам понравилась!