Раздел 23 · Rust

Что такое WebAssembly: стековая машина и линейная память

senior~35 мин

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

Что такое WebAssembly: стековая машина и линейная память

Весь этот раздел ты пользовался виджетами, которые на самом деле не на JavaScript. Пошаговый bcpu из блока про эмулятор, эмулятор NES, симулятор netcode из прошлого блока, всё это один и тот же Rust-код из examples/, скомпилированный в WebAssembly и запущенный прямо в браузере. Тот же код, две поверхности: и боевой эмулятор в тестах, и живой виджет урока. Сегодня мы наконец открываем коробку. Не «как прикрутить wasm-pack» (это следующий урок), а что такое WebAssembly по своему устройству: какая это машина, где у неё память, что она умеет и чего не умеет, и почему Rust ложится на неё так ровно. Если ты прошёл машинный уровень и биты с раскладкой, половина урока покажется знакомой: WebAssembly это ещё одна машина, аккуратно спроектированная.

WebAssembly это не язык, а цель компиляции

Первое, что путает: WebAssembly не конкурент Rust или JavaScript, на нём не пишут руками. Это переносимый бинарный формат и набор инструкций виртуальной машины, цель компиляции. В него компилируются Rust, C, Go, Zig. Браузеру (или серверному рантайму вроде Wasmtime) дают готовый модуль .wasm, и он исполняет его быстро и предсказуемо, потому что у формата есть строгая спецификация: те же инструкции, та же арифметика, то же поведение везде.

У формата два лица. Бинарное это .wasm, компактные байты, которые едут по сети. Текстовое это WAT, тот же модуль в виде читаемых s-выражений. Мы будем смотреть на WAT ровно по той же причине, по которой читали дизассемблер через godbolt: чтобы видеть, во что превращается код, а не верить на слово.

Стековая машина, а не регистровая

Вот ключевое отличие от всего, что ты строил в этом разделе. Твой bcpu и настоящий x86 это регистровые машины: у инструкции есть именованные ячейки-операнды (r0..r7, rax, rbx), и она говорит «сложи r1 и r2, положи в r3». WebAssembly это стековая машина: у инструкций нет операндов вообще. Есть один операндный стек, и инструкции общаются только через него. i32.const 2 кладёт число на стек. i32.add снимает два верхних и кладёт обратно их сумму.

Вот функция (2 + 3) * 4 на WAT, и рядом то же самое на Rust:

(func $calc (result i32)
  i32.const 2    ;; стек: [2]
  i32.const 3    ;; стек: [2, 3]
  i32.add        ;; стек: [5]
  i32.const 4    ;; стек: [5, 4]
  i32.mul)       ;; стек: [20]
fn calc() -> i32 {
    (2 + 3) * 4
}

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

Когда модуль реально исполняется, этот стек, конечно, не живёт стопкой в памяти: рантайм при загрузке отображает его обратно в регистры процессора и в нативный код. Стековая модель это контракт формата, а не способ исполнения. Ровно как твой fetch-decode-execute был моделью, а не тем, что делает кремний.

Всего четыре типа значений

На стеке могут лежать значения только четырёх типов: i32, i64, f32, f64. Тридцати- и шестидесятичетырёхбитные целые и числа с плавающей точкой, и всё. Целые при этом без знаковости: знак выбирает операция, а не тип, поэтому есть i32.div_s (знаковое) и i32.div_u (беззнаковое). Если это звучит знакомо, так и есть: ровно про эту разницу был урок про дополнительный код, а плавающая точка тут тот же IEEE-754, что ты разбирал по битам.

А где же строки, векторы, структуры? Их на уровне типов значений нет. Совсем. String, Vec<u8>, твой Snapshot из протокола, всё это не значения WebAssembly. Они живут байтами в одном месте, и на стек попадает только число-адрес. Это место называется линейной памятью, и оно следующее.

Линейная память: один большой Vec<u8>

У модуля есть линейная память: один непрерывный массив байт, адресуемый с нуля, который можно растить страницами по 64 КиБ. Это вся куча модуля. Когда Rust в WASM делает String::from("hi") или vec![1, 2, 3], байты ложатся именно сюда, а «указатель» это просто i32-смещение от начала массива. Многобайтовые числа лежат little-endian, ровно как ты упаковывал их to_le_bytes.

Мысленная модель прямая: линейная память это Vec<u8>, который видят сразу двое. Внутри модуля по нему ходит твой Rust. Снаружи хост (JavaScript) видит тот же массив как обычный ArrayBuffer и может читать и писать его напрямую. Именно так из WASM «возвращают строку»: модуль кладёт байты в память и отдаёт наружу пару чисел, смещение и длину, а хост читает этот срез. Ты уже видел этот приём в боевом коде. Вот как bcpu отдаёт текст дизассемблера в браузер:

thread_local! {
    /// Буфер для текста дизассемблера. JS читает его через указатель и длину.
    static TEXT: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
}

/// Смещение текстового буфера в линейной памяти. Хост возьмёт TEXT.len() байт
/// начиная с этого адреса и раскодирует их как UTF-8.
#[no_mangle]
pub extern "C" fn bcpu_text_ptr() -> *const u8 {
    TEXT.with(|t| t.borrow().as_ptr())
}

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

