- BrainTools - https://www.braintools.ru -

Методичка которую я формировал для себя, когда учил ассемблер x86-32. Ч.1

Привет.

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

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

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

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

Однако для того, чтобы умело управлять абстракцией, будь то язык программирования С или С++, или более высокого уровня JavaPython или будь то английский язык для составления промпта в нейросети, вам все равно необходимо знать, как выполняется ваш код, чтобы делать его наиболее быстрым, компактным, поддерживаемым и эффективным, чтобы уметь его читать, чтобы видеть сквозь абстракции.

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

VMA 2026. Author Max. A. Vavaev.

Начало

Ресурсы

Для проверки компиляции программ я использовал сайт: Asm-Editor [1]
Перевод чисел между системами: Конвертер числовых систем [2]

Не стоит на сто процентов доверятся этому и аналогичным эмуляторам. Например, я обнаружил что эмулятор, на котором я все тестировал, зажигает неправильные флаги в системных регистрах при операторах перехода, и еще к тому же пишет хренотень в регистр (чего быть по определению не может никак). Неизвестно сколько еще ошибок, критичных для восприятия [3] в ходе обучения [4], можно там найти. Так что в конечном итоге, лучше всего установить виртуальную машину и поставить на нее DOS.

P. S. В конкретно этом эмуляторе недавно было крупное обновление и автор эмулятора сообщил, что полностью его переработал и теперь багов там нет. Я пока не проверял.

Оглавление

Основы ассемблерного кода [5]
Введение [6]
Перемещение и базовая арифметика [7]
Операции с оперативной памятью [8]
Управление потоком [9]
Побитовые операции [10]
Операции с оперативной памятью [8]
Построение алгоритмов [11]

Что скрывается за абстракциями языков высокого уровня? [12]

Основы ассемблерного кода

Введение

Что такое x86-ассемблер?

Ассемблер – это язык, где одна инструкция ≈ одна команда процессора. В отличие от C/Java, здесь нет абстракций типа if или for — управление регистрами и памятью [13] осуществляется напрямую.

Вся методичка будет построена на работе с 32-битными процессорами и ниже. Но во введении будут упомянуты и 64-битные регистры.

Итак. Процессор x86 работает с:

  • Регистрами

  • Оперативной памятью (RAM)

  • Инструкциями

Примечание: Адреса памяти определяются в шестнадцатиричной системе счисления.

В методичке используются и перемешаны три системы счисления: двоичная, десятичная и шестнадцатиричная. Двоичная понятно почему — машинный код. Десятичная — удобное человеческое общепринятое счисление. Но причем тут шестнадцатеричная? Я думаю вы уже догадываетесь, что компьютеру переводить значения из двоичной в десятичную не в пример сложнее, чем из двоичной в шестнадцатеричную. В тоже время, человеку понимать двоичный код еще тяжелее, чем компьютеру десятичный. Так что по сути это компромисс между человеческой десятичной системой и машинным двоичным кодом, удобный как для компьютера, так и для человека.

Везде в коде я где я использую одиночные числа, например 7, 8, 10 — это числа в десятичной системе. Однако учитывайте, что компилятор либо автоматически преобразует их в шестнадцатиричные. Там где явно хочу указать шестнадцатеричное число, я пишу через 0х… и восьмизначние шестнадцатеричное число, включая нули, для наглядности. Там где я явно указываю двоичные числа, которые по числу меньше 32, это просто упрощение, мысленно держим всегда в голове что речь идет про 32-битные процессоры.

Что такое регистры?

Регистры — это сверхбыстрые ячейки памяти внутри самого процессора, с которыми он работает напрямую. Начиная с 4 бит они развивались к 64 битам.

Про 4-битные первые процессоры упоминать детально не будем ввиду отсутствия практического смысла. Единственное что полезно знать — Carry Flag для переноса впервые появился там. Которому позже выделили отдельный системный регистр.

Регистры х86 процессора делятся на регистры общего назначения и специальные (или системные) регистры.

Регистры имеют обратную совместимость. Это можно назвать «матрешкой» регистров для выполнения программ написанных под 8-битный процессор, на 16/32/64 битном процессоре более старших моделей.

Регистры состоят из байтов — блоков по 8 бит — поэтому они все кратны степени двойки — просто исторически так сложилось, что они «наращивались» путем условного «соединения» двух в один.

Регистры общего назначения групп 1 и 2.

Также существуют еще B, C, D. В рамках общего образования полезно знать что эти буквы не просто соответствуют первым четырем буквам латинского алфавита, но и также имеют исторические названия.

Группа 1 (A, B, C, D) используется для прямых операций.

  • A – Accumulator – результат каких-либо арифметических операций.

  • B – Base – указатель на данные.

  • С – Counter – счетчик (например в цикле)

  • D – Data – расширение A – тоже как результат.

Группа 2 (SI, DI, BP, SP) — для работы с данными. — SI — Source Index — «регистр-источник». Указывает на адрес в памяти (в шестнадцатеричной форме), откуда надо начать копирование или откуда начать чтение. — DI — Destination Index — «регистр-приемник». Указывает на адрес в памяти (в шестнадцатеричной форме), куда мы хотим записать данные. — SP — Stack Pointer — указатель верхушки стека. Удобен для операций внутри функции. — BP — Base Pointer — якорь функции. Он остается постоянным на протяжении всего выполнения функции.

Пояснение: Стек можно представить себе как стопку чашек. Вы никак не можете достать самую нижнюю чашку или чашку из середины. Только по очереди самую верхнюю. То есть если вам будет нужна 4 чашка сверху, вы должны снять три предыдущих, забрать четвертую, а потом вернуть 3 оставшихся на место

