JavaRush /Курси /C++ SELF /ASan/UBSan: які класи проблем вони виявляють

ASan/UBSan: які класи проблем вони виявляють

C++ SELF
Рівень 35 , Лекція 1
Відкрита

1. Навіщо потрібні санітайзери і як вони працюють

Якщо ви пишете програму і вона «нібито запускається», мозок автоматично ставить галочку: «готово». Я вас чудово розумію: мозку подобається закривати вкладки. Проблема в тому, що деякі помилки в C++ не зобовʼязані проявлятися одразу. Вони можуть датися взнаки лише за іншого рівня оптимізації, на іншому компʼютері, після невеликого рефакторингу або взагалі лише тоді, коли ви вже показали демо замовнику. І зазвичай це стається в найнезручніший момент.

Санітайзери потрібні, щоб програма падала раніше й зрозуміліше, замість того щоб тихо псувати памʼять або обчислювати безглузді значення. Їх зручно сприймати як «режим підвищеної чесності»: компілятор додає до вашої програми додаткові перевірки, а в разі порушення правил друкує докладний звіт.

Важливо памʼятати: санітайзер — це не «ще одна бібліотека», яку ми підключаємо через #include <sanitizer>. Це саме режим збирання, коли компілятор інструментує машинний код, тобто додає перевірки.

Інструментування: чому це інший бінарник

Коли ми кажемо «зібрати із санітайзером», то маємо на увазі, що компілятор вставляє додаткові інструкції: перевірки меж, перевірки коректності операцій, «охоронні зони» навколо масивів та інші хитрощі. У результаті маємо виконуваний файл, який працює повільніше, зате дуже уважно стежить за тим, що відбувається.

Зручно уявити це як дві різні реальності того самого вихідного коду:

flowchart LR
    A[main.cpp] --> B[Звичайне збирання]
    A --> C[Збирання із санітайзером]
    B --> D[app]
    C --> E[app_sanitized]
    D --> F[Запуск: швидко, але без «детектора брехні»]
    E --> G[Запуск: повільніше, зате виявляє цілі класи помилок]

Тобто ви не «вмикаєте ASan на хвилинку всередині програми». Ви створюєте інше збирання (часто навіть окремий каталог build-asan/), запускаєте його й дивитеся, що воно скаже.

2. ASan і UBSan: ролі й зона відповідальності

Сьогодні нас цікавлять два санітайзери, які найчастіше дають найбільше користі початківцю. І не лише початківцю.

ASan: AddressSanitizer

Він спеціалізується на помилках доступу до памʼяті. На цьому етапі для нас це насамперед «вийшли за межі масиву, буфера чи вектора» і «прочитали або записали не туди».

UBSan: UndefinedBehaviorSanitizer

Він діагностує деякі види Undefined Behavior, повʼязані не стільки з «памʼяттю як контейнером», скільки з «операціями мови»: діленням на нуль, переповненням типу зі знаком, некоректними зсувами та подібними речами.

Щоб не перетворювати це на список «пʼятдесят відтінків UB», зробімо компактну таблицю — своєрідну мапу місцевості:

Інструмент Що ловить найкраще Найтиповіші «влучання» на нашому рівні
ASan
Помилки доступу до памʼяті вихід за межі масиву a[i], помилки індексації vector при operator[], запис за кінець буфера
UBSan
Деякі види UB у виразах ділення на 0, signed overflow, некоректний бітовий зсув

Важлива думка: ASan і UBSan не конкурують, а доповнюють одне одного. Часто ви вмикаєте один із них, коли маєте конкретну підозру, а інколи — обидва, щоб не гадати.

3. ASan: які помилки доступу до памʼяті ловить

Коли кажуть «помилки памʼяті», початківці часто думають про new/delete і страшні вказівники. Але найчастіший шлях до проблем із памʼяттю для тих, хто тільки починає, — це зовсім не new/delete, а банальний неправильний індекс.

