Курс Лекція 8
▶ СлайдиСРС 15+16⌨️ команди 5 · 4 нових
Конспект · Лекція 8

АСЕМБЛЕР x86-64

Мова, якою насправді говорить процесор

Ключова ідея. Асемблер (*assembler*) — це не «низький рівень заради складності», а пряме відображення того, що вміє робити процесор: регістри, стек, порівняння і переходи.
Демо. gcc -O0 -S на 5-рядковій C-функції додавання двох чисел; читаємо згенерований .s файл рядок за рядком і зіставляємо кожну інструкцію з тим, що робив би сам процесор.
🧩 СРС ДО ЦІЄЇ ЛЕКЦІЇ · 2-га година заняття

Після презентації теми — самостійна робота в аудиторії над двома темами. Вони ж готують до захисту та розширення ЛР8. Матеріали публікуються в Google Classroom разом із лекцією.

📚 ТЕОРЕТИЧНІ ВІДОМОСТІ

Вступ: навіщо системному програмісту машинна мова

Коли компілятор перетворює вираз a + b на трибуквені мнемоніки mov, add, ret, він не винаходить нову мову — він перекладає намір програміста на єдину мову, яку фізично розуміє кристал кремнію: набір інструкцій x86-64 (розширення 64-бітної адресації набору x86, відоме також як AMD64 чи Intel 64). У цій лекції ми не вчимося писати великі програми асемблером (assembler) — це неефективно й у 2026 році майже ніколи не потрібно, — а вчимося його читати: розбирати вивід gcc -S, objdump -d, розуміти, що саме бачить процесор, коли виконує вашу функцію.

Це фундамент, без якого незрозуміло:

  • чому одні оптимізації компілятора взагалі можливі, а інші — ні;
  • чому переповнення стека (stack overflow, use-after-return) — реальна, а не абстрактна загроза;
  • чому в Rust unsafe-блок здатний зламати гарантії, які Rust дає в безпечному коді (Модуль 2);
  • і чому WebAssembly (Лекція 9) взагалі існує — це теж набір інструкцій, тільки для іншої, віртуальної машини, і порівнювати їх можна лише розуміючи обидва.

Асемблер — не «низький рівень заради складності». Це пряме, майже без втрат, відображення того, що фізично вміє робити процесор: перекласти число з одного місця в інше, додати, порівняти, стрибнути за умовою.

Регістри x86-64: анатомія

Регістр (register) — це комірка пам'яті всередині самого процесора, читання й запис у яку не потребують звернення до шини пам'яті і тому займають один такт (або й менше — сучасні ядра виконують кілька інструкцій за такт). x86-64 має 16 регістрів загального призначення по 64 біти кожен. Молодші розряди того самого фізичного регістра доступні під окремими іменами — спадок від 16-бітного 8086 і 32-бітного i386, збережений заради сумісності:

64-біт 32-біт 16-біт 8-біт Типове призначення за угодою
rax eax ax al акумулятор, значення, що повертає функція
rbx ebx bx bl callee-saved, довготривале зберігання
rcx ecx cx cl лічильник (loop, зсуви), 4-й аргумент
rdx edx dx dl 3-й аргумент, старша половина при діленні
rsi esi si sil 2-й аргумент, джерело в рядкових операціях
rdi edi di dil 1-й аргумент, приймач у рядкових операціях
rbp ebp bp bpl базовий вказівник кадру стека
rsp esp sp spl вказівник вершини стека
r8r15 r8dr15d r8wr15w r8br15b 5-й, 6-й аргументи; тимчасові значення

Окремо стоїть rip (instruction pointer) — адреса наступної інструкції; його не можна записати інструкцією mov, лише непрямо через jmp, call чи ret. Важливо розуміти: запис у 32-бітну частину регістра (eax) обнуляє верхні 32 біти 64-бітного регістра (rax), а от запис у 16- чи 8-бітну частину (ax, al) верхні біти не чіпає — це джерело неочевидних багів, коли в 64-бітному регістрі лишається «сміття» від попереднього обчислення.

Синтаксис: AT&T проти Intel

Одна й та сама інструкція записується по-різному залежно від традиції інструментарію:

