Курс СРС SRS17
до ЛР9 рівень 2 → «4»↔ Лабораторна 9↔ Лекція 9⌨️ команди 6
Самостійна робота · ~2.5 год

Угоди виклику (calling conventions) x86-64: System V AMD64 ABI

🌌 Як ворожий процесор приймає команди — На тому боці — не Стосова стійка, а цілий квартал іменованих комірок: у кожного регістра своя підписана шухляда, і процесор роздає аргументи команд у суворо визначеному порядку. С.І.Д. знає цей протокол напам'ять і залюбки ділиться: переплутаєш, у чию шухляду лягає який аргумент, — і перший же виклик функції піде не туди, а антивірус помітить аномалію в стеку викликів.

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

ABI — контракт, без якого окремо скомпільовані частини не з'єднаються

Application Binary Interface (ABI) — це набір домовленостей на рівні машинного коду про те, як функції передають одна одній аргументи, повертають значення, і хто саме відповідає за збереження вмісту регістрів під час виклику. Без такого спільного контракту функція, скомпільована одним компілятором (чи навіть просто написана вручну на асемблері), не змогла б коректно викликати функцію, скомпільовану іншим інструментом чи в іншому модулі — саме ABI робить можливим роздільну компіляцію, динамічне лінкування та виклик системних функцій ОС. На Linux/macOS для x86-64 стандартом де-факто є System V AMD64 ABI.

Ключове правило передачі параметрів: перші шість цілочисельних/ вказівникових аргументів функції передаються через регістри в чітко визначеному порядку — rdi, rsi, rdx, rcx, r8, r9 (легко запам'ятати мнемонікою «Diane's Silk Dress Costs $89» за першими літерами). Сьомий і наступні аргументи (а також аргументи з плаваючою точкою — окремо через xmm0xmm7) передаються через стек. Значення, що повертається функцією, за замовчуванням опиняється в регістрі rax (для 64-бітних цілих) чи в парі rax:rdx для 128-бітних значень.

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

Ця угода — не довільний вибір компілятора, а частина стабільного публічного контракту платформи: саме завдяки їй objdump -d скомпільованої функції можна читати передбачувано — побачивши на початку тіла функції mov %rdi, ... для функції з одним параметром, можна бути впевненим, що це саме перший аргумент, незалежно від того, яким компілятором (gcc, clang, rustc) вона була зібрана — усі вони на цій платформі підпорядковуються тому самому ABI. Саме тому Rust може напряму викликати функції з C-бібліотек (extern "C") і навпаки — обидві сторони угоди про виклик описують той самий протокол передачі даних через регістри й стек.

Практична навичка читання дизасембльованого коду функції з кількома параметрами полягає в тому, щоб зіставити перші інструкції тіла функції (де вона зазвичай копіює вхідні аргументи з rdi/rsi/ rdx/... у власні робочі регістри чи на локальний кадр стека) з порядком параметрів у вихідному коді — це і є прямий, «дослівний» доказ роботи угоди виклику на реальному, а не теоретичному прикладі.

📖 ОПРАЦЮВАТИ

Прочитати розділ System V AMD64 ABI про передачу параметрів у регістрах (rdi, rsi, rdx, rcx, r8, r9) і поділ на caller-saved/callee-saved регістри; переглянути objdump -d довільної скомпільованої функції з двома й більше параметрами й позначити, де саме опиняється кожен.

🖥️ ТЕРМІНАЛ — СПРОБУЙ САМ
🎯 ЗАВДАННЯ
  1. Написати просту функцію на C (int sum3(int a, int b, int c) { return a + b + c; }) або Rust (з #[no_mangle] extern "C"), скомпілювати з -O0 (без оптимізацій) і переглянути objdump -d, позначивши, з якого регістру (rdi/rsi/rdx) читається кожен з трьох параметрів.
  2. Написати функцію з сімома цілочисельними параметрами, скомпілювати, і в objdump -d знайти, як передається сьомий аргумент (через стек), порівнявши з першими шістьма (через регістри).
  3. Написати функцію, що всередині явно використовує callee-saved регістр (наприклад, r12) для проміжних обчислень, і в дизасемблюванні знайти відповідну пару push %r12 на початку функції та pop %r12 перед ret.
  4. Викликати з Rust функцію, оголошену як extern "C" { fn abs(x: i32) -> i32; } (стандартна C-функція libc), передати їй значення й вивести результат — на практиці показати, що обидві мови «домовляються» через ту саму угоду виклику ABI.
  5. Знайти в objdump -d функції з поверненим значенням явний рядок, де результат обчислення потрапляє саме в регістр %rax перед ret, і пояснити письмово, чому саме цей регістр, а не будь-який інший.
❓ КОНТРОЛЬНІ ПИТАННЯ
  1. У якому порядку System V AMD64 ABI передає перші шість цілочисельних аргументів функції через регістри?
  2. У якому регістрі опиниться третій цілочисельний аргумент функції, і чому не одразу на стеці?
  3. Як передаються сьомий і наступні цілочисельні аргументи функції, коли шести регістрів недостатньо?
  4. У якому регістрі за замовчуванням опиняється 64-бітне ціле значення, яке повертає функція?
  5. Чим caller-saved регістр відрізняється від callee-saved за тим, хто відповідає за збереження його вмісту під час виклику?
  6. Чому саме дотримання спільного ABI дозволяє функції, скомпільованій з Rust, напряму викликати функцію, скомпільовану з C?
  7. Що конкретно в дизасемблюванні (objdump -d) на початку тіла функції свідчить про використання нею callee-saved регістра для власних потреб?
✅ САМОПЕРЕВІРКА

У якому регістрі опиниться третій цілочисельний аргумент функції за System V AMD64 ABI, і чому саме там, а не одразу на стеці?

🥚 ПАСХАЛКА
С.І.Д. любить згадувати, як творці вчили його System V на одному виклику: sum3(101, 9, 3101) — «частота, ще частота і кіт». Скомпілюй такий виклик і подивись у objdump -d: третій аргумент ляже в %edx, а в байтах mov $0xc1d,%edx буде видно ba 1d 0c 00 00 — підпис, розкладений little-endian.
⌨️ КОМАНДИ НА ЦІЙ СТОРІНЦІ 6

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

gcc
Компілятор C (GNU); gcc -O0 -c f.c -o f.o — лише скомпілювати в об'єктний файл без оптимізацій.
objdump
Дизасемблер: objdump -d f.o показує машинний код як інструкції з адресами й опкодами (-M intel — синтаксис Intel).
grep
Фільтрує текст: виводить лише рядки, що містять шаблон (ps aux | grep bash).
tail
Показує останні рядки (tail -1 — останній; tail -f — стежити за файлом наживо).
clang
Компілятор C/C++ (LLVM); з --target=wasm32 збирає той самий C у WebAssembly — щоб порівняти з x86-64.
rustc
Компілятор Rust; напряму викликається рідко (rustc --version, rustc --explain E0382 — розшифрувати помилку), зазвичай його запускає Cargo.

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

← SRS16усі темиSRS18 →