Что находится в этом `.wasm`? Представляем: `wasm-decompile`
У нас растет число компиляторов и других инструментов, которые генерируют или обрабатывают файлы .wasm
, и иногда вам может захотеться посмотреть, что находится внутри. Возможно, вы разработчик такого инструмента, или, более непосредственно, программист, нацеленный на Wasm, и вам интересно, как выглядит сгенерированный код, например, с точки зрения производительности или по другим причинам.
Проблема в том, что Wasm довольно низкоуровневый, почти как язык ассемблера. В частности, в отличие, скажем, от JVM, все структуры данных были скомпилированы до операций загрузки/выгрузки, а не до удобно названных классов и полей. Такие компиляторы, как LLVM, могут выполнять впечатляющее количество преобразований, из-за которых сгенерированный код совсем не похож на исходный.
Дизассемблировать или... декомпилировать?
Вы можете использовать такие инструменты, как wasm2wat
(часть инструментария WABT), чтобы преобразовать .wasm
в стандартный текстовый формат Wasm, .wat
, который является очень точным, но не особо читаемым представлением.
Например, простая C-функция, как скалярное произведение:
typedef struct { float x, y, z; } vec3;
float dot(const vec3 *a, const vec3 *b) {
return a->x * b->x +
a->y * b->y +
a->z * b->z;
}
Мы используем clang dot.c -c -target wasm32 -O2
, а затем wasm2wat -f dot.o
, чтобы преобразовать это в .wat
:
(func $dot (type 0) (param i32 i32) (result f32)
(f32.add
(f32.add
(f32.mul
(f32.load
(local.get 0))
(f32.load
(local.get 1)))
(f32.mul
(f32.load offset=4
(local.get 0))
(f32.load offset=4
(local.get 1))))
(f32.mul
(f32.load offset=8
(local.get 0))
(f32.load offset=8
(local.get 1))))))
Это совсем небольшой код, но уже не очень читабелен по многим причинам. Помимо отсутствия выраженного синтаксиса и общей многословности, понимание структур данных через память загрузки сложно. Теперь представьте, что вы смотрите на вывод большого приложения, и всё станет быстро непонятным.
Вместо wasm2wat
запустите wasm-decompile dot.o
, и вы получите:
function dot(a:{ a:float, b:float, c:float },
b:{ a:float, b:float, c:float }):float {
return a.a * b.a + a.b * b.b + a.c * b.c
}
Это выглядит гораздо более знакомо. Помимо выраженного синтаксиса, напоминающего языки программирования, с которыми вы могли быть знакомы, декомпилятор анализирует все загрузки и выгрузки в функции и старается определить их структуру. Затем он аннотирует каждую переменную, используемую как указатель, с "встроенным" описанием структуры. Однако он не создает именованные описания структур, так как не обязательно знает, какие использования 3-х float значений представляют одно и то же понятие.
Что получить в результате декомпиляции?
wasm-decompile
генерирует вывод, который старается выглядеть как "очень усреднённый язык программирования", при этом оставаясь максимально близким к Wasm.
Его цель №1 — читабельность: помочь читателям понять, что находится в .wasm
, предоставив максимально понятный код. Его цель №2 — всё ещё представлять Wasm максимально один к одному, чтобы не терять его утилитарной функции как дизассемблера. Очевидно, что эти две цели не всегда совместимы.
Этот вывод не предназначен для использования в качестве реального языка программирования и в настоящее время не существует способа скомпилировать его обратно в Wasm.
Загрузки и выгрузки
Как демонстрируется выше, wasm-decompile
анализирует все загрузки и выгрузки по определённому указателю. Если они образуют непрерывный набор доступов, он выводит одну из этих "встроенных" структур.
Если не все "поля" доступны, он не может с уверенностью определить, является ли это структурой или другой формой несвязанных доступов к памяти. В этом случае он возвращается к более простым типам, таким как float_ptr
(если типы одинаковы) или, в худшем случае, выведет доступ к массиву, например, o[2]:int
, что означает: o
указывает на значения типа int
, и мы обращаемся к третьему из них.
Этот последний случай происходит чаще, чем может показаться, поскольку локальные переменные в Wasm больше похожи на регистры, чем на переменные, поэтому оптимизированный код может использовать один и тот же указатель для несвязанных объектов.
Декомпилятор старается умно обрабатывать индексацию и обнаруживает шаблоны, такие как (base + (index << 2))[0]:int
, которые возникают при стандартных операциях индексирования массивов в C, например, base[index]
, где base
указывает на 4-байтовый тип. Они очень распространены в коде, так как в Wasm на загрузках и выгрузках используются только константные смещения. Вывод wasm-decompile
преобразует их обратно в base[index]:int
.
Кроме того, он знает, когда абсолютные адреса ссылаются на раздел данных.
Управление потоком
Наиболее знакомая конструкция в Wasm — это условный оператор if-then, который соответствует привычному синтаксису if (cond) { A } else { B }
, с добавлением того, что в Wasm он может возвращать значение, так что он также может представлять тернарный синтаксис cond ? A : B
, доступный в некоторых языках.
Остальная часть управления потоком в Wasm основана на блоках block
и loop
, а также на переходах br
, br_if
и br_table
. Декомпилятор стремится оставаться близким к этим конструкциям, а не пытаться вывести конструкции while/for/switch, из которых они могли быть преобразованы, поскольку это, как правило, лучше работает с оптимизированным выводом. Например, типичный цикл в выводе wasm-decompile
может выглядеть так:
loop A {
// тело цикла здесь.
if (cond) continue A;
}
Здесь A
— это метка, позволяющая вложение нескольких таких конструкций. Наличие if
и continue
для управления циклом может выглядеть немного непривычно по сравнению с циклом while, но это непосредственно соответствует конструкции Wasm br_if
.
Блоки аналогичны, но вместо перехода назад, они переходят вперед:
block {
if (cond) break;
// тело здесь.
}
Это на самом деле реализует конструкцию if-then. Будущие версии декомпилятора могут переводить их в настоящие конструкции if-then, когда это возможно.
Наиболее неожиданный элемент управления в Wasm — это br_table
, который реализует что-то вроде switch
, но с использованием вложенных блоков block
, что обычно трудно читать. Декомпилятор упрощает их, чтобы сделать их немного
легче для восприятия, например:
br_table[A, B, C, ..D](a);
label A:
return 0;
label B:
return 1;
label C:
return 2;
label D:
Это похоже на switch
для a
, где D
является случаем по умолчанию.
Другие интересные особенности
Декомпилятор:
- Может извлекать имена из отладочной или линкерной информации, либо генерировать имена самостоятельно. При использовании существующих имен у него есть специальный код для упрощения переманглированных символов на C++.
- Уже поддерживает предложение multi-value, которое усложняет преобразование в выражения и операторы. Дополнительные переменные используются, когда возвращаются несколько значений.
- Может даже генерировать имена из содержимого секций данных.
- Выводит аккуратные объявления для всех типов секций Wasm, а не только для кода. Например, он старается сделать секции данных читаемыми, выводя их в виде текста, если это возможно.
- Поддерживает приоритет операторов (распространенный для большинства языков C-типа), чтобы уменьшить количество
()
в обычных выражениях.
Ограничения
Декомпиляция Wasm принципиально сложнее, чем, например, байт-кода JVM.
Последний является не оптимизированным, поэтому относительно точно соответствует структуре исходного кода, и, даже если имена могут отсутствовать, он ссылается на уникальные классы, а не просто на местоположения в памяти.
В отличие от этого, большая часть .wasm
вывода сильно оптимизируется с помощью LLVM, и поэтому часто теряет большую часть своей оригинальной структуры. Выводимый код значительно отличается от того, что написал бы программист. Это делает создание полезного декомпилятора для Wasm сложной задачей, но это не значит, что мы не должны пытаться!
Подробнее
Лучший способ узнать больше, конечно же, декомпилировать ваш собственный проект на Wasm!
Кроме того, более подробное руководство по wasm-decompile
находится здесь. Его реализация находится в исходных файлах, начинающихся с decompiler
здесь (не стесняйтесь прислать PR, чтобы сделать его лучше!). Несколько тестовых случаев, показывающих дальнейшие примеры различий между .wat
и декомпилятором, находятся здесь.