В предыдущей статье мы познакомились с основами синтаксиса языка ассемблера и смогли создать программу на основе всего двух команд. Впечатляющий результат!

На этом уроке мы изучим новые команды и воспользуемся этими знаниями, чтобы перенести на язык ассемблера первую конструкцию высокого уровня: условные конструкции.

▍ Команды передачи управления


Последовательность декодируемых и исполняемых CPU команд называется потоком команд. Можно представить его в виде массива, индексами которого являются адреса команд1.

Текущая исполняемая команда — это та, адрес которой хранится как значение в регистре rip; именно поэтому он называется регистром указателя команд.

В псевдокоде исполнение программы должно выглядеть примерно так:

while (!exited) {
  // Получаем команду в `registers.rip`
  instruction = instruction_stream[registers.rip]
  // Исполняем команду и возвращаем
  // адрес следующей команды.
  next_pointer = instruction.execute()
  // Присваиваем `rip` значение нового адреса,
  // чтобы на следующей итерации получить новую команду.
  registers.rip = next_pointer
  // Обрабатываем побочные эффекты (это мы рассматривать не будем)
}

Чаще всего исполнение происходит линейно: команды исполняются одна за другой в том порядке, в котором они закодированы, сверху вниз. Однако некоторые команды могут нарушать этот порядок; они называются командами передачи управления (Control Transfer Instruction, CTI).

Интересующие нас CTI относятся к категории условных и безусловных; они обеспечивают возможность потока управления в языке ассемблера, допуская выполнение команд, идущих не по порядку. Ещё один тип CTI — это программные прерывания; мы не будем рассматривать их в явном виде2, потому что они тесно связаны с операционными системами и выходят за рамки нашей серии статей.

Первой исследуемой нами CTI станет jmp (jump, переход).

▍ Безусловные переходы


Переходы позволяют исполнять код в произвольном месте потока команд. Они необходимы лишь для изменения rip, после чего в следующем такте CPU возьмёт команду по новому адресу.

Синтаксически переход выглядит так:

jmp label

Где операнд обозначает целевую команду.

Почти всегда целевая команда обозначается меткой; естественным языком приведённую выше команду можно описать так: «Продолжить исполнение с команды, метка которой label».

Ассемблер, то есть ПО, превращающее программу на языке ассемблера в машинный код, преобразует метки в численные адреса потока команд, и при исполнении этот адрес будет присвоен регистру rip.

На самом деле, численные адреса и относительные смещения тоже являются допустимыми для rip значениями, но с ними удобнее работать машинам, а не людям. Например, компиляторы с флагами оптимизации или дизассемблеры предпочитают численную адресацию, а не метки.

Внимательные читатели должны были заметить, что описанный нами переход не зависит от каких-либо условий: если программа достигнет этой строки, она совершит переход. Поэтому такая команда считается безусловной.

Давайте рассмотрим пример.

Мы используем ту же программу «hello world» из первого урока. Сделаем её более удобной для чтения, добавив переходы для разбиения кода на фрагменты. Параллельно мы добавим числовые константы, чтобы убрать из нашего кода магические числа.

section .data
  ; Так же, как и раньше, мы определяем константу msg
  msg db `Hello, World!\n`
  ; На этот раз мы также определим здесь её длину,
  ; а также другие константы для повышения читаемости. 
  ; Директива `equ` (equals) используется для определения
  ; числовых констант.
  len       equ 14 ; длина буфера
  sys_write equ 1  ; идентификатор системного вызова write
  sys_exit  equ 60 ; идентификатор системного вызова exit
  stdout    equ 1  ; дескриптор файла для stdout

section .text
  global _start
_start:
  ; Переходы могут показаться непонятными. Чтобы упростить это
  ; введение, мы используем пункты (1), (2) ... 
  ; для описания этапов кода и их порядка.

  ; (1) Здесь мы мгновенно переходим к коду,
  ; выводящему сообщение. Конечная точка - это метка
  ; `print_msg`, то есть исполнение будет продолжено
  ; со строки прямо под ней. 
  ; Давайте перейдём к (2), чтобы посмотреть,
  ; как разворачивается эта история.
  jmp print_msg

exit:
  ; (3) Мы уже знаем принцип: при помощи `sys_exit` из
  ; верхнего блока мы можем вызывать системный вызов exit, 
  ; чтобы выйти из программы с кодом состояния 0.
  mov rax, sys_exit
  mov rdi, 0
  syscall

