Ціна помилки керування пам'яттю
Після презентації теми — самостійна робота в аудиторії над двома темами. Вони ж готують до захисту та розширення ЛР4. Матеріали публікуються в Google Classroom разом із лекцією.
Кожна серйозна аварія в проді — від витоку персональних даних до віддаленого виконання коду —
рано чи пізно зводиться до одного питання: хто і коли мав право торкнутися цієї ділянки пам'яті.
Компілятори мов на кшталт C і C++ довіряють відповідь програмісту повністю: вказівник — це просто
число, а операція free() — просто повідомлення аллокатору "ця ділянка більше не потрібна", без
жодної гарантії, що ніхто її потім не прочитає. Ця лекція розбирає, чому таке довір'я коштує
дорого, порівнює три історичні підходи до керування пам'яттю і вводить ідею, яку ми детально
розберемо в лекціях 5–7: перевірку правил володіння (ownership) пам'яттю на етапі компіляції.
Кожен процес отримує від операційної системи власний адресний простір, і в межах цього простору є щонайменше дві принципово різні ділянки, з якими працює звичайна програма.
Стек (stack) — це пам'ять поточного виклику функції. Кожен виклик додає новий "кадр" (stack frame) з локальними змінними фіксованого розміру, які відомі ще на етапі компіляції. Коли функція завершується, її кадр просто відкидається — це операція зі складністю O(1), без жодного пошуку вільної ділянки. Саме тому робота зі стеком настільки дешева: виділення пам'яті — це зсув одного вказівника (stack pointer), а звільнення — зворотний зсув.
Купа (heap) — це пам'ять із динамічним часом життя, розмір якої заздалегідь невідомий або
який має пережити виклик функції, що його створив. Виділення ділянки на купі — це звернення до
аллокатора (malloc у C, глобальний аллокатор у Rust), який мусить знайти вільний блок потрібного
розміру серед фрагментованої пам'яті — операція значно дорожча за зсув вказівника.
Ключова відмінність для цієї лекції: вказівник на купу можна скопіювати скільки завгодно разів, але виділена ділянка — це одна фізична адреса. Якщо копій вказівника кілька, а власник ділянки не визначений однозначно — виникає питання, хто саме відповідає за її звільнення. Практично всі помилки керування пам'яттю, які ми розберемо нижче, — це наслідок саме цієї невизначеності.
| Стек | Купа | |
|---|---|---|
| Розмір | Фіксований, відомий під час компіляції | Динамічний, може бути невідомий заздалегідь |
| Швидкість виділення | O(1), зсув вказівника | Повільніше, пошук вільного блоку |
| Час життя | Прив'язаний до області видимості | Керується явно або через володіння |
| Типовий доступ | Локальні змінні функції | Box, Vec, String, об'єкти на купі |
Ці п'ять патернів формують левову частку критичних уразливостей (CWE Top 25) уже десятиліттями.
Use-after-free. Пам'ять звільнили, але вказівник на неї лишився і його продовжують використовувати. Ділянка може бути ще фізично незмінена (тоді "все працює") або вже перевикористана під інший об'єкт (тоді читання дає сміття, а запис ламає чужі дані).
int *p = malloc(sizeof(int));
*p = 3101; // 0xC1D
free(p);
printf("%d\n", *p); // undefined behavior: читаємо звільнену пам'ять
*p = 100; // undefined behavior: пишемо у звільнену пам'ять
Double free. Той самий блок звільняють двічі. Більшість аллокаторів веде внутрішні структури метаданих (наприклад, зв'язний список вільних блоків) прямо в самій пам'яті — подвійне звільнення може пошкодити ці структури й дати зловмиснику примітив для довільного запису.
Buffer overflow. Запис виходить за межі виділеного блоку — найчастіше через відсутність
перевірки довжини вхідних даних. Класика: strcpy у буфер фіксованого розміру без перевірки
довжини джерела.
Memory leak. Пам'ять виділили і забули звільнити. У короткоживучих програмах це непомітно, у довгоживучих сервісах — поступове вичерпання RAM і, зрештою, аварійне завершення процесу (OOM-killer).
Dangling pointer. Вказівник, що вказує на вже звільнену або взагалі невалідну ділянку пам'яті (наприклад, повернений з функції вказівник на локальну змінну, яка вже знищена).
C спроєктовано під філософію "довіряй програмісту, не заважай йому". Компілятор не відстежує, чи
ділянка, на яку вказує int *p, ще жива — з погляду типової системи int * після free(p)
нічим не відрізняється від int * до нього. free() — це лише повідомлення аллокатору
"поверни блок у пул вільних", саме значення за адресою фізично може ще лежати незмінним якийсь
час, тому баг часто "працює" на тестах розробника і ламається у проді, коли той самий блок
пам'яті вже перевикористаний іншим виділенням під навантаженням.
Інструменти на кшталт AddressSanitizer (-fsanitize=address) чи Valgrind ловлять такі помилки —
але лише в рантаймі, і лише на тому шляху виконання, який реально був пройдений під час тесту.
Якщо тест не зачепив саме ту гілку коду, де стається use-after-free, санітайзер мовчатиме, а баг
проникне в прод.
За звітами Microsoft Security Response Center, близько 70% CVE високої критичності в коді Microsoft за багаторічний період — це саме помилки безпеки пам'яті (use-after-free, buffer overflow, тощо). Google повідомляв подібну частку для кодової бази Chrome та Android. Це не статистична випадковість — це системний наслідок того, що ручне керування пам'яттю у великому кодовому базі гарантовано, рано чи пізно, дає збій людського фактора хоч в одному з тисяч місць виділення/звільнення.
Ціна помилки — не в моменті написання рядка коду, а в моменті, коли цей рядок зустрічається з реальним, часто зловмисним вхідним потоком. Use-after-free і buffer overflow — стандартний будівельний блок експлойтів: контрольований запис у чужу пам'ять дає зловмиснику примітив "написати довільні байти за довільною адресою", з якого будується виконання довільного коду.
| Підхід | Приклади мов | Контроль | Накладні витрати в рантаймі | Клас помилок |
|---|---|---|---|---|
| Ручне | C, C++ | Повний, на програмісті | Немає | use-after-free, double free, leak можливі |
| Збирач сміття (GC) | Java, Python, Go, JavaScript | Автоматичний | Паузи (stop-the-world), накладні витрати трасування | Виключено (переважно), ціна — передбачуваність |
| Володіння (ownership) | Rust | Перевіряється компілятором | Немає | Виключено на етапі компіляції |
Ручне керування дає максимальний контроль і максимальну відповідальність: кожен malloc
має рівно один відповідний free, і людина мусить це утримати в голові на весь час життя
проєкту, включно з усіма змінами, які внесуть інші розробники через рік.
Збирач сміття знімає це навантаження з програміста: рантайм відстежує досяжність об'єктів і звільняє недосяжні автоматично. Ціна — непередбачувані паузи (GC stop-the-world), додаткова пам'ять для метаданих і трасування, і накладні витрати CPU на сам процес збирання сміття. Для серверів реального часу чи вбудованих систем це часто неприйнятно.
Володіння (ownership) — підхід Rust: правила керування пам'яттю (хто власник, коли звільняти) перевіряються статично, під час компіляції, і при цьому в скомпільованому бінарнику не лишається жодного рантайм-механізму — ані збирача сміття, ані підрахунку посилань за замовчуванням. Це спроба поєднати безпеку GC-мов зі швидкістю C, не платячи компромісом у продуктивності рантайму.
У кожного значення в Rust є рівно один власник. Коли власник виходить з області видимості,
компілятор автоматично вставляє виклик звільнення пам'яті — без участі програміста і без
збирача сміття в рантаймі. Спроба використати значення після того, як воно вже "мертве"
(наприклад, після move в іншу змінну чи явного drop), — це помилка компіляції, а не
помилка виконання:
let p = Box::new(42);
drop(p); // явно звільняємо пам'ять
println!("{}", p); // error[E0382]: use of moved value: `p`
// компілятор відмовляється зібрати цей код —
// баг знайдено ДО того, як програма хоч раз запустилась
Це і є принципова відмінність від санітайзерів: помилка знайдена не на конкретному шляху виконання під час тестування, а структурно, для всіх можливих шляхів одразу, ще до того, як з'явився скомпільований бінарник.
Щоб працювати з моделлю володіння практично, потрібен інструментарій. Rust встановлюється через
rustup — офіційний менеджер тулчейнів:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustc --version # rustc 1.8x.0 (stable)
cargo --version # cargo — офіційний менеджер збірки і пакетів
rustup керує кількома каналами (stable, beta, nightly) і дозволяє перемикатися між ними
(rustup default stable). У курсі ми працюємо виключно з каналом stable.
Cargo — це і збирач, і менеджер залежностей, і тест-раннер в одному інструменті. Новий проєкт створюється командою:
cargo new lockpick
cd lockpick
Це генерує мінімальну структуру:
lockpick/
├── Cargo.toml # маніфест: назва, версія, залежності
├── Cargo.lock # точні версії залежностей (генерується автоматично)
└── src/
└── main.rs # точка входу
Cargo.toml описує пакет декларативно:
[package]
name = "lockpick"
version = "0.1.0"
edition = "2021"
[dependencies]
# зовнішні крейти додаються тут, наприклад:
# serde = "1.0"
Ключові команди, якими користуються щодня:
| Команда | Що робить |
|---|---|
cargo build |
Компілює проєкт (режим debug за замовчуванням) |
cargo run |
Компілює й одразу запускає |
cargo check |
Перевіряє, чи компілюється код, без генерації бінарника — набагато швидше за build |
cargo test |
Компілює й запускає всі юніт- та інтеграційні тести |
cargo add <crate> |
Додає залежність у Cargo.toml |
cargo fmt |
Автоматично форматує код за офіційним стилем |
cargo clippy |
Лінтер: ловить типові антипатерни й підказує ідіоматичніший код |
Тести — невіддільна частина мови, а не окрема бібліотека, яку треба підключати. Найпростіший
тест живе прямо поруч із кодом, у вкладеному модулі, позначеному атрибутом #[cfg(test)], щоб
тестовий код не потрапляв у релізний бінарник:
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);
}
#[test]
fn adds_negative_and_positive() {
assert_eq!(add(-2, 3), 1);
}
}
Запуск cargo test компілює проєкт у спеціальному тестовому режимі й запускає кожну функцію,
позначену #[test], паралельно в окремому потоці. Тест вважається неуспішним, якщо він
панікує — найчастіше через макроси assert!, assert_eq! чи assert_ne!, які панікують із
детальним повідомленням, коли умова не виконана:
running 2 tests
test tests::adds_two_positive_numbers ... ok
test tests::adds_negative_and_positive ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured
Тести — це той самий механізм, яким автотести перевірятимуть лабораторні роботи цього курсу:
функція, що не проходить assert_eq!, провалює тест так само надійно, як і код, що взагалі не
компілюється.
За замовчуванням значення в Rust живуть на стеку: прості типи фіксованого розміру (i32, f64,
bool, char, кортежі з таких типів) копіюються і знищуються разом зі своєю областю видимості
без жодного звернення до аллокатора. Щойно потрібна ділянка динамічного розміру чи ділянка, яка
має пережити поточну функцію, — використовується тип, що всередині утримує вказівник на купу:
String, Vec<T>, Box<T>. Сам Box<T> як змінна лежить на стеку (це просто вказівник плюс
трохи метаданих), а дані, на які він вказує, — на купі.
let x: i32 = 5; // повністю на стеку
let boxed: Box<i32> = Box::new(5); // вказівник на стеку, значення 5 — на купі
Це розділення прямо готує ґрунт для наступної лекції: саме через те, що типи на купі не можна дешево копіювати, Rust за замовчуванням переміщує (move) власність замість того, щоб копіювати дані — і саме це унеможливлює double free структурно, ще до появи перевірки запозичень (borrow checker) як такої.
| Пастка | Чому це проблема | Як уникнути |
|---|---|---|
Плутати cargo build і cargo check під час розробки |
build генерує повний бінарник — повільніше |
Використовуйте cargo check для швидкої ітерації, build/run — коли треба виконати |
Забути #[cfg(test)] над тестовим модулем |
Тестовий код і залежності (наприклад, use super::*) потрапляють у реліз |
Завжди огортайте тести в #[cfg(test)] mod tests { ... } |
Плутати assert_eq! з ручним if + println! |
Ручна перевірка не зупиняє тест і не дає структурованого звіту | Використовуйте макроси assert*! — вони панікують із діагностикою |
| Думати, що санітайзер C замінює перевірку компілятора | ASan/Valgrind ловлять лише пройдені шляхи виконання | Статична перевірка (як у Rust) покриває всі шляхи одразу |
Помилки керування пам'яттю — use-after-free, double free, переповнення буфера, витоки — не є
рідкісною екзотикою: за даними Microsoft і Google, вони формують більшість критичних CVE в
масштабних кодових базах на C/C++. Причина системна: C довіряє програмісту відстежувати життєвий
цикл кожної ділянки пам'яті вручну, а компілятор жодним чином це не перевіряє; санітайзери
ловлять такі помилки лише в рантаймі й лише на пройдених шляхах виконання. Rust пропонує третій
шлях між ручним керуванням і збирачем сміття: правила володіння пам'яттю перевіряються статично,
під час компіляції, без жодних накладних витрат у скомпільованому бінарнику. Інструментарій для
цього — rustup, cargo new, cargo build/run/test — стандартний і вбудований, включно з
тестуванням через #[test] та assert_eq!. Наступні три лекції розберуть механізм цієї
перевірки в деталях: володіння (Л5), запозичення (Л6), Option/Result (Л7).
Фокус: ціна помилки, а не синтаксис жодної з мов
Стек дешевий і безпечний за конструкцією; купа — джерело всіх подальших проблем
| Ознака | Стек | Купа |
|---|---|---|
| Розмір | фіксований, відомий під час компіляції | динамічний, часто невідомий заздалегідь |
| Виділення | O(1): зсув вказівника стека | пошук вільного блоку аллокатором |
| Час життя | область видимості функції | явно (free) або через володіння |
| Типові дані | локальні змінні, i32, bool | Box, Vec, String, об'єкти |
| Джерело помилок | переповнення стека (рекурсія) | use-after-free, double free, витік |
Стек дешевий і безпечний за конструкцією; купа — джерело всіх подальших проблем
П'ять перших формують левову частку критичних уразливостей CWE Top 25 десятиліттями
int *p = malloc(sizeof(int));
*p = 3101; // 0xC1D
free(p);
printf("%d\n", *p); // UB: читаємо звільнену пам'ять
*p = 100; // UB: пишемо у звільнену пам'ятьКомпілятор мовчить: жодного попередження за замовчуванням
p — просто числоСанітайзери (ASan, Valgrind) ловлять це під час виконання — і лише на пройдених шляхах
Ціна — не в моменті написання рядка, а коли рядок зустрічає реальний, часто зловмисний вхідний потік
| Підхід | Мови | Хто перевіряє | Ціна в рантаймі | Помилки пам'яті |
|---|---|---|---|---|
Ручне (malloc/free) | C, C++ | програміст | немає | можливі всі п'ять |
| Збирач сміття (GC) | Java, Python, Go, JS | рантайм | паузи stop-the-world, трасування | здебільшого виключені |
| Володіння (ownership) | Rust | компілятор | немає | виключені на етапі компіляції |
Rust — спроба взяти безпеку GC-мов і швидкість C без компромісу в рантаймі
let p = Box::new(42);
drop(p); // явно звільняємо пам'ять
println!("{}", p); // error[E0382]: use of moved value: `p`
// компілятор відмовляється зібрати цей код —
// баг знайдено ДО того, як програма хоч раз запустиласьПомилка виявлена на етапі компіляції, а не на етапі експлуатації
Ліворуч C: free(p) лишає p висячим, *p — UB уже в проді. Праворуч Rust: після drop(p) використання p — error[E0382], бінарник просто не збереться
Очікувано: C: непередбачувана поведінка або крах у рантаймі. Rust: помилка компіляції, програма взагалі не збирається
Санітайзер знаходить помилку після запуску; компілятор — до появи бінарника
--version — перевіркаОдин інструмент (rustup) керує компілятором, Cargo і документацією одразу
cargo new lockpick
cd lockpick
tree .
# lockpick/
# ├── Cargo.toml ← маніфест: назва, версія, залежності
# ├── Cargo.lock ← точні версії залежностей (генерується автоматично)
# └── src/
# └── main.rs ← точка входуcargo new одразу ініціалізує git-репозиторій, якщо він ще не існує
| Команда | Що робить | Коли |
|---|---|---|
cargo check | перевіряє компіляцію без бінарника | найшвидша ітерація під час написання |
cargo build / run | компілює / компілює й запускає (debug) | коли треба виконати |
cargo test | запускає юніт- та інтеграційні тести | перед кожним комітом; автотести ЛР |
cargo add <crate> | додає залежність у Cargo.toml | потрібен зовнішній крейт |
cargo fmt | форматує за офіційним стилем | перед комітом |
cargo clippy | лінтер антипатернів | перед рев'ю |
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)]` виключає тестовий модуль з релізного бінарника
Семантичне версіювання: мажорна.мінорна.патч; ^ дозволяє оновлення в межах мажорної
Очікувано: Перший запуск: test result: FAILED. Другий запуск: test result: ok. 1 passed; 0 failed
Наступні три лекції — як саме працює перевірка: володіння, запозичення, Option/Result
Тестування програми може показати наявність помилок, але ніколи — їхню відсутність. — Едсгер Дейкстра, «Notes on Structured Programming» (1970)
Кожна команда терміналу з цієї сторінки — одним реченням. Позначка нове — команда зустрічається в курсі вперше; далі вважаємо її знайомою.
cargo buildtarget/debug/; --release — з оптимізаціями у target/release/.cargocargo newновеCargo.toml і src/main.rs (cargo new lockpick; --lib — бібліотека).cdновеcd .. — на рівень вище.treeновеcargo testнове#[test]; можна вказати ім'я тесту (cargo test назва_тесту) або -- --show-output.curlcurl -i http://localhost:8080/ — разом із заголовками); також уміє завантажувати файли й скрипти.shbash може бути відсутній.rustcrustc --version, rustc --explain E0382 — розшифрувати помилку), зазвичай його запускає Cargo.rustupновеrustup defaultновеrustup default stable).cargo runнове-- йдуть програмі (cargo run -- staff.log).cargo checkновеcargo addновеCargo.toml (cargo add serde --features derive).cargo fmtновеcargo clippyнове💡 Прочитати README прямо в терміналі: cat README.md (виведе весь файл) або less README.md (посторінково; вихід — клавіша q).
Довідка з будь-якої команди: man curl (повний посібник, вихід — q) або коротко curl --help; для підкоманд — docker run --help, cargo build --help.