Пояснение: Представьте, что внутри функции нам нужно обратиться к локальной переменной. ESP постоянно прыгает туда-сюда (мы что-то кладем в стек для вызова других функций), а EBP – фиксируем в начале функции. Тогда любая переменная внутри функции будет доступна по фиксированному адресу, например: [EBP – 4]

Примеры «матрешки» регистров на примере А.

64-битный процессор


  • RAX (64 бита): Полный регистр.

  • EAX (32 бита): Младшая половина RAX.

  • AX (16 бит): Младшая половина EAX.

  • AL (8 бит): Младший байт AX.

  • AH (8 бит): Старший байт AX.

Примечание: Также в современных 64-битных процессорах существуют дополнительные регистры общего назначения R8-R15, не имеющие исторического назначения.
Обращение к младшим регистрам этих регистров осуществляется путем добавления суффикса.
— D — DoubleWord — младшие 32 бита (напр. R8D — младшие 32 бита R8 регистра) — W — Word — младшие 16 бит — B — Byte — младшие 8 бит

32-битный процессор

  • EAX (32 бита): Полный регистр.

  • AX (16 бит): Младшая половина EAX.

  • AL (8 бит): Младший байт AX.

  • AH (8 бит): Старший байт AX.

16-битный процессор

  • AX (16 бит): Полный регистр.

  • AL (8 бит): Младший байт AX.

  • AH (8 бит): Старший байт AX.

8-битный процессор

  • A (8 бит): Единственный байт.

Пояснение: В x86-64 запись в 32-битный субрегистр (например, MOV EAX, 5) автоматически обнуляет верхние 32 бита регистра RAX. Это сделано для оптимизации работы процессора. Для всех остальных процессоров эта операция аналогична!

Системные регистры – или флаги, принимающие значение 0 либо 1.

  • C (CF, Carry Flag) — Флаг переноса: Переключается, если результат операции не влез в разрядную сетку (например, сложили два очень больших числа) или если при вычитании пришлось «занимать» бит из несуществующего старшего разряда.

  • Z (ZF, Zero Flag) — Флаг нуля: Один из самых важных. Если результат операции равен 0, этот флаг становится 1. Именно его проверяют команды типа JZ (Jump if Zero).

  • S (SF, Sign Flag) — Флаг знака: Становится 1, если результат отрицательный (копирует самый старший бит результата).

  • O (OF, Overflow Flag) — Флаг переполнения: Загорается, если произошла ошибка [14] при работе с числами со знаком. Например, если ты сложил два положительных числа, а результат получился таким огромным, что «вылетел» в знаковый бит и число стало выглядеть как отрицательное.

Другие системные регистры (для общего ознакомления)

  • A (AC, Auxiliary Carry Flag) — Вспомогательный перенос: Используется для BCD-арифметики (двоично-десятичный код). Почти не встречается в обычном коде.

  • P (PF, Parity Flag) — Флаг четности: Устанавливается, если в младшем байте результата четное количество единиц. В современном программировании используется редко, это наследие древних протоколов передачи данных.

Перемещение и базовая арифметика

Инструкции MOV и XOR

Инструкция mov выполняет операцию копирования второго в первое.

mov eax, 5

Здесь второе значение (число 5) копируется в регистр eax и регистр eax принимает значение 5.

Однако операции можно проводить не только с числами. Например:

mov eax, ebx

Здесь второе значение из регистра ebx копируется в регистр eax. При этом, ebx по прежнему содержит значение.

Если же значение в регистре ebx нам более не потребуется в программе, мы можем его удалить, обнулив регистр инструкцией xor

xor ebx, ebx

Тогда ebx станет 0.

Вообще, инструкция xor это операция “исключающее ИЛИ”. Просто его удобно использовать для обнуления значений, потому что значение⊕значение = 0. О других применениях можно почитать в интернете.

Инструкции ADD и SUB, INC и DEC

Инструкция add выполняет операцию сложения первого со вторым

add eax, 5
add eax, ebx

Здесь мы добавляем первое значение ко второму — будь то число или значение регистра. Результат автоматически сохраняется в первое значение.

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

sub eax, 5
sub eax, ebx

Здесь мы вычитаем из первого значения второе. Сразу вопрос — что будет если мы попытаемся вычесть из меньшего числа большее? Процессор не знает, работаешь ли ты с отрицательными числами или с положительными, он просто выполняет операции над битами. Вычитание из меньшего числа большее сдвигает адрес памяти в соседнюю ячейку, которая может быть уже вообще-то занятой!. Тогда такой сдвиг вызовет следующий сдвиг и так далее, что может привести к крашу к тому что вообще вся программа сдвинется и превратится не пойми во что с точки зрения [15] компилятора. Чтобы этого не происходило, были придуманы те самые системные регистры для различания видов операций.

Что произойдет по шагам?

  • Результат математически [16] равен -3.

  • В компьютере отрицательные числа хранятся в дополнительном коде (two’s complement).

  • Для 32-битного EAX число будет выглядеть в шестнадцатеричном виде как 0xFFFFFFFD.

При получении отрицательного результата компилятор “зажгет” флаги:

  1. SF = 1: Флаг знака. Он всегда копирует самый старший бит результата. Раз там 10xF...), значит результат отрицательный (если мы считаем его числом со знаком).

  2. CF = 1: Флаг переноса/заема. Поскольку мы вычитали большее из меньшего, произошел «заем». Для беззнаковых чисел это означает переполнение вниз (underflow).

  3. ZF = 0: Результат не ноль.

  4. OF = 0: Флаг переполнения для чисел со знаком. Здесь он будет 0, так как прекрасно укладывается в диапазон 32-битного числа со знаком.