print_msg:
  ; (2) После вызова `jmp`, мы выполняем ту же
  ; подпрограмму, которую определили в первом уроке.
  ; Можете вернуться назад, если не помните точно,
  ; для чего нужны представленные ниже регистры.
  mov rax, sys_write
  mov rdi, stdout
  mov rsi, msg
  mov rdx, len
  syscall

  ; Мы закончили с выводом, пока выполнять выход
  ; из программы. Снова используем переход для выполнения
  ; блока по метке `exit`.
  ;
  ; Стоит отметить, что если бы мы не перешли куда-то ещё,
  ; даже если больше кода для исполнения не осталось,
  ; программа не выполнила бы выход! Она осталась бы в чистилище
  ; и рано или поздно сгенерировала бы ошибку сегментации.
  ; Закомментируйте следующую строку, если захотите это проверить.
  ;
  ; Готово? Увидели, как поломалась программа? Отлично!
  ; А теперь исправим это, выполнив переход к метке `exit`.
  ; Отправляйтесь к (3), чтобы увидеть конец этой короткой истории о переходах.
  jmp exit

▍ Условные переходы


Как вы могли догадаться, мы реализуем условный поток управления при помощи условных CTI, и, в частности, условных переходов. Не волнуйтесь, мы уже заложили фундамент, условные переходы — это просто расширение той же концепции переходов.

Работая с высокоуровневыми языками, вы могли привыкнуть к гибким условным операторам наподобие if, unless и when. В языке ассемблера используется другой подход. Вместо нескольких универсальных условных операторов в нём есть большое количество специализированных команд для конкретных проверок.

К счастью, эти команды имеют логичную структуру имён, из-за чего их легко запомнить.

Давайте рассмотрим пример:

jne label

Здесь label указывает на команду в нашем коде, как и в случае с безусловными переходами. На естественном языке это можно прочитать как «Jump (переход) к label, если Not (не) Equal (равно)».

В таблице ниже представлены самые частые обозначения условных переходов3:

Буква Значение
j (префикс) jump (переход)
n not (не)
z zero (ноль)
e equals (равно)
g greater than (больше, чем)
l less than (меньше, чем)
Вот ещё несколько примеров:

  • je label: «перейти, если равно»,
  • jge label: «перейти, если больше или равно»,
  • jnz label: «перейти, если не ноль».

Эти команды делают ровно то, что говорится в их именах: если условие выполняется, программа переходит к целевой метке. Если нет, то она просто продолжает со следующей строки. Как и в случае безусловных переходов, место перехода можно указать численно.

Возможно, вы зададитесь вопросом: «равно чему?», «больше, чем что?», «ноль по сравнению с чем?»

Давайте ответим на эти вопросы, углубившись в механику сравнений на языке ассемблера и представив особый регистр, играющий важнейшую роль в этом процессе: регистр eflags.

▍ Флаги


eflags — это 32-битный регистр, в котором хранятся различные флаги. В отличие от регистров общего назначения, eflags считывается побитово, и каждая его позиция обозначает конкретный флаг. Можно представить эти флаги как множество булевых значений, встроенных прямо в CPU. Когда бит равен 1, то соответствующий флаг имеет значение true, а когда 0, то флаг равен false.


Флаги предназначены для множества разных задач4, но нам важно только то, что они используются для предоставления контекста после операции. Например, если результатом сложения стал ноль, то флаг переполнения (OF) может сообщить нам, было ли это вызвано действительно нулём или переполнением. Они важны для нас, потому что именно при помощи флагов язык ассемблера хранит результаты сравнений.

В этом разделе мы рассмотрим только следующие флаги:

  • zero flag (ZF), равен 1, когда результат операции равен нулю,
  • sign flag (SF), равен 1, когда результат операции отрицательный.

Команда cmp (compare, сравнить) — это один из стандартных способов выполнения сравнений:

cmp rax, rbx

Эта команда вычитает второй операнд из первого без сохранения результата, вместо этого задавая флаги. Например:

  • Если операнды равны, zero flag (ZF) принимает значение 1.
  • Если первый операнд больше второго, то sign flag (SF) принимает значение 0.

