В нашем распоряжении имеется множество компиляторов и других инструментов, позволяющих создавать .wasm-файлы и работать с ними. Количество этих инструментов постоянно растёт. Иногда нужно заглянуть в .wasm-файл и разобраться с тем, что у него внутри. Может быть, вы — разработчик одного из Wasm-инструментов, или, возможно, вы — программист, который пишет код, рассчитанный на преобразование в Wasm, и интересующийся тем, как выглядит то, во что превратится его код. Такой интерес может быть вызван, например, соображениями производительности.
Проблема заключается в том, что в .wasm-файлах содержится довольно-таки низкоуровневый код, который сильно похож на настоящий ассемблерный код. В частности, в отличие, например, от JVM, все структуры данных компилируются в наборы операций load/store, а не в нечто такое, в чём имеются понятные имена классов и полей. Компиляторы, вроде LLVM, могут так изменить входной код, что то, что у них получается, и близко на него не похоже.
Как быть тому, кто хочет, взяв .wasm-файл, узнать о том, что в нём происходит?
Дизассемблирование или… декомпиляция?
Для преобразования .wasm-файлов в файлы .wat, содержащие стандартное текстовое представление Wasm-кода, можно воспользоваться инструментами наподобие wasm2wat (это — часть набора инструментов WABT). Результаты такого преобразования очень точны, но читать получившийся код не особенно удобно.
Вот, например, простая функция, написанная на 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;
}
Код хранится в файле dot.c
.
Воспользуемся следующей командой:
clang dot.c -c -target wasm32 -O2
Далее, чтобы преобразовать то, что получилось, в .wat-файл — применим следующую команду:
wasm2wat -f dot.o
Вот что это нам даст:
(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.
Первая цель wasm-decompiler — формирование читабельного кода. То есть — такого кода, который позволит его читателю легко разобраться в том, что происходит в декомпилированном .wasm-файле. Вторая цель этого инструмента заключается в том, чтобы выдать как можно более точное представление .wasm-файла, сформировав код, который полно представляет то, что происходит в исходном файле. Очевидно то, что эти цели далеко не всегда хорошо друг с другом согласуются.
То, что выводит wasm-decompiler, изначально не задумывалось как код, представляющий некий реальный язык программирования. Сейчас нет способа скомпилировать этот код в Wasm.
Команды загрузки и сохранения данных
Как показано выше, wasm-decompile ищет команды загрузки и сохранения данных, связанные с конкретным указателем. Если эти команды формируют непрерывную последовательность, декомпилятор выводит одно из «встроенных» объявлений структуры данных.
Если обращались не ко всем «полям», декомпилятор не может с уверенностью отличить структуру от некоей последовательности операций по работе с памятью. В таком случае 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, которые могли бы послужить основой для них. Дело в том, что такой подход лучше показывает себя при обработке оптимизированного кода. Например, обычная конструкция loop
может выглядеть в коде, выдаваемом wasm-decompile, так:
loop A {
// здесь будет тело цикла.
if (cond) continue A;
}
Здесь A
— это метка, которая позволяет строить вложенные друг в друга конструкции loop
. То, что тут есть команды if
и continue
, используемые для управления циклом, может выглядеть несколько чужеродно для циклов while, но они соответствуют Wasm-конструкции br_if
.
Блоки оформляются похожим образом, но тут условия находятся в начале, а не в конце:
block {
if (cond) break;
// здесь будет тело блока.
}
Здесь показан результат декомпиляции конструкции if-then. В будущих версиях декомпилятора, вероятно, вместо такого кода, там, где это возможно, будет формироваться более привычная конструкция if-then.
Самое необычное средство Wasm, использующееся для управления потоком выполнения программы, это br_table
. Это средство представляет собой нечто вроде оператора switch, за исключением того, что тут используются встроенные блоки. Всё это усложняет чтение кода. Декомпилятор упрощает структуру подобных конструкций, стремясь к тому, чтобы немного облегчить их восприятие:
br_table[A, B, C, ..D](a);
label A:
return 0;
label B:
return 1;
label C:
return 2;
label D:
Это напоминает использование switch
для анализа a
, когда вариантом, используемым по умолчанию, является D
.
Другие интересные возможности
Вот ещё некоторые возможности wasm-decompile:
- Декомпилятор может извлекать имена из отладочных данных или из данных компоновки, а также может генерировать имена самостоятельно. При использовании существующих имён предусмотрено упрощение искажённых C++-имён.
- Система уже поддерживает предложение, касающееся, кроме прочего, возврата из функции нескольких значений. Это немного усложняет превращение исходного кода в выражения и инструкции. Если функции возвращают несколько значений, используются дополнительные переменные.
- Имена могут быть сгенерированы на основании содержимого раздела данных.
- Декомпилятор формирует аккуратные объявления для всех типов разделов Wasm-файлов, а не только для кода. Например, wasm-decompile пытается улучшить читабельность разделов данных, выводя их, если это возможно, в виде текста.
- Система пытается уменьшить количество скобок в выражениях, учитывая приоритет операторов (по правилам, которыми обычно пользуются в C-подобных языках).
Ограничения
Декомпиляция Wasm-кода — это задача, которая гораздо сложнее, чем, например, декомпиляция байт-кода JVM.
Байт-код не подвергается оптимизации, то есть — довольно точно воспроизводит структуру исходного кода. При этом, несмотря на то, что в таком коде могут отсутствовать исходные имена, в байт-коде используются ссылки на уникальные классы, а не на области памяти.
В отличие от байт-кода JVM, код, попадающий в .wasm-файлы, сильно оптимизирован LLVM. В результате такой код часто теряет большую часть исходной структуры. Выходной код очень не похож на то, что написал бы программист. Это значительно усложняет задачу декомпиляции Wasm-кода с выводом результатов, способных принести программистам реальную пользу. Однако это не означает, что мы не должны стремиться к решению этой задачи!
Итоги
Если вам интересна тема декомпиляции Wasm-кода, то, пожалуй, лучший способ в этой теме разобраться — взять и декомпилировать собственный .wasm-проект! Кроме того, здесь вы можете найти более подробное руководство по wasm-decompile. Код декомпилятора можно найти в файлах этого репозитория, имена которых начинаются с decompile
(если хотите — присоединяйтесь к работе над декомпилятором). Здесь можно найти тесты, показывающие дополнительные примеры различий между .wat-файлами и результатами декомпиляции.
А с помощью каких инструментов вы исследуете .wasm-файлы?
Напоминаем, что у нас продолжается конкурс прогнозов, в котором можно выиграть новенький iPhone. Еще есть время ворваться в него, и сделать максимально точный прогноз по злободневным величинам.
Автор: ru_vds