Инструкция inc выполняет операцию прибавления единицы. Выполняется быстрее чем add

inc eax

Здесь означает что значение внутри регистра eax увеличится на 1 (помним про шестнадцатиричную систему).

Например, было 0х00000005, станет 0х00000006, было 0х00000009 – станет 0х0000000А, было 0х0000000F – станет 0х00000010 и.т.д).

Особенность inc: Команда inc не меняет флаг переноса (CF - Carry Flag). Это важное отличие от команды add.

Инструкция dec аналогично выполняет операцию отнимания единицы. Выполняется быстрее чем sub

dec eax

Здесь означает что значение внутри регистра eax уменьшится на 1 (помним про шестнадцатиричную систему).

Очевидное напрашивающееся практическое применение этих инструкций – циклы.

Практика 1.

Опиши, что происходит при выполнении этого кода построчно — объясни себе по шагам, без проверки на компиляторе.

mov eax, 5
mov ebx, 4
add eax, ebx
sub eax, 2
xor eax

Вопрос: Какое значение будет содержать eax?

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

Практика 2.

Опиши, что происходит при выполнении этого кода построчно — объясни себе по шагам.

mov eax, 5
mov ebx, eax
add eax, 1
add ecx, 1
mov edx, ecx
add edx, ecx

Вопрос 1: Какое значение будет содержать eax?
Вопрос 2: Какое значение будет содержать ebx?
Вопрос 3: Какое значение будет содержать ecx?
Вопрос 4: Какое значение будет содержать edx?

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

Инструкции MUL, IMUL, SHL, DIV, IDIV, SHR, SAR

Знаковые и беззнаковые операции

Почему существует две версии инструкций mul, imul или div idiv? Потому что существуют знаковые и беззнаковые операции умножения и деления. Что это означает? Это просто интерпретация компьютером самого старшего бита. Беззнаковая операция всегда положительная: 1111 1111 = 255. Если представить это в упрощенной форме — мы имеем гирьки с весами, лежащие в коробке: 128, 64, 32, 16, 8, 4, 2, 1. А для IMUL/IDIV меняется интерпретация самого старшего (первого) бита. Он воспринимает 0 как + и 1 как . Например, при знаковом IMUL если мы берем 1111 1111, то первое значение интерпретируется как знак -128, 64, 32, 16, 8, 4, 2, 1 Как же эта операция работает с точки зрения компьютера? Смотрите: если мы берем число 1111 1111 в знаковом виде, то самый левый бит будет равен -128. А давайте сложим остальные значения: 64+32+16+8+4+2+1… получается 127! Таким образом –128 + 127 получается –1 — как раз тот самый знак минус!

Умножение

Операция mul является самой старой и примитивной: она умножает значение в текущем регистре на значение, которое было в предыдущем регистре. То есть, если вы пишете:

mov eax, 4
mov ecx, 3
mul eax

То команда берет значение eax, умножает его на значение в предыдущей строке ecx и сохраняет туда же, в eax, перезаписывая значение. А вот инструкция IMUL является более современной и позволяет умножать в более привычном виде через два операнда imul ebx, eax (умножить eax на ebx и сохранить в ebx) или три операнда: imul edx, eax, ebx. (умножить eax на ebx и сохранить в edx).

Важно! Результат умножения всегда расширяет разрядность. Например, перемножение двух 32-битных чисел приведет к занятию двух регистров (64 бита) и сохранится в результат EDX:EAX. Если вы хотите остаться в рамках одного 32-разрядного регистра, не перемножайте числа больше 16 бит.

Пример использования MUL:

mov eax, 0xFFFFFFFF
mov ebx, 2
mul ebx

Чему будет равен ebx?

Пример использования IMUL с двумя операндами:

mov eax, 0xFFFF4e
mov ebx, 4
imul ebx, eax

Чему будет равен ebx?

Пример использования IMUL с тремя операндами:

mov eax, 0xFAFe
mov ebx, 3
imul edx, eax, ebx

Чему будет равен edx?

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

Практика 3.

  1. Напиши программу вычисления математического выражения: x = (3 + 5) * (8 – 4).

  2. Напиши программу возведения числа A = 5 в квадрат.

  3. Напиши программы перемножения чисел 5, 4, 3 используя сперва только mul, затем imul с двумя операндами и затем imul с тремя операндами.

Операция SHL — побитовый сдвиг влево (умножение на 2n)
Что такое операция shl? Это операция побитового сдвига влево (сокр. shift left). Часто используется как более быстрая альтернатива обычному умножению. При ее использовании все числа, заполняющие регистр, смещаются в сторону старшего разряда на заданное количество бит. Условно, представьте что у вас стоит пять кубиков на столе и они стоят на квадратиках где написано 1, 2, 3, 4, 5. И тут вы берете и рукой сдвигаете кубики влево, оставляя последний пустым. Так и работает побитовый сдвиг: допустим eax: 0101 1101 — после операции shl eax, 1 все числа сдвинутся на одну позицию влево. То есть, после выполнения eax будет равен 1010 1010. Математический смысл сдвига на n значений влево эквивалентен умножению на 2^n.

Например, число 00000101 в двоичном это 5. А сдвиг на 1 бит влево дает 00001010 — а это уже 10. Таким образом произошло умножение на 2. Сдвиг на два бита уже будет означать умножение на 4. (2^2 = 4)

Пример использования SHL:

mov eax, 0x101
shl eax, 2

Тестирование: Попробуй подставлять разные числа вместо 2 и наблюдай что происходит.

Практика 4.

Напишите программу, который умножает значение в регистре eax на 10, используя только команды mov, add и логический сдвиг shl