AT&T (GNU: gcc -S, objdump) Intel (NASM, документація Intel)
Порядок операндів джерело → приймач: mov %rax, %rbx приймач → джерело: mov rbx, rax
Регістри префікс %: %rax без префіксу: rax
Безпосередні значення префікс $: $5 без префіксу: 5
Розмір операнда суфікс мнемоніки: movl, movq ключове слово при операнді: dword ptr, qword ptr
Адресація пам'яті зсув(%база,%індекс,масштаб) [база + індекс*масштаб + зсув]

Обидва синтаксиси описують той самий фізичний код — це питання нотації, а не семантики. У курсі ми читаємо вивід gcc -S та objdump -d (AT&T за замовчуванням), а пишемо руками — в NASM (Intel). Найпоширеніша помилка новачків — переплутати напрямок операндів при переході між ними; правило: в AT&T читай справа наліво, в Intel — зліва направо, і в обох випадках приймач опиниться на «своєму» боці.

Стек і кадр функції

Стек — це ділянка пам'яті процесу, що росте вниз: кожен push зменшує rsp на розмір значення (зазвичай 8 байтів на x86-64) і записує значення за новою адресою; pop робить навпаки. Кожен виклик функції створює на стеку свій кадр (stack frame) — «робочий стіл», на якому лежать збережений rbp викликача, локальні змінні й місце для аргументів, що не влізли в регістри.

Класичний пролог і епілог:

func:
    pushq   %rbp        ; зберегти кадр викликача
    movq    %rsp, %rbp  ; rbp = початок нового кадру
    ; ... тіло, звертання через -N(%rbp) ...
    popq    %rbp        ; повернути кадр викликача
    ret                 ; зняти адресу повернення й стрибнути туди

call неявно заштовхує на стек адресу наступної інструкції (адресу повернення), а ret її знімає — тому кожному call має відповідати рівно один ret, інакше стек «з'їжджає» і функція поверне керування в довільне місце пам'яті.

System V AMD64 ABI: контракт виклику

ABI (Application Binary Interface) — угода на рівні байтів, без якої окремо скомпільовані .o-файли не змогли б викликати одне одного, навіть якщо написані різними компіляторами. Для Linux/macOS x86-64 діє System V AMD64 ABI:

Аргумент № Регістр (ціле/вказівник) Регістр (float/double)
1 rdi xmm0
2 rsi xmm1
3 rdx xmm2
4 rcx xmm3
5 r8 xmm4
6 r9 xmm5
7+ на стеку на стеку

Результат повертається в rax (ціле/вказівник, до 64 біт; для 128-бітних значень — пара rax:rdx) або xmm0 (float/double). Регістри діляться на дві групи за тим, хто відповідає за їхнє збереження:

  • callee-saved (rbx, rbp, rsp, r12r15): якщо функція їх використовує, вона зобов'язана зберегти й відновити значення до виходу — інакше зламає викликача;
  • caller-saved (rax, rcx, rdx, rsi, rdi, r8r11): викликач не може розраховувати, що їхній вміст переживе виклик, і має сам зберегти потрібне до call, якщо воно знадобиться після.

Стек перед кожним call повинен бути вирівняний на 16 байтів (rsp % 16 == 0 у момент виклику) — вимога SSE-інструкцій, які компілятор використовує для роботи з float/double; порушення вирівнювання — джерело важких для діагностики крашів саме в released-збірках, де SSE активно застосовується.

Прапорці й умовні переходи

Немає окремої машинної інструкції «if». Натомість cmp a, b обчислює a - b, відкидає результат, але виставляє біти регістра rflags:

  • ZF (zero flag) — результат нульовий (a == b);
  • SF (sign flag) — результат від'ємний;
  • CF (carry flag) — було безнакове перенесення/позика;
  • OF (overflow flag) — знакове переповнення.

Умовні переходи (jcc) читають комбінацію цих прапорців:

Мнемоніка Умова Використовує
je / jz a == b ZF=1
jne / jnz a != b ZF=0
jl / jnge a < b (знакове) SF≠OF
jg / jnle a > b (знакове) ZF=0 і SF=OF
jb / jc a < b (беззнакове) CF=1
ja a > b (беззнакове) CF=0 і ZF=0

