Урок 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:  источник, приёмник  (то же действие!)

Это одна и та же операция и одни и те же байты в файле. Убедиться легко — возьмём один и тот же скомпилированный файл и попросим дизассемблер показать его двумя способами:

Терминал: objdump -d -M intel movdir | grep 40100e — 48 89 d8 mov rax,rbx (Intel: приёмник, источник); objdump -d movdir | grep 40100e — 48 89 d8 mov %rbx,%rax (AT&T: источник, приёмник)

Пример разобран на бинарнике movdir.

Байты одни и те же — 48 89 d8. Поменялась только запись, и в ней операнды переставлены местами.

А какое направление на самом деле? Проверим экспериментом: положим в rbx пятёрку, в rax девятку, выполним mov rax, rbx и посмотрим, что осталось в rax. Вот этот опыт в отладчике на том же movdir (с отладчиком gdb подробно познакомимся во втором модуле):

Терминал gdb: break *0x40100e, run, x/i $rip — mov rax,rbx; info registers: rax = 9, rbx = 5; stepi; info registers: rax = 5, rbx = 5

Ответ — 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, читайте её как арифметику, а не как чтение памяти.

Что делает lea rax, [rdi + rsi*4]? · 4 б.

Арифметика и логика

Инструкция Смысл (в 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.
Зачем в коде обычно ставят xor eax, eax? · 4 б.

Сравнения и флаги

Отдельно про то, как принимаются решения. Две инструкции ничего не «делают» с данными — они только выставляют флаги (в 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.

Что делает инструкция cmp eax, 5? · 4 б.

Как читать фрагмент: переводим в псевдо-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, тот самый цикл суммирования:

IDA Free, графовый вид функции sum_below. Четыре блока: верхний — пролог, mov [rbp+var_8], 0, mov [rbp+var_4], 0 и jmp short loc_40117A; средний loc_40117A — mov eax, [rbp+var_4], cmp eax, [rbp+var_14], jl short loc_401170; от него зелёная стрелка вниз в блок loc_401170 (mov eax, [rbp+var_4], add [rbp+var_8], eax, add [rbp+var_4], 1) и красная — в блок выхода (mov eax, [rbp+var_8], pop rbp, retn); из тела цикла длинная синяя стрелка возвращается к проверке

Пример разобран на бинарнике branch_demo.

Читается граф сверху вниз. Верхний блок — подготовка (sum = 0, i = 0) и сразу jmp на проверку. Средний блок — сама проверка: cmp и jl. От неё две стрелки: зелёная — переход выполнен, идём в тело цикла; красная — не выполнен, идём на выход, к retn. А из тела цикла стрелка возвращается обратно к проверке — вот он, цикл.

Обратите внимание: компилятор переставил проверку в конец, а перед циклом поставил jmp на неё — так на каждом витке выполняется один переход, а не два. Запись получилась не такая, как мы писали руками, но признак цикла тот же: jl short loc_401170 прыгает на меньший адрес, то есть назад.

После cmp eax, edi идёт jne skip. Когда произойдёт переход на skip? · 4 б.
R3

Прочитай арифметику

10 б.

Функция f принимает аргумент в edi, результат возвращает в eax:

mov  eax, edi
add  eax, 5
imul eax, eax, 3
xor  eax, 0x11
ret

Чему равно f(10)? Прочитай код построчно и посчитай. Ответ — десятичное число.

R4

Прочитай цикл

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 именно так: «куда прыгаем и что при этом пропускаем».

R11

Прочитай условие

10 б.

Функция 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.