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

ЧОМУ ПРОГРАМИ ПСУЮТЬ ПАМ'ЯТЬ

Ціна помилки керування пам'яттю

ВТФК · Системне програмування · 2026

План лекції

  • Як програма бачить пам'ять: стек і купа
  • Класичні помилки: use-after-free, double free, витік, переповнення буфера
  • Чому C/C++ дозволяють такі помилки
  • Три підходи до керування пам'яттю: ручне, збирач сміття, володіння
  • Що таке Rust і чому компілятор опирається
  • Rust і Cargo: встановлення, перший проєкт, перший тест
  • Живе демо: один і той самий баг у C та в Rust

Фокус: ціна помилки, а не синтаксис жодної з мов

Стек і купа

  • Стек — пам'ять функції: локальні змінні, звільняється сама при виході з функції
  • Купа (heap) — динамічний час життя: виділяється і звільняється явно (malloc/free) або керовано
  • Вказівник на купу можна скопіювати, а виділена ділянка — одна фізична адреса
  • Помилки керування пам'яттю майже завжди стосуються саме купи

Стек дешевий і безпечний за конструкцією; купа — джерело всіх подальших проблем

СТЕК (швидко, LIFO) КУПА (heap) let s = String ptr,len,cap x: i32 = 5 на стеку "HELLO" дані рядка let s2 = s; // MOVE s — недійсний moved out s2 → "HELLO" один власник рівно один власник даних у купі → немає подвійного звільнення

Стек проти купи

ОзнакаСтекКупа
Розмірфіксований, відомий під час компіляціїдинамічний, часто невідомий заздалегідь
ВиділенняO(1): зсув вказівника стекапошук вільного блоку аллокатором
Час життяобласть видимості функціїявно (free) або через володіння
Типові данілокальні змінні, i32, boolBox, Vec, String, об'єкти
Джерело помилокпереповнення стека (рекурсія)use-after-free, double free, витік

Стек дешевий і безпечний за конструкцією; купа — джерело всіх подальших проблем

Класичні помилки керування пам'яттю

💀
Use-after-freeвикористання пам'яті після її звільнення
✂️
Double freeповторне звільнення тієї самої ділянки
🌊
Buffer overflowзапис за межі виділеного блоку
🕳️
Memory leakвиділили і забули звільнити
🧷
Dangling pointerвказівник на вже звільнену ділянку
⚡
Data raceдва потоки, ті самі дані, хоч один запис (Л6)

П'ять перших формують левову частку критичних уразливостей CWE Top 25 десятиліттями

Use-after-free на C

c
int *p = malloc(sizeof(int));
*p = 3101;             // 0xC1D
free(p);
printf("%d\n", *p);   // UB: читаємо звільнену пам'ять
*p = 100;              // UB: пишемо у звільнену пам'ять

Компілятор мовчить: жодного попередження за замовчуванням

Чому use-after-free «працює» на тестах

  1. 1
    mallocаллокатор віддає блок; вказівник p — просто число
  2. 2
    free(p)блок повернуто в пул; байти фізично ще лежать на місці
  3. 3
    Читання *p на тестізначення ще там — тест зелений, компілятор мовчить
  4. 4
    Прод під навантаженнямблок перевикористано під інший об'єкт
  5. 5
    Крах або експлойтчитання дає сміття, запис ламає чужі дані

Санітайзери (ASan, Valgrind) ловлять це під час виконання — і лише на пройдених шляхах

Ціна помилки

≈70%
CVE високої критичності Microsoft
помилки безпеки пам'яті (MSRC, 2006–2018)
≈70%
серйозних багів безпеки Chromium
Google: та сама частка для Chrome та Android
№5 і №7
CWE Top 25 (2025)
Out-of-bounds Write і Use-after-free; №1 — XSS, тобто вебова, не памʼять
0
попереджень gcc за замовчуванням
на use-after-free із попереднього слайда

Ціна — не в моменті написання рядка, а коли рядок зустрічає реальний, часто зловмисний вхідний потік

Три підходи до керування пам'яттю

ПідхідМовиХто перевіряєЦіна в рантайміПомилки пам'яті
Ручне (malloc/free)C, C++програмістнемаєможливі всі п'ять
Збирач сміття (GC)Java, Python, Go, JSрантаймпаузи stop-the-world, трасуванняздебільшого виключені
Володіння (ownership)Rustкомпіляторнемаєвиключені на етапі компіляції

