Що таке EAX в асемблері
Мова асемблер. Команди та основи асемблера
У статті будуть розглянуті основи мови асемблер стосовно архітектури win32. Він є символічним записом машинних кодів. У будь-якій електронно-обчислювальній машині найнижчим рівнем є апаратний. Тут управління процесами відбувається командами чи інструкціями машинною мовою. Саме в цій галузі асемблеру призначено працювати.
Програмування на асемблер
Написання програми на асемблері – вкрай важкий та витратний процес. Щоб створити ефективний алгоритм, необхідно глибоке розуміння роботи ЕОМ, знання деталей команд, а також підвищену увагу та акуратність. Ефективність – це критичний параметр для програмування на асемблер.
Головна перевага мови асемблер в тому, що вона дозволяє створювати короткі та швидкі програми. Тому використовується, як правило, для вирішення вузькоспеціалізованих завдань. Необхідний код, що працює ефективно з апаратними компонентами, або потрібна програма, яка вимагає пам'яті або часу виконання.
Реєстри
- Регістри загального призначення (РН).
- Прапори.
- Вказівник команд.
- Реєстри сегментів.
Є 8 регістрів загального призначення, кожен розміром 32 біти.
Доступ до регістрів EAX, ECX, EDX, EBX може здійснюватися в 32-бітовому режимі, 16-бітовому - AX, BX, CX, DX, а також 8-бітовому - AH і AL, BH і BL і т.д.
Літера "E" у назвах регістрів означає Extended (розширений). Самі ж імена пов'язані з їхніми назвами англійською:
- Accumulator register (AX) – для арифметичних операцій.
- Counter register (CX) - для зсувів та циклів.
- Data register (DX) - для арифметичних операцій та операцій введення/виводу.
- Base register (BX) – для покажчика на дані.
- Stack Pointer register (SP) – для вказівника вершини стека.
- Stack Base Pointer register (BP) – для індикатора основи стека.
- Source Index register (SI) - для покажчика відправника (джерела).
- Destination Index register (DI) – для одержувача.
Спеціалізація РОН мови асемблер є умовною. Їх можна використовувати у будь-яких операціях. Однак деякі команди здатні застосовувати лише певні регістри. Наприклад, команди циклу використовують ESX для збереження значення лічильника.
Реєстр прапорів. Під цим мається на увазі байт, який може набувати значень 0 і 1. Сукупність всіх прапорів (їх порядку 30) показують стан процесора. Приклади прапорів: Carry Flag (CF) – Прапор перенесення, Overflow Flag (OF) – переповнення, Nested Flag (NT) – прапор вкладеності завдань та багато інших. Прапори поділяються на 3 групи: стан, управління та системні.
Покажчик команд (EIP – Instruction Pointer). Цей регістр містить адресу інструкції, яка має бути виконана наступною, якщо немає інших умов.
Регістри сегментів (CS, DS, SS, ES, FS, GS). Їхня наявність в асемблері продиктована особливим управлінням оперативної пам'яттю, щоб збільшити її використання у програмах. Завдяки їм можна було керувати пам'яттю розміром до 4 Гб. В архітектурі Win32 необхідність у сегментах відпала, але назви регістрів збереглися та використовуються по-іншому.
Стек
Це область пам'яті, виділена до роботи процедур. Особливість стека полягає в тому, що останні дані, записані в нього, доступні для читання першими. Або інакше кажучи: перші записи стека витягуються останніми. Уявити цей процес собі можна як вежу з шашок. Щоб дістати шашку (нижню шашку в основі вежі або будь-яку в середині) потрібно спочатку зняти всі, що лежать зверху. І, відповідно, остання покладена на вежу шашка при розборі вежі знімається першою. Такий принцип організації пам'яті та роботи з нею продиктовано її економією.Стек постійно очищається і в кожний момент часу одна процедура використовує його.
Ідентифікатори, цілі числа, символи, коментарі, еквівалентність
Ідентифікатор у мові програмування асемблер має такий же зміст, як і в будь-якому іншому. Допускається використання латинських літер, цифр та символів "_", ".", "?", "@", "$". При цьому великі та малі літери еквівалентні, а точка може бути лише першим символом ідентифікатора.
Цілі числа в асемблері можна вказувати в системах відліку з підставами 2, 8, 10 і 16. Будь-який інший запис чисел буде розглядатися компілятором асемблера як ідентифікатор.
У записі символьних даних можна використовувати як апострофи, так і лапки. Якщо символьному рядку потрібно вказати одне із них, то правила такі:
- у рядку, укладеному в апострофи, лапки вказуються один раз, апостроф - двічі: 'can''t', 'he said "to be or not to be" ';
- для рядка, укладеного в лапки, правило зворотне: дублюються лапки, апострофи вказуються так: "couldn't", "My favourite bar is" "Black Cat"" ".
Для вказівки коментування в мові асемблер використовується символ крапка з комою - ";". Допустимо використовувати коментарі як на початку рядків, так і після команди. Закінчується коментар перекладом рядка.
Директива еквівалентності використовується так само, як у інших мовах вказують константні висловлювання. Еквівалентність вказується в такий спосіб:
Таким чином у програмі всі входження будуть замінюватися на , на місці якого можна вказувати ціле число, адресу, рядок або інше ім'я. Директива EQU схожа по роботі на #define у мові С++.
Директиви даних
Мови високого рівня (C++, Pascal) є типизованими. Тобто в них використовуються дані, що мають певний тип, є функції їх обробки і т.п. буд. У мові програмування асемблер подібного немає.Існує лише 5 директив для визначення даних:
- DB – Byte: виділити 1 байт під змінну.
- DW - Word: виділити 2 байти.
- DD - Double word: виділити 4 байти.
- DQ – Quad word: виділити 8 байтів.
- DT – Ten bytes: виділити 10 байтів під змінну.
Літера D означає Define.
Будь-яка директива може бути використана для оголошення будь-яких даних та масивів. Однак, для рядків рекомендується використовувати DB.
Як операнда допустимо використовувати числа, символи і питання - "?", що означає змінну без ініціалізації. Розглянемо приклади:
real1 DD 12.34 char db 'c' ar2 db '123456',0; масив з 7 байт num1 db 11001001b; двійкове число num2 dw 7777o; восьмеричне число num3 dd -890d; десяткове число num4 dd 0beah; шістнадцяткове число var1 dd? ; змінна без початкового значення ar3 dd 50 dup (0); масив з 50 ініціалізованих ел-тів ar4 dq 5 dup (0, 1, 1.25); масив з 15 ел-тів, ініціалізований повторами 0, 1 та 1.25
Команди (інструкції)
Синтаксис команд асемблера або інструкцій асемблера виглядає так:
Записки програміста
Шпаргалка за основними інструкціями асемблера x86/x64
Минулої статті ми написали наш перший hello world додаток на асмі, навчилися його компілювати та налагоджувати, а також дізналися, як робити системні виклики в Linux. Сьогодні ми познайомимося безпосередньо з асемблерними інструкціями, поняттям регістрів, стека і ось цього всього. Ассемблери для архітектур x86 (a.k.a i386) і x64 (a.k.a amd64) дуже схожі, тому немає сенсу розглядати в окремих статтях. Притому акцент я постараюся робити на x64, попутно відзначаючи відмінності від x86, якщо вони є. Далі передбачається, що ви вже знаєте, наприклад, чим стек відрізняється від купи, і пояснювати такі речі не потрібно.
Реєстри загального призначення
Регістр – це невеликий (зазвичай 4 або 8 байт) шматочок пам'яті у процесорі з надзвичайно великою швидкістю доступу. Регістри поділяються на регістри спеціального призначення та регістри загального призначення. Нас зараз цікавлять регістри загального призначення. Як можна здогадатися за назвою, програма може використовувати ці регістри під свої потреби, як їй заманеться.
На x86 є вісім 32-бітних регістрів загального призначення - eax, ebx, ecx, edx, esp, ebp, esi і edi. Регістри не мають заданого наперед типу, тобто вони можуть трактуватися як знакові чи беззнакові цілі числа, покажчики, булеві значення, ASCII-коди символів тощо. Незважаючи на те, що теоретично ці регістри можна використовувати як завгодно, на практиці зазвичай кожен регістр використовується певним чином. Так, esp вказує на вершину стека, ecx відіграє роль лічильника, а в eax записується результат виконання операції чи процедури. Існують 16-бітні регістри ax, bx, cx, dx, sp, bp, si і di, що являють собою 16 молодших біт відповідних 32-бітних регістрів. Також доступні і 8-бітові регістри ah, al, bh, bl, ch, cl, dh і dl, які являють собою старші і молодші байти регістрів ax, bx, cx і dx відповідно.
Розглянемо приклад. Допустимо, виконуються такі три інструкції:
(gdb) x/3i $pc
=> 0x8048074: mov $0xaabbccdd,%eax
0x8048079: mov $0xee,%al
0x804807b: mov $0x1234, %ax
Значення регістрів після запису в eax значення 0 x AABBCCDD:
(gdb) p/x $eax
$1 = 0xaabbccdd
(gdb) p/x $ax
$2 = 0xccdd
(gdb) p/x $ah
$3 = 0xcc
(gdb) p/x $al
$4 = 0xdd
Значення після запису в регістр значення 0 x EE:
(gdb) p/x $eax
$5 = 0xaabbccee
(gdb) p/x $ax
$6 = 0xccee
(gdb) p/x $ah
$7 = 0xcc
(gdb) p/x $al
$8 = 0xee
Значення регістрів після запису в ax числа 0 x 1234:
(gdb) p/x $eax
$9 = 0xaabb1234
(gdb) p/x $ax
$10 = 0x1234
(gdb) p/x $ah
$11 = 0x12
(gdb) p/x $al
$12 = 0x34
Як бачите, нічого складного.
Примітка: Синтаксис GAS дозволяє явно вказувати розміри операндів шляхом використання суфіксів b (байт), w (слово, 2 байти), l (довге слово, 4 байти), q (четверка, 8 байт) та деяких інших. Наприклад, замість команди mov $0xEE, % al можна написати movb $0xEE, % al, замість mov $0x1234, % ax — movw $0x1234, % ax, і так далі. У сучасному GAS ці суфікси опціональні і я особисто їх не використовую. Але не лякайтеся, якщо побачите їх у чужому коді.
На x64 розмір регістрів було збільшено до 64-х біт. Відповідні регістри отримали назву rax, rbx тощо. Крім того, регістрів загального призначення стало шістнадцять замість восьми. Додаткові регістри отримали назви R8, R9, …, R15. Відповідні їм регістри, які репрезентують молодші 32, 16 і 8 біт, отримали назву r8d, r8w, r8b, і за аналогією для регістрів r9-r15. Крім того, з'явилися регістри, що є молодшими 8 біт регістрів rsi, rdi, rbp і rsp - sil, dil, bpl і spl відповідно.
Про адресацію
Як зазначалося, регістри можуть трактуватися, як покажчики дані у пам'яті. Для розіменування таких покажчиків використовується спеціальний синтаксис:
Цей запис означає «прочитай 8 байт за адресою, записаною в регістрі rsp, і збережи їх у регістр rax». При запуску програми rsp вказує на вершину стека, де зберігається кількість аргументів, переданих програмі (argc), покажчики на ці аргументи, а також змінні оточення та інша інформація. Таким чином, в результаті виконання наведеної вище інструкції (зрозуміло, за умови, що перед нею не виконувалось жодних інших інструкцій) в rax буде записано кількість аргументів, з якими була запущена програма.
В одній команді можна вказувати адресу та змішання (як позитивне, так і негативне) щодо нього:
Цей запис означає «візьми rsp, додай до нього 8, прочитай 8 байт за адресою, що вийшла, і поклади їх в rax». Таким чином, в rax буде записана адреса рядка, що представляє собою перший аргумент програми, тобто ім'я файлу, що виконується.
Працюючи з масивами буває зручно звертатися до елемента з певним індексом. Відповідний синтаксис:
Читається так: «Порахуй rcx*8 + rsp + 16, і поміняй місцями 8 байт (розмір регістру) за адресою, що вийшла, і значення регістра rax». Іншими словами, rsp і 16 так само грають роль зміщення, rcx грає роль індексу в масиві, а 8 - це розмір елемента масиву. При використанні цього синтаксису допустимими розмірами елемента є лише 1, 2, 4 і 8. Якщо потрібен інший розмір, можна використовувати інструкції множення, бінарного зсуву та інші, які ми розглянемо далі.
Нарешті, наступний код також валідний:
.data
msg :
. ascii "Hello, world!\n"
. text
. globl _start
_start :
# Обнулення rcx
xor%rcx,%rcx
mov msg (% rcx, 8), % al
mov msg, % ah
У сенсі, що можна не вказувати регістр зі усуненням або взагалі будь-які регістри. В результаті виконання цього коду регістри al і ah буде записаний ASCII-код літери H, або 0 x 48.
У цьому контексті хотілося б згадати ще одну корисну інструкцію асемблера:
Інструкція lea дуже зручна, тому що дозволяє відразу виконати множення та кілька додавань.
Fun fact! На x64 в байткоді інструкцій ніколи не використовуються 64-бітові зсуви. На відміну від x86, інструкції часто оперують не абсолютними адресами, а адресами щодо адреси самої інструкції, що дозволяє звертатися до найближчих +/- 2 Гб оперативної пам'яті. Відповідний синтаксис:
Порівняємо довжини опкодів «звичайного» та «відносного» mov (objdump -d):
4000b0: 8a 0c 25 e8 00 60 00 mov 0x6000e8,%cl
4000b7: 8a 05 2b 00 20 00 mov 0x20002b(%rip),%al # 0x6000e8
Як бачите, «відносний» mov ще й на один байт коротший! Що це за регістр такий rip ми дізнаємось трохи нижче.
Для запису повного 64-х бітового значення в регістр передбачена спеціальна інструкція:
Іншими словами, процесори x64 так само економно кодують інструкції, як і процесори x86, і в наш час немає особливо сенсу використовувати процесори x86 в системах, що мають пару гігабайт оперативної пам'яті або менше (мобільні пристрої, холодильники, мікрохвильові печі, і так далі). Швидше за все, процесори x64 будуть навіть більш ефективні за рахунок більшої кількості доступних регістрів і більшого розміру цих регістрів.
Арифметичні операції
Розглянемо основні арифметичні операції:
# Інціалізуємо значення регістрів
mov $ 123, % rax
mov $ 456, % rcx
# инкремент: rax = rax + 1 = 124
inc % rax
# Декремент: rax = rax - 1 = 123
dec% rax
# додавання: rax = rax + rcx = 579
add % rcx , % rax
# віднімання: rax = rax - rcx = 123
sub % rcx , % rax
# Зміна знака: rcx = - rcx = -456
neg%rcx
Тут і далі операндами можуть бути не тільки регістри, а й ділянки пам'яті чи константи. Але обидва операнди не можуть бути ділянками пам'яті. Це правило стосується всіх інструкцій асемблера x86/x64, принаймні, з розглянутих у цій статті.
У цьому прикладі інструкція mul множить al на cl і зберігає результат множення в пару регістрів al і ah. Таким чином, ax прийме значення 0 x 12C або 300 у десятковій нотації. У найгіршому разі збереження результату перемноження двох N-байтових значень може знадобитися до 2*N байт. Залежно від розміру операнда, результат зберігається в al:ah, ax:dx, eax:edx або rax:rdx.При цьому як множники завжди використовується перший з цих регістрів і передано інструкції аргумент.
Знакове множення виробляється так само за допомогою інструкції imul. Крім того, існують варіанти imul з двома та трьома аргументами:
mov $ 123, % rax
mov $ 456, % rcx
# rax = rax * rcx = 56088
imul % rcx , % rax
# rcx = rax * 10 = 560880
imul $ 10, % rax, % rcx
Інструкції div та idiv роблять дії, зворотні mul та imul. Наприклад:
mov $ 0% rdx
mov $ 456 , % rax
mov $ 123, % rcx
# rax = rdx: rax / rcx = 3
# rdx = rdx: rax % rcx = 87
div%rcx
Як бачите, був отриманий результат цілого чисельного поділу, а також залишок від поділу.
Це далеко не всі арифметичні вказівки. Наприклад, є ще adc (додавання з урахуванням прапора переносу), sbb (віднімання з урахуванням позики), а також відповідні їм інструкції, що виставляють та очищають відповідні прапори (ctc, clc), та багато інших. Але вони поширені набагато менше, і тому в рамках цієї статті не розглядаються.
Логічні та бітові операції
Як зазначалося, особливої типізації в асемблері x86/x64 не передбачено. Тому не варто дивуватися, що в ньому немає окремих інструкцій для виконання булевих операцій та окремих для виконання бітових операцій. Натомість є один набір інструкцій, що працюють з бітами, а як інтерпретувати результат — вирішує конкретна програма.
Так, наприклад, виглядає обчислення найпростішого логічного виразу:
mov $ 0 , % rax # a = false
mov $ 1, % rbx # b = true
mov $ 0 % rcx # c = false
# rdx: = a | !(b && c)
mov % rcx , % rdx # rdx = c
and % rbx , % rdx # rdx &= b
not % rdx # rdx = ~ rdx
or % rax , % rdx # rdx | = a
and $ 1, % rdx # rdx &= 1
Зверніть увагу, що тут ми використовували по одному молодшому біту в кожному з 64-х бітових регістрів.Таким чином, у старших бітах утворюється сміття, яке ми обнулюємо останньою командою.
Ще одна корисна інструкція - це xor (що виключає або). У логічних виразах xor використовується нечасто, але з його допомогою часто відбувається обнулення регістрів. Якщо подивитися на опкоди інструкцій, стає зрозуміло, чому:
4000b3: 48 31 db xor %rbx,%rbx4000b6: 48 ff c3 inc %rbx
4000b9: 48 c7 c3 01 00 00 00 mov $0x1,%rbx
Як бачите, інструкції xor та inc кодуються всього лише трьома байтами кожна, в той час, як та сама інструкція mov займає цілих сім байт. Кожен окремий випадок, звичайно, краще бенчмаркати окремо, але загальне евристичне правило таке - чим коротший код, тим більше його міститься в кеші процесора, тим швидше він працює.
У цьому контексті також слід згадати інструкції побітового зсуву, тестування бітів (bit test) та сканування бітів (bit scan):
# покладемо щось у регістр
movabs $0xc0de1c0ffee2beef,% rax
# Зрушення вліво на 3 біти
# rax = 0x0de1c0ffee2beef0
shl $ 4 , % rax
# Зрушення вправо на 7 біт
# rax = 0x001bc381ffdc57dd
shr $ 7 , % rax
циклічний зсув вправо на 5 біт
# rax = 0xe800de1c0ffee2be
ror $ 5 , % rax
циклічний зсув вліво на 5 біт
# rax = 0x001bc381ffdc57dd
rol $ 5 , % rax
# покласти в CF (див. далі) значення 13-го біта
# CF = !! (0x1bc381ffdc57dd & (1 bt $ 13, % rax
# те саме + встановити біт (bit test and set)
# rax = 0x001bc381ffdc77dd, CF = 0
bts $ 13 , % rax
# те саме + скинути біт (bit test and reset)
# rax = 0x001bc381ffdc57dd, CF = 1
btr $ 13 , % rax
# те саме + інвертувати біт (bit test and complement)
# rax = 0x001bc381ffdc77dd, CF = 0
btc $ 13, % rax
знайти наймолодший ненульовий байт (bit scan forward)
# rcx = 0, ZF = 0
bsf % rax , % rcx
знайти найстарший ненульовий байт (bit scan reverse)
# rdx = 52, ZF = 0
bsr % rax , % rdx
якщо всі біти нульові, ZF = 1, значення rdx невизначено
xor % rax , % rax
bsf % rax , % rdx
Ще є бітові зрушення зі знаком (sal, sar), циклічні зрушення з прапором перенесення (rcl, rcr), і навіть зрушення подвійний точності (shld, shrd). Але використовуються вони не так часто, та й втомишся перераховувати взагалі всі інструкції. Тому їх вивчення я залишаю вам як домашнє завдання.
Умовні висловлювання та цикли
Вище кілька разів згадувалися якісь там прапори, наприклад, прапор перенесення. Під прапорами розуміються біти спеціального регістру eflags/rflags (назва на x86 і x64 відповідно). Безпосередньо звертатися до цього регістру за допомогою інструкцій mov, add і подібних не можна, але він змінюється і використовується різними інструкціями побічно. Наприклад, вже згаданий прапор переносу (carry flag, CF) зберігається в нульовому біті eflags/rflags і використовується, наприклад, у тій же інструкції bt. Ще з прапорів, що часто використовуються, можна назвати zero flag (ZF, 6-ий біт), sign flag (SF, 7-ий біт), direction flag (DF, 10-ий біт) і overflow flag (OF, 11-ий біт) .
Ще з таких неявних регістрів слід назвати eip/rip, що зберігає адресу поточної інструкції. До нього також не можна звертатися безпосередньо, але він видно в GDB разом з eflags/rflags, якщо сказати info registers, і опосередковано змінюється усіма інструкції. Більшість інструкцій просто збільшують eip/rip на довжину цієї інструкції, але є винятки з цього правила. Наприклад, інструкція jmp просто здійснює перехід за заданою адресою:
# обнулюємо rax
xor % rax , % rax
jmp next
ця інструкція буде пропущена
inc % rax
next :
inc % rax
В результаті значення rax дорівнюватиме одиниці, так як перша інструкція inс буде пропущена. Зауважте, що адреса переходу також може бути записана в регістрі:
Втім, на практиці такого коду краще уникати, оскільки він ламає прогноз переходів і тому менш ефективний.
Примітка: GAS дозволяє давати міткам цифрні імена типу 1: , 2: , і так далі, і переходити до найближчої попередньої або наступної мітки із заданим номером інструкціями на зразок jmp 1b і jmp 1f . Це досить зручно, тому що іноді буває важко придумати мітки осмислені імена. Подробиці можна знайти тут.
Умовні переходи зазвичай здійснюються за допомогою інструкції cmp, яка порівнює два своїх операнда та виставляє відповідні прапори, за якою слідує інструкція із сімейства je, jg та подібних:
je 1f # перейти, якщо рівні (equal)
jl 1f # перейти, якщо знаково менше (less)
jb 1f # перейти, якщо беззнаково менше (below)
jg 1f # перейти, якщо знаково більше (greater)
ja 1f # перейти, якщо беззнаково більше (above)
Існує також інструкції jne (перейти, якщо не рівні), jle (перейти, якщо знаково менше або рівні), jna (перейти, якщо беззнаково не більше) та подібні. Принцип їхнього іменування, сподіваюся, очевидний. Замість je/jne часто пишуть jz/jnz, тому що інструкції je/jne просто перевіряють значення ZF. Також є інструкції, які перевіряють інші прапори - js, jo та jp, але на практиці вони використовуються рідко. Всі ці інструкції разом узяті зазвичай називають jcc. Тобто замість конкретних умов пишуться дві літери "c", від "condition". Тут можна знайти хорошу зведену таблицю за всіма інструкціями jcc та тому, які прапори вони перевіряють.
Крім cmp часто використовують інструкцію test:
test % rax , % rax
jz 1f # перейти, якщо rax == 0
js 2f # перейти, якщо rax < 0
1 :
# якийсь код
2 :
# якийсь ще код
Fun fact! Цікаво, що cmp і test у душі є тими самими sub і and, тільки змінюють своїх операндов.Це знання можна використовувати для одночасного виконання sub або and умовного переходу, без додаткових інструкцій cmp або test.
Ще з інструкцій, пов'язаних з умовними переходами, можна назвати такі.
Інструкція jrcxz здійснює перехід тільки у тому випадку, якщо значення регістра rcx дорівнює нулю.
Інструкції сімейства cmovcc (conditional move) працюють як mov, але при виконанні заданої умови, за аналогією з jcc.
Інструкції setcc надають однобайтовому регістру або байту в пам'яті значення 1, якщо задана умова виконується, та 0 інакше.
Порівняти rax із заданим шматком пам'яті. Якщо рівні, виставити ZF і зберегти за вказаною адресою значення вказаного регістра, у цьому прикладі rcx. Інакше очистити ZF і завантажити значення з пам'яті в rax. Також обидва операнди можуть бути регістрами.
Інструкція cmpxchg8b головним чином потрібна x86. Вона працює аналогічно cmpxchg, тільки виробляє compare and swap відразу 8 байт. Регістри edx:eax використовуються для порівняння, а регістри ecx:ebx зберігають те, що хочемо записати. Інструкція cmpxchg16b за тим же принципом виробляє compare and swap відразу 16 байт на x64.
Важливо! Зверніть увагу, що без префікса lock всі ці compare and swap інструкції не атомарні.
Інструкція loop зменшує значення регістра rcx на одиницю, і якщо після цього rcx != 0 здійснює перехід на задану мітку. Інструкції loopz і loopnz працюють аналогічно, тільки більш складні умови (rcx != 0) && (ZF == 1) і (rcx != 0) && (ZF == 0) відповідно.
Не потрібно бути семи п'ядей у лобі, щоб зобразити за допомогою цих інструкцій конструкцію if-then-else або цикли for/while, тому рухаємось далі.
«Рядкові» операції
Розглянемо наступний шматок коду:
У регістри rsi та rdi кладуться адреси двох рядків. Командою cld очищається прапор напряму (DF). Інструкція, яка виконує зворотну дію, називається std.Потім у справу вступає інструкція cmpsb. Вона порівнює байти (%rsi) та (%rdi) і виставляє прапори відповідно до результату порівняння. Потім, якщо DF = 0, rsi та rdi збільшуються на одиницю (кількість байт у тому, що ми порівнювали), інакше зменшуються. Аналогічні інструкції cmpsw, cmpsl та cmpsq порівнюють слова, довгі слова та четверні слова відповідно.
Інструкції cmps цікаві тим, що можуть використовуватися з префіксом rep, repe (repz) та repne (repnz). Наприклад:
Префікс rep повторює інструкцію задану в регістрі rcx кількість разів. Префікси repz і repnz роблять те саме, але тільки після кожного виконання інструкції додатково перевіряється ZF. Цикл переривається, якщо ZF = 0 у разі repz і якщо ZF = 1 у випадку repnz. Таким чином, наведений вище код перевіряє рівність двох буферів однакового розміру.
Аналогічні інструкції movs перекладає дані з буфера, адреса якого вказана в rsi, в буфер, адреса якого вказана в rdi (легко запам'ятати - rsi означає source, rdi означає destination). Інструкції stos заповнює буфер за адресою з регістра rdi байтами з регістра rax (або eax, або ax, або al, залежно від конкретної інструкції). Інструкції lods роблять зворотну дію - копіюють байти за вказаною в rsi адресою в регістр rax. Нарешті, інструкції scas шукають байти з регістра rax (або відповідних регістрів меншого розміру) у буфері, адреса якого вказана у rdi. Як і cmps, всі ці інструкції працюють із префіксами rep, repz та repnz.
На базі цих інструкцій легко реалізуються процедури memcmp, memcpy, strcmp та подібні. Цікаво, що, наприклад, для обнулення пам'яті інженери Intel рекомендують використовувати на сучасних процесорах rep stosb, тобто обнуляти побайтово, а не, скажімо, четверними словами.
Робота зі стеком та процедури
Зі стеком все дуже просто.Інструкція push кладе свій аргумент на стек, а інструкція pop витягує значення зі стека.
Існують інструкції, що поміщають на стек і витягують з нього регістр rflags / eflags:
pushf# робимо щось, що змінює прапори
popf
# прапори відновлені, саме час зробити jcc
А так, наприклад, можна отримати значення прапора CF:
На x86 також існують інструкції pusha і popa, що зберігають на стеку і відновлюють з нього значення всіх регістрів. У x64 цих інструкцій більше немає.
Процедури, як правило, «створюються» за допомогою інструкцій call і ret.
someproc :
типовий пролог процедури
# Для прикладу виділяємо 0x10 байт на стеку під локальні змінні
# rbp - покажчик на фрейм стека
push % rbp
mov %rsp, %rbp
sub $ 0x10 , % rsp
# Тут типу якісь обчислення.
mov $ 1 , % rax
типовий епілог процедури
add $ 0x10 , % rsp
pop % rbp
вихід з процедури
ret
_start:
# як і у випадку з jmp, адреса переходу може бути в регістрі
call someproc
test % rax , % rax
jnz error
Примітка: Аналогічний пролог і епілог можна написати за допомогою інструкцій $0x10, $0 і leave. Але в наш час ці інструкції використовуються рідко, оскільки вони виконуються повільніше через додаткову підтримку вкладених процедур.
Як правило, значення, що повертається передається в регістрі rax або, якщо його розміру недостатньо, записується в структуру, адреса якої передається як аргумент.До питання передачі аргументів. Угод про виклики існує безліч. В одних всі аргументи завжди передаються через стек (окреме питання — в якому порядку) і за очищення стека від аргументів відповідає сама процедура, в інших частина аргументів передається через регістри, а частина через стек, і за очищення стека від аргументів відповідає сторона, що викликає, плюс безліч варіантів посередині, з окремими правилами щодо вирівнювання аргументів на стеку, передачі цього, якщо це ООП мову, і так далі. У загальному випадку для довільно взятої архітектури, компілятора та мови програмування угода про виклики може бути взагалі будь-якою.
Наприклад розглянемо асемблерний код, згенерований CLang 3.8 для простої програми мовою C під x64. Так виглядає одна з процедур:
unsigned int
hash ( const unsigned char * data , const size_t data_len ) {
unsigned int hash = 0x4841434B;
for ( int i = 0 ; i < data_len ; i ++ ) {
hash = (( hash }
return hash;
}
Дизассемблерний лістинг (при компіляції з -O0, коментарі мої):
типовий пролог процедури
# Регістр rsp не змінюється, так як процедура не викликає жодних
# інших процедур
400950: 55% push %rbp
400951: 48 89 e5 mov %rsp,%rbp
# ініціалізація локальних змінних:
# -0x08(%rbp) - const unsigned char *data (8 байт)
# -0x10(%rbp) - const size_t data_len (8 байт)
# -0x14(%rbp) - unsigned int hash (4 байти)
# -0x18(%rbp) - int i (4 байти)
400954: 48 89 7d f8 mov %rdi,-0x8(%rbp)
400958: 48 89 75 f0 mov %rsi,-0x10(%rbp)
40095c: c7 45 ec 4b 43 41 48 movl $0x4841434b,-0x14(%rbp)
400963: c7 45 e8 00 00 00 00 movl $0x0,-0x18(%rbp)
# rax := i. якщо досягли data_len, виходимо із циклу
40096a: 48 63 45 e8 movslq -0x18(%rbp),%rax
40096e: 48 3b 45 f0 cmp -0x10(%rbp),%rax
400972: 0f 83 28 00 00 00 jae 4009a0
# eax := (hash 400978: 8b 45 ec mov -0x14(%rbp),%eax
40097b: c1 e0 05 shl $0x5,%eax
40097e: 03 45 ec add -0x14(%rbp),%eax
# eax += data[i]
400981: 48 63 4d e8 movslq -0x18(%rbp),%rcx
400985: 48 8b 55 f8 mov -0x8(%rbp),%rdx
400989: 0f b6 34 0a movzbl (%rdx,%rcx,1),%esi
40098d: 01 f0 add %esi,%eax
# hash := eax
40098f: 89 45 ec mov %eax,-0x14(%rbp)
# i++ і перейти до початку циклу
400992: 8b 45 e8 mov -0x18(%rbp),%eax
400995: 83 c0 01 add $0x1,%eax
400998: 89 45 e8 mov %eax,-0x18(%rbp)
40099b: e9 ca ff ff ff jmpq 40096a
# Повертається значення (hash) кладеться в регістр eax
4009a0: 8b 45 ec mov -0x14(%rbp),%eax
типовий епілог
4009a3: 5d pop %rbp
4009a4: c3 retq
Тут ми зустріли дві нові інструкції – movs та movz. Вони працюють так само, як mov, тільки розширюють один операнд до розміру другого, знаково і беззнаково відповідно. Наприклад, інструкція movzbl (%rdx,%rcx,1),%esi читайт байт (b) за адресою (%rdx,%rcx,1) , розширює його в довге слово (l) шляхом додавання на початок нулів (z) і кладе результат у регістр esi.
Як бачите, два аргументи були передані процедурі через регістри rdi та rsi. Очевидно, використовується конвенція під назвою System V AMD64 ABI. Стверджується, що це стандарт де-факто під x64 на *nix системах. Я не бачу сенсу переказувати опис цієї конвенції тут, зацікавлені читачі можуть ознайомитися з повним описом наведеного посилання.
Висновок
Само собою зрозуміло, в рамках однієї статті, описати весь асемблер x86/x64 неможливо (більше того, я не впевнений, що сам знаю його прямо таки весь). Як мінімум, за кадром залишилися такі теми, як операції над числами з плаваючою точкою, MMX-, SSE- та AVX-інструкції, а також будь-які екзотичні інструкції на кшталт lidt, lgdt, bswap, rdtsc, cpuid, movbe, xlatb, або prefetch. Я намагатимусь висвітлити їх у наступних статтях, але нічого не обіцяю.Слід зазначити, що у висновку objdump -d для більшості реальних програм ви дуже рідко побачите щось крім описаного вище.
Ще цікавий топік, що залишився за кадром, — це атомарні операції, бар'єри пам'яті, спинлоки і ось це все. Наприклад, compare and swap часто реалізується просто як інструкція cmpxchg із префіксом lock. За аналогією реалізується атомарний інкремент, декремент та інше. На жаль все це тягне на тему для окремої статті.
Як джерела додаткової інформації можна рекомендувати книгу Modern X86 Assembly Language Programming, і, звичайно ж, мануали від Intel. Також досить непогана книга Assembly x86 на wikibooks.org.
З онлайн-довідників з асемблерних інструкцій варто звернути увагу на такі:
Додаток: На продовження теми вас може зацікавити пост Учбовий мікропроцесорний комплект УМК-80. У ньому розповідається про асемблері 8080, 8-бітному предку асемблера x86/x64.
Ви можете надіслати свій коментар мені на пошту, або скористатися коментарями в Telegram-групі.
Записки програміста
Шпаргалка за основними інструкціями асемблера x86/x64
Минулої статті ми написали наш перший hello world додаток на асмі, навчилися його компілювати та налагоджувати, а також дізналися, як робити системні виклики в Linux. Сьогодні ми познайомимося безпосередньо з асемблерними інструкціями, поняттям регістрів, стека і ось цього всього. Ассемблери для архітектур x86 (a.k.a i386) і x64 (a.k.a amd64) дуже схожі, тому немає сенсу розглядати в окремих статтях. Притому акцент я постараюся робити на x64, попутно відзначаючи відмінності від x86, якщо вони є. Далі передбачається, що ви вже знаєте, наприклад, чим стек відрізняється від купи, і пояснювати такі речі не потрібно.
Реєстри загального призначення
Регістр - це невеликий (зазвичай 4 або 8 байт) шматочок пам'яті в процесорі з надзвичайно великою швидкістю доступу. Регістри діляться на регістри спеціального призначення та регістри загального призначення. під свої потреби, як їй заманеться.
На x86 доступно вісім 32-бітних регістрів загального призначення - eax, ebx, ecx, edx, esp, ebp, esi і edi. Бульова значення, ASCII-коди символів, і так далі. теоретично ці регістри можна використовувати як завгодно, на практиці зазвичай кожен регістр використовується певним чином. , cx, dx, sp, bp, si та di, що являють собою 16 молодших біт відповідних 32-х бітних регістрів.
Розглянемо приклад. Допустимо, виконуються такі три інструкції:
(gdb) x/3i $pc
=> 0x8048074: mov $0xaabbccdd,%eax
0x8048079: mov $0xee,%al
0x804807b: mov $0x1234, %ax
Значення регістрів після запису в eax значення 0 x AABBCCDD:
(gdb) p/x $eax
$1 = 0xaabbccdd
(gdb) p/x $ax
$2 = 0xccdd
(gdb) p/x $ah
$3 = 0xcc
(gdb) p/x $al
$4 = 0xdd
Значення після запису в регістр значення 0 x EE:
(gdb) p/x $eax
$5 = 0xaabbccee
(gdb) p/x $ax
$6 = 0xccee
(gdb) p/x $ah
$7 = 0xcc
(gdb) p/x $al
$8 = 0xee
Значення регістрів після запису в ax числа 0 x 1234:
(gdb) p/x $eax
$9 = 0xaabb1234
(gdb) p/x $ax
$10 = 0x1234
(gdb) p/x $ah
$11 = 0x12
(gdb) p/x $al
$12 = 0x34
Як бачите, нічого складного.
Примітка: Синтаксис GAS дозволяє явно вказувати розміри операндів шляхом використання суфіксів b (байт), w (слово, 2 байти), l (довге слово, 4 байти), q (четверка, 8 байт) та деяких інших. Наприклад, замість команди mov $0xEE, % al можна написати movb $0xEE, % al, замість mov $0x1234, % ax — movw $0x1234, % ax, і так далі. У сучасному GAS ці суфікси опціональні і я особисто їх не використовую. Але не лякайтеся, якщо побачите їх у чужому коді.
На x64 розмір регістрів було збільшено до 64-х біт. Відповідні регістри отримали назву rax, rbx тощо. Крім того, регістрів загального призначення стало шістнадцять замість восьми. Додаткові регістри отримали назви R8, R9, …, R15. Відповідні їм регістри, які репрезентують молодші 32, 16 і 8 біт, отримали назву r8d, r8w, r8b, і за аналогією для регістрів r9-r15. Крім того, з'явилися регістри, що є молодшими 8 біт регістрів rsi, rdi, rbp і rsp - sil, dil, bpl і spl відповідно.
Про адресацію
Як зазначалося, регістри можуть трактуватися, як покажчики дані у пам'яті. Для розіменування таких покажчиків використовується спеціальний синтаксис:
Цей запис означає «прочитай 8 байт за адресою, записаною в регістрі rsp, і збережи їх у регістр rax». При запуску програми rsp вказує на вершину стека, де зберігається кількість аргументів, переданих програмі (argc), покажчики на ці аргументи, а також змінні оточення та інша інформація. Таким чином, в результаті виконання наведеної вище інструкції (зрозуміло, за умови, що перед нею не виконувалось жодних інших інструкцій) в rax буде записано кількість аргументів, з якими була запущена програма.
В одній команді можна вказувати адресу та змішання (як позитивне, так і негативне) щодо нього:
Цей запис означає «візьми rsp, додай до нього 8, прочитай 8 байт за адресою, що вийшла, і поклади їх в rax». Таким чином, в rax буде записана адреса рядка, що представляє собою перший аргумент програми, тобто ім'я файлу, що виконується.
Працюючи з масивами буває зручно звертатися до елементу з певним індексом. Відповідний синтаксис:
Читається так: «Порахуй rcx*8 + rsp + 16, і поміняй місцями 8 байт (розмір регістру) за адресою, що вийшла, і значення регістра rax». Іншими словами, rsp і 16 так само грають роль зміщення, rcx грає роль індексу в масиві, а 8 - це розмір елемента масиву. При використанні цього синтаксису допустимими розмірами елемента є лише 1, 2, 4 і 8. Якщо потрібен інший розмір, можна використовувати інструкції множення, бінарного зсуву та інші, які ми розглянемо далі.
Нарешті, наступний код також валідний:
.data
msg :
. ascii "Hello, world!\n"
. text
. globl _start
_start :
# Обнулення rcx
xor%rcx,%rcx
mov msg (% rcx, 8), % al
mov msg, % ah
У сенсі, що можна не вказувати регістр зі усуненням або взагалі будь-які регістри. В результаті виконання цього коду регістри al і ah буде записаний ASCII-код літери H, або 0 x 48.
У цьому контексті хотілося б згадати ще одну корисну інструкцію асемблера:
Інструкція lea дуже зручна, тому що дозволяє відразу виконати множення та кілька додавань.
Fun fact! На x64 в байткоді інструкцій ніколи не використовуються 64-бітові зсуви. На відміну від x86, інструкції часто оперують не абсолютними адресами, а адресами щодо адреси самої інструкції, що дозволяє звертатися до найближчих +/- 2 Гб оперативної пам'яті. Відповідний синтаксис:
Порівняємо довжини опкодів «звичайного» та «відносного» mov (objdump -d):
4000b0: 8a 0c 25 e8 00 60 00 mov 0x6000e8,%cl
4000b7: 8a 05 2b 00 20 00 mov 0x20002b(%rip),%al # 0x6000e8
Як бачите, «відносний» mov ще й на один байт коротший! Що це за регістр такий rip ми дізнаємось трохи нижче.
Для запису повного 64-х бітового значення в регістр передбачена спеціальна інструкція:
Іншими словами, процесори x64 так само економно кодують інструкції, як і процесори x86, і в наш час немає особливо сенсу використовувати процесори x86 в системах, що мають пару гігабайт оперативної пам'яті або менше (мобільні пристрої, холодильники, мікрохвильові печі, і так далі). Швидше за все, процесори x64 будуть навіть більш ефективні за рахунок більшої кількості доступних регістрів і більшого розміру цих регістрів.
Арифметичні операції
Розглянемо основні арифметичні операції:
# Інціалізуємо значення регістрів
mov $ 123, % rax
mov $ 456, % rcx
# инкремент: rax = rax + 1 = 124
inc % rax
# Декремент: rax = rax - 1 = 123
dec% rax
# додавання: rax = rax + rcx = 579
add % rcx , % rax
# віднімання: rax = rax - rcx = 123
sub % rcx , % rax
# Зміна знака: rcx = - rcx = -456
neg%rcx
Тут і далі операндами можуть бути не тільки регістри, а й ділянки пам'яті чи константи. Але обидва операнди не можуть бути ділянками пам'яті. Це правило стосується всіх інструкцій асемблера x86/x64, принаймні, з розглянутих у цій статті.
У цьому прикладі інструкція mul множить al на cl і зберігає результат множення в пару регістрів al і ah. Таким чином, ax прийме значення 0 x 12C або 300 у десятковій нотації. У найгіршому разі збереження результату перемноження двох N-байтових значень може знадобитися до 2*N байт. Залежно від розміру операнда, результат зберігається в al:ah, ax:dx, eax:edx або rax:rdx.При цьому як множники завжди використовується перший з цих регістрів і передано інструкції аргумент.
Знакове множення виробляється так само за допомогою інструкції imul. Крім того, існують варіанти imul з двома та трьома аргументами:
mov $ 123, % rax
mov $ 456, % rcx
# rax = rax * rcx = 56088
imul % rcx , % rax
# rcx = rax * 10 = 560880
imul $ 10, % rax, % rcx
Інструкції div та idiv роблять дії, зворотні mul та imul. Наприклад:
mov $ 0% rdx
mov $ 456 , % rax
mov $ 123, % rcx
# rax = rdx: rax / rcx = 3
# rdx = rdx: rax % rcx = 87
div%rcx
Як бачите, був отриманий результат цілого чисельного поділу, а також залишок від поділу.
Це далеко не всі арифметичні вказівки. Наприклад, є ще adc (додавання з урахуванням прапора переносу), sbb (віднімання з урахуванням позики), а також відповідні їм інструкції, що виставляють та очищають відповідні прапори (ctc, clc), та багато інших. Але вони поширені набагато менше, і тому в рамках цієї статті не розглядаються.
Логічні та бітові операції
Як зазначалося, особливої типізації в асемблері x86/x64 не передбачено. Тому не варто дивуватися, що в ньому немає окремих інструкцій для виконання булевих операцій та окремих для виконання бітових операцій. Натомість є один набір інструкцій, що працюють з бітами, а як інтерпретувати результат — вирішує конкретна програма.
Так, наприклад, виглядає обчислення найпростішого логічного виразу:
mov $ 0 , % rax # a = false
mov $ 1, % rbx # b = true
mov $ 0 % rcx # c = false
# rdx: = a | !(b && c)
mov % rcx , % rdx # rdx = c
and % rbx , % rdx # rdx &= b
not % rdx # rdx = ~ rdx
or % rax , % rdx # rdx | = a
and $ 1, % rdx # rdx &= 1
Зверніть увагу, що тут ми використовували по одному молодшому біту в кожному з 64-х бітових регістрів. Таким чином, у старших бітах утворюється сміття, яке ми обнулюємо останньою командою.
Ще одна корисна інструкція - це xor (що виключає або). У логічних виразах xor використовується нечасто, але з його допомогою часто відбувається обнулення регістрів. Якщо подивитися на опкоди інструкцій, стає зрозуміло, чому:
4000b3: 48 31 db xor %rbx,%rbx4000b6: 48 ff c3 inc %rbx
4000b9: 48 c7 c3 01 00 00 00 mov $0x1,%rbx
Як бачите, інструкції xor та inc кодуються всього лише трьома байтами кожна, в той час, як та сама інструкція mov займає цілих сім байт. Кожен окремий випадок, звичайно, краще бенчмаркати окремо, але загальне евристичне правило таке - чим коротший код, тим більше його міститься в кеші процесора, тим швидше він працює.
У цьому контексті також слід згадати інструкції побітового зсуву, тестування бітів (bit test) та сканування бітів (bit scan):
# покладемо щось у регістр
movabs $0xc0de1c0ffee2beef,% rax
# Зрушення вліво на 3 біти
# rax = 0x0de1c0ffee2beef0
shl $ 4 , % rax
# Зрушення вправо на 7 біт
# rax = 0x001bc381ffdc57dd
shr $ 7 , % rax
циклічний зсув вправо на 5 біт
# rax = 0xe800de1c0ffee2be
ror $ 5 , % rax
циклічний зсув вліво на 5 біт
# rax = 0x001bc381ffdc57dd
rol $ 5 , % rax
# покласти в CF (див. далі) значення 13-го біта
# CF = !! (0x1bc381ffdc57dd & (1 bt $ 13, % rax
# те саме + встановити біт (bit test and set)
# rax = 0x001bc381ffdc77dd, CF = 0
bts $ 13 , % rax
# те саме + скинути біт (bit test and reset)
# rax = 0x001bc381ffdc57dd, CF = 1
btr $ 13 , % rax
# те саме + інвертувати біт (bit test and complement)
# rax = 0x001bc381ffdc77dd, CF = 0
btc $ 13, % rax
знайти наймолодший ненульовий байт (bit scan forward)
# rcx = 0, ZF = 0
bsf % rax , % rcx
знайти найстарший ненульовий байт (bit scan reverse)
# rdx = 52, ZF = 0
bsr % rax , % rdx
якщо всі біти нульові, ZF = 1, значення rdx невизначено
xor % rax , % rax
bsf % rax , % rdx
Ще є бітові зрушення зі знаком (sal, sar), циклічні зрушення з прапором перенесення (rcl, rcr), і навіть зрушення подвійний точності (shld, shrd). Але використовуються вони не так часто, та й втомишся перераховувати взагалі всі інструкції. Тому їх вивчення я залишаю вам як домашнє завдання.
Умовні висловлювання та цикли
Вище кілька разів згадувалися якісь там прапори, наприклад, прапор перенесення. Під прапорами розуміються біти спеціального регістру eflags/rflags (назва на x86 і x64 відповідно). Безпосередньо звертатися до цього регістру за допомогою інструкцій mov, add і подібних не можна, але він змінюється і використовується різними інструкціями побічно. Наприклад, вже згаданий прапор переносу (carry flag, CF) зберігається в нульовому біті eflags/rflags і використовується, наприклад, у тій же інструкції bt. Ще з прапорів, що часто використовуються, можна назвати zero flag (ZF, 6-ий біт), sign flag (SF, 7-ий біт), direction flag (DF, 10-ий біт) і overflow flag (OF, 11-ий біт) .
Ще з таких неявних регістрів слід назвати eip/rip, що зберігає адресу поточної інструкції. До нього також не можна звертатися безпосередньо, але він видно в GDB разом з eflags/rflags, якщо сказати info registers, і опосередковано змінюється усіма інструкції. Більшість інструкцій просто збільшують eip/rip на довжину цієї інструкції, але є винятки з цього правила. Наприклад, інструкція jmp просто здійснює перехід за заданою адресою:
# обнулюємо rax
xor % rax , % rax
jmp next
ця інструкція буде пропущена
inc % rax
next :
inc % rax
В результаті значення rax дорівнюватиме одиниці, так як перша інструкція inс буде пропущена. Зауважте, що адреса переходу також може бути записана в регістрі:
Втім, на практиці такого коду краще уникати, оскільки він ламає прогноз переходів і тому менш ефективний.
Примітка: GAS дозволяє давати міткам цифрні імена типу 1: , 2: , і так далі, і переходити до найближчої попередньої або наступної мітки із заданим номером інструкціями на зразок jmp 1b і jmp 1f . Це досить зручно, тому що іноді буває важко придумати мітки осмислені імена. Подробиці можна знайти тут.
Умовні переходи зазвичай здійснюються за допомогою інструкції cmp, яка порівнює два своїх операнда та виставляє відповідні прапори, за якою слідує інструкція із сімейства je, jg та подібних:
je 1f # перейти, якщо рівні (equal)
jl 1f # перейти, якщо знаково менше (less)
jb 1f # перейти, якщо беззнаково менше (below)
jg 1f # перейти, якщо знаково більше (greater)
ja 1f # перейти, якщо беззнаково більше (above)
Існує також інструкції jne (перейти, якщо не рівні), jle (перейти, якщо знаково менше або рівні), jna (перейти, якщо беззнаково не більше) та подібні. Принцип їхнього іменування, сподіваюся, очевидний. Замість je/jne часто пишуть jz/jnz, тому що інструкції je/jne просто перевіряють значення ZF. Також є інструкції, які перевіряють інші прапори - js, jo та jp, але на практиці вони використовуються рідко. Всі ці інструкції разом узяті зазвичай називають jcc. Тобто замість конкретних умов пишуться дві літери "c", від "condition". Тут можна знайти хорошу зведену таблицю за всіма інструкціями jcc та тому, які прапори вони перевіряють.
Крім cmp часто використовують інструкцію test:
test % rax , % rax
jz 1f # перейти, якщо rax == 0
js 2f # перейти, якщо rax < 0
1 :
# якийсь код
2 :
# якийсь ще код
Fun fact! Цікаво, що cmp і test у душі є тими самими sub і and, тільки змінюють своїх операндов. Це знання можна використовувати для одночасного виконання sub або and умовного переходу, без додаткових інструкцій cmp або test.
Ще з інструкцій, пов'язаних з умовними переходами, можна назвати такі.
Інструкція jrcxz здійснює перехід тільки у тому випадку, якщо значення регістра rcx дорівнює нулю.
Інструкції сімейства cmovcc (conditional move) працюють як mov, але при виконанні заданої умови, за аналогією з jcc.
Інструкції setcc надають однобайтовому регістру або байту в пам'яті значення 1, якщо задана умова виконується, та 0 інакше.
Порівняти rax із заданим шматком пам'яті. Якщо рівні, виставити ZF і зберегти за вказаною адресою значення вказаного регістра, у цьому прикладі rcx. Інакше очистити ZF і завантажити значення з пам'яті в rax. Також обидва операнди можуть бути регістрами.
Інструкція cmpxchg8b головним чином потрібна x86. Вона працює аналогічно cmpxchg, тільки виробляє compare and swap відразу 8 байт. Регістри edx:eax використовуються для порівняння, а регістри ecx:ebx зберігають те, що хочемо записати. Інструкція cmpxchg16b за тим же принципом виробляє compare and swap відразу 16 байт на x64.
Важливо! Зверніть увагу, що без префікса lock всі ці compare and swap інструкції не атомарні.
Інструкція loop зменшує значення регістра rcx на одиницю, і якщо після цього rcx != 0 здійснює перехід на задану мітку. Інструкції loopz і loopnz працюють аналогічно, тільки більш складні умови (rcx != 0) && (ZF == 1) і (rcx != 0) && (ZF == 0) відповідно.
Не потрібно бути семи п'ядей у лобі, щоб зобразити за допомогою цих інструкцій конструкцію if-then-else або цикли for/while, тому рухаємось далі.
«Рядкові» операції
Розглянемо наступний шматок коду:
У регістри rsi та rdi кладуться адреси двох рядків. Командою cld очищається прапор напряму (DF). Інструкція, яка виконує зворотну дію, називається std. Потім у справу вступає інструкція cmpsb. Вона порівнює байти (%rsi) та (%rdi) і виставляє прапори відповідно до результату порівняння.Потім, якщо DF = 0, rsi та rdi збільшуються на одиницю (кількість байт у тому, що ми порівнювали), інакше зменшуються. Аналогічні інструкції cmpsw, cmpsl та cmpsq порівнюють слова, довгі слова та четверні слова відповідно.
Інструкції cmps цікаві тим, що можуть використовуватися з префіксом rep, repe (repz) та repne (repnz). Наприклад:
Префікс rep повторює інструкцію задану в регістрі rcx кількість разів. Префікси repz і repnz роблять те саме, але тільки після кожного виконання інструкції додатково перевіряється ZF. Цикл переривається, якщо ZF = 0 у разі repz і якщо ZF = 1 у випадку repnz. Таким чином, наведений вище код перевіряє рівність двох буферів однакового розміру.
Аналогічні інструкції movs перекладає дані з буфера, адреса якого вказана в rsi, в буфер, адреса якого вказана в rdi (легко запам'ятати - rsi означає source, rdi означає destination). Інструкції stos заповнює буфер за адресою з регістра rdi байтами з регістра rax (або eax, або ax, або al, залежно від конкретної інструкції). Інструкції lods роблять зворотну дію - копіюють байти за вказаною в rsi адресою в регістр rax. Нарешті, інструкції scas шукають байти з регістра rax (або відповідних регістрів меншого розміру) у буфері, адреса якого вказана у rdi. Як і cmps, всі ці інструкції працюють із префіксами rep, repz та repnz.
На базі цих інструкцій легко реалізуються процедури memcmp, memcpy, strcmp та подібні. Цікаво, що, наприклад, для обнулення пам'яті інженери Intel рекомендують використовувати на сучасних процесорах rep stosb, тобто обнуляти побайтово, а не, скажімо, четверними словами.
Робота зі стеком та процедури
Зі стеком все дуже просто. Інструкція push кладе свій аргумент на стек, а інструкція pop отримує значення зі стека. Наприклад, якщо тимчасово забути про інструкцію xchg, то поміняти місцями значення двох регістрів можна так:
Існують інструкції, що поміщають на стек і витягують з нього регістр rflags / eflags:
pushf# робимо щось, що змінює прапори
popf
# прапори відновлені, саме час зробити jcc
А так, наприклад, можна отримати значення прапора CF:
На x86 також існують інструкції pusha і popa, що зберігають на стеку і відновлюють значення всіх регістрів. У x64 цих інструкцій немає. Мабуть, тому що регістрів побільшало і самі регістри тепер довші — зберігати і відновлювати їх все стало дорожче.
Процедури, зазвичай, «створюються» з допомогою інструкцій call і ret. Інструкція call кладе на стек адресу наступної інструкції та передає управління за вказаною в аргументі адресою. Інструкція ret читає зі стека адресу повернення і передає по ньому керування. Наприклад:
someproc :
типовий пролог процедури
# Для прикладу виділяємо 0x10 байт на стеку під локальні змінні
# rbp - покажчик на фрейм стека
push % rbp
mov %rsp, %rbp
sub $ 0x10 , % rsp
# Тут типу якісь обчислення.
mov $ 1 , % rax
типовий епілог процедури
add $ 0x10 , % rsp
pop % rbp
вихід з процедури
ret
_start :
# як і у випадку з jmp, адреса переходу може бути в регістрі
call someproc
test % rax , % rax
jnz error
Примітка: Аналогічний пролог та епілог можна написати за допомогою інструкцій enter $0x10, $0 та leave. Але нині ці інструкції використовуються рідко, оскільки вони виконуються повільніше через додаткову підтримку вкладених процедур.
Як правило, значення, що повертається передається в регістрі rax або, якщо його розміру недостатньо, записується в структуру, адреса якої передається як аргумент. До питання передачі аргументів. Угод про виклики існує безліч.В одних всі аргументи завжди передаються через стек (окреме питання — в якому порядку) і за очищення стека від аргументів відповідає сама процедура, в інших частина аргументів передається через регістри, а частина через стек, і за очищення стека від аргументів відповідає сторона, що викликає, плюс безліч варіантів посередині, з окремими правилами щодо вирівнювання аргументів на стеку, передачі this, якщо це ООП мову, і так далі. У загальному випадку для довільно взятої архітектури, компілятора та мови програмування угода про виклики може бути взагалі будь-якою.
Наприклад розглянемо асемблерний код, згенерований CLang 3.8 для простої програми мовою C під x64. Так виглядає одна з процедур:
unsigned int
hash ( const unsigned char * data , const size_t data_len ) {
unsigned int hash = 0x4841434B;
for ( int i = 0 ; i < data_len ; i ++ ) {
hash = (( hash }
return hash;
}
Дизассемблерний лістинг (при компіляції з -O0, коментарі мої):
типовий пролог процедури
# Регістр rsp не змінюється, так як процедура не викликає жодних
# інших процедур
400950: 55% push %rbp
400951: 48 89 e5 mov %rsp,%rbp
# ініціалізація локальних змінних:
# -0x08(%rbp) - const unsigned char *data (8 байт)
# -0x10(%rbp) - const size_t data_len (8 байт)
# -0x14(%rbp) - unsigned int hash (4 байти)
# -0x18(%rbp) - int i (4 байти)
400954: 48 89 7d f8 mov %rdi,-0x8(%rbp)
400958: 48 89 75 f0 mov %rsi,-0x10(%rbp)
40095c: c7 45 ec 4b 43 41 48 movl $0x4841434b,-0x14(%rbp)
400963: c7 45 e8 00 00 00 00 movl $0x0,-0x18(%rbp)
# rax := i. якщо досягли data_len, виходимо із циклу
40096a: 48 63 45 e8 movslq -0x18(%rbp),%rax
40096e: 48 3b 45 f0 cmp -0x10(%rbp),%rax
400972: 0f 83 28 00 00 00 jae 4009a0
# eax := (hash 400978: 8b 45 ec mov -0x14(%rbp),%eax
40097b: c1 e0 05 shl $0x5,%eax
40097e: 03 45 ec add -0x14(%rbp),%eax
# eax += data[i]
400981: 48 63 4d e8 movslq -0x18(%rbp),%rcx
400985: 48 8b 55 f8 mov -0x8(%rbp),%rdx
400989: 0f b6 34 0a movzbl (%rdx,%rcx,1),%esi
40098d: 01 f0 add %esi,%eax
# hash := eax
40098f: 89 45 ec mov %eax,-0x14(%rbp)
# i++ і перейти до початку циклу
400992: 8b 45 e8 mov -0x18(%rbp),%eax
400995: 83 c0 01 add $0x1,%eax
400998: 89 45 e8 mov %eax,-0x18(%rbp)
40099b: e9 ca ff ff ff jmpq 40096a
# Повертається значення (hash) кладеться в регістр eax
4009a0: 8b 45 ec mov -0x14(%rbp),%eax
типовий епілог
4009a3: 5d pop %rbp
4009a4: c3 retq
Тут ми зустріли дві нові інструкції – movs та movz. Вони працюють так само, як mov, тільки розширюють один операнд до розміру другого, знаково і беззнаково відповідно. Наприклад, інструкція movzbl (%rdx,%rcx,1),%esi читайт байт (b) за адресою (%rdx,%rcx,1) , розширює його в довге слово (l) шляхом додавання на початок нулів (z) і кладе результат у регістр esi.
Як бачите, два аргументи були передані процедурі через регістри rdi та rsi. Очевидно, використовується конвенція під назвою System V AMD64 ABI. Стверджується, що це стандарт де-факто під x64 на *nix системах. Я не бачу сенсу переказувати опис цієї конвенції тут, зацікавлені читачі можуть ознайомитися з повним описом наведеного посилання.
Висновок
Само собою зрозуміло, в рамках однієї статті, описати весь асемблер x86/x64 неможливо (більше того, я не впевнений, що сам знаю його прямо таки весь). Як мінімум, за кадром залишилися такі теми, як операції над числами з плаваючою точкою, MMX-, SSE- і AVX-інструкції, а також будь-які екзотичні інструкції на кшталт lidt, lgdt, bswap, rdtsc, cpuid, movbe, xlatb, або prefetch. Я намагатимусь висвітлити їх у наступних статтях, але нічого не обіцяю. Слід зазначити, що у висновку objdump -d для більшості реальних програм ви дуже рідко побачите щось крім описаного вище.
Ще цікавий топік, що залишився за кадром, — це атомарні операції, бар'єри пам'яті, спинлоки і ось це все. Наприклад, compare and swap часто реалізується просто як інструкція cmpxchg із префіксом lock. За аналогією реалізується атомарний інкремент, декремент та інше. На жаль все це тягне на тему для окремої статті.
Як джерела додаткової інформації можна рекомендувати книгу Modern X86 Assembly Language Programming, і, звичайно ж, мануали від Intel. Також досить непогана книга Assembly x86 на wikibooks.org.
З онлайн-довідників з асемблерних інструкцій варто звернути увагу на такі:
Додаток: На продовження теми вас може зацікавити пост Учбовий мікропроцесорний комплект УМК-80. У ньому розповідається про асемблері 8080, 8-бітному предку асемблера x86/x64.
Ви можете надіслати свій коментар мені на пошту, або скористатися коментарями в Telegram-групі.
Подібні статті
- Що це таке місячні
- Що це таке трейлер
- Що таке ефект собаки Павлова
- Що таке цисти артемія
- Що таке чорні павуки з білими плямами
- Що таке яйця артемії
- Що таке часте мелірування
- Що таке експлікація будівель