Мова, якою насправді говорить процесор
Після презентації теми — самостійна робота в аудиторії над двома темами. Вони ж готують до захисту та розширення ЛР8. Матеріали публікуються в Google Classroom разом із лекцією.
Коли компілятор перетворює вираз a + b на трибуквені мнемоніки mov, add, ret, він не винаходить нову мову — він перекладає намір програміста на єдину мову, яку фізично розуміє кристал кремнію: набір інструкцій x86-64 (розширення 64-бітної адресації набору x86, відоме також як AMD64 чи Intel 64). У цій лекції ми не вчимося писати великі програми асемблером (assembler) — це неефективно й у 2026 році майже ніколи не потрібно, — а вчимося його читати: розбирати вивід gcc -S, objdump -d, розуміти, що саме бачить процесор, коли виконує вашу функцію.
Це фундамент, без якого незрозуміло:
unsafe-блок здатний зламати гарантії, які Rust дає в безпечному коді (Модуль 2);Асемблер — не «низький рівень заради складності». Це пряме, майже без втрат, відображення того, що фізично вміє робити процесор: перекласти число з одного місця в інше, додати, порівняти, стрибнути за умовою.
Регістр (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 |
вказівник вершини стека |
r8–r15 |
r8d–r15d |
r8w–r15w |
r8b–r15b |
5-й, 6-й аргументи; тимчасові значення |
Окремо стоїть rip (instruction pointer) — адреса наступної інструкції; його не можна записати інструкцією mov, лише непрямо через jmp, call чи ret. Важливо розуміти: запис у 32-бітну частину регістра (eax) обнуляє верхні 32 біти 64-бітного регістра (rax), а от запис у 16- чи 8-бітну частину (ax, al) верхні біти не чіпає — це джерело неочевидних багів, коли в 64-бітному регістрі лишається «сміття» від попереднього обчислення.
Одна й та сама інструкція записується по-різному залежно від традиції інструментарію:
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, інакше стек «з'їжджає» і функція поверне керування в довільне місце пам'яті.
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). Регістри діляться на дві групи за тим, хто відповідає за їхнє збереження:
rbx, rbp, rsp, r12–r15): якщо функція їх використовує, вона зобов'язана зберегти й відновити значення до виходу — інакше зламає викликача;rax, rcx, rdx, rsi, rdi, r8–r11): викликач не може розраховувати, що їхній вміст переживе виклик, і має сам зберегти потрібне до 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, тільки замість регістра-бази там буде єдина, спільна для всього модуля область байтів.
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, безшовно викликає системні виклики, реалізовані асемблерними вставками в ядрі.
mov src, dst) та Intel (mov dst, src) — найчастіша помилка новачків при читанні чужого коду.call — SSE-інструкції (робота з float/double) у такому разі падають з SIGSEGV лише в оптимізованій збірці, де компілятор дозволяє собі їх використовувати.jl замість jb) — критично при порівнянні розмірів (size_t), де від'ємне число «загортається» у величезне додатне.call/ret — ручний push без відповідного pop у тілі функції зсуває адресу повернення, і ret передає керування в довільне місце.rsp, які «листові» функції (без власних викликів) можуть використовувати без явного зменшення rsp; але цей простір непридатний усередині обробників сигналів, які можуть перезаписати його асинхронно.Асемблер x86-64 — це не окрема «мова програмування» для написання застосунків, а словник, яким компілятор і процесор домовляються між собою. Регістри — обмежена, фізично існуюча кількість «змінних»; стек — дисципліноване використання пам'яті для кадрів викликів; ABI — контракт, без якого окремо скомпільовані файли не могли б співпрацювати; прапорці й jcc — весь механізм, з якого складені if і цикли будь-якої мови вищого рівня. Уміння читати цей рівень — не самоціль, а інструмент: коли Лекція 9 покаже WebAssembly, ви побачите ту саму задачу (виконати обчислення на реальному чи віртуальному процесорі), розв'язану інакше — без регістрів, зате переносно.
Фокус: прочитати згенерований асемблер, а не писати його з нуля
Мета — читати й розуміти, а не писати великі програми вручну
callУсі чотири числа — з System V AMD64 ABI; кожне повернеться далі в лекції
Регістри — це «змінні» процесора, яких рівно стільки, скільки є фізично
Аргументи 1–6 — у регістрах у фіксованому порядку; це і є System V ABI, за якою C і asm розуміють одне одного
| 64-біт | 32-біт | 16-біт | 8-біт | Запис обнуляє верхні біти? |
|---|---|---|---|---|
rax | eax | ax | al | 32-біт — так; 16/8-біт — ні |
rdi | edi | di | dil | те саме правило |
rsi | esi | si | sil | те саме правило |
rsp | esp | sp | spl | вершина стека — не чіпати руками |
r8…r15 | r8d…r15d | r8w…r15w | r8b…r15b | те саме правило |
Запис у eax обнуляє верхні 32 біти rax; запис у ax чи al — ні: типове джерело «сміття» в регістрі. Перевірка: objdump -d покаже movl (обнуляє) чи movw (не обнуляє)
Один фізичний код, різна нотація. Читаємо AT&T (gcc -S, objdump), пишемо Intel (NASM). Плутанина №1 новачків — напрямок операндів
int add(int a, int b) {
return a + b;
}5 рядків, з яких народиться асемблер
add:
pushq %rbp
movq %rsp, %rbp
movl %edi, -4(%rbp)
movl %esi, -8(%rbp)
movl -4(%rbp), %eax
addl -8(%rbp), %eax
popq %rbp
retgcc -O0 -S add.c — без оптимізацій, щоб бачити кожен крок
pushq %rbpзберегти базовий вказівник викликача на стекуmovq %rsp, %rbpновий кадр стека (frame pointer) починається тутpopq %rbpповернути базовий вказівник викликачаretзняти адресу повернення зі стека і перейти тудиКадр стека (stack frame) — «робочий стіл» функції на час її виконання; кожному call відповідає рівно один ret
rsp — вершина, rbp — база кадру; -O0 копіює аргументи з регістрів у -4(%rbp), -8(%rbp)
| Аргумент | Ціле / вказівник | float / double | У прикладі add |
|---|---|---|---|
| 1-й | rdi | xmm0 | a → edi (int = 4 байти) |
| 2-й | rsi | xmm1 | b → esi |
| 3-й | rdx | xmm2 | — |
| 4-й | rcx | xmm3 | — |
| 5-й, 6-й | r8, r9 | xmm4, xmm5 | — |
| 7-й і далі | стек | стек | — |
| Результат | rax (eax) | xmm0 | a + b → eax |
Угода виклику (calling convention), вона ж ABI, — контракт, без якого окремо скомпільовані .o файли не змогли б викликати одне одного
Порушення поділу — класична помилка ручного асемблера: «працює», доки функцію не викликали з іншого місця
cmp a, b обчислює a − b, виставляє прапорці, операнди не змінює. Окремої інструкції if немає: cmp + jcc (наступний слайд) і jmp складають кожен if і цикл
| Мнемоніка | Умова після cmp a, b | Прапорці |
|---|---|---|
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: від'ємне «загортається» у величезне додатне
; 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) — це просто іменовані адреси інструкцій
Так компілятор реалізує a[i] за один такт адресної арифметики; -4(%rbp) — окремий випадок без індексу. Та сама ідея «база + зсув» повернеться в лінійній пам'яті WebAssembly (Л9)
Очікувано: Студенти бачать 1:1 відповідність між C-виразом a + b і трьома інструкціями mov/add/mov
section .textвиконуваний код — інструкції функційsection .dataініціалізовані дані: константи, рядкиsection .bssнеініціалізовані дані — лише розмірglobal ім'ясимвол видимий лінкеру — аналог «не static» у CNASM (Netwide Assembler) — інструмент, яким асемблер пишуть руками, а не отримують з компілятора; синтаксис Intel: mov dst, src
section .text
global add
add:
mov eax, edi ; перший аргумент (int) прийшов у edi
add eax, esi ; додати другий аргумент
ret ; результат уже в eaxТой самий add, що й у gcc -S, але написаний руками в Intel-синтаксисі
Жодної обгортки: лінкер бачить символ add як звичайну C-функцію. Умова — дотримання System V ABI (аргументи в rdi/rsi, результат у rax). Так само libc викликає асемблерні системні виклики ядра
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 — покривають шлях від тексту до перевіреного бінарника
Вирівнювання стека — один із найчастіших джерел 'працює у мене, падає на сервері'
Очікувано: Студенти бачать, що NASM-функція, написана вручну, працює нарівні з функцією, згенерованою gcc
Люди, які цікавляться комп'ютерами більше ніж побіжно, повинні мати хоч якесь уявлення про те, як влаштоване апаратне забезпечення під ними. Інакше програми, які вони пишуть, будуть доволі дивними. — Дональд Кнут, «Мистецтво програмування», т. 1
1d 0c 00 00 — це 3101 у little-endian. Число не випадкове: у курсі воно трапляється не раз.Кожна команда терміналу з цієї сторінки — одним реченням. Позначка нове — команда зустрічається в курсі вперше; далі вважаємо її знайомою.
nasmновеnasm -f elf64 add.asm -o add.o — текст мнемонік → об'єктний файл ELF64.ldновеld -o pixel pixel.o).objdumpновеobjdump -d f.o показує машинний код як інструкції з адресами й опкодами (-M intel — синтаксис Intel).gccновеgcc -O0 -c f.c -o f.o — лише скомпілювати в об'єктний файл без оптимізацій.grepps aux | grep bash).💡 Прочитати README прямо в терміналі: cat README.md (виведе весь файл) або less README.md (посторінково; вихід — клавіша q).
Довідка з будь-якої команди: man curl (повний посібник, вихід — q) або коротко curl --help; для підкоманд — docker run --help, cargo build --help.