Плутанина знакових (jl/jg) із беззнаковими (jb/ja) варіантами — типова причина логічних багів, коли, наприклад, порівнюють size_t як знакове число і від'ємний результат «загортається» у величезне беззнакове.

Адресація пам'яті

Загальна форма операнда пам'яті: зсув(база, індекс, масштаб), що обчислюється як адреса = база + індекс*масштаб + зсув. Саме так компілятор реалізує доступ до елемента масиву a[i] за один такт адресної арифметики: (%rbx, %rax, 4) означає «база rbx, індекс rax, масштаб 4» — прямий аналог int-масиву. Ця ж ідея — «базова адреса плюс зсув» — повернеться в Лекції 9 як модель лінійної пам'яті WebAssembly, тільки замість регістра-бази там буде єдина, спільна для всього модуля область байтів.

NASM: коли асемблер пишуть руками

gcc -S показує, що згенерував компілятор; NASM (Netwide Assembler) — інструмент, яким асемблер пишуть, коли потрібна ділянка коду поза досяжністю компілятора (примітиви ОС, крос-мовні бібліотеки, навчальні вправи на кшталт наших). NASM використовує синтаксис Intel, і файл ділиться на секції:

section .text          ; виконуваний код
    global add          ; зробити символ 'add' видимим для лінкера

add:
    mov     eax, edi     ; перший аргумент (int) прийшов у edi
    add     eax, esi     ; додати другий аргумент (esi)
    ret                  ; результат уже в eax — угода ABI дотримана

Асемблювання й компонування — два окремі кроки:

nasm -f elf64 add.asm -o add.o     # текст → об'єктний файл (формат ELF64)
gcc main.c add.o -o app            # лінкер зшиває .o з рештою програми
./app

Символ add, оголошений global, стає видимим лінкеру так само, як звичайна C-функція — саме тому NASM-код можна викликати з C напряму, без жодної обгортки, якщо він дотримується System V AMD64 ABI: параметри в rdi/rsi/…, результат у rax, збережені callee-saved регістри. З боку C оголошення виглядає як звичайний прототип:

extern int add(int a, int b);   /* реалізація — у add.asm */

int main(void) {
    return add(2, 3);
}

Компілятор C не генерує для extern-функції жодного коду — лише довіряє лінкеру знайти символ з тим самим іменем. Це та сама угода, завдяки якій libc, написана здебільшого на C, безшовно викликає системні виклики, реалізовані асемблерними вставками в ядрі.

Підводні камені

  • Наплутати напрямок операндів між AT&T (mov src, dst) та Intel (mov dst, src) — найчастіша помилка новачків при читанні чужого коду.
  • Порушити вирівнювання стека на 16 байтів перед call — SSE-інструкції (робота з float/double) у такому разі падають з SIGSEGV лише в оптимізованій збірці, де компілятор дозволяє собі їх використовувати.
  • Записати в 16/8-бітну частину регістра, розраховуючи, що верхні біти обнулилися — на відміну від 32-бітного запису, вони не обнуляються, і в 64-бітному регістрі лишається «сміття».
  • Переплутати знакові й беззнакові умовні переходи (jl замість jb) — критично при порівнянні розмірів (size_t), де від'ємне число «загортається» у величезне додатне.
  • Забути парність call/ret — ручний push без відповідного pop у тілі функції зсуває адресу повернення, і ret передає керування в довільне місце.
  • Ігнорувати red zone — 128 байтів нижче rsp, які «листові» функції (без власних викликів) можуть використовувати без явного зменшення rsp; але цей простір непридатний усередині обробників сигналів, які можуть перезаписати його асинхронно.

Підсумок

Асемблер x86-64 — це не окрема «мова програмування» для написання застосунків, а словник, яким компілятор і процесор домовляються між собою. Регістри — обмежена, фізично існуюча кількість «змінних»; стек — дисципліноване використання пам'яті для кадрів викликів; ABI — контракт, без якого окремо скомпільовані файли не могли б співпрацювати; прапорці й jcc — весь механізм, з якого складені if і цикли будь-якої мови вищого рівня. Уміння читати цей рівень — не самоціль, а інструмент: коли Лекція 9 покаже WebAssembly, ви побачите ту саму задачу (виконати обчислення на реальному чи віртуальному процесорі), розв'язану інакше — без регістрів, зате переносно.