Rust — спроба взяти безпеку GC-мов і швидкість C без компромісу в рантаймі

Ідея Rust: володіння перевіряється компілятором

  • У кожного значення є рівно один власник (owner)
  • Власник виходить з області видимості — компілятор сам вставляє звільнення пам'яті
  • Використати «мертве» значення — помилка компіляції, а не помилка виконання
  • Саме це розберемо далі: володіння і запозичення (Л5), зрізи й Option (Л6), Result і власні помилки (Л7)

Той самий баг у Rust не компілюється

rust
let p = Box::new(42);
drop(p);              // явно звільняємо пам'ять
println!("{}", p);   // error[E0382]: use of moved value: `p`

// компілятор відмовляється зібрати цей код —
// баг знайдено ДО того, як програма хоч раз запустилась

Помилка виявлена на етапі компіляції, а не на етапі експлуатації

Одна помилка — дві ціни

USE-AFTER-FREE: ДВІ ЦІНИ ОДНІЄЇ ПОМИЛКИ C · ПОМИЛКУ ЗНАЙДЕ ПРОД malloc p ← блок *p = 3101 p живий free(p) блок у пул p той самий p — просто число байти ще на місці *p = UB · крах у проді ⟶ час RUST · ПОМИЛКУ ЗНАЙДЕ КОМПІЛЯТОР Box::new(42) p — власник p живий власник один drop(p) або кінець { } E0382 код не збереться спроба вжити p жодного GC у рантаймі drop автоматичний бінарника з багом не буде ⟶ час звільненняUB у рантайміпомилка компіляції ціну помилки платять або на етапі компіляції, або в проді — третього не буває

Ліворуч C: free(p) лишає p висячим, *p — UB уже в проді. Праворуч Rust: після drop(p) використання p — error[E0382], бінарник просто не збереться

Живе демо

  1. Компілюємо і запускаємо приклад use-after-free на C (gcc, без прапорів санітайзера) — програма щось виводить, поведінка не гарантована
  2. Компілюємо той самий приклад із санітайзером (-fsanitize=address) — тепер помилка виявляється, але тільки під час виконання
  3. Пишемо еквівалентний код на Rust з Box і drop(p) перед використанням
  4. Запускаємо `cargo build` — компіляція падає з error[E0382] ще до запуску

Очікувано: C: непередбачувана поведінка або крах у рантаймі. Rust: помилка компіляції, програма взагалі не збирається

Санітайзер проти перевірки компілятора

ASan / Valgrind (C, C++)
  • Ловить під час виконання
  • Лише пройдені шляхи виконання
  • Потребує покриття тестами
  • Сповільнює програму в кілька разів
  • Баг уже в бінарнику
vs
Перевірка володіння (Rust)
  • Ловить під час компіляції
  • Усі шляхи виконання одразу
  • Не залежить від тестів
  • Нуль накладних витрат у рантаймі
  • Бінарник із багом не з'явиться

Санітайзер знаходить помилку після запуску; компілятор — до появи бінарника

Компілятор — союзник, не ворог

  • Перевірка запозичень (borrow checker) здається суворою новачкам — це нормально, це її робота
  • Кожна помилка компіляції — це баг, який НЕ потрапить у прод
  • Ціна: трохи довше писати перший робочий варіант коду
  • Виграш: use-after-free, double free, data race стають структурно неможливими

Перше входження: що таке Rust

  • Компілювана мова: `rustc` робить з тексту машинний код, як `gcc` для C — жодної віртуальної машини й жодного інтерпретатора
  • **Немає збирача сміття** — і при цьому не треба писати `free()` руками: компілятор сам знає, де вставити звільнення
  • Синтаксис ближче до C++/TypeScript, ніж до чогось екзотичного: `fn`, `let`, типи виводяться самі
  • Беруть його там, де падіння дороге: браузери, ядра ОС, вбудовані системи, інфраструктура

Далі в модулі — не «вивчити мову», а розібрати один її механізм: як саме компілятор це перевіряє.

Як виглядає Rust: перша програма

rust
fn main() {
    let name = "ранер";          // тип &str виведено, писати не треба
    let mut count = 0;            // без mut змінна незмінна за замовчуванням
    count += 1;
    println!("{name}, спроба {count}");   // ! — це макрос, не функція
}

Чотири речі, які варто помітити: fn, let, відсутній mut за замовчуванням, макрос із «!»