Модуль: импорты, экспорты и больше ничего

Модуль WebAssembly это закрытая коробка с двумя списками отверстий. Экспорты это то, что модуль отдаёт наружу: функции, которые хост может вызвать по имени, и саму линейную память. Импорты это то, что модуль просит у хоста до запуска: функции, которых у него самого нет.

И вот тут самое важное про безопасность. Чистый модуль WebAssembly не умеет ничего, кроме арифметики и работы со своей памятью. У него нет ни консоли, ни часов, ни сети, ни файлов, ни случайных чисел. Хочешь напечатать строку в консоль? Объяви функцию импортом, и пусть хост её даст:

(module
  ;; Просим у хоста функцию печати. Сами мы в консоль не умеем.
  (import "env" "log" (func $log (param i32 i32)))

  ;; Отдаём наружу нашу память и функцию.
  (memory (export "memory") 1)
  (func (export "greet")
    i32.const 0    ;; смещение строки в памяти
    i32.const 5    ;; её длина
    call $log))

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

Портируемость: один .wasm на всех

Раз у модуля нет ни предположений об операционной системе, ни прямого доступа к железу, один и тот же .wasm исполняется одинаково в Chrome, Firefox, Safari, в Node, в Wasmtime на сервере. Арифметика детерминирована спецификацией: i32.add переполняется одинаково везде, f64 следует IEEE-754 везде. Это не «обычно совпадает», а гарантия формата. Именно поэтому наш приём «один Rust-код, две поверхности» вообще работает: bcpu проходит тесты нативно на твоей машине и крутится виджетом в чужом браузере, и это байт в байт одно и то же поведение.

Как Rust попадает в WebAssembly

Теперь связка с твоим инструментом. У Rust для браузера есть отдельная цель wasm32-unknown-unknown. Имя читается как target triple: архитектура wasm32 (32-битные адреса в линейной памяти), а вендор и ОС оба unknown, то есть «никакой операционной системы под нами». Поэтому из std доступна не вся библиотека: файлов и потоков ОС тут нет, их при нужде пришлось бы просить у хоста импортами.

Ставится цель и собирается ровно так:

rustup target add wasm32-unknown-unknown
cargo build --release --target wasm32-unknown-unknown

Чтобы из крейта получился загружаемый модуль, его тип должен быть cdylib (динамическая библиотека с C-совместимым ABI). Вот реальный кусок Cargo.toml нашего эмулятора, тот самый, что даёт и обычную библиотеку для уроков и тестов, и WASM-модуль для виджета:

[lib]
# rlib для уроков и тестов, cdylib для сборки в wasm32 (движок виджета).
# Тот же код, две поверхности.
name = "our_cpu"
crate-type = ["rlib", "cdylib"]

А экспортируемые функции помечаются двумя вещами: #[no_mangle], чтобы Rust не искажал имя, и extern "C", чтобы соглашение о вызове было плоским и предсказуемым. Через границу при этом ходят только числа из тех самых четырёх типов:

/// Один шаг fetch-decode-execute. Возврат: 0 исполнено, 1 остановлено, 2 прерывание.
#[no_mangle]
pub extern "C" fn bcpu_step() -> i32 {
    MACHINE.with(|m| {
        let m = &mut *m.borrow_mut();
        match m.cpu.step(&mut m.bus) {
            Step::Executed { .. } => 0,
            Step::Halted { .. } => 1,
            Step::Interrupt { .. } => 2,
        }
    })
}

Возвращать структуру Step напрямую нельзя: на границе живут только i32, i64, f32, f64. Поэтому Step свёрнут в число-код, а сложное (текст, состояние) отдаётся через буфер в линейной памяти, как видели выше. Это работает, но писать руками такие плоские обёртки для каждой функции скучно и ошибкоопасно. Ровно эту скуку снимает wasm-bindgen: он генерирует и Rust-обёртки, и JS-склейку, чтобы строки, структуры и замыкания ходили через границу будто сами собой. Как именно, разберём в следующем уроке.

Что унести из урока

WebAssembly это не язык, а переносимый бинарный формат и набор инструкций виртуальной машины, цель компиляции для Rust. Машина эта стековая, а не регистровая: у инструкций нет операндов, они снимают аргументы с операндного стека и кладут туда результат, и рантайм отображает этот стек в настоящие регистры при загрузке. На стеке живут значения всего четырёх типов: i32, i64, f32, f64, причём целые без знаковости, знак выбирает операция. Всё сложное (строки, векторы, структуры) лежит байтами в линейной памяти, единственной куче модуля, которую хост видит как ArrayBuffer, а наружу отдаётся числом-смещением. Модуль это закрытая коробка: он умеет только считать и работать со своей памятью, а любой контакт с миром (консоль, время, сеть) приходит импортом от хоста, и в этом вся песочница. Один .wasm исполняется одинаково везде, потому что поведение задано спецификацией. Rust собирается в это под целью wasm32-unknown-unknown, крейтом cdylib, а функции на границе помечаются #[no_mangle] extern "C" и обмениваются только числами. Дальше: мост Rust и JS через wasm-bindgen.

Домашка