Хабрахабр

[Перевод] Компиляция C в WebAssembly без Emscripten

Компилятор — часть Emscripten. А что, если удалить все свистелки и оставить только его?

Но это гораздо больше, чем просто компилятор. Emscripten необходим для компиляции C/C++ в WebAssembly. Для этого Emscripten эмулирует всю операционную систему POSIX. Цель Emscripten в том, чтобы полностью заменить ваш компилятор C/C++ и запустить в вебе код, который изначально не предназначен для Сети. Если используется OpenGL, то Emscripten предоставит С-совместимый контекст GL, поддерживаемый WebGL. Если программа использует fopen(), то Emscripten предоставит эмуляцию файловой системы. Но можно ли просто… удалить его?
Собственно компилятором в наборе инструментов Emscripten является LLVM. Это немалая работа, и немало кода, который придётся внедрить в итоговый пакет. Это современный модульный фреймворк для анализа, трансформации и оптимизации программ. Именно он переводит код C в байт-код WebAssembly. Вместо этого встроенный фронтенд-компилятор генерирует промежуточное представление (IR). LLVM модульный в том смысле, что он никогда не компилирует прямо в машинный код. Это промежуточное представление, собственно, и называется LLVM, аббревиатура от Low-Level Virtual Machine, отсюда и название проекта.

Преимущество такого строгого разделения заключается в том, что новые архитектуры поддерживаются «простым» добавлением нового компилятора. Затем бэкенд-компилятор выполняет перевод IR в машинный код хоста. Начиная с LLVM 8 цель компиляции WebAssembly доступна по умолчанию. В этом смысле WebAssembly — лишь одна из многих целей компиляции, которые поддерживает LLVM, и в течение некоторого времени он активировался специальным флагом.

На MacOS вы можете установить LLVM с помощью homebrew:

$ brew install llvm
$ brew link --force llvm

Проверяем поддержку WebAssembly:

$ llc --version
LLVM (http://llvm.org/): LLVM version 8.0.0 Optimized build. Default target: x86_64-apple-darwin18.5.0 Host CPU: skylake Registered Targets: # …Господи, сколько архитектур… systemz - SystemZ thumb - Thumb thumbeb - Thumb (big endian) wasm32 - WebAssembly 32-bit # Ура! Ура! Ура! wasm64 - WebAssembly 64-bit x86 - 32-bit X86: Pentium-Pro and above x86-64 - 64-bit X86: EM64T and AMD64 xcore - XCore

Кажется, мы готовы!

Если вам их трудно понять, это нормально. Примечание: здесь рассмотрим некоторые низкоуровневые форматы RAW WebAssembly. Если ищете код для копипаста, см. Хорошее использование WebAssembly не требует обязательного понимания всего текста в этой статье. Но если вам интересно, продолжайте читать! вызов компилятора в разделе «Оптимизация». Ранее я написал введение в чистый Webassembly и WAT: это основы, необходимые для понимания этого поста.

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

// Filename: add.c
int add(int a, int b) { return a*a + b;
}

Какой умопомрачительный инженерный подвиг! Особенно потому что программа называется add, а на самом деле она ничего не add (не добавляет). Что более важно: программа не использует стандартную библиотеку, а из типов здесь только 'int'.

Превращаем C во внутреннее представление LLVM

Первый шаг — превратить нашу программу C в LLVM IR. Это задача фронтенд-компилятора clang, который установился с LLVM:

clang \ --target=wasm32 \ # Target WebAssembly -emit-llvm \ # Emit LLVM IR (instead of host machine code) -c \ # Only compile, no linking just yet -S \ # Emit human-readable assembly rather than binary add.c

И в результате мы получаем add.ll с внутренним представлением LLVM IR. Я показываю его только ради полноты картины. При работе с WebAssembly или даже clang вы как разработчик на C никогда не вступаете в контакт с LLVM IR.

; ModuleID = 'add.c'
source_filename = "add.c"
target datalayout = "e-m:e-p:32:32-i64:64-n32:64-S128"
target triple = "wasm32" ; Function Attrs: norecurse nounwind readnone
define hidden i32 @add(i32, i32) local_unnamed_addr #0 { %3 = mul nsw i32 %0, %0 %4 = add nsw i32 %3, %1 ret i32 %4
} attributes #0 = !llvm.module.flags = !{!0}
!llvm.ident = !{!1} !0 = !{i32 1, !"wchar_size", i32 4}
!1 = !{!"clang version 8.0.0 (tags/RELEASE_800/final)"}

