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