Тут ASan справді виручає: він перетворює «випадкове псування даних» на чіткий сигнал: «ви вийшли за межі, ось рядок, ось стек».

Вихід за межі C-масиву: stack-buffer-overflow

Почнімо з класики: масиву фіксованого розміру.

#include <iostream>

int main() {
    int a[3] = {10, 20, 30};

#if 0
    std::cout << a[3] << '\n'; // UB: індекс 3 поза діапазоном 0..2
#endif

    std::cout << a[0] << '\n'; // 10
}

Чому це важливо саме в контексті ASan? Без санітайзера такий код може «просто вивести сміття», може «випадково вивести 0», а може «впасти не тут». З ASan ви, найімовірніше, отримаєте зрозуміле повідомлення на кшталт stack-buffer-overflow і місце, де це сталося.

Вихід за межі std::vector при operator[]

std::vector — контейнер зручний, але його operator[] не перевіряє межі. Це свідомий дизайн: швидше, але небезпечніше.

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v{1, 2, 3};
    std::size_t i = v.size(); // i == 3

#if 0
    std::cout << v[i] << '\n'; // UB: v[3] — це «за кінцем»
#endif

    std::cout << v[2] << '\n'; // 3
}

Ця помилка настільки типова, що її варто запамʼятати як рефлекс: size() — це кількість елементів, а останній індекс — size() - 1 (якщо контейнер не порожній).

Запис за межі: зазвичай небезпечніший за читання

Читання «за межею» вже погане. Але запис «за межею» зазвичай гірший: ви псуєте памʼять, і програма може ще довго працювати, доки не впаде зовсім в іншому місці.

#include <vector>

int main() {
    std::vector<int> v{10, 20, 30};

#if 0
    v[3] = 99; // UB: пишемо за кінець
#endif

    return 0;
}

Тут ASan особливо корисний, бо часто ловить такі речі «на місці злочину», а не за годину, коли у вас зламалося сортування.

Що ASan не гарантує зловити

ASan ловить цілі класи проблем, але він не чарівник і не психотерапевт для вашої бізнес-логіки. Якщо ви переплутали формулу й рахуєте середнє як sum / (n + 1) — це не до ASan. Якщо ви неправильно обробили введення, це теж не до ASan.

Саме тому санітайзер — не «заміна уважності», а підсилювач: він допомагає ловити ті помилки, які інакше виглядали б як хаос.

4. UBSan: які види UB діагностує

UBSan корисний там, де код компілюється, виглядає «майже нормально», але порушує жорсткі правила мови. Підступність таких місць у тому, що компілятор має право оптимізувати їх так, що логіка почне поводитися дивно.

До речі, навіть стандартна бібліотека і сам стандарт постійно застерігають: «ось тут поведінка не визначена, так робити не можна», особливо в усьому, що стосується арифметики та ітераторів.

Ділення на нуль у цілих числах

Навіть якщо ви «в математиці бачили такий вираз», у цілочисельній арифметиці C++ ділення на 0 — UB.

#include <iostream>

int main() {
    int a = 10;
    int b = 0;

#if 0
    int c = a / b;            // UB: ділення на 0
    std::cout << c << '\n';
#endif

    std::cout << a << '\n';   // 10
}

UBSan зазвичай повідомляє щось на кшталт runtime error: division by zero. І це дуже зручно: ви перестаєте гадати, «чому воно впало».

Signed overflow: переповнення int — це UB

Багато хто приходить у C++ з очікуванням: «ну, переповнилося й стало відʼємним, як у звичному двокомплементарному світі». Але за правилами мови переповнення signed цілого типу — UB.

#include <iostream>
#include <limits>

int main() {
    int x = std::numeric_limits<int>::max();

#if 0
    int y = x + 1;            // UB: signed overflow
    std::cout << y << '\n';
#endif

    std::cout << x << '\n';   // 2147483647 (зазвичай)
}

