Как стать автором
Обновить

WebAssembly голыми руками

Уровень сложностиСредний
Время на прочтение32 мин
Количество просмотров3.5K

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

Вступление

Что вообще такое WASM и почему я решил, что писать на нем это хорошая идея?

Начнем с того, что в названии "Web Assembly" оба слова не совсем правда. Это не ассемблер (в привычном понимании), а с вебом его связывает только история происхождения.
WASM это виртуальная машина общего назначения, байткод для нее, а также человеко-читаемое текстовое представление для этого байткода.
В отличии от JVM или SECD, WASM не рассчитан под какой либо конкретный язык или парадигму. Вместо этого он старается быть универсальной целью для компиляции самых разных по природе языков. Уже сейчас в WebAssembly можно собрать более 60 языков от низкоуровневого насквозь императивного C с ручным управлением памятью, до функционального Хаскела со всем его рантаймом и сборкой мусора или объектно-ориентированного скриптового Ruby упаковывающегося в WASM вместе с интерпретатором.
Ну а не только для веба эта технология на столько, что Solomon Hykes, основатель Docker, отзывался о ней так:

Если бы WASM+WASI существовали в 2008 году, нам бы не пришлось создавать Docker.

Хорошо, но зачем программировать непосредственно на этом вручную?

Если коротко то: демосцена, Computer Science, криптовалюты и отладка.

Криптовалюты в контексте этой серии статей меня особо не интересуют, но стоит упомянуть, что многие из них используют именно WebAssembly в качестве языка для смарт-контрактов. И там, где стоимость исполнения контрактов достаточно велика, a экономить нужно каждую инструкцию, становится целесообразным их написание на голом WASM.

Что касается демосцены, то WASM подходит для нее просто идеально по ряду причин:

  • Уровень абстракции. WebAssembly находится на интересном уровне: сильно ниже C или даже Fortran, но выше того, что обычно называют ассемблерами. Написание кода на нем вручную все еще состоит в основном из перекладывания чисел, но тут все же имеются функции, как отдельные сущности, глобальные и локальные переменные, конструкции if-then-else и упрощенные циклы. Все это делает возню с байтами чуть менее мазохистичной, чем при работе с системами команд реального железа.

  • Производительность. Перформанс WASM кода исполняемого современными рантаймами стремится к нативному, и по мере совершенствования рантаймов, разрыв сокращается. WASM программам уже доступны для использования SIMD инструкции, a в процессе стандартизации ожидает множество других расширений для ускорения WebAssembly программ.

  • WAT. Как уже упоминалось, у кода для виртуальной машины WASM есть помимо бинарного еще и текстовое представление - WAT (Web Assembly Text format). Оно основано на S-выражениях и также добавляет немного сахара в нелегкий процесс низкоуровневого программирования.

  • Компактность. Байткод WebAssembly достаточно компактен. Хотя инструкции здесь 32х битные, для их записи в двоичном формате используется кодировка LEB128, что сокращает размер файла в разы. А это ох как актуально когда стоящая задача - впихнуть как можно больше в границы нескольких килобайт.

  • Портативность и изоляция. Реализации рантайма WASM есть уже почти под все. В сочетании с отсутствием фиксированного API для ввода-вывода, который бы привязывал WASM программу конкретному окружению, это дает возможность запускать один и тот же код везде, от микроконтроллеров до браузеров. В разрезе демосцены это актуально, потому что позволяет разрабатывать и отлаживать WASM код в куда менее ограниченных и более удобных условиях, чем те, в которых он будет исполняться.

  • Импорты. WASM легко импортировать и вызывать из языков высокого уровня. В результате можно иметь код программы в котором экономится каждый байт с помощью хаков и такой то матери, а рядом портянки тестов на высокоуровневом, скажем, JS и REPL на нем же, чтобы копаться в состоянии виртуальной машины прямо на живую по ходу исполнения.

По, в общем-то, тем же причинам он хорош для того, чтобы на его примере изучать низкоуровневое программирование, алгоритмы и прочий CS.

Но даже если демосцена вам не интересна, криптовалюты, тем более, a все знания CS которые вам были нужны, вы уже освоили, материал из этих статей все равно пригодится вам, если вы столкнетесь со сборкой чего либо в WASM. Дело в том, что при текущем уровне отполированности инструментов нет-нет, да и приходится лезть руками в WASM байткод который вам нагенерировал rustc, go или кто еще.

Так или иначе, чем бы вас не заинтересовал топик, в этой статье мы пройдемся по основным возможностям WASM и напишем реализацию простых клеточных автоматов, включая игру жизнь. В будущих же статьях рассмотрим, что еще можно написать вручную на WASM не сойдя с ума, от работы с текстом до простеньких сетевых сервисов и графических демо под фентези консоль wasm4.

Дисклеймер

Важно упомянуть, что WASM все еще в процессе активного развития, и некоторые вещи, которые мы рассмотрим в этих статьях, все ещё находятся в стадии стандартизации. С актуальными версиями инструментов все работает, но сильно более старые или, наоборот, будущие могут не переварить примеры отсюда. Я постараюсь отслеживать это и актуализировать материал по мере надобности.

Сетап

Для этой статьи мы будем транслировать примеры из текстовой формы в бинарную с помощью утилиты wat2wasm из набора wabt и играться с ними из REPL'а nodejs.
Поскольку статья не про JS на нем не будет акцентироваться внимание и тут не будет пачки транспиляторов и 200 метров зависимостей сверху. Все, что вам понадобится, это версия ноды вышедшая не ранее пары лет назад, вышеупомянутый Web Assembly Binary Toolkit в более-менее актуальном состоянии и вот этот скрипт с функциями-хелперами буквально на 60 строк.
Полный код всех примеров отсюда, все связанные файлы и инструкции по установке вышеописанных зависимостей вы можете найти в этом репозитории.
Все приводимые ниже команды выполняются в нем в корневой директории.

Для того чтобы убедиться, что все работает, запустим вот такой простой пример (разберем его чуть ниже):

(module
	(func $answer (result i32)
		i32.const 0x2A
	)
	(export "theAnswer" (func $answer))
)

Запускам node без аргументов чтобы попасть в REPL.
Подгружаем функции-помощники командой .load helpers.js.
Загружаем наш пример: let example = await load("intro/basic-example").
И узнаем ответ на вопрос жизни, вселенной и вообще: example.theAnswer().

Запись с терминала:

asciicast
asciicast

Функция load под капотом втч транслирует для нас пример в байткод посредством wat2wasm intro/basic-example.wat -o intro/basic-example.wasm.

Синтаксис, модули

Тем, кто не знаком с языками семейства lisp, синтаксис мог показаться странным, лисперы же почувствовали себя как дома.
Так или иначе, перед вами S-выражения - простой, как палка, старый, почти как сами интегральные схемы, но крайне выразительный синтаксис. Круглые скобки здесь обрамляют границы списков, а пробелы отделяют их элементы друг от друга. Т.е. (a (b c)) в S-выражениях будет эквивалентом ["a", ["b", "c"]] из JSON. На этом синтаксические конструкции, в общем, то и заканчиваются. Все остальное строится из комбинаций вложенных друг в друга списков.
В большинстве S-выражений, которые вы встретите, первый элемент обычно обозначает действие/команду/функцию/etc, а остальное - его аргументы. Так (a 1 (b 2 3)) переводится на язык смертных усредненный C-подобный язык как a(1, b(2, 3)).