Розбір за слайдами

План лекції

Фокус: прочитати згенерований асемблер, а не писати його з нуля

Навіщо це системному програмісту

Мета — читати й розуміти, а не писати великі програми вручну

x86-64 у цифрах

16
регістрів загального призначення
по 64 біти: rax … r15
6
аргументів у регістрах
rdi, rsi, rdx, rcx, r8, r9 — далі стек
16 Б
вирівнювання rsp перед call
вимога SSE-інструкцій
128 Б
red zone нижче rsp
листова функція — без зсуву rsp

Усі чотири числа — з System V AMD64 ABI; кожне повернеться далі в лекції

Регістри x86-64

Регістри — це «змінні» процесора, яких рівно стільки, скільки є фізично

Карта регістрів і що таке rsp/rbp

РЕГІСТРИ X86-64: ХТО ЗА ЩО ВІДПОВІДАЄ rax результат rdi арг 1 rsi арг 2 rdx арг 3 rcx арг 4 r8 арг 5 r9 арг 6 r10–r15 робочі System V ABI: 6 аргументів у регістрах ОДИН РЕГІСТР — ЧОТИРИ ІМЕНІ rax 64 біт eax 32 біт ax 16 біт al 8 біт rsp — вершина стека rbp — база кадру функції rip — наступна інструкція решта аргументів — на стеку; молодші імена пишуть у ту саму фізичну комірку

Аргументи 1–6 — у регістрах у фіксованому порядку; це і є System V ABI, за якою C і asm розуміють одне одного

Один фізичний регістр — чотири імені

64-біт32-біт16-біт8-бітЗапис обнуляє верхні біти?
raxeaxaxal32-біт — так; 16/8-біт — ні
rdiedididilте саме правило
rsiesisisilте саме правило
rspespspsplвершина стека — не чіпати руками
r8r15r8dr15dr8wr15wr8br15bте саме правило

Запис у eax обнуляє верхні 32 біти rax; запис у ax чи al — ні: типове джерело «сміття» в регістрі. Перевірка: objdump -d покаже movl (обнуляє) чи movw (не обнуляє)

AT&T проти Intel: один код, два записи

AT&T — gcc -S, objdump -d
  • Джерело зліва, приймач справа: mov %rax, %rbx
  • Регістри з префіксом %, константи з префіксом $
  • Розмір — суфікс мнемоніки: movl, movq
  • Пам'ять: зсув(%база, %індекс, масштаб)
  • Читати справа наліво
Intel — NASM, документація Intel
  • Приймач зліва, джерело справа: mov rbx, rax
  • Без префіксів: rax, 5
  • Розмір — при операнді: dword ptr, qword ptr
  • Пам'ять: [база + індекс*масштаб + зсув]
  • Читати зліва направо

Один фізичний код, різна нотація. Читаємо AT&T (gcc -S, objdump), пишемо Intel (NASM). Плутанина №1 новачків — напрямок операндів

Функція додавання: C-код

c
int add(int a, int b) {
    return a + b;
}

5 рядків, з яких народиться асемблер

Той самий add: асемблер (AT&T, -O0)

asm
add:
    pushq   %rbp
    movq    %rsp, %rbp
    movl    %edi, -4(%rbp)
    movl    %esi, -8(%rbp)
    movl    -4(%rbp), %eax
    addl    -8(%rbp), %eax
    popq    %rbp
    ret

gcc -O0 -S add.c — без оптимізацій, щоб бачити кожен крок

Пролог і епілог функції

  1. 1
    pushq %rbpзберегти базовий вказівник викликача на стеку
  2. 2
    movq %rsp, %rbpновий кадр стека (frame pointer) починається тут
  3. 3
    Тіло функціїпараметри й локальні змінні — за зсувами від %rbp
  4. 4
    popq %rbpповернути базовий вказівник викликача
  5. 5
    retзняти адресу повернення зі стека і перейти туди

Кадр стека (stack frame) — «робочий стіл» функції на час її виконання; кожному call відповідає рівно один ret

Кадр стека: що і за якими зсувами лежить

