Привет, друзья!
Представляю вашему вниманию перевод этой замечательной статьи, в которой автор рассказывает о том, как разработать компилятор для WebAssembly на TypeScript.
Обратите внимание: мой вариант компилятора можно найти в этом репозитории, а поиграть с его кодом можно в этой песочнице.
Что такое WebAssembly и зачем он нужен?
Если вы никогда раньше не слышали о WebAssembly
(далее - WA
или wasm
), рекомендую взглянуть на это визуальное руководство от Lin Clark.
Сравнение WA
с JavaScript
(далее - JS
) можно представить следующим образом:
На верхней диаграмме представлен упрощенный процесс выполнения JS-кода
в браузере. Слева направо: код (обычно доставляемый в виде минифицированной мешанины) р азбирается (парсится) в абстрактное синтаксическое дерево (Abstract Syntax Tree, AST), первоначально выполняется в интерпретаторе, затем прогрессивно оптимизируется/повторно оптимизируется до тех пор, пока не будет выполняться достаточно быстро. Современный JS
, в конечном счете, является очень быстрым, но для его "подготовки" к выполнению требуется какое-то время.
На нижней диаграмме представлен эквивалент WA
. Код, написанный на одном из нескольких языков (Rust
, C
, C#
и др.) компилируется в WA
, который доставляется в бинарном (двоичном) формате. Этот формат легко декодируется, компилируется и выполняется, что обеспечивает предсказуемо высокую производительность.
Наша цель
Наша цель - разработать язык программирования, достаточный для создания программы для рендеринга множества Мандельброта.
Это будет выглядеть так:
Я назвал свой язык chasm
. Его исходный код можно найти в этом репозитории на GitHub, а в этой онлайн-песочнице можно с ним поиграть.
Минимальный модуль wasm
Создадим минимальный модуль wasm
(далее - модуль).
Определим эмиттер (emitter - часть компилятора, генерирующая инструкции для целевой системы), создающего такой модуль:
// https://webassembly.github.io/spec/core/binary/modules.html#binary-module
const magicModuleHeader = [0x00, 0x61, 0x73, 0x6d]
const moduleVersion = [0x01, 0x00, 0x00, 0x00]
const emiter: Emitter = () => {
const buffer = [
...magicModuleHeader,
...moduleVersion
]
return Uint8Array.from(buffer)
}
Эмиттер состоит из 2 частей: "магического" заголовка, представляющего собой строку \0asm
в формате ASCII
, и номера версии. Эти 8 байт
формируют валидный модуль. Обычно, модуль доставляется в браузер в виде файла с расширением .wasm
.
Для выполнения модуля его необходимо инстанцировать (instantiate - создать экземпляр класса) следующим образом:
const wasm = emitter()
const instance = await WebAssembly.instantiate(wasm)
Если мы запустим этот код, то ничего не произойдет, поскольку наш модуль пока не содержит никаких инструкций.
В указанном выше репозитории имеются коммиты для каждого шага, которые мы будем рассматривать в дальнейшем.
Функция для сложения чисел
Реализуем функцию для сложения 2 чисел с плавающей точкой/запятой (float).
WA
- это бинарный формат, не рассчитанный на чтение людьми, поэтому был специально разработан текстовый формат WA
(WebAssembly TextFormat
, WAT
). Вот пример модуля в текстовом формате, в котором определяется функция $add
, принимающая 2 числа с плавающей точкой, складывающая их и возвращающая результат:
(module
(func $add (param f32) (result f32)
get_local 0
get_local 1
f32.add)
(export "add" (func 0))
)
Для компиляции WAT
в wasm
можно использовать wat2wasm
из бинарного набора инструментов для WA.
Из приведенного выше кода можно извлечь следующую информацию:
WA
- это низкоуровневый язык с небольшим набором инструкций (всего их около60
), в котором большая часть инструкций близко связана с инструкциями для центрального процессора/ЦП (CPU
). Это облегчает процесс компиляции модулей в специфичный для процессора машинный код;- он не имеет встроенного ввода/вывода (
input/output
,I/O
). Не существует инструкций для вывода данных в терминал, на экран или в сеть. Поэтому для взаимодействия с внешним миром модули используют хостовое окружение (host environment), которым в случае с браузером являетсяJS
; WA
- это стек машина (stack machine). В приведенном примереget_local 0
получает локальную переменную (параметр функции), находящуюся на позиции с индексом 0, и помещает ее в стек. То же самое делает следующая инструкция (меняется только индекс). Функцияf32.add
извлекает значения из стека, складывает их и помещает результат обратно в стек;WA
имеет всего лишь 4 числовых типа: 2 для целых чисел и 2 для чисел с плавающей точкой. Об этом позже...
Обновим код эмиттера.
Модули - это композиция предварительно определенных опциональных разделов (sections), каждый раздел имеет префикс - числовой идентификатор. Имеется раздел для типа, в котором закодированы сигнатуры типов, и раздел для функций, определяющий тип каждой функции. Я не буду на этом останавливаться - реализация является достаточно наивной. Вы найдете ее здесь.
Интересным является раздел для кода. Вот как функция add
преобразуется в бинарный формат:
const code = [
Opcodes.get_local, // 0x20
...unsignedLEB128(0),
Opcodes.get_local, // 0x20
...unsignedLEB128(1),
Opcodes.f32_add // 0x92
]
const functionBody = encodeVector([
...encodeVector([]), // locals
...code,
Opcodes.end // 0x0b
])
const codeSection = createSection(Section.code /* 0x0a */, encodeVector([functionBody]))
Opcodes
(opcode, operation code - код операции) - это перечисление (enum
), содержащее все инструкции wasm
. unsignedLEB128
- это функция для сжатия кода переменной длины для хранения произвольно больших целых чисел в небольшом количестве байтов (LEB128). Она используется для кодирования параметров инструкций.
Инструкции функции комбинируются с ее локальными переменными (которые в данном случае отсутствуют), код операции end
сигнализирует о завершении выполнения функции. Наконец, все функции помещаются в раздел. Функция encodeVector
просто добавляет префикс со значением общей длины к коллекции байтовых массивов.
Длина получившегося модуля составляет около 30 байт
.
Обновим JS-код
:
const { instance } = await WebAssembly.instantiate(wasm)
console.log(instance.exports.add(5, 6))
Обратите внимание: если вы исследуете экспортируемую функцию add
с помощью инструментов разработчика в Chrome
, то увидите, что это "обычная функция" (native function).