Практичний висновок без фанатизму: якщо ви рахуєте суми, добутки, накопичуєте результат у циклі, думайте про діапазон типів. Іноді достатньо замінити int на long long. Іноді треба перевіряти межі. А UBSan корисний як сигнал: «ось тут ви справді порушили правила мови».

Некоректні зсуви: відʼємні й занадто великі

Бітові операції виглядають як арифметика, але в них є суворі вимоги: не можна зсувати на відʼємне число і не можна зсувати «занадто сильно».

#include <iostream>

int main() {
    int x = 1;
    int shift = -1;

#if 0
    int y = x << shift;       // UB: відʼємний зсув
    std::cout << y << '\n';
#endif

    std::cout << x << '\n';   // 1
}

На практиці такі помилки виникають, коли shift обчислюється з вхідних даних або з розміру типу чи масиву, а ви не перевірили межі.

5. Як вмикати ASan/UBSan у збиранні

Увімкнення санітайзерів майже завжди робиться прапорами компілятора. Не заглиблюватимемося в тонкощі різних платформ і рідкісних режимів: нам потрібен базовий робочий рецепт.

Типова форма команди

Якщо ви компілюєте з консолі (або IDE робить це за вас), загальна ідея така:

# ASan
g++     -std=c++23 -O0 -g -fsanitize=address     main.cpp
clang++  -std=c++23 -O0 -g -fsanitize=address     main.cpp

# UBSan
g++     -std=c++23 -O0 -g -fsanitize=undefined   main.cpp
clang++  -std=c++23 -O0 -g -fsanitize=undefined   main.cpp

Чому тут майже завжди використовують -O0 і -g?

Із -g санітайзер може показати вам нормальні «файл:рядок», а не загадкові адреси. Із -O0 поведінка ближча до «як написано», а звіти зазвичай зрозуміліші. Оптимізації не заборонені, але для навчання й діагностики на рівні початківця простіше починати з -O0.

В IDE та CMake: загальна ідея

Якщо проєкт на CMake, ви зазвичай додаєте прапори до цільового застосунку. Ідея приблизно така: це не «єдино правильний» варіант, а просто зрозумілий шаблон.

# Приклад-ідея: у CMakeLists.txt
target_compile_options(app PRIVATE -g -O0 -fsanitize=address)
target_link_options(app PRIVATE -fsanitize=address)

Суть у тому, що санітайзер — це не лише компіляція, а й лінкування: підключаються потрібні runtime-компоненти.

6. Практичний приклад: StepStats і пошук помилок

Далі ми розвиватимемо маленький консольний застосунок. Нехай він зберігає кроки за днями й рахує статистику. Це навмисно проста задача: ми хочемо, щоб увага була зосереджена на діагностиці, а не на тому, «як спроєктувати бізнес-логіку».

Базова структура даних

#include <vector>

struct StepStats {
    std::vector<int> steps;
};

Жодної магії: steps — просто список значень.

Функція додавання значення

#include <vector>

void AddSteps(std::vector<int>& steps, int value) {
    steps.push_back(value);
}

Це «нудний» код — і це добре. Чим більше у вас нудних функцій, тим менше несподіваних пригод.

Середнє: місце, де легко отримати UB

Середнє здається невинним, але тут є дві типові проблеми: «ділення на нуль» і «переповнення під час підсумовування». Почнімо з найбазовішої версії й подивімося, де тут ризик.

#include <vector>

double Average(const std::vector<int>& steps) {
    int sum = 0;
    for (int x : steps) sum += x;
    return static_cast<double>(sum) / steps.size();
}

Якщо steps порожній, steps.size() дорівнює 0, і ділення перетворюється на UB. UBSan якраз любить такі місця: він не міркує, «а чи хотів програміст так», а просто повідомляє: «ділення на 0».

Щоб не лишати в коді «мін», зробімо безпечнішу версію:

#include <vector>

double AverageOrZero(const std::vector<int>& steps) {
    if (steps.empty()) return 0.0;
    long long sum = 0;
    for (int x : steps) sum += x;
    return static_cast<double>(sum) / steps.size();
}

