Урок 4. Ассемблер x86-64 для чтения: инструкции, условия и циклы
Мы разобрали процессор, память и стек. Пора научиться читать то, что процессор исполняет, — ассемблер. Сразу успокою: наша цель не писать на ассемблере, а понимать короткие фрагменты. Это как читать на иностранном языке со словарём — не то же самое, что говорить на нём. Инструкций в x86-64 сотни, но для чтения олимпиадного кода хватает примерно полутора десятков. Их и разберём.
Если ассемблер поплыл — вернитесь к видео-разбору из урока 3: там регистры и стек показаны в движении, и сразу видно, что именно делают эти инструкции. Открыть можно прямо сейчас: видео-разбор в gdb.
Синтаксис: Intel против AT&T
Одну и ту же инструкцию разные программы печатают в двух разных видах. Сразу уточним, чтобы не было путаницы: это не про процессоры Intel и не про выбор железа. «Intel» и «AT&T» — просто два способа записи одних и тех же инструкций x86-64, придуманные когда-то в документации Intel и в инструментах проекта AT&T. Процессор о них ничего не знает: в файле лежат одинаковые байты, отличается только то, как их показывают вам.
Мы в курсе читаем синтаксис Intel — его же использует Ghidra и выдаёт objdump -M intel:
mov rax, rbx ; Intel: приёмник, источник (справа налево)
Иногда встретится AT&T (по умолчанию в gdb и objdump): регистры с %, операнды в обратном порядке, у инструкций суффиксы размера:
movq %rbx, %rax ; AT&T: источник, приёмник (то же действие!)
Это одна и та же операция и одни и те же байты в файле. Убедиться легко — возьмём один и тот же скомпилированный файл и попросим дизассемблер показать его двумя способами:
Пример разобран на бинарнике movdir.
Байты одни и те же — 48 89 d8. Поменялась только запись, и в ней операнды переставлены местами.
А какое направление на самом деле? Проверим экспериментом: положим в rbx пятёрку, в rax девятку, выполним mov rax, rbx и посмотрим, что осталось в rax. Вот этот опыт в отладчике на том же movdir (с отладчиком gdb подробно познакомимся во втором модуле):
Ответ — 5. Значит, в Intel-записи значение едет справа налево: mov rax, rbx — это «положить rbx в rax», то есть в точности rax = rbx, как присваивание в C. Приёмник всегда первый.
Запомнить помогает именно эта аналогия: читайте mov rax, rbx как rax = rbx. А в AT&T та же строка выглядит как mov %rbx, %rax — и если по привычке прочитать её слева направо, смысл перевернётся.
Почему мы всё же договариваемся о едином виде, а не «читайте как придётся». Посмотрите на две записи выше ещё раз: порядок операндов в них противоположный. В Intel приёмник слева, в AT&T — справа. Если вы привыкли к одному и по невнимательности прочитали другое, смысл строки перевернётся: вместо «положить rbx в rax» получится «положить rax в rbx». На чекере пароля такая ошибка уводит анализ полностью не туда.
Практическая сторона простая:
- Ghidra показывает листинг в Intel-подобном виде (приёмник первым, память в квадратных скобках), переключателя синтаксиса у неё нет. А её декомпилятор вообще выдаёт C, где вопрос синтаксиса ассемблера не возникает.
- objdump по умолчанию печатает AT&T, но легко переключается ключом
-M intel— им мы и пользуемся весь курс. - gdb — единственный инструмент нашего набора, который по умолчанию показывает AT&T. Поэтому в уроке про отладчик будет ровно одна настройка:
set disassembly-flavor intel.
Итого: мы приводим все инструменты к одному виду, чтобы вы читали листинги «на автомате», а не вспоминали каждый раз, в какую сторону сегодня направлена стрелка. Дальше в курсе — только Intel.
Перемещение данных
| Инструкция | Что делает |
|---|---|
mov dst, src |
скопировать src в dst |
mov dst, [addr] |
прочитать из памяти по адресу addr в dst |
mov [addr], src |
записать src в память по адресу |
lea dst, [expr] |
вычислить адресное выражение expr и положить в dst (без обращения в память!) |
movzx / movsx |
скопировать меньший регистр в больший, дополнив нулями / знаком |
Квадратные скобки [...] означают «содержимое памяти по этому адресу». Ключевая ловушка — lea: скобки в ней есть, а в память она не лезет. lea просто считает выражение. Компиляторы обожают её как быстрый калькулятор: lea rax, [rdi + rsi*4] означает rax = rdi + rsi*4, а lea rax, [rdi + rdi*2] — это rax = rdi*3. Увидев lea, читайте её как арифметику, а не как чтение памяти.
Арифметика и логика
| Инструкция | Смысл (в C-подобной записи) |
|---|---|
add dst, src |
dst += src |
sub dst, src |
dst -= src |
imul dst, src |
dst *= src |
imul dst, src, N |
dst = src * N — форма с тремя операндами, множитель прямо в инструкции |
inc dst / dec dst |
dst += 1 / dst -= 1 |
xor dst, src |
dst ^= src |
and dst, src / or dst, src |
dst &= src / dst |= src |
shl dst, n |
сдвиг влево на n бит (<<) |
shr dst, n / sar dst, n |
сдвиг вправо (>>): shr — для беззнаковых, sar — для знаковых (сохраняет знак) |
neg dst |
dst = -dst |
Обратите внимание на imul с тремя операндами: imul eax, eax, 3 — это просто eax = eax * 3. Такая запись часто встречается, потому что компилятору удобно зашить множитель прямо в инструкцию.
Два идиома, которые надо узнавать мгновенно:
xor eax, eax— это не «хитрый XOR», а самый частый способ записать ноль в регистр (eax = 0). Компилятор так обнуляет, потому что это короче и быстрее, чемmov eax, 0.test eax, eax— проверка «а не ноль лиeax?».testделаетand, но результат выбрасывает, оставляя только флаги. Дальше обычноje/jne.
Сравнения и флаги
Отдельно про то, как принимаются решения. Две инструкции ничего не «делают» с данными — они только выставляют флаги (в rflags):
cmp a, b— вычисляетa - b, но результат не сохраняет, только флаги (равно? больше? меньше?). Самaне меняется.test a, b— то же, но черезand.
Сразу за ними идёт условный переход, который смотрит на флаги:
| Переход | Прыгнет, если (после cmp a, b) |
|---|---|
je / jz |
a == b (результат ноль) |
jne / jnz |
a != b |
jg / jl |
a > b / a < b (знаковые) |
jge / jle |
a >= b / a <= b (знаковые) |
ja / jb |
a > b / a < b (беззнаковые) |
jmp |
всегда (безусловный) |
Знаковые (jg/jl) и беззнаковые (ja/jb) переходы — разные; какой выбрал компилятор, подсказывает, считал он число со знаком или без. Пока достаточно узнавать пару je/jne.
Как читать фрагмент: переводим в псевдо-C
Приём один: идём сверху вниз и переписываем каждую инструкцию в понятную запись. Вот функция (аргумент в edi, результат в eax):
mov eax, edi ; eax = x
add eax, 5 ; eax = x + 5
imul eax, eax, 3 ; eax = (x + 5) * 3
xor eax, 0x11 ; eax = ((x + 5) * 3) ^ 0x11
ret ; вернуть eax
Читается как return ((x + 5) * 3) ^ 0x11;. Всё. Именно так вы будете «расшифровывать» чекеры: собираете цепочку операций и, если нужно, проделываете её в обратную сторону.
Теперь условие. Пара «cmp + переход» — это if:
cmp edi, 0x2a ; сравнить x с 42
jne fail ; если x != 42 — прыгнуть на fail (провал)
; ... сюда попадаем, только если x == 42 (успех)
И цикл — это переход назад:
xor eax, eax ; sum = 0
xor ecx, ecx ; i = 0
again:
cmp ecx, edi ; i < n ?
jge done ; если i >= n — выйти
add eax, ecx ; sum += i
inc ecx ; i++
jmp again ; назад к проверке
done:
ret ; вернуть sum
Переход jge done вперёд — это выход из цикла, а jmp again назад — это «повторить». Увидели прыжок на метку выше по коду — почти всегда перед вами цикл.
Имена меток (again, done) придумывает тот, кто печатает листинг: настоящий компилятор назовёт их безлико — .L2, .L3. Смысл от этого не меняется, ориентируйтесь на направление прыжка, а не на имя.
Где переходы видно глазами: графовый вид
Есть инструменты, которые рисуют переходы стрелками, и тогда ветвления и циклы видно сразу, без сопоставления адресов. Так умеют Ghidra и radare2, а нагляднее всех — IDA Free: она по умолчанию открывает функцию графом. Вот обе конструкции из этого урока, собранные компилятором в один маленький файл, — функция sum_below, тот самый цикл суммирования:
Пример разобран на бинарнике branch_demo.
Читается граф сверху вниз. Верхний блок — подготовка (sum = 0, i = 0) и сразу jmp на проверку. Средний блок — сама проверка: cmp и jl. От неё две стрелки: зелёная — переход выполнен, идём в тело цикла; красная — не выполнен, идём на выход, к retn. А из тела цикла стрелка возвращается обратно к проверке — вот он, цикл.
Обратите внимание: компилятор переставил проверку в конец, а перед циклом поставил jmp на неё — так на каждом витке выполняется один переход, а не два. Запись получилась не такая, как мы писали руками, но признак цикла тот же: jl short loc_401170 прыгает на меньший адрес, то есть назад.
Прочитай арифметику
Функция f принимает аргумент в edi, результат возвращает в eax:
mov eax, edi
add eax, 5
imul eax, eax, 3
xor eax, 0x11
ret
Чему равно f(10)? Прочитай код построчно и посчитай. Ответ — десятичное число.
Прочитай цикл
Функция g принимает аргумент n в edi, результат в eax:
xor eax, eax
xor ecx, ecx
again:
cmp ecx, edi
jge done
add eax, ecx
inc ecx
jmp again
done:
ret
Чему равно g(6)? Ответ — десятичное число.
Ещё два фрагмента для тренировки
Чтение — навык, он ставится повторением. Разберём ещё два типовых куска, которые вы будете встречать постоянно.
Фрагмент 1. Что делает эта функция? Аргумент — указатель на строку, он приходит в rdi:
xor eax, eax ; счётчик = 0
again:
movzx ecx, BYTE PTR [rdi+rax] ; ecx = очередной байт строки
test cl, cl ; этот байт нулевой?
je done ; да — выходим
inc eax ; счётчик++
jmp again
done:
ret ; вернуть счётчик
Читаем построчно. [rdi+rax] — обращение к памяти: «байт по адресу начала строки плюс счётчик», то есть попросту s[i]. movzx берёт один байт и расширяет его нулями до 32 бит. test cl, cl — знакомая проверка «не ноль ли?». Цикл идёт вперёд, пока не встретит нулевой байт, и считает шаги.
Строки в C заканчиваются нулевым байтом — значит, перед нами подсчёт длины строки, то есть strlen. Такой цикл вы будете узнавать с полувзгляда.
Фрагмент 2. Условие с двумя ветками. Аргумент — число в edi:
cmp edi, 0
jge pos ; если x >= 0 — прыгнуть вперёд
neg edi ; иначе сменить знак
pos:
mov eax, edi
ret
Если число неотрицательное, jge перепрыгивает через neg, и возвращается как есть. Если отрицательное — переход не происходит, выполняется neg (смена знака), и уже потом значение уходит в eax. Итого: return x < 0 ? -x : x; — это модуль числа.
Обратите внимание на приём: ветка «иначе» здесь — это просто пропущенная инструкция. В ассемблере нет слова else; есть прыжок, который что-то перескакивает. Привыкайте читать if именно так: «куда прыгаем и что при этом пропускаем».
Прочитай условие
Функция h принимает аргумент в edi, результат возвращает в eax:
cmp edi, 0x14
jg big
imul eax, edi, 2
ret
big:
mov eax, edi
sub eax, 0x14
ret
Чему равно h(37)?
Внимание: здесь две ветки, и сначала нужно определить, какая из них выполнится. Ответ — десятичное число.
Читать можно даже то, что не запускается
Важный вывод: чтобы прочитать логику, программу не обязательно запускать. Иногда её и нельзя запустить. На заключительном этапе ВСОШ 2025–2026 в задаче reverse 9-1 · заключительный этап, 2025–2026, 9 класс участникам выдали листинг ассемблера под процессор Эльбрус — архитектуру, которой у большинства под рукой нет. Запустить нечего. Но в листинге была видна операция cmpesb (сравнение строк по байтам): достаточно было прочитать, какие байты с какими сравниваются, собрать их и перевести hex в ASCII — и флаг восстанавливался прямо из листинга. Инструкции у Эльбруса другие, но метод тот же: идёшь сверху вниз и переводишь в смысл. Навык чтения ассемблера переносится на любую архитектуру.
Что запомнить
- Мы читаем Intel-синтаксис:
приёмник, источник. AT&T (%, обратный порядок) — то же самое, другое написание. lea— это арифметика, а не чтение памяти, несмотря на скобки.xor eax, eax= «обнулить»;test eax, eax= «проверить на ноль».cmp a, bтолько выставляет флаги; решение принимает следующий за нимje/jne/jg/....- Прыжок назад на метку — это цикл. Читать фрагмент = построчно переводить в псевдо-C.