☰ Конспект ← Курс
ЛЕКЦІЯ 8

АСЕМБЛЕР x86-64

Мова машини

VTFK · Системне програмування · Модуль 3

План лекції

  • Навіщо системному програмісту асемблер
  • Регістри x86-64: загального призначення і спеціальні
  • Синтаксис: AT&T проти Intel
  • Стек, виклик функції, System V AMD64 ABI
  • Прапорці, порівняння, умовні переходи
  • Живе демо: розбір 5-рядкової функції

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

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

  • Компілятор — не магія: C, Rust і навіть WebAssembly зрештою стають послідовністю інструкцій процесора
  • Розуміння асемблера пояснює, чому push/pop швидкі, а виклик функції — ні
  • Дизасемблювання (*disassembly*: objdump, gdb) — інструмент дебагу, коли вихідного коду немає або він бреше
  • Це той самий словник, яким пізніше говоритиме 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

  • 16 регістрів (*registers*) загального призначення по 64 біти: rax…rdx, rsi, rdi, rbp, rsp, r8–r15
  • Молодші частини того самого регістра: rax → eax → ax → al
  • rsp — вказівник вершини стека, rbp — базовий вказівник кадру функції
  • rip — вказівник наступної інструкції (недоступний напряму для запису)
  • За угодою: rax — акумулятор і місце для результату функції

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

Карта регістрів і що таке 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

  • Перед кожним call rsp має бути кратним 16 — цього вимагають SSE-інструкції для float/double
  • Порушення вирівнювання не завжди падає одразу — часто виявляється лише в release-збірці з SSE
  • Red zone — 128 байтів нижче rsp: листова функція використовує їх без зсуву rsp
  • 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

Підсумок

  • Регістри — це «змінні» процесора; стек — область пам'яті для кадрів функцій
  • System V AMD64 ABI визначає, де шукати аргументи (rdi, rsi, …) і результат (rax)
  • cmp виставляє прапорці, jcc їх читає — з цього складені всі if та цикли
  • Уміння читати asm-вивід компілятора — базовий навик перед переходом до WebAssembly

🥚 У демо-терміналі main повертає 0x2a. Якщо замінити константу на 0xC1D, objdump покаже байти 1d 0c 00 00 — це 3101 у little-endian. Число не випадкове: у курсі воно трапляється не раз.

VTFK · TERMLINK · L08← → · space · f — на весь екран