
Декомпиляция — это не магия, а очень упрямый, скрупулёзный и грязноватый процесс, где каждый байт может оказаться фатальным. В этой статье я разложу по винтикам, как мыслят современные декомпиляторы: как они восстанавливают структуру кода, зачем строят SSA, почему не верят ни одному call’у на слово, и как Ghidra и RetDec реализуют свои механизмы под капотом. Это не глянцевый обзор, а техразбор, вплоть до IR, реконструкции управляющего графа и попытки угадать типы переменных там, где они уже испарились. Будет сложно, но весело.
Введение
Когда-то давно, в эпоху до удобных IDA, я сидел в холодной общаге и вручную распутывал рекурсивные вызовы в дизассемблере, который даже не умел выделять функции. Тогда я еще думал, что дизассемблер — это просто таблица соответствий: байт → инструкция. Наивно, но душевно.
С тех пор многое поменялось. Сегодняшние декомпиляторы вроде Ghidra и RetDec умеют реконструировать не просто код, а чуть ли не начальную логику программы. Но как? Как они догадываются, где функция начинается и заканчивается? Почему они иногда путают указатели с int’ами? И при чем тут SSA и Control Flow Graph?
Давайте заглянем внутрь. Прямо в кишки.
1. Что такое декомпиляция и почему это боль
Коротко: дизассемблирование — это превращение бинарного кода в инструкции ассемблера. А декомпиляция — это превращение того же бинаря в некий суррогат высокоуровневого языка, чаще всего — C.
Но! Это не обратимое преобразование. Информация утеряна. Типы? Потеряны. Имена функций? Нет их. Границы блоков? Возможно. И если дизассемблер ещё может просто механически разбирать инструкции, то декомпилятор должен... угадывать. Местами буквально.
2. Общий пайплайн: от байта к коду
Наивный взгляд на декомпилятор:
- Загрузи бинарь 
- Разбери инструкции 
- Построй граф 
- Восстанови функции 
- Построй AST 
- Сгенерируй C-код 
На практике:
- Всё не так. 
- Вообще не так. 
Вот как это реально устроено (в Ghidra и RetDec):
[Bytes] → [Instruction Decoder] → [Intermediate Representation (IR)] →  
→ [Control Flow Graph] → [SSA Form] → [Type Recovery] →  
→ [Decompilation Rules] → [AST Generator] → [C-like Output]3. Ghidra: её мозг — это Sleigh
Если вы думали, что в Ghidra всё делают скрипты на Java — не совсем так. Центральное место здесь занимает язык описания архитектур Sleigh. Он позволяет описывать, как из последовательности байт получаются инструкции.
Пример фрагмента Sleigh для x86:
define token opcodes (8)
  ADD = 0x01;
...
:ADD reg8, reg8 is opcodes=0x00; reg8; reg8
{
  reg8 = reg8 + reg8;
}Это DSL, по которому Ghidra строит декодер инструкций. И этот слой уже превращает байты в IR — промежуточное представление, на котором и происходит основная аналитика.
4. RetDec и сила LLVM
RetDec построен на базе LLVM. Бинар разбирается в LLVM IR, после чего к нему применяются те же оптимизации, что и в компиляторе. Бонус: можно декомпилировать под любые архитектуры, если есть фронтенд.
Пример IR-фрагмента после разбора:
define void @func() {
entry:
  %x = alloca i32
  store i32 42, i32* %x
  %y = load i32, i32* %x
  ret void
}Это не C, но уже что-то, с чем можно работать: видны переменные, потоки управления, инструкции. И, главное, их можно анализировать.
5. Control Flow Graph — хребет анализа
Один из первых этапов — построение графа управления (CFG). Он показывает, какие блоки кода исполняются после каких. Без него невозможно построить нормальную картину исполнения.
Пример на Python с networkx (просто для иллюстрации):
import networkx as nx
G = nx.DiGraph()
G.add_edges_from([
    ('start', 'check'),
    ('check', 'true_branch'),
    ('check', 'false_branch'),
    ('true_branch', 'end'),
    ('false_branch', 'end'),
])
nx.draw(G, with_labels=True)На практике всё сложнее: приходится учитывать условные переходы, прямые jmp, call, ret, и экзотические jump table.
6. SSA: Static Single Assignment
Следующий шаг — SSA. Каждая переменная должна быть присвоена один раз. Это позволяет проще анализировать зависимости.
Пример:
int x = 1;
if (cond) {
    x = 2;
}
use(x);В SSA:
x1 = 1
if (cond) {
    x2 = 2
}
x3 = phi(x1, x2)
use(x3)Зачем? Это облегчает оптимизации и упрощает анализ. Операции с переменными становятся графом, а не спагетти.
7. Восстановление типов: гадание на байтах
Типов в бинаре нет. Есть только байты, mov, push и call. Но декомпилятор должен как-то показать char*, int, double.
Он строит гипотезы. Например:
mov eax, [ebp+8]
mov [ebx], eax
call printf→ Может быть, это указатель?
 → Может быть, он передаётся в функцию?
 → Что эта функция делает?
