Приклади на асемблері
Приклади на асемблері дозволяють, по-перше, потренуватися в програмуванні цією мовою, а по-друге, глибше вникнути в асемблер і більш повно в ньому розібратися.
Тому я вирішив нарешті створити розділ, де наводитиму приклади щодо простих програм. Я намагатимуся, щоб це були не просто вихідники на асемблері, де розглядається використання будь-якої команди (як це я роблю в розповідях про інструкції процесора Інтел), а щоб це були якісь більш-менш закінчені програми, які повністю або частково можна було б застосувати десь ще - у своїх розробках, курсових/контрольних і т.п.
Тобто в цьому розділі будуть вихідні коди нескладних програм на асемблері. І це, сподіваюся, допоможе вам у вивченні асемблера, а також дозволить якісь частини цих програм використовувати у своїх розробках.
Очевидно, що у вас є початкові знання в цій галузі. Інакше, хоча всі приклади будуть із докладними коментарями, зрозуміти повною мірою все це буде складно.
Для створення програм я використовуватиму різні засоби розробки. Зазвичай це буде текстовий редактор і якийсь асемблер.
Найпростіші програми буду створювати в Emu8086. Програми складніше писатиму в текстовому редакторі PSPad, а компілювати або за допомогою TASM, або за допомогою MASM. Сам процес компіляції (ассемблювання) я описувати не буду. Сподіваюся ви знаєте, як це робиться. Якщо ні, то див. посилання вище.
ВАЖЛИВО!
Не всі програми працюватимуть у Windows NT чи інших операційних системах. Сподіваюся, це також зрозуміло.
ВАЖЛИВО!
Деякі приклади будуть мовами високого рівня, а код на асемблері буде лише у вигляді невеликої вставки.Це буде в тих випадках, коли треба показати простий приклад на асемблері, а захаращувати вихідний текст великими шматками коду не найважливіших функцій виводу-введення не хочеться.
Отже, ось що є на сьогоднішній день:
Записки програміста
Шпаргалка за основними інструкціями асемблера 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 отримує значення зі стека. Наприклад, якщо тимчасово забути про інструкцію 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 або, якщо його розміру недостатньо, записується в структуру, адреса якої передається як аргумент. До питання передачі аргументів. Угод про виклики існує безліч.В одних усі аргументи завжди передаються через стек (окреме питання — в якому порядку) і за очищення стека від аргументів відповідає сама процедура, в інших частина аргументів передається через регістри, а частина через стек, і за очищення стека від аргументів відповідає сторона, що викликає, плюс безліч варіантів посередині, з окремими правилами щодо вирівнювання аргументів на стеку, передачі цього, якщо це ООП мову, і так далі. У загальному випадку для довільно взятої архітектури, компілятора та мови програмування угода про виклики може бути взагалі будь-якою.
Наприклад розглянемо асемблерний код, згенерований 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-групі.
Асемблер для початківців
У будь-якій справі головне почати. Або ще хороша приказка: «Початок — половина справи». Але іноді навіть не знаєш як підступитися до питання, що цікавить. У зв'язку з тим, що спогади мої ще свіжі, поспішаю поділитися своїми міркуваннями з тими, хто цікавиться.
Скажу відразу, що особисто я асемблюю не під PC, а під мікроконтролери.Але це не має великого значення, тому що (на відміну від мікроконтролерів AVR) система команд даних мікроконтролерів з PC вкрай схожа. Та й, власне, асемблер він і в Африці асемблер.
Звичайно, я не ставлю за мету описати в цій статті все необхідне від початку і до кінця. Благо, за асемблером написано вже неймовірне число літератури. І так, мій досвід може відрізнятись від досвіду інших програмістів, але я вважаю не зайвим викласти основну концепцію цього питання в моєму розумінні.
Для початку заспокою допитливих новобранців: асемблер - це зовсім не складно, попри стереотипну думку. Просто він ближче до «землі», тобто до архітектури. Насправді він дуже простий, якщо вхопити основну ідею. На відміну від мов високого рівня та різноманітних спеціалізованих платформ для програмування (під усім перерахованим я розумію будь-яке на кшталт C++, MatLAB та інших подібних штук, де потрібні программерські навички), команд тут раз-два й усе. Спочатку навіть, коли мені потрібно було порахувати подвійний інтеграл, це завдання викликало лише подив: як за допомогою такої убогої кількості операцій можна здійснити подібну процедуру? Адже образно кажучи, на асемблері можна хіба що складати, віднімати та зрушувати числа. Але за допомогою асемблера можна здійснювати як завгодно складні операції, а код виходитиме вкрай легкий. Ось навіть для прикладу, вам потрібно запалити світлодіод, який підключений, наприклад, до нульового контакту порту номер 2, ви просто пишете:
bset P2.0
І, як кажуть, жодних проблем. Потрібно включити відразу чотири штуки, підключених послідовно? Так запросто:
mov P2, #000fh
Так, тут я маю на увазі, що боєць-початківець вже знайомий хоча б із системами числення. Ну хоча б із десятковою. ;)
Отже, задля досягнення успіху у справі асемблювання, слід розумітися на архітектурі (у разі) микроконтроллера. Це вкотре.
До речі, одне з хворих місць у пізнанні архітектури – це організація пам'яті. Тут на Хабрі я бачила відповідну статтю: habrahabr.ru/blogs/programming/128991. Ще можу згадати ключові болючі точки: переривання. Штука не складна, але спочатку (чомусь) важка для сприйняття.
Якщо перед вами стоїть складне завдання і ви навіть не знаєте як спочатку до нього підступитися, найкраще написати алгоритм. Це справді рятує. А спочатку, навіть якщо програма зовсім не складна, краще все ж почати з алгоритму, бо цей процес допомагає розкласти все в голові по місцях. Повертаючись наприклад з обчисленням подвійного інтеграла за експериментальними даними, обмірковувала алгоритм я весь день, зате потім програму по ньому написала всього за 20 хвилин. Плюс алгоритм буде корисним при подальшій модернізації та/або експлуатації програми, а то асемблерний код, часом, якщо і буде зрозумілий рядково, то щоб розібратися в чому ж загальна ідея, доведеться чимало попрацювати.
Отже, другий ключ до успіху – докладно написаний та добре продуманий алгоритм. Настійно рекомендую не сідати відразу за апарат та писати програму. Нічого ділового ви з ходу не напишете. Це два.
Власне, хотілося б як Фандорін написати: Це т-т-три ... Але, боюся, на цьому поки можна зупинитися. Хоча хотілося б додати ще кілька рекомендацій та пряників.
Підсумовуючи моєму дещо сумбурному монологу, ключові моменти в програмуванні на асемблері — це знання архітектури та зв'язне побудова думок.Звичайно, не обов'язково відразу з головою кидатися в студіюванні літератури з описом нутрощів того ж PC, але загальне уявлення (повторюся, хоча б для початку) буде дуже потрібне.
А тепер обіцяні пряники! Ось я тут розпинаюся про якогось незрозумілого асемблера, а що ж у ньому, власне кажучи, гарного? Та багато всього! По-перше, звичайно, не потрібно запам'ятовувати багато команд, що використовуються бібліотек та іншої супутньої дребедені. Усього парочка команд і, вважайте, ви у всеозброєнні. По-друге, через крайню близькість до машинних кодів, ви можете робити практично все, що душі завгодно (на відміну від тих же мов високого рівня)! По-третє, асемблерний код, через максимальну лаконічність у формулюваннях, виконується вкрай швидко.
Загалом суцільні плюси. На цій оптимістичній ноті дозвольте вклонитися.
