Урок 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 меняет только младший байт, остальное остаётся как было. Эта асимметрия иногда объясняет «странности» в декомпиляции.

Где процессор держит значения, с которыми считает прямо сейчас (аргументы, промежуточные результаты)? · 4 б.

АЛУ — то, что собственно считает

Внутри процессора есть АЛУ — арифметико-логическое устройство. Оно берёт значения из регистров и выполняет над ними операции: сложить, вычесть, сравнить, сделать побитовые XOR, AND, OR, сдвиг. Результат кладётся обратно в регистр (и заодно обновляет флаги). Почти вся «логика» любой программы — это цепочка таких простых операций. Когда позже вы будете обращать чекер пароля, вы будете читать именно последовательность операций АЛУ и проделывать их в обратную сторону.

Память — один большой массив байт

Оперативная память с точки зрения процессора — это один огромный массив байт, пронумерованных по порядку. Номер байта называется его адресом. Адреса принято записывать в шестнадцатеричном виде: 0x401136, 0x404000. Ничего волшебного в hex нет — это просто удобная запись чисел (одна hex-цифра = 4 бита, два символа = один байт), поэтому адреса и байты компактно читаются.

В этом массиве лежит всё: и код программы (секция .text), и её строки-константы (.rodata), и данные (.data). Разницу между «регистры» и «память» держите в голове всегда:

  • регистров мало, они внутри процессора и очень быстрые — это рабочий стол;
  • памяти много, она снаружи и медленнее — это склад с адресами-номерами.

Перекладывание между ними делает инструкция mov. Важная деталь синтаксиса Intel, который мы используем в курсе: приёмник пишется первым. То есть mov rax, rbx означает «скопировать значение из rbx в rax» (справа налево), а не наоборот. Это ещё одна классическая ловушка — запомните направление сразу.

Цикл выполнения: выборка — декодирование — исполнение

Теперь соберём всё вместе. Процессор крутит один и тот же цикл:

Процессор, регистры, АЛУ и память; rip указывает на текущую инструкцию, цикл выборка-декодирование-исполнение

  1. Выборка (fetch). Процессор смотрит на rip, идёт по этому адресу в память и читает оттуда байты инструкции.
  2. Декодирование (decode). Понимает, что это за инструкция: например, байты 83 F8 42 — это cmp eax, 0x42 (сравнить eax с числом 0x42).
  3. Исполнение (execute). Выполняет её: АЛУ сравнивает, выставляет флаги; или значение перекладывается между регистром и памятью; или происходит переход.
  4. rip сдвигается на длину выполненной инструкции (инструкции в x86-64 разной длины!) — или, если это был переход/вызов, rip прыгает на новый адрес. И цикл повторяется.

Вот и весь процессор. Всё, что делает любая программа — от игры до вируса, — это очень длинная последовательность таких шагов. Когда вы в отладчике gdb нажимаете «один шаг», вы прогоняете ровно один оборот этого цикла и можете посмотреть, как изменились регистры и память.

Что хранит регистр rip? · 4 б.

Трассируем пять инструкций

Пока это звучит абстрактно, поэтому пройдём цикл руками. Вот настоящий фрагмент — слева адреса и машинные байты, справа инструкции (длина у всех разная, обратите внимание):

Терминал: objdump -d -M intel trace_demo — mov eax,0x7 (5 байт), mov ebx,0x5 (5 байт), add eax,ebx (2 байта), cmp eax,0xc (3 байта), je 40113e <ok> (2 байта), дальше хвост программы

Пример разобран на бинарнике 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

Что тут важно заметить:

  1. rip сдвигается на разную величину — на 5, 5, 2, 3 байта. Инструкции в x86-64 не одинаковой длины, поэтому «следующая инструкция» — это не «адрес + 4», а «адрес + длина текущей».
  2. cmp не изменил ни eax, ни ebx — он только выставил ZF. Сравнение ничего не портит, оно лишь оставляет отметку.
  3. je сам ничего не сравнивает — он смотрит на ZF, который поставила предыдущая инструкция. Пара «cmp + переход» всегда работает в связке, и читать их нужно вместе.

Ровно это вы и будете видеть в отладчике, нажимая «шаг»: меняются несколько чисел, и rip ползёт вперёд.

Вот эти же пять шагов в отладчике gdb (с ним мы подробно познакомимся во втором модуле). Команды display просят gdb после каждого шага печатать следующую инструкцию и значения eax, ebx и флагов, а si выполняет ровно одну инструкцию — один оборот цикла:

Терминал gdb: break _start, run, display для $pc, $eax, $ebx, $eflags и пять команд si; eax становится 7, затем 12, ebx — 5, после cmp в флагах появляется ZF, je переходит на ok

Сверьте с таблицей: eax становится 7, потом 12, ebx — 5, после cmp среди флагов появляется ZF, и je уводит rip на ok. В самом начале eax и ebx нулевые: программа только что запущена. Флаг PF после add — флаг чётности, для нас он неважен.

Процессор только что выполнил одну инструкцию. Что происходит дальше? · 4 б.

Порядок байт: little-endian

Остался один нюанс, без которого невозможно правильно читать дампы памяти. Регистр rax хранит, скажем, 32-битное число 0x12345678. Как эти четыре байта (12, 34, 56, 78) лягут в память? Логично ожидать «как пишем»: 12 34 56 78. Но x86-64 хранит их наоборот.

Число 0x12345678 в памяти: little-endian хранит младший байт по младшему адресу

Это называется 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 на простой программе. Можно посмотреть прямо сейчас: видео-разбор «Регистры, стек и память» — и вернуться сюда.

В дампе памяти x86-64 подряд идут байты: 78 56 34 12. Какое 32-битное число там записано? · 4 б.
J1

Число из дампа (little-endian)

10 б.

Реализуй функцию le_hex_to_int(s).

На вход — строка hex-байт в том порядке, в каком они лежат в памяти x86-64 (little-endian). В строке могут быть пробелы и переносы строк, регистр букв любой. Верни целое число, которое эти байты представляют.

Пример: "78 56 34 12" → 305419896 (это 0x12345678). Пустая строка → 0.

J2

Разбери дамп на слова

10 б.

Отладчик показывает память сплошным потоком байт, а процессор работает с ней 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.