Ghidra и RetDec используют эвристику + сигнатуры стандартных библиотек (вроде libc). Если call указывает на printf, и туда передаётся eax, то, возможно, eax — это char*.
8. От IR к C: магия шаблонов
Когда граф построен, SSA применена, типы угаданы, остаётся «вернуть» код. Тут начинается шаблонный генератор — превращение IR в C-подобный код.
Пример из Ghidra:
int __cdecl main(int argc, const char **argv)
{
  int result;
  result = puts("Hello, world!");
  return result;
}Это не ваш оригинальный код, но он логически близок. Важно: тут возможны ошибки. И чем экзотичнее бинарь, тем больше вероятность получить чушь.
9. Почему декомпиляция — это не точная наука
Типичный пример боли:
mov eax, [ebx]
add eax, 4
call eaxЧто это?
 — Индирект вызов?
 — Таблица виртуальных функций?
 — Динамический переход?
Декомпилятор может только предположить. Тут спасают паттерны, эвристика и context-aware анализ. Но 100% гарантии — нет.
10. Сюрпризы: оптимизации, инлайнинг, tail-call
Оптимизирующий компилятор — злейший враг декомпилятора. Он меняет структуру, инлайнит функции, превращает циклы в goto и tail-call’ы. В итоге декомпилятору приходится гадать, была ли тут вообще функция.
В RetDec есть опции, чтобы бороться с инлайном, но он всё равно не всесилен. Ghidra умеет находить куски функций по паттернам, но это напоминает охоту на привидений.
Заключение
Декомпилятор — это не просто инструмент, а маленький сумасшедший компилятор наоборот. Он мыслит графами, строит гипотезы, обманывает себя SSA и надеется, что call не делает подлянку. Ghidra и RetDec — отличные примеры того, как далеко зашёл реверс, но за кулисами у них всё ещё идёт постоянная борьба с отсутствием информации, костылями и багами компиляции.
И если вы когда-нибудь пытались понять, что делает бинарь без отладочных символов, вы понимаете: без этих инструментов — никак. Но понимать, как они думают — значит использовать их на максимум.
Если вам интересно углубиться в декомпиляцию руками — можно будет разобрать конкретный кейс или кусок бинаря в следующей статье. А пока — байты вам в стек и SSA без конфликтов.
Комментарии (5)
 - Ilya_JOATMON28.07.2025 15:55- Перевод в промежуточное представление машинных кодов хоть и обеспечивает универсальность для дальнейшей декомпиляции имеет и свои недостатки, такие как потеря той информации, которая идет в порядке инструкций и выборе машинных инструкций. Декомпиляторов имеющих возможности посмотреть из верхнего уровня на уровень инструкций за подсказками я не знаю. - Использование LLVM для целей декомпиляции же я считаю вообще ошибкой, эта штука проектировалась для ОПТИМИЗАЦИИ, а в этой задаче нужна ДЕОПТИМИЗАЦИЯ - для человекочитаемости. Нужно распутивать граф управления в том числе дублированием блоков кода (это насколько знаю умеет только fernflower), приводить switch case к нормальному виду из мешанины if и прочее. - Ни один из декомпиляторов нормально не поддерживает объектный и шаблонный код на С++, результат - месиво просто. 
 - NeriaLab28.07.2025 15:55- Тут совсем так немножечко решил написать свой декомпилятор и дизассемблер:пока только для x16-x32 - "заболел" ретро играми (DOS, Windows, Atari) - Часть из моего ТЗ- 10.1. Функциональные критерии - Корректное декодирование инструкций x86 (16/32 бита) на реальных бинарных примерах, включая все addressing modes, префиксы, переходы, вызовы, арифметику, работу с памятью, стеком, портами, флагами, прерываниями. 
- Соответствие формата вывода выбранному синтаксису (по умолчанию — Intel/MASM/TD), поддержка всех вариантов записи операндов, префиксов, сегментов, смещений, литералов. 
- Корректная работа виртуализации (отображение только видимых строк), отсутствие артефактов, задержек, ошибок при прокрутке, обновлении диапазона. 
- Корректная интеграция с кастомным hex-редактором, поддержка всех событий, навигации, выделения, обновления данных. 
- Возможность расширения (новые инструкции, режимы, форматы, модули анализа) без переписывания ядра, через интерфейсы и точки расширения. 
- Подробные логи для отладки, анализ ошибок, предупреждений, событий, производительности. 
- Документация и примеры использования для всех основных сценариев, модулей, интерфейсов, расширений. 
 - 10.2. Нефункциональные критерии - Время декодирования 1000 инструкций — не более 100 мс на среднестатистическом ПК (Intel i5, 8 ГБ RAM), при больших объёмах — линейное масштабирование. 