Подсказка – раскладывайте умножение на множители, кратные 2n

Деление
Забудьте про абстракцию в виде десятичных дробей — процессор делит в столбик. И как и в математике, делить на ноль нельзя — это приведет к аварийному завершению программы. С делением всё немного сложнее, чем с умножением. Если умножение — это просто «взяли два числа и получили одно побольше», то деление — это целая процедура, где процессор заранее ожидает данные в строго определённых местах.
Деление всегда происходит «большого на малое». Чтобы результат (частное) поместился в регистр, само делимое должно быть в два раза длиннее делителя. Если вы делите на 32-битный регистр (например, ebx), то делимое должно занимать сразу два регистра: edx (старшая часть) и eax (младшая часть). Это записывается как edx:eax

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

Обычное деление div просто делит числа как есть, записывая целое в eax, а остаток — в edx.

Пример DIV:

mov eax, 100   
mov edx, edx     
mov ecx, 7      
div ecx 

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

При знаковом делении idiv процессор должен понимать, что делимое имеет знак и что результат тоже должен будет иметь знак. То есть результат должен быть заполнен нулями (если положительное) и единицами (если отрицательное делимое или делитель). Для этого надлежит использовать команду-помощник cdq (Convert Doubleword to Quadword). Эта команда готовит регистры к знаковому делению, просто копируя самый старший бит делимого во все 32 бита выходного регистра (обычно edx). Кроме того, у этой команды существуют аналоги на других уровнях, например:

  • cbw (Byte to Word): Расширяет AL в AX.

  • cwd (Word to Doubleword): Расширяет AX в DX:AX.

  • cqo (Quadword to Octoword): Расширяет RAX в RDX:RAX.

Пример IDIV:

mov eax, -100
cdq
mov ecx, 7
idiv ecx

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

Практика 5.

Напиши программу, которая делит 50 на 12.

Практика 6.

Напиши программу, которая делит -47 на 13

Операция SHR и SAR — побитовые сдвиги вправо
shrлогический побитовый сдвиг вправо (shift right). Биты аналогично shl сдвигаются, только вправо. Математический смысл — сдвиг на n значений вправо это деление на 2n.
sarарифметический побитовый сдвиг вправо (shift arithmetic right). Тоже самое, но для знаковых чисел. При сдвиге вправо знаковый бит сохраняется. Если число было отрицательным (начиналось с 1), то слева будут дописываться единицы. Если положительным — нули.

Пример обоих сразу — положительные числа:

mov eax, 40
shr eax, 1

mov ebx, 40
sar ebx, 1

В этом примере результат будет одинаковым. Подумай каким. Чему будет равен eax? Чему будет равен ebx?

Пример – отрицательные числа:
Возьмем число -8 (в двоичном виде 1111 1000)

mov ecx, -8
mov edx, -8
shr ecx, 1

При такой операции число сдвинутое вправо, станет огромным положительным числом и разумеется, совершенно неверным результатом. Было: 1111…11111000 (-8), а станет: 0111…11111100 (2 147 483 644) Поэтому для деления отрицательных чисел всегда используется sar.

Тестирование: Теперь попробуй выполнить программу, а затем еще раз, заменив shr на sar и проследи, что происходит.

Практика 7.

Есть число 16 (в двоичном виде это 00010000).

  1. Что получится, если сдвинуть его вправо на 1 бит (shr eax, 1)?

  2. Что получится, если сдвинуть его вправо на 2 бита (shr eax, 2)?

  3. На сколько бит нужно сдвинуть число, чтобы поделить его на 8?

Операции с оперативной памятью

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

Обращение к памяти по адресу осуществляется также, как и обращение к регистру, только адрес памяти заключается в квадратные скобки. Если обращение к памяти может быть неоднозначным (например, вы хотите записать в память напрямую значение), то используются уточняющие системные директивы размеры и указатели. Память для процессора — это бесконечная «лента» байтов. Чтобы он знал, брать ему один байт или несколько сразу, ему нужно явно указывать это. То есть, допустим, вы хотите напрямую в память записать число 0x0000433b. Вам очевидно, что оно должно занимать два байта, а вот процессору это совершенно неочевидно. Поэтому чтобы записать данные прямо в память, вам надо непосредственно указать процессору что вы перемещаете в память по адресу конкретное количество байт: mov word ptr [0x1050], 66. Если вы попробуете записать больше, чем влазит в указанный размер, в лучшем случае вы просто потеряете эти данные. Например, 0xfffff в word (2 байта) записать не выйдет. В случае с регистрами эти уточняющие директивы не нужны, так как процессору «известно» о размере регистров.

Чтение значения по адресу в памяти называется разыменованием.

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

mov eax, [0x00001030]

Помним! Это шестнадцатеричная 1030, которая в десятичной системе будет 4144

Практика 8.

mov eax, 5
mov [0x00001030], eax
mov ebx, [0x00001030]
add ebx, 3

Чему будет равен eax? Чему будет равен ebx?

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

Практика 9.

mov eax, 0xA            
mov ebx, 2            
mov [0x00001040], ebx       
mov ebx, [0x00001040]
add ebx, 2            
mov [0x00001040], ebx
xor ebx, ebx            
mov ebx, [0x00001040]
add ebx, eax        
mov [0x00001050], ebx 
mov eax, [0x00001050] 
mov [0x00001040], ebx

Чему станет равно значение в памяти по адресу 0x00001040? Чему станет равно значение в памяти по адресу 0x00001050? Чему равен eax? Чему равен ebx?

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

Стек. Инструкции работы со стеком — PUSH и POP

Теперь, когда мы уже понимаем разницу между памятью, адресом памяти, регистром и инструкциями, можно разобрать операции со стеком. Стек — это область памяти, которая работает по принципу «последний зашел — первый вышел». Я уже приводил пример с чашками в начале.