Узнав об этом, мы начнём понимать смысл условных переходов:

  • «перейти, если равно» (je) означает «перейти, если ZF=1»,
  • «перейти, если больше или равно» (jge) означает «перейти, если SF=0 или ZF=1»,
  • «перейти, если не ноль» (jnz) означает «перейти, если ZF=0».5

▍ Наконец-то условные конструкции


Мы наконец-то готовы писать на языке ассемблера условные конструкции. Ура!

Рассмотрим следующий псевдокод:

if rax == rbx 
  success()
else
  error()

На языке ассемблера мы можем выразить эту логику следующим образом:

; Сравнить значения в rax и rbx
cmp rax rbx
; Если они равны, перейти к `success`
je success
; Иначе перейти к `error`
jmp error

Этот ассемблерный код сначала сравнивает значения в регистрах rax и rbx при помощи команды cmp. Затем он использует условный и безусловный переход (je и jmp) для управления потоком исполнения программы на основании результата сравнения.

Давайте рассмотрим другой пример, хватит с нас «hello world». На этот раз мы создадим серьёзное ПО, которое выполняет сложение и проверяет, равен ли результат ожидаемому. Очень серьёзное.

section .data
  ; Первым делом мы задаём константы,
  ; чтобы повысить читаемость
  sys_exit  equ 60
  sys_write equ 1
  stdout    equ 1
  
  ; Здесь мы задаём параметры нашей программы.
  ; Мы суммируем `a` и `b` и ожидаем, что результат
  ; будет равен значению константы `expected`.
  a         equ 100
  b         equ 50
  expected  equ 150

  ; Если сумма верна, мы хотим показать
  ; пользователю сообщение
  msg       db  `Correct!\n`
  msg_len   equ 9

section .text
global _start

_start: 
  ; Мы используем команду `add`, суммирующую
  ; два целых значения. `add` получает в качестве операндов
  ; регистры, поэтому мы копируем константы
  ; в регистры `rax` и `rbx`
  mov rax, a
  mov rbx, b
  
  ; Вот наша новая команда!
  ; Она использует арифметические способности
  ; CPU, чтобы суммировать операнды, и сохраняет
  ; результат в `rax`.
  ; На языках высокого уровня это выглядело бы так:
  ;    rax = rax + rbx
  add rax, rbx

  ; Здесь мы используем команду `cmp` (compare),
  ; чтобы проверить равенство rax == expected
  cmp rax, expected
  
  ; `je` означает "перейти, если равно", так что если сумма
  ; (в `rax`) равна `expected` (константе), мы переходим
  ; к метке `correct`
  je correct

  ; Если же результат неправильный, мы переходим
  ; к метке `exit_1`, чтобы выйти с кодом состояния 1
  jmp exit_1

exit_1:
  ; Здесь то же самое, что и в предыдущем уроке,
  ; но теперь мы используем код состояния 1,
  ; традиционно применяемый, чтобы сигнализировать об ошибках.
  mov rax, sys_exit
  mov rdi, 1
  syscall

correct:
  ; Мы уже знакомы с этим блоком: здесь мы
  ; делаем системный вызов `write` для вывода сообщения,
  ; говорящего пользователю, что сумма верна.
  mov rax, sys_write
  mov rdi, stdout
  mov rsi, msg
  mov rdx, msg_len
  syscall
  ; После вывода сообщения мы можем перейти к
  ; `exit_0`, где выполняется выход с кодом
  ; состояния 0, обозначающим успех
  jmp exit_0

exit_0:
  ; Это тот же самый код, который мы видели во всех
  ; предыдущих упражнениях; вам он должен быть знаком.
  mov rax, sys_exit
  mov rdi, 0
  syscall

▍ Заключение


Итак, мы освоили фундаментальные строительные блоки потока управления на языке ассемблера.

Мы узнали о командах передачи управления (Control Transfer Instruction, CTI) на примере безусловных и условных переходов. Мы разобрали, как указатель команд (rip) управляет исполнением программы и как переходы манипулируют этим потоком. Мы изучили регистр eflags и узнали о его важной роли в сравнениях, поняв, как флаг нуля (zero flag, ZF) и флаг знака (sign flag, SF) связаны с условными операциями. Соединив команду cmp с переходами, мы создали ассемблерный эквивалент условных конструкций из высокоуровневых языков.

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



