Урок 2. Процессор изнутри: регистры, память и порядок байт
В прошлом уроке мы увидели, что программа — это машинные байты, уложенные в ELF-файл, и что реверс идёт от этих байтов к смыслу. Но чтобы читать байты, надо понять, кто и как их исполняет. Этим занимается процессор (CPU). И хорошая новость: по сути процессор делает очень простую вещь — просто делает её миллиарды раз в секунду. Разберём эту простую вещь по частям.
Регистры — рабочий стол процессора
У процессора есть крошечная, но сверхбыстрая память прямо внутри него — регистры. Это несколько ячеек, каждая размером 64 бита (8 байт) в архитектуре x86-64. Представьте рабочий стол: он маленький, но всё, с чем вы прямо сейчас работаете, лежит именно на нём, под рукой. Большие «шкафы» — это оперативная память, до неё дальше тянуться.
Считает процессор в основном в регистрах. Чтобы сложить два числа из памяти, он кладёт одно из них в регистр, прибавляет к нему второе и при необходимости отправляет результат обратно в память. Поэтому в дизассемблере вы постоянно будете видеть перекладывание значений между регистрами и памятью.
Одна оговорка: x86 умеет и прибавить число прямо к ячейке памяти — в каждом цикле без оптимизации встретится add DWORD PTR [rbp-0x4], 0x1, то есть i++ без регистра. А вот два числа из памяти одной инструкцией он не складывает: хотя бы один операнд — регистр или число.
Регистров немного, и у каждого есть имя. Для чтения кода важны эти:
- rax, rbx, rcx, rdx, rsi, rdi, r8–r15 — регистры общего назначения. В них лежат числа, символы, адреса — всё, с чем идёт работа. Например,
raxтрадиционно хранит возвращаемое значение функции, аrdi,rsi,rdx— первые аргументы (об этом подробно в уроке 3). - rip — указатель инструкций (instruction pointer). Хранит адрес следующей инструкции, которую процессор собирается выполнить. Это как палец, которым вы ведёте по строчкам книги.
- rsp и rbp — указатели стека. Им посвящён весь следующий урок, пока просто запомните имена.
- rflags — регистр флагов. Отдельные биты в нём — это результат последнего сравнения: было ли равно, что больше. Инструкция
cmp a, bне «прыгает» никуда сама — она лишь выставляет флаги, а следующая инструкция перехода (je,jne) уже смотрит на них.
Одна ячейка — четыре имени
Первое, обо что спотыкаются все: в дизассемблере один и тот же регистр называется по-разному. Это не разные регистры — это разные куски одной и той же 64-битной ячейки:
| Имя | Размер | Что это |
|---|---|---|
rax |
64 бита (8 байт) | вся ячейка целиком |
eax |
32 бита | младшая половина rax |
ax |
16 бит | младшие 2 байта |
al |
8 бит | самый младший байт |
Так же устроены и остальные: rbx/ebx/bx/bl, rcx/ecx/cx/cl, а у новых регистров — r8/r8d/r8w/r8b. Увидев eax, читайте «младшие 32 бита rax».
Зачем это знать на практике: компилятор выбирает имя по типу данных. Работа с int — это eax, с указателем или long — rax, с отдельным символом (char) — al. Поэтому цепочка сравнений одного символа выглядит как cmp al, 0x76, а не cmp rax, .... По имени регистра вы уже догадываетесь о типе данных.
И одна ловушка, о которой стоит знать сразу: запись в 32-битную половину обнуляет старшую. После mov eax, 5 весь rax станет равен 5, даже если раньше в верхних битах что-то лежало. А вот mov al, 5 меняет только младший байт, остальное остаётся как было. Эта асимметрия иногда объясняет «странности» в декомпиляции.
АЛУ — то, что собственно считает
Внутри процессора есть АЛУ — арифметико-логическое устройство. Оно берёт значения из регистров и выполняет над ними операции: сложить, вычесть, сравнить, сделать побитовые XOR, AND, OR, сдвиг. Результат кладётся обратно в регистр (и заодно обновляет флаги). Почти вся «логика» любой программы — это цепочка таких простых операций. Когда позже вы будете обращать чекер пароля, вы будете читать именно последовательность операций АЛУ и проделывать их в обратную сторону.
Память — один большой массив байт
Оперативная память с точки зрения процессора — это один огромный массив байт, пронумерованных по порядку. Номер байта называется его адресом. Адреса принято записывать в шестнадцатеричном виде: 0x401136, 0x404000. Ничего волшебного в hex нет — это просто удобная запись чисел (одна hex-цифра = 4 бита, два символа = один байт), поэтому адреса и байты компактно читаются.
В этом массиве лежит всё: и код программы (секция .text), и её строки-константы (.rodata), и данные (.data). Разницу между «регистры» и «память» держите в голове всегда:
- регистров мало, они внутри процессора и очень быстрые — это рабочий стол;
- памяти много, она снаружи и медленнее — это склад с адресами-номерами.
Перекладывание между ними делает инструкция mov. Важная деталь синтаксиса Intel, который мы используем в курсе: приёмник пишется первым. То есть mov rax, rbx означает «скопировать значение из rbx в rax» (справа налево), а не наоборот. Это ещё одна классическая ловушка — запомните направление сразу.
Цикл выполнения: выборка — декодирование — исполнение
Теперь соберём всё вместе. Процессор крутит один и тот же цикл:
- Выборка (fetch). Процессор смотрит на
rip, идёт по этому адресу в память и читает оттуда байты инструкции. - Декодирование (decode). Понимает, что это за инструкция: например, байты
83 F8 42— этоcmp eax, 0x42(сравнитьeaxс числом0x42). - Исполнение (execute). Выполняет её: АЛУ сравнивает, выставляет флаги; или значение перекладывается между регистром и памятью; или происходит переход.
ripсдвигается на длину выполненной инструкции (инструкции в x86-64 разной длины!) — или, если это был переход/вызов,ripпрыгает на новый адрес. И цикл повторяется.
Вот и весь процессор. Всё, что делает любая программа — от игры до вируса, — это очень длинная последовательность таких шагов. Когда вы в отладчике gdb нажимаете «один шаг», вы прогоняете ровно один оборот этого цикла и можете посмотреть, как изменились регистры и память.
Трассируем пять инструкций
Пока это звучит абстрактно, поэтому пройдём цикл руками. Вот настоящий фрагмент — слева адреса и машинные байты, справа инструкции (длина у всех разная, обратите внимание):
Пример разобран на бинарнике trace_demo.
У je вместо ok здесь настоящий адрес — 0x40113e. За пятью инструкциями идёт хвост программы: она просто завершается, с кодом 0, если сравнение сошлось.
Теперь проследим, что происходит с регистрами. ZF («флаг нуля») — тот самый бит в rflags, который ставится, когда результат сравнения оказался нулевым, то есть значения равны:
| Выполняется | eax |
ebx |
ZF |
rip станет |
|---|---|---|---|---|
| (старт) | ? | ? | ? | 0x401126 |
mov eax, 7 |
7 | ? | — | 0x40112b |
mov ebx, 5 |
7 | 5 | — | 0x401130 |
add eax, ebx |
12 | 5 | — | 0x401132 |
cmp eax, 12 |
12 | 5 | 1 (равно) | 0x401135 |
je ok |
12 | 5 | 1 | адрес ok |
Что тут важно заметить:
ripсдвигается на разную величину — на 5, 5, 2, 3 байта. Инструкции в x86-64 не одинаковой длины, поэтому «следующая инструкция» — это не «адрес + 4», а «адрес + длина текущей».cmpне изменил ниeax, ниebx— он только выставилZF. Сравнение ничего не портит, оно лишь оставляет отметку.jeсам ничего не сравнивает — он смотрит наZF, который поставила предыдущая инструкция. Пара «cmp+ переход» всегда работает в связке, и читать их нужно вместе.
Ровно это вы и будете видеть в отладчике, нажимая «шаг»: меняются несколько чисел, и rip ползёт вперёд.
Вот эти же пять шагов в отладчике gdb (с ним мы подробно познакомимся во втором модуле). Команды display просят gdb после каждого шага печатать следующую инструкцию и значения eax, ebx и флагов, а si выполняет ровно одну инструкцию — один оборот цикла:
Сверьте с таблицей: eax становится 7, потом 12, ebx — 5, после cmp среди флагов появляется ZF, и je уводит rip на ok. В самом начале eax и ebx нулевые: программа только что запущена. Флаг PF после add — флаг чётности, для нас он неважен.
Порядок байт: little-endian
Остался один нюанс, без которого невозможно правильно читать дампы памяти. Регистр rax хранит, скажем, 32-битное число 0x12345678. Как эти четыре байта (12, 34, 56, 78) лягут в память? Логично ожидать «как пишем»: 12 34 56 78. Но x86-64 хранит их наоборот.
Это называется little-endian («остроконечный с младшего конца»): младший байт кладётся по младшему адресу. Число 0x12345678 в памяти выглядит как байты 78 56 34 12. Младший байт 0x78 — первым, старший 0x12 — последним.
Почему это критично для реверса: когда вы смотрите дамп памяти в gdb или xxd и видите 78 56 34 12, это не число 0x78563412. Это 0x12345678, просто разложенное «задом наперёд». Тот, кто читает дамп слева направо буквально, получает неверное число — и весь дальнейший анализ рушится. Ту же ошибку часто делают с адресами и константами.
Практическое правило: чтобы из идущих подряд в памяти байт b0 b1 b2 b3 получить число, надо собрать их так: b3 — старший, b0 — младший. В Python это одна строчка:
int.from_bytes(bytes([0x78, 0x56, 0x34, 0x12]), "little") # → 0x12345678 = 305419896
Именно этот навык вы закрепите в задании урока.
Голова закипает от регистров и байтов? Это нормально — реверс проще один раз увидеть, чем прочитать. В уроке 3 этого модуля есть видео-разбор, где регистры, стек и память показаны вживую в
gdbна простой программе. Можно посмотреть прямо сейчас: видео-разбор «Регистры, стек и память» — и вернуться сюда.
Число из дампа (little-endian)
Реализуй функцию le_hex_to_int(s).
На вход — строка hex-байт в том порядке, в каком они лежат в памяти x86-64 (little-endian). В строке могут быть пробелы и переносы строк, регистр букв любой. Верни целое число, которое эти байты представляют.
Пример: "78 56 34 12" → 305419896 (это 0x12345678). Пустая строка → 0.
Разбери дамп на слова
Отладчик показывает память сплошным потоком байт, а процессор работает с ней 8-байтовыми «словами» (64 бита). Научимся резать дамп на такие слова.
Реализуй dump_to_words(s): на вход — hex-байты в том порядке, в каком они лежат в памяти (возможны пробелы и переносы). Верни список чисел: каждые 8 подряд идущих байт — одно число в little-endian.
Пример: "45 11 40 00 00 00 00 00" → [4198725] (это 0x401145).
Длина входа всегда кратна 8 байтам. Пустая строка → пустой список.
Что запомнить
- Регистры — сверхбыстрые ячейки внутри процессора; в них лежат текущие значения (аргументы, результаты, адреса).
rax— результат функции,rip— адрес следующей инструкции,rflags— результат последнего сравнения. - АЛУ считает над содержимым регистров; память — большой массив байт с адресами;
mov приёмник, источникперекладывает значения (Intel-синтаксис — справа налево). - Процессор крутит цикл выборка → декодирование → исполнение, двигая
rip. - x86-64 хранит числа в little-endian: в памяти
78 56 34 12— это число0x12345678.