КАДР СТЕКА: RBP — НЕРУХОМА ТОЧКА ВІДЛІКУ ↑ вищі адреси arg 7 … arg N на стеку адреса повернення push від call збережений %rbp ← %rbp -4(%rbp) → a -8(%rbp) → b вільно ← %rsp ↓ нижчі адреси · стек росте вниз арг. 1–6 — у регістрах rdi rsi rdx rcx r8 r9 у add: a → %edi, b → %esi ПРОЛОГ І ЕПІЛОГ push %rbp — зберегти кадр mov %rsp,%rbp — новий кадр тіло: локальні за -N(%rbp) pop %rbp / leave — згорнути ret — зняти адресу повернення rbp не рухається до самого ret — тому локальні мають сталі зсуви

rsp — вершина, rbp — база кадру; -O0 копіює аргументи з регістрів у -4(%rbp), -8(%rbp)

System V AMD64 ABI: де шукати аргументи

АргументЦіле / вказівникfloat / doubleУ прикладі add
1-йrdixmm0a → edi (int = 4 байти)
2-йrsixmm1b → esi
3-йrdxxmm2
4-йrcxxmm3
5-й, 6-йr8, r9xmm4, xmm5
7-й і далістекстек
Результатrax (eax)xmm0a + b → eax

Угода виклику (calling convention), вона ж ABI, — контракт, без якого окремо скомпільовані .o файли не змогли б викликати одне одного

Хто зберігає регістри

callee-saved — зберігає функція
  • rbx, rbp, rsp, r12–r15
  • Використала — зобов'язана відновити до ret
  • Інакше зламає викликача
vs
caller-saved — зберігає викликач
  • rax, rcx, rdx, rsi, rdi, r8–r11
  • Після call їхній вміст — довільний
  • Потрібне після виклику — збережи до call

Порушення поділу — класична помилка ручного асемблера: «працює», доки функцію не викликали з іншого місця

Прапорці rflags: що виставляє cmp

0
ZF — zeroрезультат a − b дорівнює нулю: a == b
SF — signрезультат від'ємний: старший біт = 1
CF — carryбеззнакове перенесення чи позика
OF — overflowзнакове переповнення: знак результату «неправильний»

cmp a, b обчислює a − b, виставляє прапорці, операнди не змінює. Окремої інструкції if немає: cmp + jcc (наступний слайд) і jmp складають кожен if і цикл

Умовні переходи: що читає jcc

МнемонікаУмова після cmp a, bПрапорці
je / jza == bZF = 1
jne / jnza != bZF = 0
jl / jngea < b (знакове)SF ≠ OF
jg / jnlea > b (знакове)ZF = 0 і SF = OF
jb / jca < b (беззнакове)CF = 1
jaa > b (беззнакове)CF = 0 і ZF = 0

Плутанина знакових (jl/jg) із беззнаковими (jb/ja) — типовий баг при порівнянні size_t: від'ємне «загортається» у величезне додатне

Цикл for у C і в асемблері

asm
; for (int i = 0; i < n; i++) sum += i;
    movl    $0, -4(%rbp)      ; i = 0
.L_cond:
    movl    -4(%rbp), %eax
    cmpl    -8(%rbp), %eax    ; порівняти i з n
    jge     .L_end
    ; ... sum += i ...
    addl    $1, -4(%rbp)      ; i++
    jmp     .L_cond
.L_end:

Мітки (.L_cond, .L_end) — це просто іменовані адреси інструкцій

Адресація пам'яті: зсув(база, індекс, масштаб)

база%rbx — початок масиву
+ індекс × масштаб%rax × 4 — i-й int
+ зсувконстанта, напр. -4
= адресаmovl (%rbx,%rax,4), %eax

Так компілятор реалізує a[i] за один такт адресної арифметики; -4(%rbp) — окремий випадок без індексу. Та сама ідея «база + зсув» повернеться в лінійній пам'яті WebAssembly (Л9)

Живе демо: розбір 5 рядків

  1. gcc -O0 -S add.c -o add.s
  2. cat add.s — знайти секцію add:
  3. Покроково: pushq %rbp / movq %rsp,%rbp — навіщо зберігати старий кадр
  4. movl %edi,-4(%rbp) — де саме опинився параметр a
  5. addl -8(%rbp), %eax — де саме склались a і b
  6. objdump -d add.o — та сама функція вже у вигляді машинного коду (байтів) поруч з мнемоніками