Зачем она вообще нужна? Стек — это место хранения временных данных в отдельной функции. Поскольку код может ветвится, а не выполнятся монолитным листом, то это идеально решение. Условно говоря, у дерева есть ветка. О том, что на ветке есть листочки, основной ствол дерева не знает — это знает только ветка. Получается такой базовый принцип наследственности — ствол знает о ветке, ветка знает о листьях. А вот ствол о листьях не знает. Да, можно объявить глобальные листы, но тогда они будут относится к стволу, а не к ветке. Так вот, когда процессор начинает выполнять код «ветки», он узнаёт о существовании листьев и сохраняет их в стек. После того, как все дела с веткой завершены, процессору больше не нужно знать ничего о листьях — и они удаляются из стека.

Здесь мы вспоминаем про ESP — главный регистр стека, хранящий адрес последнего значения стека (верхней чашки) во время выполнения функции. А EBP мы объявляем для того, чтобы знать, где было начало ветки, чтобы вернутся к ней и продолжить выполнение кода по стволу дерева.

В x86 стек растёт вниз по памяти (адреса уменьшаются).

mov eax, 0xAAAA
mov ebx, 0xBBBB 
push eax
push ebx

Что здесь происходит? Процессор делает следующее:

  1. Уменьшает значение ESP на 4 (в 32-bit режиме).

  2. Записывает значение eax по адресу [ESP]

  3. Уменьшает значение ESP еще на 4.

  4. Записывает значение ebx по адресу [ESP-4]

Пояснение: Представьте себе в этот момент стек как пачку Pringles — кстати, отличный пример. Вы можете класть чипсинки в стопку несколько раз. Но чтобы потом нижнюю достать, нужно опять по очереди вытащить все те, что лежат над ней.

А теперь про pop — давайте улучшим наш предыдущий код.

mov eax, 0xAAAA    
mov ebx, 0xBBBB     
push eax
push ebx
pop eax
pop ebx

Последними двумя операциями процессор “распаковывает” стек, сохраняя верхнее значение 0xBBBB в eax, а следующее за ним 0xAAAA в ebx.
Ух ты! Мы поменяли значения местами, не прибегая к операции перезаписывания!

Так работает стек.

Практика 10.

Напиши программу “Сумма трех значений”. Положи в стек числа 3, 5 и 4. Затем по очереди извлеки их и сложи так, чтобы итоговая сумма (0xC) оказалась в регистре eax.

Практика 11.

Напиши программу “Математическое выражение”: Реализуй вычисление eax = (5 + A) * 3. Попробуй сделать это, используя только mov, add и imul.

Практика 12.

Напиши программу “Обмен”. У тебя есть значение в eax и значение в ebx. Поменяй их местами, используя только команды push и pop (без использования дополнительных регистров или mov).

Управление потоком

Команда сравнения CMP и операторы переходов JMP

CMP eax, ebx — это, по сути, вычитание (eax - ebx), но результат никуда не записывается. Вместо этого процессор меняет состояние специальных флагов в регистре EFLAGS.

  • Если числа равны, устанавливается флаг нуля (ZF = 1).

  • Если eax < ebx, устанавливается флаг знака (SF = 1).

  • Если eax > ebx, то результат сравнения (вычитания) изменен не будет.

То есть это read-only команда простой проверки значений. На основании этой проверки можно добавлять точки ветвления кода, которое осуществляется благодаря переходам по меткам, на которые указывают операторы перехода.

Команды переходов обычно пишутся сразу после команды сравнения, так как в этот момент системные флаги наверняка будут иметь актуальное значение. На основании именно этих актуальных значений и работает логика [17] команд переходов. Ниже представлена небольшая табличка. Эти команды смотрят на флаги и решают: прыгать на «метку» или идти дальше.

Команда

Описание

Условие (после CMP)

je (Jump Equal)

Переход, если равно

eax == ebx

jne (Jump Not Equal)

Переход, если не равно

eax != ebx

jg (Jump Greater)

Переход, если больше

eax > ebx

jl (Jump Less)

Переход, если меньше

eax < ebx

jmp

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

Переходит всегда

Небольшой пример:

mov eax, 10
mov ebx, 10
cmp eax, ebx
je label
mov ecx, 6
label:
mov ecx, 8

Какой флаг зажгет процессор при выполнении этого кода? Какое значение будет в ecx после выполнения этого кода?

Для беззнаковых чисел тоже существуют свои операторы перехода, однако они оперируют терминами “выше” ja (jump if above — переход, если выше (больше)) или “ниже” jb (jump if below — переход, если ниже (меньше)), а также jae (above or equal — выше или равно) и jbe (below or equal — ниже или равно).
Беззнаковые операторы переключают только флаги CF и ZF

Небольшая памятка по ним:

Команда

Описание

Условие на языке флагов

Почему так?

ja (Above)

Больше

CF=0 и ZF=0

Нет заема (значит a >= b) и результат не ноль (значит a != b).

jb (Below)

Меньше

CF=1

Произошел заем, значит мы вычитали большее из меньшего.

jae (Above or Equal)

Больше или равно

CF=0

Заема не было — либо числа равны, либо первое больше.

jbe (Below or Equal)

Меньше или равно

CF=1 или ZF=1

Либо произошел заем, либо результат обнулился.

Давайте посмотрим как это на практике происходит.
Пусть в eax у нас будет –1… 0xffffffff

mov eax, 0xffffffff
mov ebx, 0xffffffff
cmp eax, ebx
ja label
mov ecx, 4
label:
mov ecx, 6