1. Такая абстракция не совсем оторвана от реальности. Эмуляторы, то есть ПО, эмулирующее системы на другом оборудовании, обычно представляют потоки команд в виде массивов. Если вам интересна симуляция, то стоит попробовать начать с CHIP-8, а в качестве вводного руководства использовать это.

2. Я говорю в явном виде, потому что, например, команда syscall может вызывать прерывание. Взаимодействие между операционными системами и пользовательскими программами — это само по себе удивительный мир, слишком обширный для изучения в наших статьях. Если вам любопытно, то прочитайте любую книгу про операционные системы. Лично я рекомендую OSTEP и, в частности, эту главу.

3. Полный обзор см. в разделе «Jump if Condition is Met» Intel Software Developer Manuals (SDM).

4. Полный список можно найти в разделе «EFLAGS Register» Intel Software Developer Manuals (SDM).

5. Обратите внимание, что проверка на равенство и проверка на ноль — это, по сути, одно и то же.

Telegram-канал со скидками, розыгрышами призов и новостями IT ?

Комментарии (7)


  1. EmmGold
    23.09.2024 13:09
    +3

    Всегда поражало - как люди из ничего могут выдумать целую статью...


    1. netch80
      23.09.2024 13:09
      +3

      Нормальная учебная статья - для тех, кто ещё не в теме и хочет понять. У Петцольда в "Code" примерно такой же уровень разворачивания разъяснений, или местами ещё подробнее. А оно как раз считается отличной книгой для старшего школьного возраста. Часто вопросы надо обжевать с 2-3 сторон, чтобы достичь понимания >95% аудитории.

      Другой вопрос, что в статье никак не сказано, что всё это для x86-64 - это можно увидеть только в URL оригинала или в предыдущей статье. Уже для ARM будут существенные отличия, а для MIPS, RISC-V и прочих, которые вполне могут попасться сейчас ассемблерщику, всё совсем не так (тех же флагов условий нет, сравнение идёт на двух регистрах или на регистре и константе). Ну и порядок статей странноватый, условия без объяснения перед этим, что в регистрах и памяти и почему...


      1. Seenkao
        23.09.2024 13:09
        +1

        Зная ассемблер под одну архитектуру, достаточно не сложно разобраться в другой архитектуре. Важно само понимание происходящего.

        И именно на этом моменте я бы лучше книги почитал, потому что там информации на порядок больше, и больше разъяснений и примеров.


      1. sami777
        23.09.2024 13:09

        Это продолжение цикла статей. В первой части автор указывал, что это под x86-64. А также, что это кодировка от intel. Мне бы вот интересней бы было почитать что нибудь по сложнее, например про работу с ассемблером АРМ-намного шире и сложнее, чем под x86-64. Всетаки под x86-64 ассемблер если не самый понятный и простой, то один из точно!


        1. netch80
          23.09.2024 13:09

          В первой части автор указывал, что это под x86-64. А также, что это кодировка от intel.

          Я так и сказал в своём комментарии :)

          Мне бы вот интересней бы было почитать что нибудь по сложнее, например про работу с ассемблером АРМ-намного шире и сложнее, чем под x86-64. Всетаки под x86-64 ассемблер если не самый понятный и простой, то один из точно!

          Статей про ARM (оба, AArch32 и AArch64) полно, и на русском я находил достаточно, хотя на английском на порядки больше.

          Что x86 (32, 64) проще и понятнее - я бы как раз не сказал. Даже на чисто прикладном уровне много проблем с неортогональностью или странной логикой команд. Где-то привязанные регистры - деление, так *AX и *DX, сдвиги, так *CX. Никому не нужная RCL, которую тянут через все разрядности. В 64 битах: где-то непосредственные константы в 4 байта, где-то в 8. В одной команде нельзя использовать AH и SIL/DIL/R8B/etc. Флаги ставятся иногда по безумной логике. Много таких примеров. ARM по сравнению с этим практически прямолинеен и логичен. А если идти в системное программирование, так ARM ещё в разы проще...


        1. Seenkao
          23.09.2024 13:09

          Держите, под 4 архитектуры и немного ссылок там же для информации.


  1. old_bear
    23.09.2024 13:09
    +1

    Может стоило упомянуть про чудесный CMOV?
    Просто чтобы у новичков было представление, что в каких-то случаях ветвление можно реализовать и без нарушения линейности исполнения.