Курс Лекція 9
▶ СлайдиСРС 17+18⌨️ команди 4 · 3 нових
Конспект · Лекція 9

WEBASSEMBLY ЯК ПЕРЕНОСНИЙ АСЕМБЛЕР

Один код — дві машини

Ключова ідея. WebAssembly — це той самий принцип 'мова, близька до заліза', але для віртуальної стекової машини (*stack machine*), яку розуміє кожен браузер і кожен рушій незалежно від фізичного CPU.
Демо. Той самий .wat-модуль з функцією додавання компілюється у wat2wasm і запускається у браузері через WebAssembly.instantiateStreaming — виклик з DevTools console дає результат миттєво.
🧩 СРС ДО ЦІЄЇ ЛЕКЦІЇ · 2-га година заняття

Після презентації теми — самостійна робота в аудиторії над двома темами. Вони ж готують до захисту та розширення ЛР9. Матеріали публікуються в Google Classroom разом із лекцією.

📚 ТЕОРЕТИЧНІ ВІДОМОСТІ

Вступ: та сама задача, інша машина

x86-64 (Лекція 8) — набір інструкцій конкретного фізичного процесора: те, що вміє виконати кристал Intel чи AMD, і більше нічого. WebAssembly (WASM) розв'язує іншу задачу: дати переносний, компактний, безпечний байт-код, що однаково виконується в Chrome, Firefox, Node.js, Wasmtime чи на мікроконтролері, незалежно від того, ARM це, x86 чи RISC-V під капотом. WASM успадкував головну ідею асемблера — «мова, близька до заліза, без прихованої магії», — але заради переносності відмовився від найінтимнішого атрибута фізичного процесора: регістрів. Замість них — операційний стек.

Важливо одразу розвіяти поширену помилку: WASM — це не заміна JavaScript і не «повільніший JS, що виглядає як асемблер». Це окремий формат байт-коду з власною віртуальною машиною, і рушій браузера компілює його в нативні інструкції (JIT/AOT) так само, як компілює JS — тільки з набагато меншим простором для здогадок про типи, бо типи в WASM статичні й перевірені ще до першого виконання.

Стекова модель виконання

x86-64 обчислює вирази через іменовані регістри: movl -4(%rbp), %eax; addl -8(%rbp), %eax. WASM не має регістрів загального призначення — натомість у кожної функції є операційний стек значень (i32, i64, f32, f64), і кожна інструкція знімає з нього стільки значень, скільки їй треба, а результат кладе назад:

i32.const 2   ;; стек: [2]
i32.const 3   ;; стек: [2, 3]
i32.add       ;; зняти 2 і 3, покласти 5 -> стек: [5]

Це запис у постфіксній (зворотній польській) нотації: порядок інструкцій буквально є порядком обчислення виразу, без потреби в дужках чи пріоритеті операцій. local.get/local.set виконують ту саму роль, що звернення -4(%rbp) в асемблері x86-64 — доступ до параметрів і локальних змінних, тільки не за адресою в пам'яті, а за індексом у таблиці локальних змінних функції.

Типи значень і валідація

WASM MVP (базова специфікація) визначає чотири числові типи: i32, i64 (цілі, без розрізнення знаку — знак визначає інструкція, наприклад i32.div_s проти i32.div_u), f32, f64 (IEEE-754). Сучасні розширення (reference types, SIMD v128, GC) додають funcref, externref, v128, але базова модель курсу — саме чотири числові типи.

Головна відмінність від x86-64: модуль перевіряється (validation) ще до виконання. Рушій статично проходить кожну функцію й переконується, що на кожному кроці стек містить рівно ті типи, яких очікує наступна інструкція. Якщо .wat намагається викликати i32.add, маючи на стеку f64, модуль не завантажиться — на відміну від x86-64, де невідповідність типів на асемблерному рівні просто не існує (там усе — байти, а тип — питання інтерпретації компілятором вищого рівня). Ця валідація — одна з причин, чому WASM безпечний за замовчуванням: неможливо «випадково» виконати довільний код через переплутаний тип, як це можливо через переповнення буфера в C.