Какой флаг зажгет процессор при выполнении этого кода? Какое значение будет в ecx после выполнения этого кода? Что будет, если если сделать ebx больше чем eax?

Что еще?

В современных версиях уже есть разные упрощенные команды типа loop — это готовый цикл, однако я намеренно перенес их в конец, так как они все являются производными от тех базовых команд, которые я показываю в методичке. Например инструкция loop это по сути inc (dec) и cmp в одной команде, то есть она складывает (вычитает), сравнивает — если еще не ноль, то продолжает. В конечном итоге эта команда уже является первой абстракцией над более простыми командами — она декодируется в более простые микрооперации перед выполнением.

Пример: здесь инструкция loop выполняет цикл, вычитая единицу из счетчика ecx, пока счетчик не станет равным 0.

mov ecx, 10
sum_loop:
add eax, ecx
loop sum_loop

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

Любопытства ради, попробуйте использовать любой другой регистр кроме ecx и посмотрите, что будет происходить.

mov ecx, 10
sum_loop_manual:
add eax, ecx
dec ecx
cmp ecx, 0
jne sum_loop_manual

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

Если провести параллели с языками высокого уровня — loop это ближе к for, а ручной цикл к do-while.

Практика 13.

Напиши программу, которая увеличивает значение в eax на 30, используя только команду inc, команду сравнения и команды переходов.

Практика 14.

Напиши программу, которая должна прибавлять по 1 к значению в eax = 3 до тех пор, пока eax не станет 47.

Практика 15.

Напиши программу, которая будет завершаться, если значение eax превысит 100.

Побитовые операции

В основе лежат четыре побитовые операции and, or, xor и not. С xor мы уже сталкивались раньше и я обещал рассказать, что это такое. Для начала вообще узнаем, что это такое.

Команда AND

and – побитовое “И” – выдает 1, если оба входных бита равны 1. Одно из оисновых применений этой команды – побитовые маски. Представьте, что есть байт данных, где каждый бит отвечает за какой-то параметр (например, состояние датчиков). Нужно проверить только 3-й бит, а остальные значения мешают.

Операция

Биты

Пояснение

Исходное число

1011 0110

Произвольные данные

Маска

0000 1000

Мы хотим только 3-й бит (считая с нуля справа)

Результат AND

0000 0000

Все обнулилось, так как 3-й бит был 0

0 в маске всегда превращает любой бит в 0, а 1 в маске сохраняет исходное значение бита.

Другой небольшой пример использования — проверка четности числа.

mov eax, 7
and eax, 1
jnz is_odd
jmp end_check

is_odd:
add eax, 2
mov [0x00001050], eax
end_check:

Число 7 в двоичном виде — 0000 0111. Число 1 — 0000 0001. Путем нехитрого сравнения получаем значение 0000 0001, которое зажигает ZF. Следовательно, команда jnz («если ZF не ноль») перенаправляет код по метке is_odd где к eax добавляется двойка и затем это значение сохраняется в память. Если же число оказалось четным (например 6 – это 0000 0110), то оно не сохраняется в память — код сразу переходит к метке end_check

Вообще вместо команды and обычно используется test, которая производит операцию побитового И “в уме”, не перезаписывая значение в регистре eax, как это делает and

Практика 16.

Напиши программу, которая записывает произвольное число и проверяет 5-й бит (считая от 0). Что дпоказывает флаг ZF? Затем переведи результат в двоичный вид и проверь компьютер.

Команда OR

or – это команда побитовое ИЛИ. Если and можно использовать как фильтр, отсекающий ненужные биты, то or — точный инструмент для побитовой правки значения или сравнения. Она сравнивает входящие значения и выдает 1, если хотя бы один из входящих значений равен 1. Как это применяется? Ну вот например, у нас есть некоторое число 01010011. И мы хотим превратить 3 бит из 0 в 1. Для этого мы применяем побитовую маску с ИЛИ: 00001000 Сначала переведем числа в шестнадцатеричную чтобы написать код – 01010011 = 0x00000053 (или просто 0х53), а 00001000 = 0x00000008 (0х8), а затем выполним код:

mov eax, 0x53
or eax, 0x8

Тестирование: Проверьте — какое значение примет eax?

Мы должны получить результат 0x0000005b (0x5b), что соответствует двоичному 1011011. Теперь смотрим:

Значения

01010011

00001000

01011011

Вот та самая единичка, которую мы «интегрировали» в исходное значение. Все сходится.

Практика 17.

Напиши программу, которая принудительно изменяет значение 0 и 31 бита, сохраняя при этом все остальные.

Команда XOR

xor — команда исключающего побитового ИЛИ. Если and можно применять как фильтр, сбрасывающий биты кроме указанных, or — устанавливающий биты в нужное положение, то xor — инвертор битов. Если сравниваемые значения одинаковые, она возвращает ноль. Если разные — единицу. Мы уже видели выше примеры использования команды в качестве обнулятора. Давайте теперь посмотрим еще на примере простейшего шифровальщика (или упаковщика) — двойного инвертирования. Допустим, хотим зашифровать число 0xb74a (в двоичной1011011101001010) ключом 0x774a3 (1110111010010100011)

mov eax, 0x0000b74a
mov ebx, 0x000774a3
xor eax, ebx
xor ebx, eax

Тестирование: Запустите код и посмотрите какое значение принимает eax после первого xor и после второго.

Мы должны после первого xor получить 0x0000c3e9, а после второго — исходное значение eax. Причем что интересно, если мы поменяем их местами, то мы сможешь получить исходное значение ebx. Таким образом мы можем хранить два значения в одном (или больше). Я думаю вам здесь на ум невольно приходят аналогии — больно похоже на всякие хеш-мапы из высокоуровневых языков, да? И не зря приходят. Ведь именно так они и работают.

