Что такое WebAssembly: стековая машина и линейная память
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Что такое 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.