LEARN X · ЗА 16 МИН

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 для строк и массивов.
Поддержать проект