Ассемблер для программистов на языках высокого уровня: условные конструкции

от автора


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

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

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

Последовательность декодируемых и исполняемых 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 💻


ссылка на оригинал статьи https://habr.com/ru/articles/845308/