Практика 18.

Напиши программу, которая инвертирует только младший байт регистра EBX, а затем, сохраняет в память.

Оператор NOT можно вставить в этот же раздел, так как он выполняет похожую функцию инвертирования, только безусловно — он просто инвертирует данное ему значение. Например

mov ebx, 0x0000004a ; 0100 1010 в двоичной (помним что ebx — 32 разрядный регистр, то есть по сути это `0000 0000 0000 0000 0000 0000 0100 1010`)
not ebx

То мы получим ebx = 0xffffffb5, что соответствует 1111 1111 1111 1111 1111 1111 1011 0101, то есть полностью инвертированному числу, где каждый ноль превратился в единицу, а каждая единица — в ноль.

О, видите в самом старшем разряде f? При использовании знака можно интерпретировать число как отрицательное! Кстати, процессор именно так и делает:

mov ebx, 0x00000005
not ebx

В двоичной системе в итоге это будет 010, что в шестнадцатеричной будет 0xfffffffa. Если вернутся к системе счисления кожаных десятичной системе счисления, то это будет число 4294967290.

Это если беззнаковое. Но мы то явно не собирались получать хрен пойми что. Однако если мы все таки хотим выяснить, какое же число получилось со знаком, то мы должны инвертировать, добавить единицу и инвертировать еще раз. И тогда мы получим число, к которому можно будет поставить знак минус (это просто пример для наглядной демонстрации примера работы команды, индусокодить так не надо — делать инвертирование два раза вместо того чтобы его вообще не делать). Это будет выглядеть как то так:

mov ebx, 0x00000005
not ebx ; получили отрицательное число 
not ebx ; инвертировали его обратно
inc ebx ; получили число, к которому можем мысленно подставить знак `–`.

Но погодите! Мы получили при таком инвертировании –6, а не –5, как ожидалось! Почему же так? Дело все в том самом алгоритме старшего бита, который был «занят» знаком. Поэтому чтобы конкретно получить нужное положительное число, нам надо ещё эту самую единицу будет отнять.

mov ebx, 0x00000005
not ebx ; получили отрицательное число
inc ebx ; добавили единицу к инвертированному числу (отняли у неинвертированного)
not ebx ; инвертировали его обратно
inc ebx ; добавили единицу и получили требуемое число, к которому можем мысленно подставить знак `–`.

Вообще существует опять же (как и loop, test и др.) обобщающая команда neg, которая делает это сразу — инвертирует и добавляет один.

mov ebx, 0x00000005
neg ebx ; получим 0xfffffffb (0xfffffffa + 1)

Практика 19.

Загрузи в EDX значение 0x0000FFFF. Выполни команду NOT. Объясни, почему результат не равен простому «минусу» в обычном понимании.

Циклические сдвиги ROL, ROR, RCL, RCR

Циклические сдвиги напоминают собой операции сдвига вправо (shr) или влево (shl), упомянутых ранее в разделе про арифметические операции, только сдвигаемые старшие биты не исчезают, а возвращаются на позицию самого младшего.

Операции rol и ror представляют собой простые циклические сдвиги. Например

Сдвиг

Значения

rol ->

001001001

orig

010010010

ror <-

100100100

Помним, что мы оперируем с 32-битным числом и там слева еще 22 нуля.

mov ebx, 0x92
rol ebx, 1

Тестирование: Запустите код и покрутите биты, поменяйте на ror. Попробуйте покрутить не только на один, но на 2 или на 3 бита.

Так как надо где то хранить сдвигаемый бит, чтобы потом записать его вначало, данные команды используют системный регистр CF — сдвигаемый старший бит заходит в него и как бы там «хранится» до востребования.

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

Например, мы можем в одном регистре хранить сразу несколько более малых чисел таким образом, компактизируя их (чем-то напоминает стек, ага?). На практике это очень популярно в использовании при передаче данных в видеопамять. Например в движке на котором я делаю игру, есть строгое ограничение на передачу за раз только 48 байт. И допустим у меня есть три обычных float, которые я могу упаковать во float3 и таким образом передав больше информации за раз.

mov eax, 0xbb
mov ebx, 0x11
mov ecx, 0x22
mov dl, al
rol edx, 8
mov dl, bl
rol edx, 8
mov dl, cl
rol edx, 8