Анатомія .wat-модуля

.wat (WebAssembly Text Format) — текстове, людинозрозуміле представлення бінарного .wasm; кожна бінарна конструкція має точний текстовий відповідник, і wat2wasm/wasm2wat конвертують між ними без втрат.

(module
  (func $add (param $a i32) (param $b i32) (result i32)
    local.get $a
    local.get $b
    i32.add)
  (export "add" (func $add)))
  • (module ...) — корінь дерева; усе, що є в модулі, вкладено сюди у вигляді S-виразів (та сама дужкова нотація, що в Lisp — кожен вузол дерева синтаксису явний);
  • (func $ім'я (param ...) (result ...) тіло) — функція з обов'язковою типізованою сигнатурою; $ім'я — символічний ідентифікатор, який компілюється в числовий індекс у бінарному форматі;
  • (export "назва" (func $ім'я)) — робить функцію видимою ззовні модуля, для виклику з JavaScript чи іншого хоста; без експорту функція існує, але недосяжна;
  • (import "модуль" "ім'я" (func ...)) — дзеркальна конструкція: імпорт функції (чи пам'яті, таблиці, глобальної змінної) з хост-середовища всередину модуля.

Керування потоком: структурованість замість довільних стрибків

x86-64 має jmp/jcc на довільну мітку — будь-яку адресу коду. WASM свідомо цього не дозволяє: керування потоком структуроване, вкладене, як дужки в мові програмування.

(func $sum_to_n (param $n i32) (result i32)
  (local $i i32) (local $acc i32)
  (block $break
    (loop $continue
      local.get $i
      local.get $n
      i32.ge_s
      br_if $break        ;; вихід з block, якщо i >= n
      local.get $acc
      local.get $i
      i32.add
      local.set $acc
      local.get $i
      i32.const 1
      i32.add
      local.set $i
      br $continue))       ;; повернутися на початок loop
  local.get $acc)
  • block ... end — іменована ділянка коду; br $мітка виходить із неї одразу (аналог break);
  • loop ... end — на відміну від block, br всередині нього повертається на початок (аналог continue), а не виходить назовні;
  • br_if — умовний варіант: знімає i32 зі стека, і якщо він не нуль — виконує br;
  • є ще if ... else ... end для структурованого розгалуження без явних міток.

Ця структурованість — не стилістичний вибір, а гарантія: неможливо стрибнути в середину чужої функції чи на довільне зміщення в пам'яті коду, як це технічно можливо (хоч і небезпечно) у x86-64 через самомодифікований код чи пошкоджену адресу повернення. Компілятори (LLVM для Rust/C/C++) генерують ці конструкції автоматично з for/while/loop вихідної мови — вручну писати їх, як у Лекції 8, потрібно лише для навчання самій моделі.

Лінійна пам'ять

Окремо від операційного стека кожен інстанс модуля має лінійну пам'ять (linear memory) — суцільний, адресований з нуля масив байтів, спільний для всіх функцій модуля:

(memory (export "mem") 1)      ;; 1 сторінка = 64 КіБ, зростає динамічно

(func $store_and_load (result i32)
  i32.const 0        ;; адреса
  i32.const 42        ;; значення
  i32.store            ;; memory[0..4] = 42 (little-endian)
  i32.const 0
  i32.load)            ;; читає ті самі 4 байти назад

Пам'ять росте сторінками по 64 КіБ через memory.grow (повертає попередній розмір у сторінках або -1 при відмові — типова точка, де забувають перевірити помилку). Доступ — за i32-адресою через i32.load/i32.store (і 8-, 16-бітні варіанти з суфіксами 8/16). Рядки, масиви, будь-які складні структури живуть саме тут, а не в локальних змінних чи операційному стеку — це прямий аналог того, як у x86-64 масиви й структури живуть за адресами відносно %rbp, тільки замість регістра-бази — фіксована база 0 та єдиний спільний адресний простір модуля.

Критична відмінність від C/асемблера: вихід за межі виділеної пам'яті — це trap (контрольоване переривання виконання з помилкою), а не невизначена поведінка (UB). Рушій завжди перевіряє межі при кожному load/store; заплатити за це доводиться швидкістю (перевірка на кожен доступ), натомість отримуємо гарантію, що модуль фізично не може прочитати чи записати пам'ять поза власною лінійною областю — навіть якщо код усередині модуля містить логічну помилку.

Порівняння з нативним x86-64

x86-64 (Л8) WebAssembly
Обчислення іменовані регістри операційний стек значень
Керування потоком довільний jmp/jcc на мітку структуровані block/loop/br/if
Пам'ять адресний простір процесу, будь-яка адреса ізольована лінійна пам'ять, з нуля, з перевіркою меж
Виклик функції ABI-угода про регістри/стек типізована сигнатура, перевірена валідатором
Перевірка коректності немає — байти є байти валідація типів при завантаженні модуля
Ціль виконання конкретний фізичний CPU будь-який рушій WASM (браузер, Node.js, Wasmtime…)

Пісочниця (sandbox): модель безпеки за замовчуванням

WASM-модуль не може виконати системний виклик, відкрити файл чи звернутися до мережі напряму — у нього немає інструкції «syscall». Єдиний спосіб взаємодіяти зі світом поза власною лінійною пам'яттю — явно оголошені імпорти, які надає хост (host — браузер чи Node.js) під час інстанціювання. Це капабіліті-модель: модуль отримує рівно ті можливості, які йому явно передали, і жодних інших. У поєднанні зі структурованим керуванням потоком (неможливо стрибнути в довільне місце) і перевіркою меж лінійної пам'яті (неможливо прочитати чужу пам'ять) це робить WASM природним кандидатом для виконання недовіреного коду — саме тому браузер може безпечно запустити модуль, завантажений з довільного сайту.

Живий приклад: .wat у браузері

wat2wasm add.wat -o add.wasm
<script type="module">
  const { instance } = await WebAssembly.instantiateStreaming(fetch('add.wasm'));
  console.log(instance.exports.add(2, 3));   // 5
</script>

instantiateStreaming компілює модуль паралельно з завантаженням байтів по мережі — рушій не чекає, доки файл довантажиться повністю, щоб почати компіляцію перших функцій.

Підводні камені

  • Забути export — функція існує в модулі, компілюється, валідується, але недосяжна з JS, доки не оголошена в (export ...).
  • Плутати i32.add з i32.div_s/i32.div_u — цілі типи WASM не мають вбудованого знаку; знак визначає інструкція, а не сам тип значення.
  • Розраховувати на UB при виході за межі пам'яті, як у C, — WASM завжди робить trap, а не «читає щось випадкове»; це інша модель помилки, яку треба явно ловити на боці хоста.
  • Не перевіряти результат memory.grow — при відмові повертається -1 (сторінок), а не викид виключення; код, що ігнорує це, продовжує роботу з пам'яттю, яка насправді не виросла.
  • Ігнорувати вирівнювання (align=) як обов'язкову вимогу — це лише підказка рушію для оптимізації, доступ спрацює й без правильного вирівнювання, просто повільніше.
  • Намагатися стрибнути на довільну мітку, як у x86-64 jmp — WASM цього структурно не дозволяє; переписувати логіку доведеться через block/br, а не через мітки-адреси.

Розширення специфікації: SIMD, reference types, exception handling

Базова специфікація (WASM MVP, 2017) визначала лише чотири числові типи й структурований потік керування, описані вище. Відтоді специфікація розширилась кількома важливими доповненнями, які станом на 2026 рік підтримує кожен сучасний рушій: reference types (funcref, externref — типізовані посилання на функції та host-об'єкти, потрібні, зокрема, для wasm-bindgen), SIMD (v128 — 128-бітний вектор для паралельної обробки чисел, критичний для кодеків і графіки), tail calls (оптимізація рекурсії без росту стека) і exception handling (нативні try/catch на рівні байт-коду, а не емуляція через коди повернення). Для навчальної моделі курсу достатньо чотирьох базових числових типів — саме вони формують фундамент, на якому побудовані всі розширення.

Підсумок

WebAssembly переносить головну ідею асемблера — пряме, без прихованої магії відображення обчислення на інструкції машини — у формат, незалежний від фізичного процесора. Замість регістрів — операційний стек; замість довільних переходів — структурований block/loop/br; замість «будь-яка адреса процесу» — ізольована, перевірена на межі лінійна пам'ять; замість «довіряй байтам» — валідація типів при завантаженні. Це той самий словник, яким ми говорили в Лекції 8, перекладений на мову, яку розуміє кожен браузер, сервер і мікроконтролер одночасно — і саме ця переносність, разом із вбудованою пісочницею, робить WASM природним місцем призначення для коду, скомпільованого з Rust, який ми розглянемо в Лекції 10.

Розбір за слайдами

План лекції

Фокус: та сама логіка обчислень, інша модель виконання

Чому не просто 'ще один асемблер'

WASM успадкував ідею 'мова, близька до заліза', але заради переносності відмовився від регістрів

WebAssembly у цифрах

4
типи значень
i32, i64, f32, f64 — і жодних рядків
0
регістрів
замість них — операційний стек
64 КіБ
сторінка лінійної пам'яті
росте через memory.grow
2017
рік специфікації MVP
одна VM у Chrome, Firefox, Node.js, Wasmtime

Кожна функція має явну сигнатуру; невідповідність типів на стеку — помилка ще під час завантаження, не виконання

Стекова машина: як це працює

2 + 3 записується як: i32.const 2; i32.const 3; i32.add — покажіть стан стека після кожної інструкції: [2] → [2, 3] → [5]

WEBASSEMBLY — СТЕКОВА МАШИНА i32.const 2 i32.const 3 i32.add 3 2 стек операндів 5 результат інструкції беруть операнди зі стека й кладуть результат назад типізовано й валідується перед виконанням → пісочниця

Модуль .wat: функція додавання

wat
(module
  (func $add (param $a i32) (param $b i32) (result i32)
    local.get $a
    local.get $b
    i32.add)
  (export "add" (func $add)))

Той самий add, що і в Л8, тепер для стекової машини

Анатомія .wat модуля

🌳
(module …)корінь: усе з бінарного .wasm має текстовий відповідник
ƒ
(func $ім'я …)оголошення функції; типи параметрів і результату обов'язкові
🔢
(param $a i32) (result i32)сигнатура — перевіряється валідатором до запуску
📥
local.get / local.setлокальні змінні й параметри — аналог -4(%rbp) з Л8
📤
(export "add" (func $add))що видно ззовні модуля, з JavaScript
🔌
(import "env" "log" …)що модуль отримує від хоста — дзеркало export

S-вирази ((…)) — та сама нотація, що в Lisp: кожен вузол дерева синтаксису явний. wat = WebAssembly Text format

Життєвий цикл модуля

  1. 1
    wat2wasm add.watтекст → бінарний .wasm без втрат (wasm2wat — назад)
  2. 2
    fetch('add.wasm')байти прибувають по мережі — компіляція починається одразу
  3. 3
    Валідаціярушій перевіряє типи на стеку кожної функції — або відмова
  4. 4
    Інстанціюванняпідставити import-об'єкт, виділити лінійну пам'ять
  5. 5
    exports.add(2, 3)виклик із JS — звичайна функція, результат 5

instantiateStreaming суміщає кроки 2–4: компілює, доки файл ще довантажується

Керування потоком: цикл у .wat

wat
(func $sum_to_n (param $n i32) (result i32)
  (local $i i32) (local $acc i32)
  (block $break
    (loop $continue
      local.get $i
      local.get $n
      i32.ge_s
      br_if $break
      local.get $acc
      local.get $i
      i32.add
      local.set $acc
      local.get $i
      i32.const 1
      i32.add
      local.set $i
      br $continue))
  local.get $acc)

block/loop замінюють мітки .L_cond/.L_end з x86-64

Керування потоком: x86-64 ↔ WebAssembly

Конструкціяx86-64 (Л8)WebAssemblyАналог у мові
Вихід із ділянкиjmp .L_endblock $b … br $bbreak
Повторjmp .L_condloop $c … br $ccontinue
Умовний перехідcmp + jgei32.ge_s + br_ifif (…) break
Розгалуженняjcc на міткуif (result T) … else … endif / else
Довільний стрибокjmp на будь-яку адресунеможливийgoto

br_if знімає i32 зі стека: не нуль — перехід. Структурованість — не стиль, а гарантія: у чужу функцію стрибнути не можна; компілятор генерує ці конструкції з for/while сам

Лінійна пам'ять: перший погляд

Місток до ЛР8, рівень 3: там студенти вручну пишуть i32.store8/i32.load8_u у лінійну пам'ять і бачать trap при виході за межі

Лінійна пам'ять: запис і читання 4 байтів

(memory 1)1 сторінка = 65 536 байтів
i32.const 0 · i32.const 42стек: [адреса, значення]
i32.storemem[0..4] = 2a 00 00 00
i32.const 0 · i32.load→ 42 назад на стек
адреса ≥ 65 536trap, а не UB

Little-endian, як і в x86-64 (Л8); «база + зсув» тут — фіксована база 0 і i32-адреса. Межі перевіряються при кожному load/store

Пам'ять модуля: чому за межу вийти неможливо

ЛІНІЙНА ПАМ'ЯТЬ: STORE, LOAD, МЕЖА → TRAP i32.store addr 0 · val 42 i32.load addr 0 → 42 addr ≥ 65 536 вихід за межу 2a 0 00 1 00 2 00 3 00 4 00 5 00 6 00 7 00 8 00 9 trap кінець пам'яті memory.size × 64 КіБ 42 = 0x2a → little-endian: молодший байт за меншою адресою запис 4 байтівчитанняtrap, не UB вказівників «назовні» немає — лише зсув у власному масиві байтів

Той самий приклад, що у флоу вище: 42 лягає як 2a 00 00 00, за межею — trap, а не чужа пам'ять

Живе демо: .wat у браузері

  1. wat2wasm add.wat -o add.wasm — компілюємо текст у бінарний формат
  2. Відкрити мінімальну HTML-сторінку з <script type=module>
  3. fetch('add.wasm') → WebAssembly.instantiateStreaming(...)
  4. У DevTools console: instance.exports.add(2, 3)
  5. Показати вкладку Network — розмір .wasm-файлу в байтах, набагато менший за еквівалентний JS-бандл

Очікувано: Console друкує 5; той самий .wat відкривається і виконується без змін у Chrome, Firefox, Node.js

x86-64 проти WebAssembly

x86-64 (Л8)WebAssembly
Обчисленняіменовані регістриопераційний стек значень
Керування потокомдовільний jmp/jcc на міткуструктуровані block/loop/br/if
Пам'ятьувесь адресний простір процесуізольована лінійна пам'ять з перевіркою меж
Виклик функціїABI-угода про регістри/стектипізована сигнатура, перевірена валідатором
Перевірка коректностінемає — байти є байтивалідація типів при завантаженні
Ціль виконанняконкретний фізичний CPUбудь-який рушій: браузер, Node.js, Wasmtime

Обидва — «мова, близька до заліза»; різниця в тому, чиє залізо і скільки йому довіряють

Пісочниця WebAssembly: модель безпеки

🚫
Немає syscallжодного прямого доступу до файлів, мережі, ОС
🔌
Тільки importєдиний вихід назовні — функції, які хост передав явно
🎟️
Капабіліті-модельмодуль має рівно ті можливості, що йому дали, і жодних інших
🧱
Потік + межі пам'ятіне стрибнути й не прочитати поза дозволеним

Пісочниця (sandbox) — ізольоване середовище, з якого код не дістає до системи; хост (host) — браузер, Node.js, Wasmtime. Тому браузер безпечно запускає WASM з недовіреного сайту

Trap замість UB: інша філософія помилок

C / асемблер: undefined behavior
  • Вихід за межі — будь-який наслідок можливий
  • Читає «щось випадкове» або пише в чуже
  • Перевірки немає — швидко, але наосліп
vs
WebAssembly: trap
  • Виконання зупиняється з чіткою помилкою
  • Межі перевіряються при кожному load/store
  • Плата — трохи повільніше за «сирий» asm

Виняток із правила: memory.grow при відмові повертає -1, а не trap — перевірка на совісті коду

Межа хост ↔ модуль: import і export

JS-хостinstantiate(…, {env: {log}})
import env.logмодуль отримує $log
func $reportlocal.get · call $log
export reportвидно з JS
exports.report(42)→ console.log(42)

Модуль сам нічого не друкує: без явного import виклику console.log просто не існувало б

Імпорт функції хоста у .wat

wat
(module
  (import "env" "log" (func $log (param i32)))
  (func $report (param $code i32)
    local.get $code
    call $log)
  (export "report" (func $report)))

Модуль сам нічого не друкує — 'log' надає хост через import

Два способи розгалуження у WASM

block + br_if — перехід
  • Умова (i32) знімається зі стека
  • Не нуль — вихід із block, нуль — далі
  • Аналог break / continue
  • Так компілятор реалізує цикли
vs
if (result T) … else … end — вираз
  • Умова так само знімається зі стека
  • Обидві гілки лишають значення типу T
  • Аналог тернарного виразу ? :
  • Так компілятор реалізує кожен if-вираз

Спільне: жодних «голих» міток — лише вкладені структуровані блоки; валідатор перевіряє тип на виході з кожної гілки

Живе демо: імпорт хост-функції в консоль браузера

  1. Написати add.wat з import env.log(i32) та export report
  2. wat2wasm add.wat -o add.wasm
  3. У JS: WebAssembly.instantiateStreaming(fetch('add.wasm'), { env: { log: console.log } })
  4. instance.exports.report(42) — переконатися, що консоль надрукувала число

Очікувано: Студенти бачать живий приклад капабіліті-моделі: без явного import виклику console.log просто не існувало б

WebAssembly — це безпечний, переносний, низькорівневий формат коду, розроблений для ефективного виконання й компактного представлення. — WebAssembly Core Specification (W3C), вступ

Підсумок

Джерела

🥚 ПАСХАЛКА
У демо-терміналі wasmtime --invoke pixel pixel.wasm 2 3 повертає 5. Спробуйте аргументи 3000 і 101: стекова машина поверне 3101 — 0xC1D. Це число не випадкове: у курсі воно трапляється не раз.
⌨️ КОМАНДИ НА ЦІЙ СТОРІНЦІ 4 · 3 нових

Кожна команда терміналу з цієї сторінки — одним реченням. Позначка нове — команда зустрічається в курсі вперше; далі вважаємо її знайомою.

wat2wasm
З WABT: перекладає текстовий формат WAT у бінарний модуль .wasm (wat2wasm m.wat -o m.wasm).
wasmtimeнове
Самостійний рантайм WebAssembly: wasmtime --invoke f m.wasm 2 3 викликає функцію з аргументами.
wasm2watнове
З WABT: зворотне — бінарний .wasm назад у читабельний WAT.
wasm-bindgenнове
Генерує JS-обгортку для модуля .wasm із Rust (його викликає wasm-pack; версія CLI має збігатися з крейтом wasm-bindgen).

💡 Прочитати README прямо в терміналі: cat README.md (виведе весь файл) або less README.md (посторінково; вихід — клавіша q).
Довідка з будь-якої команди: man curl (повний посібник, вихід — q) або коротко curl --help; для підкоманд — docker run --help, cargo build --help.