Код на WAT состоит из корневого выражения module, содержащего в себе все остальное, что есть в программе. Впрочем, сами WASM программы как раз правильно называть модулями. В некоторых примерах я буду опускать выражение module, чтобы не раздувать и без того многословный код.

Элементы, начинающиеся с символа $ это идентификаторы. Они существуют только в текстовой форме для удобства человека, а при трансляции в байткод заменяются на номера: функция-1, переменная-137 итд.
Вот что мы получим, сконвертировав код из прошлого примера туда и обратно между WAT и байткодом:

;; wat2wasm intro/basic-example.wat -o intro/basic-example.wasm
;; wasm2wat intro//basic-example.wasm
(module
  (type (func (result i32))) ;; type 0
  (func (type 0) (result i32)  ;; func 0
    i32.const 42)
  (export "theAnswer" (func 0)))
;; Комментарии, кстати, начинаются с двух символов точки с запятой.

Функции

Основными компонентами модуля являются функции. По соображениям безопасности, в WASM нельзя передавать исполнение на произвольный участок кода (Дейкстра доволен), да и вообще к собственному коду программа доступа тут не имеет. В связи с этим функции пришлось ввести в язык как самостоятельные сущности вместе с операцией их вызова и некоторыми другими вспомогательными механиками, которые мы рассмотрим ниже.
Виртуальная машина WebAssembly исключительно стековая, регистров тут нет. Аргументы и возвращаемые значения функций и встроенных операций передаются через стек.

У каждой функции также есть "тип", который описывает, что должно лежать на стеке при ее вызове (параметры ака аргументы) и что там должно остаться после выполнения функции. Все функции должны в любой ситуации оставлять после себя стек ровно в таком состоянии, которое описано в их типе. Некорректные результаты, ровно как и попытка взять со стека больше элементов, чем описано в типе функции, приводят к ошибке VM на этапе верификации байткода перед исполнением. Ну и wat2wasm такой код также откажется транслировать. Функции с переменным числом аргументов или возвращаемых значений в WASM невозможны.
Вот так выглядит описание типа:

;; При вызове функции на стеке должно лежать два числа типа i32.
;; После вызова, одно типа i64.
(type $typename (param i32) (param i32) (result i64))

;; Этот же тип можно записать короче.
(type $typename (param i32 i32) (result i64))

Как вы могли заметить, в оригинальном коде мы тип отдельным выражением не описывали, просто указав возвращаемое значение в самом описании функции. Это один из многих примеров сахара, который нам предоставляет WAT для упрощения жизни. Жаль, что при конвертации в байткод он теряется, а при конвертации обратно не восстанавливается.

Для того, чтобы функция была видна извне модуля, ее нужно явно экспортировать. Иначе вызвать ее можно будет только из другой функции того же модуля. При экспорте указывается имя функции utf-8 строкой, которая, в отличие от внутренних идентификаторов, никуда не пропадает и перекочевывает в байткод, как есть. Экспорт можно обозначить прямо в описании функции:

(module
	(func $answer (export "theAnswer") (result i32)
		i32.const 0x2A
	)
)

Это опять сахар, байткод все равно получится такой же.

В описании функции после аргументов и возвращаемых значений идет собственно ее тело в виде списка встроенных в WASM операций. В нашем примере это i32.const 0x2A - операция кладущая на стек одно 32-битное число. Больше о числовых типах и операциях над ними мы узнаем в следующем блоке этой статьи, а пока что этого нам будет достаточно.

Функция в WASM может вернуть более одного значения:

(module
	(func (export "numbers") (result i32 i32 i32)
		i32.const 1
		i32.const 2
		i32.const 3
	)
)

При попытке вызова ее из JS мы получим значения списком:

[1, 2, 3]

На случай, если у нас образовалась ситуация, когда на стеке больше элементов, чем нам надо, есть операция drop, выкидывающая с него один элемент.

;; Возвращает 1
(func (result i32)
	i32.const 1 i32.const 2 i32.const 3
	drop drop
)

Естественно, функции можно вызывать друг из друга. Для этого есть операция call у которой надо указать идентификатор функции, которую мы хотим вызвать. Этот идентификатор статический и является частью кода операции. До того, как вызывать функции динамически, мы доберемся почти под конец статьи (там не все так просто).

(func $one (result i32)
	i32.const 1
)

;; Возвращает 1 2
(func $oneTwo (result i32 i32)
	call $one ;; После вызова на стеке 1
	i32.const 2 ;; После вызова на стеке 1 2
)

Если функция (или операция) принимает аргументы, они должны быть помещены на стек в том же порядке:

;; Возвращает собственный же аргумент и число 4
(func $addFour (param i32) (result i32 i32)
	;; "Принимаем" первый аргумент
	;; Мы подробнее рассмотрим эту операцию в блоке про переменные
	local.get 0
	i32.const 4
)

(func $threeFour (result i32 i32)
	i32.const 3
	;; Забирает со стека 3
	;; ВКладет 3 4
	call $addFour
)

И вот тут мы можем воспользоваться еще одним сахаром, доступным в WAT. Функцию threeFour также можно записать следующим образом:

(func $threeFour (result i32 i32)
	(call $addFour (i32.const 3))
)

Здесь выполняются те же операции, в том же порядке, и даже байткод один в один тот же самый. Но читается это гораздо проще, чем просто (обычно огромный) линейный список операций. Этот синтаксис применим не только для вызова функций, но и для вообще любой последовательности операций втч не обязательно связанных друг с другом. Поскольку этот подход встречается часто в написанном вручную коде и уровней вложенности там может быть много, имеет смысл разобрать его подробнее.