Тут ми одночасно прибрали ділення на 0 і знизили ризик signed overflow, використавши long long для суми.

Типова помилка індексації: кандидат для ASan

Уявімо, що ми хочемо отримати «останнє значення». Початківець іноді пише так:

#include <vector>

int LastBad(const std::vector<int>& v) {
#if 0
    return v[v.size()]; // UB: size() — не індекс останнього елемента
#else
    return 0;
#endif
}

У реальному коді це може «працювати» роками, доки вектор малий і вам «щастить». Але з ASan шанси, що помилку спіймають, різко зростають.

Правильна безпечна версія може виглядати так:

#include <vector>

int LastOrZero(const std::vector<int>& v) {
    if (v.empty()) return 0;
    return v.back();
}

Так, back() — це «чесний спосіб» узяти останній елемент.

Міні-main, щоб звʼязати все разом

#include <iostream>
#include <vector>

int main() {
    std::vector<int> steps;
    steps.push_back(5000);
    steps.push_back(8300);

    std::cout << AverageOrZero(steps) << '\n'; // 6650
}

Якщо ви поекспериментуєте і зробите steps порожнім, то стара версія Average() дала б UB (ділення на 0). А UBSan допоміг би не «вгадувати» це, а одразу побачити.

7. Практична стратегія вибору санітайзера

Іноді здається, що правильна відповідь — «вмикати все завжди». На практиці це не завжди зручно: звітів може бути багато, програма може помітно сповільнитися, а ви ризикуєте потонути в діагностиці.

Практичніша стратегія така: якщо ви підозрюєте, що проблема в «індексі» або в тому, що «пишемо не туди», починайте з ASan. Якщо проблема схожа на «раптово впало на діленні, зсуві або підозрілій арифметиці», починайте з UBSan. Якщо ви не впевнені, а код невеликий, вмикайте обидва по черзі (або разом, якщо ваше середовище це підтримує) і дивіться, що вийде.

Головна звичка дня: не намагайтеся налагоджувати UB як звичайний баг. Спочатку виключаємо UB (санітайзер + перевірки), а вже потім зʼясовуємо, чому формула неправильна або чому UX сумний.

8. Типові помилки під час роботи з ASan/UBSan

Помилка № 1: намагатися «підключити санітайзер кодом».
Початківці інколи шукають #include або думають, що ASan — це якась функція на кшталт EnableAsan(). Насправді санітайзер вмикається прапорами збирання, бо він змінює машинний код: додає перевірки, змінює розкладку памʼяті навколо обʼєктів, підключає runtime-компоненти.

Помилка № 2: збирати із санітайзером, але без налагоджувальної інформації.
Санітайзер і без -g інколи може показати адреси та частину стеку, але для навчання і швидкої локалізації проблеми майже завжди хочеться бачити main.cpp:123. Тому в діагностичному режимі залишайте -g, навіть якщо ви взагалі не любите дебагери.

Помилка № 3: очікувати, що санітайзер знайде логічні помилки.
Якщо програма рахує середнє не так, плутає валюти або неправильно сортує, санітайзери не зобовʼязані цього помічати. Вони ловлять класи помилок, повʼязані з памʼяттю та UB, але не перевіряють «сенс задачі». Для сенсу задачі потрібні тести, уважність і, інколи, добра кава.

Помилка № 4: усувати симптом, а не причину.
Іноді санітайзер свариться на рядок return v[i];, і рука тягнеться «поправити тут». Але часто причина вище: i став неправильним ще в іншій функції. Варто памʼятати: місце, де помилку виявлено, не завжди є місцем, де вона виникла.

Помилка № 5: думати, що якщо санітайзер мовчить, то UB точно немає.
Санітайзери дуже корисні, але вони не дають математичної гарантії «все чисто». Вони підвищують імовірність спіймати помилки й роблять діагностику набагато зручнішою, але дисципліна перевірок (межі, empty(), діапазони типів) усе одно залишається вашим обовʼязком.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