- Время обновления диапазона строк — не более 50 мс, отсутствие заметных задержек при прокрутке, навигации. 
- Потребление памяти — не более 50 МБ на 1 МБ кода, оптимизация хранения промежуточных данных. 
- Корректная обработка ошибок и исключений, отсутствие сбоев, утечек памяти, зависаний. 
- Соответствие архитектурным принципам (разделение логики и UI, использование интерфейсов, абстракций, точек расширения). 
 - ... 
 18.4. Ограничения и реалистичные цели- Создание такого анализатора — задача, сравнимая по сложности с разработкой компилятора или переводчика между двумя сложнейшими языками. x86-ассемблер допускает произвольные переходы, самомодифицирующийся код, нестандартные прологи/эпилоги, что делает автоматическое определение границ и назначения функций крайне сложным. Даже лучшие инструменты (IDA, Ghidra) не всегда справляются идеально и всегда оставляют место для ручной корректировки. - В связи с этим: - Анализатор позиционируется как "помощник", а не "заменитель эксперта". 
- Основная задача — автоматизация рутинных операций (поиск функций, построение call graph, подсказки по паттернам), а не "магическое" понимание смысла любого кода. 
- Всегда должна быть возможность ручной корректировки, переименования, комментирования, визуализации. 
- Архитектура должна быть расширяемой: поддержка новых эвристик, паттернов, сигнатур, интеграция с внешними базами, возможность дообучения. 
- Вся работа анализатора должна быть прозрачной для пользователя: показывать, почему принято то или иное решение, давать возможность "откатить" или скорректировать результат. 
 - 18.5. Польза для реверса и обучения - Существенно ускоряет первичный разбор неизвестного бинарного кода. 
- Помогает быстро выявить ключевые функции, точки входа, связи между частями программы. 
- Позволяет отслеживать реальные пути исполнения, видеть "живое" поведение функций. 
- Делает процесс обучения ассемблеру более наглядным: пользователь видит, как реально исполняется код, какие участки "живые", а какие — только "на бумаге". 
- Обеспечивает удобную платформу для накопления и обмена знаниями (база сигнатур, паттернов, пользовательских комментариев). 
 - mov eax, [ebx]- add eax, 4- call eax- Это даже не боль - мелочь кошачья и легко понимается - Берём значение из памяти по адресу в ebx → кладём в eax 
- Прибавляем 4 к этому значению. 
- Вызываем функцию по получившемуся адресу. 
 - Такой шаблон очень характерен для C++ при вызове виртуальных функций через полиморфный объект - class Animal { public: virtual void speak() { cout << "Animal sound\n"; } virtual void move() { cout << "Animal moves\n"; } // ← это вторая виртуальная функция }; Animal* animal = new Dog(); animal->move(); // ← вот этот вызов может компилироваться в эти 3 строки - molnij28.07.2025 15:55- Поэтому автор и привел три варианта, любой из которых может быть скомпилирован в приведенный asm-код. Вы выбрали всего лишь один из них.  - NeriaLab28.07.2025 15:55- Так я исходил из того, что знаю. Есть два пути, короткий и длинный: 
 1. Короткий:
 1.1. То, что я написал и без вариантов
 2. Длинный
 2.1. Открываем DIE (Github)
 2.2. Открываем ImHex (Github)
 Согласно пунктам 2.1. и 2.2. получили базовую инфу о программе
 2.3. Если у нас C то:- С (пример)- struct GameModule { void (*init)(); void (*update)(); void (*render)(); }; GameModule* mod = ...; mod->update(); // → mov eax, [ebx] → add eax, 4 → call eax- 2.4. Если у нас C++ - пример уже написан 
 2.5. Есть вариант что это указатели на функции, которые хранятся в структуре, загруженной из DLL, но обычно, они так редко записываются
 3. Открываем Гидру, Иду или другое...
 Итог:
 Если видишь, что ebx указывает на объект, а [ebx] — на глобальную таблицу, и так везде — тогда можно сказать: "Да, это vtable." В противном случае лучше сказать: "индиректный вызов через таблицу функций".
 
 
 
           
 
Goron_Dekar
Ага. Интересно.
Можно пример декомпиляции кода на VB6 с вызовом внешних функций из С библиотек. С войной с соглашениями вызовов и угадыванием сигнатур?