LLVM IR полна дополнительных метаданных и аннотаций, что позволяет компилятору принимать более обоснованные решения при генерации машинного кода.

Превращаем LLVM IR в объектные файлы

Следующий шаг — вызов бэкенд-компилятора llc, чтобы сделать из внутреннего представления объектный файл.

Но обычно вы не сможете запустить объектные файлы, поскольку в них отсутствуют существенные части. Выходной файл add.o — это уже валидный модуль WebAssembly, который содержит весь скомпилированный код нашего файла C.

Однако инструмент llvm-mc для работы с такими файлами ещё не полностью поддерживает формат и часто не может обработать файлы. Если бы в команде мы опустили -filetype=obj, то получили бы ассемблер LLVM для WebAssembly, человекочитаемый формат, который в чём-то похож на WAT. Для проверки этих объектных файлов нужен специфический инструмент. Поэтому дизассемблируем объектные файлы постфактум. В случае WebAssembly это wasm-objdump, часть WebAssembly Binary Toolkit или wabt для краткости.

$ brew install wabt # in case you haven’t
$ wasm-objdump -x add.o add.o: file format wasm 0x1 Section Details: Type[1]: - type[0] (i32, i32) -> i32
Import[3]: - memory[0] pages: initial=0 <- env.__linear_memory - table[0] elem_type=funcref init=0 max=0 <- env.__indirect_function_table - global[0] i32 mutable=1 <- env.__stack_pointer
Function[1]: - func[0] sig=0 <add>
Code[1]: - func[0] size=75 <add>
Custom: - name: "linking" - symbol table [count=2] - 0: F <add> func=0 binding=global vis=hidden - 1: G <env.__stack_pointer> global=0 undefined binding=global vis=default
Custom: - name: "reloc.CODE" - relocations for section: 3 (Code) [1]
R_WASM_GLOBAL_INDEX_LEB offset=0x000006(file=0x000080) symbol=1 <env.__stack_pointer>

Выдача показывает, что наша функция add() находится в этом модуле, но она также содержит разделы custom с метаданными и, что удивительно, несколько импортов. На следующем этапе компоновки разделы custom будут проанализированы и удалены, а с импортом разберётся компоновщик (линкер).

Компоновка

Традиционно задачей компоновщика является сборка нескольких объектных файлов в исполняемый файл. Компоновщик LLVM называется lld, и он вызывается с указанием целевой символической ссылки. Для WebAssembly это wasm-ld.

wasm-ld \ --no-entry \ # We don’t have an entry function --export-all \ # Export everything (for now) -o add.wasm \ add.o

В результате получается модуль WebAssembly размером 262 байта.

Запуск

Конечно, самое главное — увидеть, что всё действительно работает. Как и в прошлой статье, можно использовать пару строк встроенного JavaScript для загрузки и запуска этого модуля WebAssembly.

<!DOCTYPE html> <script type="module"> async function init() { const { instance } = await WebAssembly.instantiateStreaming( fetch("./add.wasm") ); console.log(instance.exports.add(4, 1)); } init();
</script>

Если всё нормально, вы увидите в консоли DevTool число 17. Мы только что успешно скомпилировали C в WebAssembly, не трогая Emscripten. Также стоит отметить, что здесь нет никакого связующего кода для настройки и загрузки модуля WebAssembly.
Для компиляции C в WebAssembly мы сделали немало шагов. Как я уже сказал, в образовательных целях мы рассмотрели подробно все этапы. Давайте пропустим человекочитаемые промежуточные форматы и сразу применим компилятор C как швейцарский армейский нож, как он и был разработан:

clang \ --target=wasm32 \ -nostdlib \ # Don’t try and link against a standard library -Wl,--no-entry \ # Flags passed to the linker -Wl,--export-all \ -o add.wasm \ add.c

Здесь мы получаем такой же файл .wasm, но одной командой.
Посмотрим на WAT нашего модуля WebAssembly, запустив wasm2wat:

(module (type (;0;) (func)) (type (;1;) (func (param i32 i32) (result i32))) (func $__wasm_call_ctors (type 0)) (func $add (type 1) (param i32 i32) (result i32) (local i32 i32 i32 i32 i32 i32 i32 i32) global.get 0 local.set 2 i32.const 16 local.set 3 local.get 2 local.get 3 i32.sub local.set 4 local.get 4 local.get 0 i32.store offset=12 local.get 4 local.get 1 i32.store offset=8 local.get 4 i32.load offset=12 local.set 5 local.get 4 i32.load offset=12 local.set 6 local.get 5 local.get 6 i32.mul local.set 7 local.get 4 i32.load offset=8 local.set 8 local.get 7 local.get 8 i32.add local.set 9 local.get 9 return) (table (;0;) 1 1 anyfunc) (memory (;0;) 2) (global (;0;) (mut i32) (i32.const 66560)) (global (;1;) i32 (i32.const 66560)) (global (;2;) i32 (i32.const 1024)) (global (;3;) i32 (i32.const 1024)) (export "memory" (memory 0)) (export "__wasm_call_ctors" (func $__wasm_call_ctors)) (export "__heap_base" (global 1)) (export "__data_end" (global 2)) (export "__dso_handle" (global 3)) (export "add" (func $add)))