Вложенные S-выражения описывают собой дерево (не только в этом случае, а в целом). Вот такой код (a b (c d) (e f (g)) можно представить так:

   a b
    |
 +-----+
 |     |
c d   e f
       |
       g

- `c d` вложенно в ` a b`
- `g` вложенно в `e f`
- `e f` вложено в `a b`

Раскрывается это все в линейный список команд в порядке слева на право от более глубоких уровней к менее глубоким:

c d
g
e f
a b

А вот такой пример настоящего кода из конца статьи:

(local.set $next (call $another))
(local.set $row (i32.const 0))
(loop $rloop
    (local.set $col (i32.const 0))
    (loop $cloop
        (call $setCell
            (local.get $col)
            (local.get $row)
            (call_indirect (type $ruleType)
                (call $getCell
                    (local.get $col)
                    (local.get $row)
                    (global.get $frame)
                )
                (call $getNeigbours (local.get $col) (local.get $row))
                (global.get $rule)
            )
            (local.get $next)
        )
        (local.set $col (i32.add (local.get $col) (i32.const 1)))
        (br_if $cloop (i32.lt_s (local.get $col) (global.get $cols)))
    )
    (local.set $row (i32.add (local.get $row) (i32.const 1)))
    (br_if $rloop (i32.lt_s (local.get $row) (global.get $rows)))
)

раскроется вот в этот ужас:

call $another
local.set $next
i32.const 0
local.set $row
loop $rloop
loop $cloop
local.get $col
local.get $row
type $ruleType
local.get $col
local.get $row
global.get $frame
call $getCell
local.get $col
local.get $row
call $getNeigbours
global.get $rule
call_indirect
call $setCell
local.get $col
i32.const 1
i32.add
local.set $col
global.get $cols
local.get $col
i32.lt_s
br_if $cloop
end
i32.const 1
local.get $row
i32.add
local.set $row
global.get $rows
local.get $row
i32.lt_s
br_if $rloop
end

Писать и читать код, структурированный S-выражениями, кратно проще, и тут я буду придерживаться этого стиля, однако учтите, что при попытке отлаживать машинно сгенерированные WASM модули, вы получите именно второй вариант (еще и с числовыми идентификаторами вместо текстовых).

Но вернемся к нашим функциям.

WASM позволяет нам с помощью выражения start указать функцию, которая будет выполнена сразу после загрузки модуля. От функции main из условного C она будет отличаться тем, что никакие аргументы в нее не передаются и ничего из нее не возвращается. Это ограничивает ее применение в основном инициализацией различных структур в памяти.

(module
	(func $initfunc
		;; ...
	)
	(start $initfunc)
)

Для преждевременного завершения функции есть оператор return. В отличие от завершения обычного, return позволяет оставить к моменту его вызова лишние данные на стеке. В таком случае из функции будет возвращено то, что указано в ее описании, а остальное отброшено.

;; Возвращает 3
(func (result i32)
	i32.const 1
	i32.const 2
	i32.const 3
	return
)

Функции (как и другие сущности в модуле) можно не только экспортировать, но и импортировать. Вообще для WASM кода запертого в песочнице, импорт функций, предоставленных кем-то из вне, это единственный способ взаимодействия с миром. Соответственно, предоставляя нашему модулю те или иные функции, мы полностью контролируем, что и как он может делать за пределами VM.
Наш скрипт helpers.js по умолчанию предоставляет всем загружаемым модулям несколько простых функций, удобных для отладки. Воспользуемся одной из них:

(module
	;; Если в вашем модуле есть импорты они все должны идти перед остальным содержимым.
	;; В отличие от экспорта, при импорте мы указываем две строки.
	;; Первая это пространство имен, а вторая, собственно, сам импортируемый объект.
	;; Пространство имен (равно как и название) можно указать произвольные
	;; на стороне, предоставляющей импорт.
	(import "debug" "printI32" (func $debugPrintI32 (param i32)))

	(func (export "printIt") (call $debugPrintI32 (i32.const 42)))
)

Загрузив модуль и вызвав printIt мы увидим в целом все то же 42. Но теперь это будет не отчет REPL'а о возвращенном функцией printIt значении, а результат вызова debug.printI32(42) нашим модулем. Примеры вывода чего либо поинтереснее чисел мы рассмотрим, добравшись до работы с памятью.

Простые операции на числах

WASM предоставляет нам 4 типа чисел: 32x и 64x битные целые и типы i32 и i64, а также два типа чисел с плавающей точкой аналогичных размеров по стандарту IEEE 754: f32 и f64 соответственно. До недавнего времени на этом система типов WebAssembly исчерпывалась, но с некоторых пор у нас также есть SIMD тип (рассмотрим в будущих статьях) и специальные типы для продвинутой работы с функциями и хостовым окружением (о них будет ниже).

Как можно заметить, у целочисленных типов нет знаковой и без знаковой версии. Вместо этого, там где знаковость важна, существует по два типа операций: с суффиксом _u для без знаковых и _s для знаковых. Для операций, у которых такого суффикса нет, знаковость не имеет значения. Типы с плавающей точкой знаковые by design.

Булевый тип отсутствует, вместо него используются i32 значения где 0 - false, а все остальное - true.

При необходимости работать с 8 и 16 битными значениями используется также i32 в сочетании с соответствующими инструкциями, которые воспринимают только младшие 8 и 16 бит значения соответственно.

Инструкции, преобразующие числовые значения друг в друга (арифметика, побитовые операции, etc) обычно имеют название, начинающееся с типа, с которым они работают. Так уже встречавшегося нам i32.const есть "братья" i64.const, f32.const, f64.const. То же касается всех прочих инструкций, которые мы рассмотрим, если не сказано обратного. Далее я буду использовать заглавную T вместо конкретного типа для обозначения того, что варианты инструкции есть для нескольких типов.

Для арифметики в нашем распоряжении операции

  • T.add - сложение

  • T.sub - вычитание

  • T.mul - умножение

  • T.div - деление

    • Для целых чисел выдает только целую часть

    • Для целых чисел существует в двух версиях: T.div_u и T.div_s

  • T.rem_s, T.rem_u - деление с возвращением остатка. Существует только для целых

Переполнение при вычислениях в WASM не является Undefined Behavior и всегда происходит по модулю (aka переполнение с зацикливанием).

Пример:

(module
	(func (export "calculateTheAnswer") (result i32)
		;; 21/5*10 + 8%3
		(i32.add
			(i32.mul
				(i32.div_s (i32.const 21) (i32.const 5))
				(i32.const 10)
			)
			(i32.rem_s (i32.const 8) (i32.const 3))
		)
	)
)

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

Побитовые операции

Из побитовых операций у нас классические

  • T.and, T.or, T.xor

  • T.shl для сдвига влево и T.shr_u с T.shr_s для сдвига в право с учетом знака или без

  • T.rotl и T.rotr для вращения влево и право соответственно

  • T.popcnt для подсчета единичных бит в двоичном представлении числа

  • T.clz и T.ctz для подсчета нулевых бит в начале и конце числа

Все это применимо только к целочисленным типам.

Как вы могли заметить, операции побитового отрицания в WASM нет. Авторы спецификации мотивируют это тем, что ее легко воспроизвести, выполнив xor со значением, в котором все биты выставлены в единицу (OxFFFFFFFF для i32 и OxFFFFFFFFFFFFFFFF для i64 соответственно).

К слову, в WASM для целых чисел используется Little Endian.

Числа с плавающей точкой

Сверх базовой арифметики, для флоатов в наличии

  • T.abs - модуль числа

  • T.neg - инвертирование знака (+/-)

  • T.ceil - округление в большую сторону

  • T.floor - округление в меньшую сторону

  • T.nearest - округление к ближайшему целому

  • T.trunc - округление к меньшему для положительных чисел и к большему для отрицательных

  • T.sqrt - извлечение квадратного корня

  • T.min, T.max - выбор меньшего/большего из двух чисел

  • T.copysign - принимает два числа, выдает первое, но со знаком как у второго

Тригонометрические операции отсутствуют искаробки и их нужно реализовывать в самом модуле. Авторы WASM объясняют это тем, что соответствующие инструкции редко распространены в железе и/или реализованы неэффективно.

Сравнения

Для всех четырех типов чисел доступны операции сравнения возвращающие 1 в качестве истины и 0 в качестве лжи:

  • T.eqz - сравнивает число с нулем

  • T.eq - сравнивает два числа друг с другом

  • T.ne - тоже что и T.eq но возвращает истину если числа НЕ равны друг другу

  • T.gt - проверяет что первое число больше другого. Для целых чисел gt_s и gt_u

  • T.ge - тоже что и gt но больше либо равно

  • T.lt, T.le - меньше и меньше либо равно соответственно

При необходимости использовать булево отрицание, можно сравнивать значение с 0

  • eqz 0 - возвращает единицу

  • eqz 1,2,3... - возвращает 0

Стоить заметить, что операции сравнения для любых типов всегда возвращают i32.

Переменные

WASM поддерживает создание и использование глобальных (область видимости: весь модуль) и локальных (область видимости: одна функция) переменных.
С локальными мы уже однажды сталкивались в блоке про функции. Свои параметры (при их наличии) функция как раз таки получает через локальные переменные. После чего их оттуда нужно доставать и класть на стек операцией local.get по необходимости.
Все создаваемые переменные в той или иной области видимости нумеруются по порядку объявления, начиная с нуля.
Операция манипулирующие переменными используют их константные идентификаторы. В WASM нельзя динамически обратиться к произвольной переменной по ее номеру, известному только в рантайме.

;; Возвращает полученные аргументы в обратном порядке.
(func $swap (param i32 i32) (result i32 i32)
	(local.get 1)
	(local.get 0)
)

;; То же но с человеко-читаемыми идентификаторами переменных.
(func $swap (param $a i32) (param $b i32) (result i32 i32)
	(local.get $b)
	(local.get $a)
)

Для установления значения локальной переменной используется операция local.set, принимающая значение со стека.

Помимо параметров, для которых локальные переменные создаются автоматически, мы также можем явно создавать дополнительные переменные. Их объявления должны идти перед телом функции.

(func
	(local $somename i32)
	(local $another i32)
	(local.set $somename (i32.const 42))
	(local.set $another (local.get $somename))
)

Глобальные переменные объявляются на одном уровне вложенности с функциями.

(module
	;; У глобальных переменных обязано быть значение по умолчанию.
	;; Чтобы значение переменной можно было менять в рантайме, нужно явно пометить ее как мутабельную.
	(global $globName (mut i32) (i32.const 101))
	;; Иначе это будет, по сути, константа.
	(global $globName2 i32 (i32.const 314))
	;; Глобальные переменные могут быть экспортированы и импортированы также, как функции.
	(global $globName3 (export "statCode") (mut i32) (i32.const 404))
)

Для работы с глобальными переменными используются операции global.get и global.set.

(module
	(global $glob (mut i32) (i32.const 101))

	(func (export "set") (param i32)
		local.get 0
		global.set $glob
	)
	(func (export "get") (result i32)
		global.get $glob
	)
)

Ветвление

Другой особенностью WASM, делающей его более высокоуровневым, чем можно ожидать от ассемблера, является поддержка условных конструкций.

;; Возвращает 42, если $cond равен 0 и 314 во всех остальных случаях.
(func $retIf (param $cond i32) (result i32)
	local.get $cond
	;; Инструкция if забирает со стека одно i32 значение и выполняет
	;; либо пропускает фрагмент кода до соответствующей инструкции end
	;; в зависимости от значения этого числа.
	;; 0 - ложь
	;; не 0 - истина
	;; Использовать в качестве условия можно только значение типа i32.
	;; Впрочем, все инструкции сравнения только i32 и возвращают.
	if
		i32.const 314
		return
	end
	i32.const 42
)

;; Условия, конечно, также можно записывать более читабельно с помощью.
;; S-выражений
;; Ту же самую функцию можно записать так:
(func $retIf (param $cond i32) (result i32)
	(if (local.get $cond)
		;; При записи через вложенные S-выражения для обозначения тела
		;; условной конструкции используется then.
		(then
			(return (i32.const 314))
		)
	)
	i32.const 42
)

;; Else тоже в наличии.
(func $retIf (param $cond i32) (result i32)
	(local $result i32)
	(if (local.get $cond)
		(then
			(local.set $result (i32.const 314))
		)
		(else
			(local.set $result (i32.const 42))
		)
	)
	local.get $result
)

Не трудно заметить, что в примерах выше в блоках if-else я либо сразу возвращаю значение из функции посредством result, либо сохраняю его в локальную переменную для дальнейшего использования.
Но ведь можно просто оставить значение на стеке перед выходом из if-а ... можно ведь?

Да, можно. Нет, это не так просто.
Как уже упоминалось в блоке про функции, виртуальная машина WASM проверяет байткод перед выполнением. И в частности, она очень строго следит за "корректностью" манипуляций со стеком. Если любая языковая конструкция, будь то функция или условие, кладет что-то на стек, то для нее должны соблюдаться два правила:

  • При любом исходе событий на стек должно попадать одинаковое количество значений одних и тех же типов.

  • Список значений который будет оставлен на стеке, должен явно указывается.

Относительно условных конструкций это означает, что:

  • На каждый if, который кладет данные на стек, должен быть свой else делающий это аналогичным образом.

  • Для такого if должно быть явно указано, какие значения будут положены на стек.

Конструкция if-then-else соответствующая этим правилам, называется сбалансированной.

Такие не сбалансированные варианты (не будут исполнены VM):

(func $retIfWrong (param $cond i32) (result i32)
	;; Отсутствует else.
	(if (local.get $cond)
		(then
			i32.const 314
		)
	)
	i32.const 42
)

(func $retIfWrong (param $cond i32) (result i32)
	;; If и else кладут на стек значения разных типов.
	(if (local.get $cond)
		(then
			i32.const 314
		)
		(else
			i64.const 42
		)
	)
)

(func $retIfWrong (param $cond i32) (result i32)
	;; If и else кладут на стек разное количество значений.
	(if (local.get $cond)
		(then
			i32.const 314
		)
		(else
			i32.const 314
			i32.const 217
		)
	)
)

(func $retIfWrong (param $cond i32) (result i32)
	;; Информация о возвращаемом значении не указана явно.
	(if (local.get $cond)
		(then
			i32.const 314
		)
		(else
			i32.const 42
		)
	)
)

Сбалансированный и корректный вариант:

(func $retIf (param $cond i32) (result i32)
	(if (result i32) (local.get $cond)
		(then
			i32.const 314
		)
		(else
			i32.const 42
		)
	)
)

В некоторых случаях вместо условных конструкций можно воспользоваться операцией select. Она забирает со стека три значения:

  • Условие; Всегда должно быть i32

  • Первый вариант

  • Второй вариант

Оба варианта могут быть значениями любого типа, главное одинакового для обоих.

В зависимости от значения условия на стек будет возвращено

  • Первое значение, если условие истинно

  • Второе значение, если условие ложно

;; (call $selectI32 (i32.const 314) (i32.const 217) (i32.const 1)) вернет 314
;; (call $selectI32 (i32.const 314) (i32.const 217) (i32.const 0)) вернет 217
(func $selectI32
	(param $opt1 i32) (param $opt2 i32) (param $cond i32) (result i32)
	(select (local.get $opt1) (local.get $opt2) (local.get $cond))
)

Память

Память в WASM это линейный, не типизированный массив байт с нумерацией от 0.
В качестве адресов тут используется i32 (i64 адреса в процессе стандартизации), поэтому более четырех гигабайт памяти (пока) использовать невозможно.

Для памяти в коде задается начальный размер и опционально максимальный. В рантайме можно запросить еще памяти, главное, не превысить 4 гигабайта или явно установленный лимит.

Хотя память и линейна, выделяется она "страницами" по 64 килобайта.

;; Объявление памяти с начальным размером в одну страницу и без максимального размера.
(memory 1)

;; С максимальном размером 11 страниц (704 кб).
(memory $memname 1 11)

;; Память тоже может быть экспортирована (и импортирована).
(memory $memname (export "mem") 1)

WASM (пока что) не предоставляет никаких механизмов по автоматическому управлению памятью. Если мы хотим аллокатор, мы должны написать его сами. Если хотим сборку мусора, аналогично. И то и то мы реализуем в будущих статьях.

Для работы с памятью используется инструкции load и store, которые, в целом, аналогичны инструкциям get и set для переменных. Но в отличии от них, для каждого типа есть свои инструкции load/store:

;; Поместить значение $value типа i32 в памяти по адресу (ака смещению от начала) $offset.
(func $storei32 (param $offset i32) (param $value i32)
	(i32.store (local.get $offset) (local.get $value))
)

;; Забрать (скопировать) значение $value типа i32 из памяти по адресу $offset.
(func $loadi32 (param $offset i32) (result i32)
	(i32.load (local.get $offset))
)

;; Тоже самое но для i64.
(func $storei64 (param $offset i32) (param $value i64)
	(i64.store (local.get $offset) (local.get $value))
)
(func $loadi64 (param $offset i32) (result i64)
	(i64.load (local.get $offset))
)

Поскольку числовых типов размером меньше i32 у нас нет, а работать с 8ми и 16и битными числами бывает нужно, в WASM присутствуют инструкции для загрузки в память / выгрузки из нее только части значения:

;; Загружает в память только младшие 8 бит числа.
(func $store8i32 (param $offset i32) (param $value i32)
	(i32.store8 (local.get $offset) (local.get $value))
)

;; Выгружает из памяти 8ми битное число, и кладет его на стек, расширив до 32х битного.
(func $load8i32 (param $offset i32) (result i32)
	;; Расширение значения меньшего типа до значения большего требует добавления
	;; лидирующих бит.
	;; И то, какие именно биты мы будем добавлять, зависит от того, работаем ли мы
	;; с числом как со знаковым или как с без знаковым.
	;; По этому i32.load8 и другие подобные инструкции имеют _u и _s варианты.
	(i32.load8_u (local.get $offset))
)

Помимо работы с памятью в рантайме, мы также можем задавать ее изначальное содержимое. Для этого в модуле могут быть объявлены "data сегменты" в которых описывается, что и по каким смещениям должно лежать после загрузки модуля виртуальной машиной.

(memory (export "mem") 1)

;; Положить бинарное представление строки "HelloWorld!" по смещению 0
;; т.е. в самом начале памяти.
;; Это будет (0x48 0x65 0x6C 0x6C 0x6F 0x20 0x57 0x6F 0x72 0x6C 0x64 0x21).
;; В data сегментах используется только ASCII текст.
(data (i32.const 0) "Hello World!")

;; Мы также можем указывать непосредственно значения байт в 16ричном виде.
(data (i32.const 100) "\2A\45")

Data сегменты могут быть "пассивными". Такие сегменты не записываются в память автоматически при загрузке модуля, но отдельной инструкцией в процессе работы. Помимо прочего, один и тот же пассивный сегмент данных можно записывать в память произвольное количество раз по произвольным адресам.
Объявление пассивного сегмента от обычного отличается тем, что:

  • У него не указывается смещение

  • У него можно указать идентификатор

(memory (export "mem") 1)

(data $passive "Some passive data UwU")

;; Для того чтобы поместить пассивный сегмент в память используется инструкция
;; memory.init в которой указывается константный(!) идентификатор сегмента
;; и которая принимает:
;; - смещение внутри сегмента начиная с которого нужно прочитать данные
;; - смещение в памяти по которому их надо положить
;; - размер (длину) данных которые нужно скопировать из сегмента в память
(func $init (param $offsetMem i32) (param $offsetData i32) (param $length i32)
	(memory.init $passive
		(local.get $offsetMem)
		(local.get $offsetData)
		(local.get $length)
	)
)

Если пассивный data сегмент становится нам более не нужен, мы можем сообщить это виртуальной машине, что позволит ей освободить занимаемое им место. Для этого предназначена инструкция data.drop с константным ID сегментом.

(data.drop $passive)

После выкидывания сегмента, попытка снова им воспользоваться приведет к ошибке.

Для управления размером памяти предназначены инструкции memory.size и memory.grow.

;; Возвращает размер памяти в страницах
(func $getMemSize (result i32)
	memory.size
)

;; Запрашивает увеличение размера памяти на $count страниц.
;; В случае успешного запроса, возвращает прошлый размер памяти (до увеличения).
;; В случае провала, возвращает -1.
(func $growMem (param $count i32) (result i32)
	(memory.grow (local.get $count))
)

Высвободить обратно память пока нельзя. Но соответствующее расширение стандарта обсуждается.

При загрузке модуля или выделении дополнительной памяти в процессе работы вся новая память заполняется нулями. Кроме той, которая была инициализирована data сегментами.

Раньше в WASM отсутствовали встроенные операции для копирования фрагментов памяти в другое место и заполнения фрагмента памяти копиями одного и того же значения.
Как следствие, все собранные в WASM программы должны были поставляться с собственными реализациями этого функционала.
И поскольку эти реализации часто оказывались узким местом на бенчмарках, авторы стандарта сжалились над разработчиками и добавили инструкции memory.copy и memory.fill.

;; Копирует содержимое фрагмента памяти длиной $size начинающегося
;; по смещению $offsetSrc в фрагмент аналогичной длины начинающийся по $offsetDst.
(func $copy
	(param $offsetSrc i32) (param $offsetDst i32) (param $size i32)
	(memory.copy
		(local.get $offsetDst) (local.get $offsetSrc) (local.get $size)
	)
)

;; Заполоняет фрагмент памяти размером $size начинающийся по смещению $offset
;; копиями младшего байта из значения $value.
;; Таким образом (call $fill (i32.const 10) (i32.const 90) (i32.const 0))
;; забьет нулями участок памяти с 10го байта по 100ый.
(func $fill
	(param $offset i32) (param $size i32) (param $value i32)
	(memory.fill
		(local.get $offset) (local.get $value) (local.get $size)
	)
)

Освоив работу с памятью, мы теперь можем вывести в терминал что-то посложнее числа, используя еще одну импортируемую из helpers.js отладочную функцию.

;; Преобразует фрагмент памяти длиной $length по смещению $offset в текст (utf-8) и
;; выводит этот текст в терминал.
(import "debug" "print" (func $debugPrint (param $offset i32) (param $length i32)))

;; Чтобы функция debug.print могла достать нашу строку из памяти, эта память
;; должна быть экспортированна. Причем именно под именем "memory".
;; Это не фундаментальное ограничение, но конретно эта функция так написана.
(memory (export "memory") 1)

(data (i32.const 0) "Hello World!")

(func (export "sayHello")
	(call $debugPrint (i32.const 0) (i32.const 12))
)

Загрузив модуль и вызвав sayHello мы закономерно увидим Hello World! в терминале.

Циклы

Циклы в WASM присутствуют, но работают они необычно. Здесь нет привычных циклов с предусловием (while) или постусловием (do-while) и уж тем более нет аналогов for и for-each.
Вместо этого есть две конструкции: loop и block, которые выделяют фрагмент кода, который может быть повторен несколько раз. Но эти повторения нужно инициировать вручную, с помощью инструкции br и родственных ей.

(import "debug" "printI32" (func $debugPrintI32 (param i32)))

;; Выводит $count чисел начиная с $start.
(func $countup (param $start i32) (param $count i32)
	;; Само по себе, содержимое цикла исполнится только единожды.
	(loop $loopname
		;; Выводим текущее число.
		(call $debugPrintI32 (local.get $start))
		;; Инкрементируем число.
		(local.set $start (i32.add (local. $start)))
		;; Декрементируем количество чисел которые надо вывести.
		(local.set $count (i32.add (local.get $count) (i32.const -1)))
		;; Перезапускаем цикл $loopname, если $count больше 0.
		(br_if $loopname (i32.gt_s (local.get $count) (i32.const 0)))
		;; По отношению к конструкции loop, инструкция br (branch) работает как
		;; continue, запуская цикл заново.
		;; br_if делает тоже самое, что br но только при соблюдении условия.
		;; br и родственные ей инструкции требуют явное указание цикла
		;; который следует перезапустить. Потому что циклы могут быть вложенными.
	)
)

(func $coundown (param $start i32) (param $count i32)
	(loop $loopname
		;; block работает почти также как loop, но по отношению к нему br инструкции
		;; работают как break, прерывая исполнение блока и переходя к его концу.
		(block $blockanme
			(call $debugPrintI32 (local.get $start))
			(local.set $start (i32.add (local.get $start) (i32.const -1)))
			(local.set $count (i32.add (local.get $count) (i32.const -1)))
			;; Завершаем блок $blockanme если $count равен нулю.
			(br_if $blockanme (i32.eqz (local.get $count)))
			;; Иначе безусловно перезапускаем цикл $loopname.
			(br $loopname)
		)
	)
)

Таблицы

Как упоминалось в блоке про функции, в WASM исполняемый код не имеет доступа к самому себе и не может вызывать произвольные функции. Каждый вызов функции в коде обязательно сопровождается ее константным ID, который должен быть захардкожен.
Это следствие мер безопасности, однако это также не позволяет нам писать код, использующий функции как объекты первого класса. Т.е. мы не можем передавать функции (или указатели на них) в другие функции, класть в переменные, размещать в памяти итд.
Для того, чтобы сделать это возможным, при этом не нарушив модель безопасности WASM, введен специальный механизм таблиц функций.

Стоит сказать, что рассматриваемое тут применение таблиц, единственное доступное на текущий момент, но не единственное возможное в принципе. Таблицы это более общий механизм, пригодный не только для хранения "указателей на функции", и в будущих расширениях стандарта WASM у них есть и другие назначения.

Итак, что это такое?
Таблицы очень похожи на память. Таблица также является массивом пронумерованных начиная с нуля ячеек, в которых могут храниться значения. Но, в отличие от памяти, ячейки здесь содержат не произвольные, не типизированные байты, а значения всякого рода "специальных" типов.
Одним из таких типов являются функции. Функции (читай указатели на них) можно помещать в ячейки таблицы и затем вызвать по номеру ячейки с помощью отдельной инструкции call_indirect. При этом в месте использования этой инструкции явно указывается тип функции, которую мы собираемся вызвать. Несоответствие указанного типа содержимому ячейки приведет к ошибке времени выполнения.

(type $math (func (param i32 i32) (result i32)))

;; Указываем, что это таблица ссылок на функции размером в 2 элемента.
;; На текущий момент в модуле может быть только одна таблица (как и одна память),
;; но в будущем можно будет объявлять несколько отдельных.
(table 2 funcref) 

;; Elem является аналогом data сегментов но для таблиц.
;; Помещаем функции $sub и $add в нулевую и первую ячейки таблицы.
(elem (i32.const 0) func $sub)
(elem (i32.const 1) func $add)

;; Складывает свои аргументы.
(func $add (param i32 i32) (result i32)
	(i32.add (local.get 0) (local.get 1))
)

;; Вычитает свой второй аргумент из первого.
(func $sub (param i32 i32) (result i32)
	(i32.sub (local.get 0) (local.get 1))
)

;; Выполнят над двумя числами одну из функций из таблицы по ее номеру.
;; (call $indirect (i32.const 0) (i32.const 5) (i32.const 2)) вернет 3
;; (call $indirect (i32.const 1) (i32.const 5) (i32.const 2)) вернет 7
(func $indirect (param $func i32) (param i32 i32) (result i32)
	;; Обязательно указываем тип.
	(call_indirect (type $math) (local.get 1) (local.get 2) (local.get $func))
)

Debug интерфейс

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

;; Преобразует фрагмент памяти длиной $length по смещению $offset в текст (utf-8) и
;; выводит этот текст в терминал.
(import "debug" "print" (func $debugPrint (param $offset i32) (param $length i32)))

;; Функции выводящие в терминал значения различных простых типов.
;; Все они рассматривают значение как знаковое (signed).
(import "debug" "printI32" (func $debugPrintI32 (param i32)))
(import "debug" "printI64" (func $debugPrintI64 (param i64)))
(import "debug" "printF32" (func $debugPrintF32 (param f32)))
(import "debug" "printF64" (func $debugPrintF64 (param f64)))

;; Очищает окно терминала.
(import "debug" "clear" (func $debugClear))

;; Приостанавливает работу на указанное число миллисекунд.
(import "debug" "sleep" (func $debugSleep (param i32)))

;; Возвращает случайное число типа f64 в диапазоне от 0 до 1.
(import "debug" "rand" (func $debugRand (result f64)))

Примеры использования всех этих функций можно найти в файле debug.wat.

Жизнь

Сложно представить человека, так или иначе относящегося к программированию, но при этом не слышавшего про игру жизнь Джона Конвея. Но на всякий случай рассмотрим, что это такое.

Игра жизнь это так называемая игра для 0 игроков. Мы задаем правила, по которым происходит игра и начальное состояния поля, а затем наблюдаем за тем, как развиваются события.
Игра происходит на двухмерном поле из квадратных клеток. Обычно бесконечного, но в нашем случае, для простоты, у него будет ограниченный размер.
Каждая клетка может быть пустой или закрашенной.
На каждом ходу состояние клетки изменяется в зависимости от окружающих ее клеток.
Если клетка закрашена (жива) и у нее есть 2 или 3 закрашенных соседа, то она остается закрашенной, иначе очищается (умирает).
Если клетка не закрашена (мертва) и у нее ровно три закрашенных соседа, она тоже закрашивается (оживает), иначе остается не закрашенной.

Эти довольно простые правила примечательны тем, что приводят к появлению очень сложных динамических структур на поле (да и вообще являются Тьюринг полными).

glider
glider
pulsar
pulsar
gun
gun

Жизнь является лишь частным случаем клеточного автомата.
Правила Жизни записываются как b3s23 (оживает при 3х соседях, выживает при 2х или 3х). Но также может существовать великое множество других правил, например b1s012345678 (оживает при одном соседе, выживает в любой ситуации).
Также можно по разному считать количество соседей. В оригинальной Жизни соседями клетки считаются все 8 окружающих ее. Это так называемое "Соседство Мура". Но также мы можем, например, считать только клетки сверху, снизу и по бокам (не учитывать соседей по диагонали). Это будет "Соседство Фон Неймана", и оно при тех же правилах будет давать другие результаты.

Мы напишем обобщенную реализацию клеточного автомата, способную обрабатывать игру по произвольным правилам и с использованием произвольного соседства. И правила и соседства будут реализованы в виде отдельных функций, передаваемых в функцию расчета следующего шага посредством таблицы.

Приступим.
Для начала определимся с тем, какие данные и в каком виде мы будем хранить.
Поскольку мы работаем с двухмерным полем, нам потребуются его ширина и высота (далее количество столбцов и строчек). Выделим под это глобальные переменные.

(global $cols (mut i32) (i32.const 0))
(global $rows (mut i32) (i32.const 0))

Размеры сторон поля, умноженные друг на друга, дают нам количество клеток на поле (aka в кадре). Чтобы не засорять и без того многословный WAT код, рассчитаем это значение единожды и вынесем в переменную.

(global $frameSize (mut i32) (i32.const 0))

В каждый момент времени нам потребуется хранить в памяти два состояния поля: состояние на текущем шаге и на будущем (которое мы рассчитываем).
Для этого выделим в памяти два фрагмента. Состояние клетки будем обозначать одним байтом, поэтому место, занимаемое одним состоянием поля (кадром), будет равно $frameSize.
Первый кадр будет располагаться в пространстве с нулевого байта по $frameSize, второй с $frameSize по $frameSize * 2.
Чтобы не копировать туда-сюда состояния поля, на каждом шаге будем чередовать роли кадров. Так что выделим также переменную для хранения того, где сейчас начинается текущий кадр.

(global $frame (mut i32) (i32.const 0))

Помимо состояния поля на разных шагах, нам также понадобится место, в котором мы будем "отрисовывать" состояние поля с помощью ASCII графики перед тем, как вывести его в терминал с помощью $debugPrint. Начинаться это место будет сразу там, где кончается второй кадр. Также рассчитаем это смещение единожды и запишем в переменную.

(global $output (mut i32) (i32.const 0))

Ну и наконец заведем переменные для хранения табличного индекса, используемого правила и соседства.

(global $rule (mut i32) (i32.const 0))
(global $neighborhood (mut i32) (i32.const 0))

Можно переходить к функциям. И начнем мы с init, которая принимает информацию о поле, рассчитывает начальные значения всех переменных и очищаем память, выделенную под кадры.

(func (export "init")
	(param $cols i32) (param $rows i32)
	(param $rule i32) (param $neighborhood i32)

	;; Глобальные $cols и $rows устанавливаются из параметров функции как есть.
	(global.set $cols (local.get $cols))
	(global.set $rows (local.get $rows))
	;; $rule и $neighborhood аналогично.
	(global.set $rule (local.get $rule))
	(global.set $neighborhood (local.get $neighborhood))
	;; Площадь поля - произведение ширины на высоту.
	(global.set $frameSize (i32.mul (local.get $cols) (local.get $rows)))
	;; На первом шаге текущим является фрейм лежащий в самом начале памяти.
	(global.set $frame (i32.const 0))
	;; Место для отрисовки человеко-читаемого состояния поля идет после обоих кадров.
	(global.set $output (i32.mul (global.get $frameSize) (i32.const 2)))
	;; Очищаем память занимаемую кадрами.
	(memory.fill
		(i32.const 0) (i32.const 0) (global.get $output)
	)
)

Теперь anotherFrame. Эта функция возвращает смещение, по которому находится фрейм со следующим (на этом ходу) состоянием поля.

(func $another (export "anotherFrame") (result i32)
	;; Если текущее значение $frame == 0, возвращаем $frameSize.
	;; Иначе наоборот.
	(if (result i32) (i32.eq (global.get $frame) (global.get $frameSize))
		(then (i32.const 0))
		(else (global.get $frameSize))
	)
)

Ну и тривиально реализуется функция смены ролей кадров.

(func $swap (export "swapFrames")
	(global.set $frame (call $another))
)

Далее нам понадобится проверять, что те или иные координаты не выходят за пределы поля, сделаем для этого функцию.

(func $checkOOB (export "checkOOB")
	(param $col i32) (param $row i32) (result i32)
	;; Возвращаем истину если
	;; - $col меньше 0
	;; - $row меньше 0
	;; - $col больше чем ширина поля
	;; - $row больше чем высота поля
	(i32.lt_s (local.get $col) (i32.const 0))
	(i32.lt_s (local.get $row) (i32.const 0))
	(i32.ge_s (local.get $col) (global.get $cols))
	(i32.ge_s (local.get $row) (global.get $rows))
	(i32.or) (i32.or) (i32.or)
)

Теперь мы можем написать функции для установки и получения состояния клетки.

(func $setCell (export "setCell")
	(param $col i32) (param $row i32) (param $val i32) (param $frame i32)
	;; Проверяем что координаты в пределах поля.
	;; Иначе просто ничего не делаем.
	(if (call $checkOOB (local.get $col) (local.get $row)) (then return))
	;; Устанавливаем состояние клетки.
	(i32.store8
		;; Адрес клетки в памяти: $frame+$row*$cols+$col
		(i32.add
			(local.get $frame)
			(i32.add
				(i32.mul (local.get $row) (global.get $cols))
				(local.get $col)
			)
		)
		(local.get $val)
	)
)

;; Установка состояния клетки работает в целом также, за исключением того, что для
;; координат вне поля всегда возвращается 0.
(func $getCell (export "getCell")
	(param $col i32) (param $row i32) (param $frame i32) (result i32)
	(if (call $checkOOB (local.get $col) (local.get $row))
		(then (return (i32.const 0)))
	)
	(i32.load8_u
		(i32.add
			(local.get $frame)
			(i32.add
				(i32.mul (local.get $row) (global.get $cols))
				(local.get $col)
			)
		)
	)
)

Теперь можем реализовать наши выбранные правила.

;; По скольку реализации правил будут передаваться через таблицы,
;; объявим тип, которому они будут соответствовать.
(type $ruleType (func (param $self i32) (param $neigbours i32) (result i32)))

(func $b3s23 (export "b3s23")
	(param $self i32) (param $neigbours i32) (result i32)
	(if (result i32) (local.get $self)
		(then
			(i32.or
				(i32.eq (local.get $neigbours) (i32.const 2))
				(i32.eq (local.get $neigbours) (i32.const 3))
			)
		)
		(else
			(i32.eq (local.get $neigbours) (i32.const 3))
		)
	)
)

(func $b1s012345678 (export "b1s012345678")
	(param $self i32) (param $neigbours i32) (result i32)
	(if (result i32) (local.get $self)
		(then
			(i32.const 1)
		)
		(else
			(i32.eq (local.get $neigbours) (i32.const 1))
		)
	)
)

И соседства.

;; Соседство принимает состояние всех восьми клеток вокруг целевой и возвращает
;; одно число - сколько засчитано живых соседей.
(type $neighborhoodType (func
	(param $upLeft i32) (param $up i32) (param $upRight i32)
	(param $right i32) (param $downRight i32) (param $down i32)
	(param $downLeft i32) (param $left i32)
	(result i32)
))

;; Соседство Мура это просто логическое И между всеми параметрами.
(func $Moore (export "Moore")
	(param $upLeft i32) (param $up i32) (param $upRight i32)
	(param $right i32) (param $downRight i32) (param $down i32)
	(param $downLeft i32) (param $left i32)
	(result i32)

	(local.get $upLeft)
	(local.get $up)
	(local.get $upRight)
	(local.get $right)
	(local.get $downRight)
	(local.get $down)
	(local.get $downLeft)
	(local.get $left)

	(i32.add) (i32.add) (i32.add) (i32.add)
	(i32.add) (i32.add) (i32.add)
)

;; Соседство Фон Неймана еще проще.
(func $VonNeumann (export "VonNeumann")
	(param $upLeft i32) (param $up i32) (param $upRight i32)
	(param $right i32) (param $downRight i32) (param $down i32)
	(param $downLeft i32) (param $left i32)
	(result i32)

	(local.get $up)
	(local.get $right)
	(local.get $down)
	(local.get $left)

	(i32.add) (i32.add) (i32.add)
)

Для отображения состояния поля пользователю, нам потребуется функция, преобразующая состояние клетки в символ.

;; Мертвая (не закрашенная) клетка отображается пробелом (ASCII 32).
;; Живая (закрашенная) - символом "+" (ASCII 43).
(func $toChar (export "toChar") (param i32) (result i32)
	(i32.add (i32.const 32) (i32.mul (i32.const 11) (local.get 0)))
)

Функция отрисовки - первая комплексная функция в этой статье:

;; Отрисовывает символами состояние поля на текущем шаге.
;; Возвращает смещение начала и конца фрагмента памяти, в котором
;; располагается отрисованное поле.
(func $render (export "render") (result i32 i32)
    (local $end i32)
    (local $inrow i32)
    (local $src i32)
    (local $dst i32)
    ;; Указатель на символ, который мы отрисовываем.
    (local.set $src (global.get $frame))
    ;; Указатель на ячейку памяти, в которую мы кладем результат.
    (local.set $dst (global.get $output))
    ;; Смещение конца отрисовываемого поля.
    ;; Координата начала поля + размер поля.
    (local.set $end (i32.add
        (global.get $frame)
        (global.get $frameSize)
    ))
    ;; Колличество клеток, отрисованных в пределах текущей строчки.
    (local.set $inrow (i32.const 0))
    (loop $loop
        (block $block
	        ;; Берем состояние клетки по смещению $src.
	        ;; Преобразуем в символ.
	        ;; Кладем его по смещению $dst.
            (i32.store8 (local.get $dst)
                (call $toChar (i32.load8_u (local.get $src)))
            )
            ;; Инкрементируем $src и $dst.
            (local.set $dst (i32.add (local.get $dst) (i32.const 1)))
            (local.set $src (i32.add (local.get $src) (i32.const 1)))
            (local.set $inrow (i32.add (local.get $inrow) (i32.const 1)))
            ;; Если $inrow равно ширине поля.
            ;; Добавляем внеочередной символ "\n" (ASCII 10) - перенос строки.
            (if
                (i32.eq (local.get $inrow) (global.get $cols))
                (then
                    (i32.store8 (local.get $dst) (i32.const 10)) ;; \n
                    ;; Инкрементируем $dst после добавление переноса строки.
                    (local.set $dst (i32.add (local.get $dst) (i32.const 1)))
                    ;; Обнуляем счетчик клеток в строке.
                    (local.set $inrow (i32.const 0))
                )
            )
            ;; Если достигли конца кадра, завершаемся.
            (br_if $block (i32.eq (local.get $end) (local.get $src)))
            (br $loop)
        )
    )
    (global.get $output) (local.get $dst)
)

;; Ну и функция $show просто выполняющая рендер и отображающая результат на терминал
;; с помощью $debugPrint.
(func $show (export "show")
	(call $debugPrint (call $render))
)

Функция рассчитывающая количество соседей, используя выбранную реализацию соседства:

(func $getNeigbours (export "getNeigbours")
    (param $col i32) (param $row i32) (result i32)
    ;; Вызываем выбранную реализацию соседства по таблице,
    ;; передав состояния всех клеток окружающих $col,$row.
    (call_indirect (type $neighborhoodType)
        (call $getCell 
            (i32.add (local.get $col) (i32.const -1))
            (i32.add (local.get $row) (i32.const -1))
            (global.get $frame)
        )
		;; ...
		;; Много (6) повторений getCell с разными комбинациями смещения от -1 до 1.
		;; Полный код в репозитории.
		;; ...
		(call $getCell 
            (i32.add (local.get $col) (i32.const -1))
            (i32.add (local.get $row) (i32.const 0))
            (global.get $frame)
        )
        (global.get $neighborhood)
    )
)

Теперь можно перейти к, по сути, главной функции - расчету следующего состояния поля.

(func $step (export "step")
    (local $col i32)
    (local $row i32)
    (local $next i32)
    ;; Определяем, где находится фрейм, с состоянием поля на следующем шаге.
    (local.set $next (call $another))
    ;; Проходимся в цикле по всем строчкам.
    (local.set $row (i32.const 0))
    (loop $rloop
		;; И во вложенном цикле по всем столбцам.
        (local.set $col (i32.const 0))
        (loop $cloop
	        ;; Получаем состояние клетки и количество живых соседей.
	        ;; Вызываем выбранное правило и результат записываем в состояние этой
	        ;; же клетки но на следующем кадре.
            (call $setCell
                (local.get $col)
                (local.get $row)
                (call_indirect (type $ruleType)
                    (call $getCell
                        (local.get $col)
                        (local.get $row)
                        (global.get $frame)
                    )
                    (call $getNeigbours (local.get $col) (local.get $row))
                    (global.get $rule)
                )
                (local.get $next)
            )
            ;; Инкрементируем номер столбца.
            (local.set $col (i32.add (local.get $col) (i32.const 1)))
            ;; Если прошлись по всем столбцам, завершаем цикл.
            (br_if $cloop (i32.lt_s (local.get $col) (global.get $cols)))
        )
        ;; Инкрементируем номер строчки.
        (local.set $row (i32.add (local.get $row) (i32.const 1)))
        ;; Если прошлись по всем строкам, завершаем цикл.
        (br_if $rloop (i32.lt_s (local.get $row) (global.get $rows)))
    )
    ;; Меняем роли кадров местами.
    (call $swap)
)

Вся логика готова, осталось лишь добавить бесконечный цикл, выполняющий шаги, и отрисовывающий результат.

(func (export "run") (param $sleep i32)
	(loop $loop
		;; Очищаем экран.
		(call $debugClear)
		;; Отображаем состояние на текущем шаге.
		(call $show)
		;; Делаем шаг.
		(call $step)
		;; Выжидаем указанную задержку, чтобы шаги не сменялись слишком быстро для
		;; человеческого глаза.
		(call $debugSleep (local.get $sleep))
		;; Повторяем бесконечно.
		(br $loop)
	)
)

Иииии...
Жизнь:

b3s23
b3s23

Мир без смерти:

b1s012345678
b1s012345678

За кадром остались функции seedGlider, seedPoint, seedRandom, seedHabr, заполняющие игровое поле соответствующим начальным шаблоном, а также другие вспомогательные функции созданные для них. Полный код модуля доступен тут.

Кстати, вес получившейся у нас реализации жизни в откомпилированном виде - менее 1.5 Кb до половины от которых занимают имена импортируемых/экспортируемых функций. Т.е. убрав лишние экспорты, нужные для отладки и выбрав более короткие имена для оставшихся, можно ужать размер программы почти в два раза. Это, к слову, о демосцене.

Заключение

WASM это прорывная технология, идея которой давно витала в воздухе, но воплотилась только сейчас. Тем не менее, идея программирования непосредственно на WASM'е остается сильно недооцененной, что я, надеюсь, исправил хотя бы для читателей этой статьи.
Полный код примеров из этой статьи (и будущих) вы найдете в репозитории blog-wasm.
А тут моя постоянно пополняющаяся коллекция примеров WASM кода, включая те, которые не поместятся в статьи, вместе с большим списком ссылок по теме.

Теги:
Хабы:
+32
Комментарии22

Публикации

Истории

Работа

Ближайшие события

19 марта – 28 апреля
Экспедиция «Рэйдикс»
Нижний НовгородЕкатеринбургНовосибирскВладивостокИжевскКазаньТюменьУфаИркутскЧелябинскСамараХабаровскКрасноярскОмск
24 апреля
VK Go Meetup 2025
Санкт-ПетербургОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань
14 мая
LinkMeetup
Москва
5 июня
Конференция TechRec AI&HR 2025
МоскваОнлайн
20 – 22 июня
Летняя айти-тусовка Summer Merge
Ульяновская область