WebAssembly (WAT)
Экспресс-тур по WebAssembly и текстовому формату WAT: модуль, типы, функции, стек, память, импорт/экспорт и загрузка из JavaScript за 16 минут.
WebAssembly (Wasm) — низкоуровневый бинарный формат, который браузеры и серверные рантаймы исполняют почти на скорости нативного кода. Это цель компиляции для C, C++, Rust, Go и других языков. WAT (WebAssembly Text) — человекочитаемое текстовое представление того же модуля: на нём удобно учиться и отлаживать. Весь тур — в комментариях к коду.
Что такое WebAssembly
Wasm — это компактный бинарный формат (файлы .wasm), который грузится и исполняется быстрее, чем разбор JS. Сам по себе он не заменяет JavaScript, а дополняет его для тяжёлых вычислений.
;; Это WAT — текстовая запись Wasm-модуля.
;; Двойная точка с запятой ;; начинает комментарий до конца строки.
(; а так выглядит блочный комментарий, он может быть многострочным ;)
;; Зачем нужен WebAssembly:
;; - скорость: близко к нативной (видео, игры, криптография, физика)
;; - переносимость: один .wasm работает в браузере, Node.js, Wasmtime
;; - язык-цель: в Wasm компилируют C/C++/Rust/Go/AssemblyScript
;; WAT нужен, чтобы читать и писать модули руками — потом он
;; собирается в .wasm инструментом wat2wasm (пакет wabt).
Модуль и S-выражения
Любой код Wasm живёт внутри модуля. WAT записывается S-выражениями — вложенными скобками (имя ...), как в Lisp.
;; Модуль — корневой контейнер. Пока пустой.
(module
;; внутри будут типы, функции, память, импорты и экспорты
)
;; S-выражение — это (ключевое-слово аргументы...).
;; Скобки задают вложенность. Например, функция, которая
;; складывает два числа, записывается так (разберём ниже):
(module
(func (param i32) (param i32) (result i32)
local.get 0
local.get 1
i32.add))
Типы данных
В Wasm всего четыре числовых типа. Нет строк, объектов и булевых — всё это надстройки поверх памяти.
;; i32 — 32-битное целое (самый частый тип; им же кодируют bool и указатели)
;; i64 — 64-битное целое
;; f32 — 32-битное число с плавающей точкой
;; f64 — 64-битное число с плавающей точкой
;; Константы кладутся на стек инструкциями <тип>.const:
(module
(func (result i32) i32.const 42) ;; вернёт целое 42
(func (result f64) f64.const 3.14)) ;; вернёт дробное 3.14
;; Знаковость — свойство ОПЕРАЦИИ, а не типа.
;; Например, деление: i32.div_s (signed) и i32.div_u (unsigned).
Функции, параметры и результат
Функция объявляется через func. У неё есть параметры (param), не более одного результата по умолчанию (result) и тело из инструкций.
(module
;; (func $имя (param ...) (result ...) тело)
;; $add — символическое имя (удобнее, чем индекс).
(func $add (param $a i32) (param $b i32) (result i32)
local.get $a ;; положить на стек значение параметра a
local.get $b ;; положить на стек значение параметра b
i32.add) ;; снять два числа, сложить, результат — на стеке
;; export делает функцию видимой снаружи (для JS) под именем "add".
(export "add" (func $add)))
Локальные и глобальные переменные
local — переменная внутри функции, global — на уровне модуля. Доступ через .get и .set.
(module
;; Глобальная переменная. mut — изменяемая; без mut была бы константой.
(global $counter (mut i32) (i32.const 0))
(func $next (result i32)
;; локальная переменная tmp типа i32 (объявляется в начале тела)
(local $tmp i32)
global.get $counter ;; взять текущее значение глобала
i32.const 1
i32.add ;; counter + 1 на стеке
global.set $counter ;; записать обратно в глобал
global.get $counter ;; снова берём, чтобы вернуть
local.set $tmp ;; сохранить в локальную
local.get $tmp) ;; и вернуть её
(export "next" (func $next)))
Стековая машина
Wasm — стековая виртуальная машина. Инструкции кладут значения на стек и снимают их. У i32.add нет аргументов в скобках — он берёт два верхних значения сам.
;; Линейная (постфиксная) запись — операнды идут ПЕРЕД операцией:
;; i32.const 2 -> стек: [2]
;; i32.const 3 -> стек: [2, 3]
;; i32.add -> снимает 3 и 2, кладёт 5 -> стек: [5]
(module
(func $five (result i32)
i32.const 2
i32.const 3
i32.add))
;; То же самое можно записать вложенными скобками (folded form) —
;; компилятор сам разложит это в линейный порядок:
(module
(func $five2 (result i32)
(i32.add (i32.const 2) (i32.const 3))))
Вызов функций
Одна функция вызывает другую инструкцией call: сначала на стек кладутся аргументы, затем идёт вызов.
(module
(func $square (param $x i32) (result i32)
local.get $x
local.get $x
i32.mul) ;; x * x
(func $sumOfSquares (param $a i32) (param $b i32) (result i32)
local.get $a
call $square ;; снимет a, вызовет square, положит a*a
local.get $b
call $square ;; b*b на стеке
i32.add) ;; (a*a) + (b*b)
(export "sumOfSquares" (func $sumOfSquares)))
Управление: if/else, loop, block, br
Ветвления и циклы — это структурированные блоки. Переходы (br, br_if) указывают на блок по его глубине или метке.
(module
;; Модуль числа: if (x < 0) -x else x
(func $abs (param $x i32) (result i32)
local.get $x
i32.const 0
i32.lt_s ;; x < 0 ? (1 — истина, 0 — ложь) на стек
(if (result i32)
(then
i32.const 0
local.get $x
i32.sub) ;; 0 - x
(else
local.get $x)))
;; Сумма 1..n циклом. block — точка выхода, loop — точка повтора.
(func $sumTo (param $n i32) (result i32)
(local $i i32)
(local $acc i32)
(block $done
(loop $again
local.get $i
local.get $n
i32.gt_s
br_if $done ;; если i > n — выйти из block $done
local.get $acc
local.get $i
i32.add
local.set $acc ;; acc += i
local.get $i
i32.const 1
i32.add
local.set $i ;; i += 1
br $again)) ;; безусловный переход в начало loop
local.get $acc)
(export "abs" (func $abs))
(export "sumTo" (func $sumTo)))
Линейная память
memory — это непрерывный массив байтов. Чтение и запись идут по адресу (смещению в байтах) через load и store.
(module
;; Память в страницах по 64 КиБ: 1 страница минимум, до 10 максимум.
(memory $mem 1 10)
(export "memory" (memory $mem))
;; Записать i32 по адресу addr, потом прочитать обратно.
(func $store (param $addr i32) (param $value i32)
local.get $addr
local.get $value
i32.store) ;; записать 4 байта по адресу addr
(func $load (param $addr i32) (result i32)
local.get $addr
i32.load) ;; прочитать 4 байта по адресу addr
;; Есть и узкие доступы: i32.load8_u, i32.store8 (по одному байту).
(export "store" (func $store))
(export "load" (func $load)))
Импорт и экспорт
export отдаёт наружу функции и память. import впускает внутрь то, что предоставит хост (обычно — функции из JS).
(module
;; Импортируем функцию из объекта JS: importObject.env.log
;; Подпись должна совпасть: один параметр i32, без результата.
(import "env" "log" (func $log (param i32)))
(func $greet
i32.const 123
call $log) ;; позовём JS-функцию log(123)
(export "greet" (func $greet)))
;; Можно импортировать и память, и глобалы:
;; (import "env" "mem" (memory 1))
;; (import "env" "seed" (global i32))
Загрузка из JavaScript
В браузере и Node модуль компилируется и инстанцируется через WebAssembly.instantiate. Экспортированные функции лежат в instance.exports.
// Объект импортов: то, что модуль ждёт через (import ...).
const importObject = {
env: {
log: (x) => console.log('из wasm:', x),
},
};
// 1) Грузим .wasm и инстанцируем (потоковый вариант — быстрее).
const { instance } = await WebAssembly.instantiateStreaming(
fetch('module.wasm'),
importObject,
);
// 2) Зовём экспортированные функции как обычные JS-функции.
console.log(instance.exports.add(2, 3)); // => 5
instance.exports.greet(); // вызовет наш log
// Если нет fetch (например, в Node со своими байтами):
// const bytes = fs.readFileSync('module.wasm');
// const { instance } = await WebAssembly.instantiate(bytes, importObject);
Компиляция из C и Rust
Руками WAT пишут редко — обычно Wasm получают компилятором. Ниже два самых частых пути.
// C / C++ через Emscripten:
// emcc add.c -o add.js -s EXPORTED_FUNCTIONS="['_add']"
// Получите add.wasm + JS-обёртку, которая всё загрузит за вас.
// Rust через wasm-pack:
// #[no_mangle] pub extern "C" fn add(a: i32, b: i32) -> i32 { a + b }
// wasm-pack build --target web
// На выходе — .wasm и удобный JS/TS-пакет с типами.
// AssemblyScript (TypeScript-подобный синтаксис -> Wasm):
// export function add(a: i32, b: i32): i32 { return a + b; }
// npx asc add.ts -o add.wasm
Типичный пример: факториал
Соберём целиком: рекурсивная функция факториала на WAT и её вызов из JS.
(module
;; factorial(n) = n <= 1 ? 1 : n * factorial(n - 1)
(func $factorial (param $n i32) (result i32)
local.get $n
i32.const 1
i32.le_s ;; n <= 1 ?
(if (result i32)
(then
i32.const 1) ;; база рекурсии
(else
local.get $n
local.get $n
i32.const 1
i32.sub
call $factorial ;; factorial(n - 1)
i32.mul))) ;; n * factorial(n - 1)
(export "factorial" (func $factorial)))
// Собираем WAT в байты (через wabt) или грузим готовый .wasm,
// затем вызываем экспорт factorial:
const { instance } = await WebAssembly.instantiateStreaming(
fetch('factorial.wasm'),
);
for (let n = 0; n <= 5; n++) {
console.log(n + '! =', instance.exports.factorial(n));
}
// 0! = 1, 1! = 1, 2! = 2, 3! = 6, 4! = 24, 5! = 120
// Дальше: связывайте Wasm с памятью и JS для строк и массивов.