Ничего себе, какой большой код. К моему удивлению, модуль использует память (что видно по операциям i32.load и i32.store), восемь локальных и несколько глобальных переменных. Вероятно, вручную можно написать более лаконичную версию. Эта программа такая большая, потому что мы не применили никаких оптимизаций. Давай сделаем это:

clang \ --target=wasm32 \
+ -O3 \ # Agressive optimizations
+ -flto \ # Add metadata for link-time optimizations -nostdlib \ -Wl,--no-entry \ -Wl,--export-all \
+ -Wl,--lto-O3 \ # Aggressive link-time optimizations -o add.wasm \ add.c

В больших проектах LTO поможет заметно уменьшить размер файла. Примечание: технически, оптимизация при компоновке (LTO) не даёт никаких преимуществ, поскольку мы компонуем только один файл.

После выполнения этих команд файл .wasm уменьшился с 262 до 197 байт, а WAT тоже стал намного проще:

(module (type (;0;) (func)) (type (;1;) (func (param i32 i32) (result i32))) (func $__wasm_call_ctors (type 0)) (func $add (type 1) (param i32 i32) (result i32) local.get 0 local.get 0 i32.mul local.get 1 i32.add) (table (;0;) 1 1 anyfunc) (memory (;0;) 2) (global (;0;) (mut i32) (i32.const 66560)) (global (;1;) i32 (i32.const 66560)) (global (;2;) i32 (i32.const 1024)) (global (;3;) i32 (i32.const 1024)) (export "memory" (memory 0)) (export "__wasm_call_ctors" (func $__wasm_call_ctors)) (export "__heap_base" (global 1)) (export "__data_end" (global 2)) (export "__dso_handle" (global 3)) (export "add" (func $add)))

Использование C без стандартной библиотеки libc кажется довольно грубым. Логично добавить её, но буду честен: это не будет легко. На самом деле мы напрямую не вызываем в статье никаких библиотек libc. Есть несколько подходящих, особенно glibc, musl и dietlibc. Однако большинство этих библиотек предполагается запускать в операционной системе POSIX, которая реализует определённый набор системных вызовов. Поскольку у нас в JavaScript нет интерфейса ядра, нам придётся самостоятельно реализовать эти системные вызовы POSIX, вероятно, через JavaScript. Это сложная задача и я не собираюсь этим тут заниматься. Хорошая новость в том, что именно этим для вас занимается Emscripten.

Такие функции, как strlen(), sin() или даже memset(), реализованы в простом C. Конечно, не все функции libc полагаются на системные вызовы. Это означает, что вы можете использовать эти функции или даже просто скопировать/вставить их реализацию из какой-нибудь упомянутой библиотеки.

Без libc нам недоступны фундаментальные интерфейсы C, такие как malloc() и free(). В неоптимизированном WAT мы видели, что компилятор в случае необходимости использует память. Это означает, что мы не можем просто использовать память, как нам нравится, не рискуя её повредить. Нужно понять, как она используется.

Модели памяти LLVM

Способ сегментации памяти WebAssembly немного удивит опытных программистов. Во-первых, в WebAssembly технически допустим нулевой адрес, но часто он всё равно обрабатывается как ошибка. Во-вторых, стек приходит первым и увеличивается вниз (к более низким адресам), а куча появляется потом и растёт вверх. Причина в том, что память WebAssembly может увеличиться во время выполнения. Это означает, что нет фиксированного конца для размещения стека или кучи.

Вот макет wasm-ld:

Стек начинается с __data_end, а куча — с __heap_base. Стек растёт вниз, а куча — вверх. Потому что стек размещается первым, он ограничен максимальным размером, установленным при компиляции, то есть __heap_base минус __data_end

Это означает, что стек может вырасти максимум до 64 КиБ, что не много. Если вернуться и посмотреть на раздел globals в нашем WAT, мы найдём эти значения: __heap_base установлен в 66560, а __data_end — в 1024. К счастью, wasm-ld позволяет изменить это значение:

clang \ --target=wasm32 \ -O3 \ -flto \ -nostdlib \ -Wl,--no-entry \ -Wl,--export-all \ -Wl,--lto-O3 \
+ -Wl,-z,stack-size=$[8 * 1024 * 1024] \ # Set maximum stack size to 8MiB -o add.wasm \ add.c

Сборка аллокатора

Известно, что область кучи начинается с __heap_base. Поскольку функция malloc() отсутствует, мы знаем, что следующую область памяти можно спокойно использовать. Мы можем разместить там данные как угодно, и не нужно бояться повреждения памяти, поскольку стек растёт в другую сторону. Однако свободная для всех куча может быстро засориться, поэтому обычно требуется какое-то динамическое управление памятью. Один из вариантов — взять полноценную реализацию malloc(), такую как реализация malloc от Дага Ли, которая используется в Emscripten. Есть ещё несколько небольших реализаций с различными компромиссами.

Мы так глубоко увязли, что это уже без разницы. Но почему бы не написать собственный malloc()? Но есть и недостаток: вы не можете освободить память. Один из простейших — bump-аллокатор: он супербыстрый, чрезвычайно маленький и простой в реализации. Концепция бамп-аллокатора заключается в том, что мы храним начальный адрес неиспользуемой памяти как глобальный. Хотя на первый взгляд такой аллокатор кажется невероятно бесполезным, но при разработке Squoosh я столкнулся с прецедентами, где он стал бы отличным выбором. Если программа запрашивает n байт памяти, мы передвигаем маркер на n и возвращаем предыдущее значение:

extern unsigned char __heap_base; unsigned int bump_pointer = &__heap_base;
void* malloc(int n) { unsigned int r = bump_pointer; bump_pointer += n; return (void *)r;
} void free(void* p) { // lol
}

Глобальные переменные из WAT фактически определяются wasm-ld, так что мы можем получить к ним доступ из нашего кода C как к обычным переменным, если объявим их extern. Итак, мы только что написали собственный malloc()… в пяти строчках C.

Например, мы не даём никаких гарантий выравнивания. Примечание: наш бамп-аллокатор не полностью совместим с malloc() из C. Но он достаточно хорошо и работает, так что...

Использование динамической памяти

Для проверки давайте сделаем функцию C, которая принимает массив чисел произвольного размера и вычисляет сумму. Не очень интересно, но это заставляет использовать динамическую память, так как мы не знаем размер массива во время сборки:

int sum(int a[], int len) { int sum = 0; for(int i = 0; i < len; i++) { sum += a[i]; } return sum;
}

Функция sum(), надеюсь, довольно понятна. Более интересный вопрос заключается в том, как передать массив из JavaScript в WebAssembly — в конце концов, WebAssembly понимает только числа. Общая идея состоит в том, чтобы использовать malloc() из JavaScript, чтобы выделить кусок памяти, скопировать туда значения и передать адрес (число!) где находится массив:

<!DOCTYPE html> <script type="module"> async function init() { const { instance } = await WebAssembly.instantiateStreaming( fetch("./add.wasm") ); const jsArray = [1, 2, 3, 4, 5]; // Allocate memory for 5 32-bit integers // and return get starting address. const cArrayPointer = instance.exports.malloc(jsArray.length * 4); // Turn that sequence of 32-bit integers // into a Uint32Array, starting at that address. const cArray = new Uint32Array( instance.exports.memory.buffer, cArrayPointer, jsArray.length ); // Copy the values from JS to C. cArray.set(jsArray); // Run the function, passing the starting address and length. console.log(instance.exports.sum(cArrayPointer, cArray.length)); } init();
</script>

После запуска вы должны увидеть в консоли DevTools ответ 15, что действительно является суммой всех чисел от 1 до 5.
Итак, вы дочитали до конца. Поздравляю! Опять же, если чувствуете себя немного перегруженным, всё в порядке. Не обязательно читать все детали. Их понимание совершенно необязательно для хорошего веб-разработчика и даже не требуется для отличного использования WebAssembly. Но я хотел поделиться этой информацией, потому что она позволяет действительно оценить всю работу, которую делает для вас такой проект, как Emscripten. В то же время это даёт понимание, насколько маленькими могут быть чисто вычислительные модули WebAssembly. Модуль Wasm для суммирования массива вместился всего в 230 байт, включая аллокатор динамической памяти. Компиляция того же кода с помощью Emscripten даст 100 байт кода WebAssembly и 11K связующего кода JavaScript. Нужно постараться ради такого результата, но бывают ситуации, когда оно того стоит.

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»