Компілятор буде тобі відмовляти — і це нормально

  • Перші дні в Rust — це діалог із компілятором, а не написання коду. Так і має бути
  • Помилки Rust читаються як інструкція: що не так, де саме, і **що зробити** (`help:`)
  • У кожної помилки є код — `error[E0382]`; `rustc --explain E0382` дає розгорнуте пояснення
  • Правило на весь модуль: **перший рядок — діагноз, останній (`help:`) — рецепт**; читай обидва
  • Кожна відмова компілятора — це баг, який не потрапить у роботу. Ціна — трохи довше писати перший варіант

Це не мотиваційний слайд, а робоча інструкція: на ЛР5 п'ять програм навмисно не компілюються.

Встановлення Rust через rustup

sh.rustup.rscurl … | sh — один інсталятор
→
rustupканали stable · beta · nightly
→
stableкурс працює лише на stable
→
rustc + cargo--version — перевірка
→
rustup updateоновлення тулчейна

Один інструмент (rustup) керує компілятором, Cargo і документацією одразу

Перший проєкт: cargo new

bash
cargo new sidkit
cd sidkit
tree .
# sidkit/
# ├── Cargo.toml   ← маніфест: назва, версія, залежності
# ├── Cargo.lock   ← точні версії залежностей (генерується автоматично)
# └── src/
#     └── main.rs  ← точка входу

cargo new одразу ініціалізує git-репозиторій, якщо він ще не існує

Команди Cargo, які знадобляться щодня

КомандаЩо робитьКоли
cargo checkперевіряє компіляцію без бінарниканайшвидша ітерація під час написання
cargo build / runкомпілює / компілює й запускає (debug)коли треба виконати
cargo testзапускає юніт- та інтеграційні тестиперед кожним комітом; автотести ЛР
cargo add <crate>додає залежність у Cargo.tomlпотрібен зовнішній крейт
cargo fmtформатує за офіційним стилемперед комітом
cargo clippyлінтер антипатернівперед рев'ю

Перший юніт-тест

rust
fn add(a: i32, b: i32) -> i32 {
    a + b
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn adds_two_positive_numbers() {
        assert_eq!(add(2, 3), 5);
    }
}

`#[cfg(test)]` виключає тестовий модуль з релізного бінарника

Cargo.toml проти Cargo.lock

Cargo.toml — маніфест
  • Пишеться вручну
  • [package]: name, version, edition = "2021"
  • [dependencies]: serde = "1.0"
  • "1.0" означає ^1.0 — сумісні 1.x.y
↔
Cargo.lock — зафіксовані версії
  • Генерується автоматично
  • Точні версії, що реально зібралися
  • Комітиться в git для бінарників
  • Гарантує відтворювану збірку

Семантичне версіювання: мажорна.мінорна.патч; ^ дозволяє оновлення в межах мажорної

Демо: тест, що падає → тест, що проходить

  1. cargo new sidkit && cd sidkit
  2. Дописуємо функцію add(a, b) і тест, що навмисно очікує неправильний результат
  3. cargo test — бачимо FAILED і повідомлення "assertion `left == right` failed"
  4. Виправляємо очікуване значення в тесті, запускаємо cargo test знову — усі тести ok

Очікувано: Перший запуск: test result: FAILED. Другий запуск: test result: ok. 1 passed; 0 failed

Маршрут модуля 2

Л4 · ЛР4мотивація · Cargo і перший тест
→
Л5 · ЛР5володіння й запозичення · borrow checker
→
Л6 · ЛР6зрізи, часи життя, Option · struct і enum
→
Л7 · ЛР7Result і ? · читання файлу

Наступні три лекції — як саме працює перевірка: володіння й запозичення, точні типи, помилки як значення

Тестування програми може показати наявність помилок, але ніколи — їхню відсутність.

— Едсгер Дейкстра, «Notes on Structured Programming» (1970)

Підсумок

  • Помилки керування пам'яттю (use-after-free, double free, переповнення) — джерело більшості критичних вразливостей
  • C/C++ довіряють програмісту й перевіряють це в кращому разі в рантаймі
  • Rust переносить перевірку володіння на етап компіляції — без накладних витрат у рантаймі
  • Наступні три лекції — як саме працює ця перевірка: володіння, запозичення, Option/Result

🥚 У прикладі use-after-free у виділену ділянку записано 3101 — це 0xC1D у шістнадцятковій системі. Число не випадкове: воно ще не раз зустрінеться в матеріалах курсу.

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