Очікувано: Студенти бачать 1:1 відповідність між C-виразом a + b і трьома інструкціями mov/add/mov

NASM: структура файлу

⚙️
section .textвиконуваний код — інструкції функцій
📦
section .dataініціалізовані дані: константи, рядки
▫️
section .bssнеініціалізовані дані — лише розмір
🔗
global ім'ясимвол видимий лінкеру — аналог «не static» у C

NASM (Netwide Assembler) — інструмент, яким асемблер пишуть руками, а не отримують з компілятора; синтаксис Intel: mov dst, src

NASM: функція додавання (Intel-синтаксис)

asm
section .text
    global add

add:
    mov     eax, edi    ; перший аргумент (int) прийшов у edi
    add     eax, esi    ; додати другий аргумент
    ret                  ; результат уже в eax

Той самий add, що й у gcc -S, але написаний руками в Intel-синтаксисі

Від NASM-тексту до бінарника

add.asmIntel-синтаксис, global add
nasm -f elf64→ add.o (ELF64)
main.cextern int add(int, int);
gcc (лінкер)зшиває .o за символом add
./app · objdump -dзапуск і перевірка байтів

Жодної обгортки: лінкер бачить символ add як звичайну C-функцію. Умова — дотримання System V ABI (аргументи в rdi/rsi, результат у rax). Так само libc викликає асемблерні системні виклики ядра

Виклик NASM-функції з C: збірка від і до

bash
nasm -f elf64 add.asm -o add.o
gcc main.c add.o -o app
./app
objdump -d app | grep -A6 '<add>:'

Три інструменти — nasm, gcc, objdump — покривають шлях від тексту до перевіреного бінарника

Вирівнювання стека й red zone

Вирівнювання стека — один із найчастіших джерел 'працює у мене, падає на сервері'

Живе демо: від NASM-тексту до перевіреного бінарника

  1. nasm -f elf64 add.asm -o add.o — отримати об'єктний файл
  2. gcc main.c add.o -o app -no-pie — зібрати виконуваний файл
  3. ./app; echo $? — переконатися, що результат коректний
  4. objdump -d app | grep -A8 '<add>:' — побачити ті самі три інструкції вже як машинний код

Очікувано: Студенти бачать, що NASM-функція, написана вручну, працює нарівні з функцією, згенерованою gcc

Люди, які цікавляться комп'ютерами більше ніж побіжно, повинні мати хоч якесь уявлення про те, як влаштоване апаратне забезпечення під ними. Інакше програми, які вони пишуть, будуть доволі дивними. — Дональд Кнут, «Мистецтво програмування», т. 1

Підсумок

Джерела

🥚 ПАСХАЛКА
У демо-терміналі main повертає 0x2a. Якщо замінити константу на 0xC1D, objdump покаже байти 1d 0c 00 00 — це 3101 у little-endian. Число не випадкове: у курсі воно трапляється не раз.
⌨️ КОМАНДИ НА ЦІЙ СТОРІНЦІ 5 · 4 нових

Кожна команда терміналу з цієї сторінки — одним реченням. Позначка нове — команда зустрічається в курсі вперше; далі вважаємо її знайомою.

nasmнове
Асемблер x86 (синтаксис Intel): nasm -f elf64 add.asm -o add.o — текст мнемонік → об'єктний файл ELF64.
ldнове
Компонувальник (linker): зшиває об'єктні файли у виконуваний бінарник (ld -o pixel pixel.o).
objdumpнове
Дизасемблер: objdump -d f.o показує машинний код як інструкції з адресами й опкодами (-M intel — синтаксис Intel).
gccнове
Компілятор C (GNU); gcc -O0 -c f.c -o f.o — лише скомпілювати в об'єктний файл без оптимізацій.
grep
Фільтрує текст: виводить лише рядки, що містять шаблон (ps aux | grep bash).

💡 Прочитати README прямо в терміналі: cat README.md (виведе весь файл) або less README.md (посторінково; вихід — клавіша q).
Довідка з будь-якої команди: man curl (повний посібник, вихід — q) або коротко curl --help; для підкоманд — docker run --help, cargo build --help.