Обратите внимание [18], здесь мы впервые с самого введения начали использовать операции обращения к младшим битам. Теперь, несмотря на то, что мы утрамбовали все в одну стопку, мы можем вытащить только конкретно нам нужное значение. Теперь нам требуется достать значение из середины (допустим хотим 0x11 и сохранить в оперативку по адресу, например [0x1050]:

mov eax, 0xbb
mov ebx, 0x11
mov ecx, 0x22
mov dl, al
rol edx, 8
mov dl, bl
rol edx, 8
mov dl, cl
rol edx, 8

ror edx, 16
mov [0x1050], dl
xor dl, dl
rol edx, 16

Супер! Этой программой мы упаковали 4 8-битных числа в 32-битное, затем распаковали точно нужное, достав его из середины, очистили этот байт и запихнули его обратно восстановив структуру, чтобы можно было по новой обратится таким же образом.

Если эти вышеперечисленныех команды rol и ror просто перезаписывают значение во флаге CF по кругу начиная с самого начала операции (им неинтересно, что там хранилось до этого), то операции циклического сдвига через перенос rcl и rcr используют его значение сразу, добавляя его в свой «расширенный цикл» как добавочный бит. То есть, они сначала «смотрят», что там во флаге уже есть, и копируют этот бит в соответствующую ячейку (смотря куда крутим). Таким образом в циклах rcl и rcr крутится 9 бит, а не 8.

Практическо применение этих ротируемых расширенных циклов — межрегистровые операции. Например, если необходимо сдвинуть 64-битное число, хранящееся в двух 32-битных регистрах, то обычный сдвиг приведет к потере крайнего бита. Прокручивание позволяет «прогнать» бит, который некуда деть, через системный регистр.

shl eax, 1    ; Сдвигаем младшую часть, старший бит уходит в CF
rcl edx, 1    ; Сдвигаем старшую часть, забирая бит из CF в начало

Практика 20.

Напиши программу, которая используя только ROL или ROR, меняет местами старшее слово (биты 16-31) и младшее слово (биты 0-15).

Практика 21.

Запиши в eax число 0x80000000, а в ebx — 0x00000001. Используя rcl и rcr, перемести единицу из старшего бита eax в младший бит ebx.

Практика 22* (задание со звездочкой).

Напиши программу, которая получает модуль числа в eax без использования команд перехода (jmp, jz и т.д.).

Подсказка: используй SAR (арифметический сдвиг) для создания маски из знакового бита и комбинацию XOR и SUB.

Практика 23* (задание со звездочкой).

Напиши цикл, который подсчитывает количество установленных бит (т.е. единиц) в регистре eax.

Подсказка: Используй shl или ror вместе с проверкой флага переноса.

Практика 24**(задание со 2 звездочками).

Напиши программу, которая шифрует произвольное 32-битное значение, записанное в eax путем сперва шифрования каждого отдельного байта этого значения, а затем компактизации в одно 32-битное зашифрованное слово, которое после этого шифруется еще раз путем инверсии и циклического сдвига. А затем напиши код, который точно также расшифровывает твое значение и получает его.

Автор: Tesmio

Источник [19]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/32570

URLs in this post:

[1] Asm-Editor: https://asm-editor.specy.app/

[2] Конвертер числовых систем: https://calculatori.ru/perevod-chisel.html

[3] восприятия: http://www.braintools.ru/article/7534

[4] обучения: http://www.braintools.ru/article/5125

[5] Основы ассемблерного кода: #%D0%BE%D1%81%D0%BD%D0%BE%D0%B2%D1%8B-%D0%B0%D1%81%D1%81%D0%B5%D0%BC%D0%B1%D0%BB%D0%B5%D1%80%D0%BD%D0%BE%D0%B3%D0%BE-%D0%BA%D0%BE%D0%B4%D0%B0

[6] Введение: #%D0%92%D0%B2%D0%B5%D0%B4%D0%B5%D0%BD%D0%B8%D0%B5

[7] Перемещение и базовая арифметика: #%D0%BF%D0%B5%D1%80%D0%B5%D0%BC%D0%B5%D1%89%D0%B5%D0%BD%D0%B8%D0%B5-%D0%B8-%D0%B1%D0%B0%D0%B7%D0%BE%D0%B2%D0%B0%D1%8F-%D0%B0%D1%80%D0%B8%D1%84%D0%BC%D0%B5%D1%82%D0%B8%D0%BA%D0%B0

[8] Операции с оперативной памятью: #%D0%BE%D0%BF%D0%B5%D1%80%D0%B0%D1%86%D0%B8%D0%B8-%D1%81-%D0%BE%D0%BF%D0%B5%D1%80%D0%B0%D1%82%D0%B8%D0%B2%D0%BD%D0%BE%D0%B9-%D0%BF%D0%B0%D0%BC%D1%8F%D1%82%D1%8C%D1%8E

[9] Управление потоком: #%D1%83%D0%BF%D1%80%D0%B0%D0%B2%D0%BB%D0%B5%D0%BD%D0%B8%D0%B5-%D0%BF%D0%BE%D1%82%D0%BE%D0%BA%D0%BE%D0%BC

[10] Побитовые операции: #%D0%BF%D0%BE%D0%B1%D0%B8%D1%82%D0%BE%D0%B2%D1%8B%D0%B5-%D0%BE%D0%BF%D0%B5%D1%80%D0%B0%D1%86%D0%B8%D0%B8

[11] Построение алгоритмов: #%D0%BF%D0%BE%D1%81%D1%82%D1%80%D0%BE%D0%B5%D0%BD%D0%B8%D0%B5-%D0%B0%D0%BB%D0%B3%D0%BE%D1%80%D0%B8%D1%82%D0%BC%D0%BE%D0%B2

[12] Что скрывается за абстракциями языков высокого уровня?: #%D1%87%D1%82%D0%BE-%D1%81%D0%BA%D1%80%D1%8B%D0%B2%D0%B0%D0%B5%D1%82%D1%81%D1%8F-%D0%B7%D0%B0-%D0%B0%D0%B1%D1%81%D1%82%D1%80%D0%B0%D0%BA%D1%86%D0%B8%D1%8F%D0%BC%D0%B8-%D1%8F%D0%B7%D1%8B%D0%BA%D0%BE%D0%B2-%D0%B2%D1%8B%D1%81%D0%BE%D0%BA%D0%BE%D0%B3%D0%BE-%D1%83%D1%80%D0%BE%D0%B2%D0%BD%D1%8F?

[13] памятью: http://www.braintools.ru/article/4140

[14] ошибка: http://www.braintools.ru/article/4192

[15] зрения: http://www.braintools.ru/article/6238

[16] математически: http://www.braintools.ru/article/7620

[17] логика: http://www.braintools.ru/article/7640

[18] внимание: http://www.braintools.ru/article/7595

[19] Источник: https://habr.com/ru/articles/1054798/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1054798

www.BrainTools.ru

Rambler's Top100