Ассемблер х86 процессоров, ч.1

Ассемблер х86 процессоров, ч.1

Нет прав для скачивания
Версия(и) Minecraft
Любая

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

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

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

Author Max. A. Vavaev. 2026.

Ресурсы​

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

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

Оглавление​

Основы ассемблерного кода
Введение
Перемещение и базовая арифметика
Операции с оперативной памятью
Управление потоком
Побитовые операции

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


Введение​

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

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

Вся методичка будет построена на работе с 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) — Флаг переполнения: Загорается, если произошла ошибка при работе с числами со знаком. Например, если ты сложил два положительных числа, а результат получился таким огромным, что "вылетел" в знаковый бит и число стало выглядеть как отрицательное.

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

  • 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
Здесь мы вычитаем из первого значения второе. Сразу вопрос - что будет если мы попытаемся вычесть из меньшего числа большее? Процессор не знает, работаешь ли ты с отрицательными числами или с положительными, он просто выполняет операции над битами. Вычитание из меньшего числа большее сдвигает адрес памяти в соседнюю ячейку, которая может быть уже вообще-то занятой!. Тогда такой сдвиг вызовет следующий сдвиг и так далее, что может привести к крашу к тому что вообще вся программа сдвинется и превратится не пойми во что с точки зрения компилятора. Чтобы этого не происходило, были придуманы те самые системные регистры для различания видов операций.

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

  • Результат математически равен -3.
  • В компьютере отрицательные числа хранятся в дополнительном коде (two's complement).
  • Для 32-битного EAX число будет выглядеть в шестнадцатеричном виде как 0xFFFFFFFD.
При получении отрицательного результата компилятор "зажгет" флаги:
  1. SF = 1: Флаг знака. Он всегда копирует самый старший бит результата. Раз там 1 (в 0xF...), значит результат отрицательный (если мы считаем его числом со знаком).
  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 - побитовый сдвиг влево (умножение на 2^n)
Что такое операция 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

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


Деление
Забудьте про абстракцию в виде десятичных дробей - процессор делит в столбик. И как и в математике, делить на ноль нельзя - это приведет к аварийному завершению программы. С делением всё немного сложнее, чем с умножением. Если умножение — это просто «взяли два числа и получили одно побольше», то деление — это целая процедура, где процессор заранее ожидает данные в строго определённых местах.
Деление всегда происходит «большого на малое». Чтобы результат (частное) поместился в регистр, само делимое должно быть в два раза длиннее делителя. Если вы делите на 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 значений вправо это деление на 2^n.
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 команда простой проверки значений. На основании этой проверки можно добавлять точки ветвления кода, которое осуществляется благодаря переходам по меткам, на которые указывают операторы перехода.

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

КомандаОписаниеУсловие (после 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-й бит (считая с нуля справа)
Результат AND0000 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
orig010010010
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
Обратите внимание, здесь мы впервые с самого введения начали использовать операции обращения к младшим битам. Теперь, несмотря на то, что мы утрамбовали все в одну стопку, мы можем вытащить только конкретно нам нужное значение. Теперь нам требуется достать значение из середины (допустим хотим 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-битное зашифрованное слово, которое после этого шифруется еще раз путем инверсии и циклического сдвига. А затем напиши код, который точно также расшифровывает твое значение и получает его.
  • Like
Реакции: fukkivdan
Автор
Maxik
Скачивания
2
Просмотры
309
Первый выпуск
Обновление
Оценка
3.50 звёзд 2 оценок

Другие ресурсы пользователя Maxik

Последние рецензии

Полезная информация.
Несмотря на то что труда в гайд вложено много, похоже что это лучше бы зашло на каком-нибудь Хабре. Даже если опустить вопрос практичности использования ассемблера для широкого спектра задач, гайд никак не связан ни с моддингом, ни с Minecraft'ом. Было бы 10/10 за счет технического хардкора если бы хотя примеры были обернуты в моды и ассемблерный код гонялся прямо из процесса игры.
Maxik
Maxik
гайд программистско-общеобразовательный. Кроме того, в самом начале в предисловии написано, для чего он нужен и почему я посчитал уместным разместить его на форуме.
Назад
Сверху