JavaRush /Курси /C++ SELF /Типовий звіт санітайзера: як читати стек

Типовий звіт санітайзера: як читати стек

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

1. Швидка орієнтація у звіті

Коли ви вперше бачите вивід AddressSanitizer або UndefinedBehaviorSanitizer, виникає бажання закрити термінал і піти в ліс вирощувати картоплю. Там хоча б «out of range» пишуть людською мовою. Але насправді звіт санітайзера такий довгий не з лихого наміру: він спеціально дає максимум контексту, щоб ви знайшли першопричину, а не просто «місце, де все вибухнуло».

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

На що дивитися в перші 10 секунд

Будь-який хороший звіт санітайзера, навіть якщо він має різний вигляд у GCC/Clang і в різних ОС, майже завжди містить одні й ті самі смислові блоки. Ваше завдання — навчитися впізнавати ці блоки, як дорожні знаки: «обережно, поворот», «тупик», «тут можна розвернутися».

Нижче — зручна «мапа» (таблиця) того, що зазвичай важливо саме новачкові. Це не довідник з усіх полів звіту, а набір «маячків», які допомагають швидко зрозуміти, куди дивитися.

Блок у звіті Як зазвичай виглядає Що це означає для вас
Тип проблеми
heap-buffer-overflow
stack-buffer-overflow
runtime error: division by zero
signed integer overflow
Це «назва хвороби». Із цього й починаємо: що саме порушено.
Місце виявлення
main.cpp:42:17
... at main.cpp:42
Це місце, де санітайзер помітив проблему. Воно не завжди збігається з місцем, де ви обчислили неправильний індекс або дільник.
Стек викликів
#0 ... #1 ... #2 ...
Це відповідь на запитання «як ми сюди прийшли». Саме тут майже завжди ховається першопричина.
Додаткові деталі (ASan)
READ of size 4
0 bytes to the right
shadow bytes
Це підказки про масштаб проблеми й тип доступу до памʼяті. Корисно, але другорядно.
Службові фрейми Згадки про libc, startup, sanitizer runtime Їх часто можна ігнорувати, доки не навчитеся впевнено знаходити першопричину.

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

Алгоритм читання звіту

Майже будь-яка діагностика — а санітайзери в цьому сенсі дуже чесні інструменти — найкраще працює тоді, коли ви маєте повторюваний алгоритм. Вам не хочеться щоразу імпровізувати, як детектив у поганому серіалі. Хочеться мати просту процедуру: зробили раз, зробили два — і мозок не перегрівається.

Зафіксуймо «уявний конвеєр». Він короткий і майже завжди працює:

flowchart TD
    A[Визначити тип проблеми] --> B[Знайти перше посилання на ваш .cpp/.hpp]
    B --> C[Подивитися на рядок коду: що саме ми робимо небезпечно]
    C --> D[Піднятися стеком: хто передав аргументи]
    D --> E[Сформулювати інваріант: що має бути істинним]
    E --> F[Знайти першопричину: де порушили інваріант]

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

3. Міні‑проєкт для прикладів

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

Ідея така: користувач вводить числа (в один рядок), потім вводить команду. Команди прості: last2avg (середнє останніх двох), norm (нормалізація: x / sum), і exit.

Почнімо з каркаса (поки що без небезпечних місць):

#include <iostream>
#include <string>
#include <vector>

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

    std::cout << "Розмір data = " << data.size() << '\n'; // Розмір data = 3
    std::cout << "Введіть команду: last2avg / norm / exit\n";

    std::string cmd;
    while (std::cin >> cmd && cmd != "exit") {
        std::cout << "Невідома команда\n";
    }
}

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

4. Розбір типових сценаріїв

Приклад з ASan: вихід за межі std::vector

Ситуація цілком життєва: ви хочете взяти «останній елемент» і випадково пишете v[v.size()]. На рівні природної мови це здається логічним («розмір — значить останній»), але в C++ розмір — це кількість, а останній індекс — size() - 1. Помилка дуже поширена, і санітайзер тут корисний саме тим, що ловить її максимально близько до місця читання.

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

#include <cstddef>
#include <vector>

int ReadAt(const std::vector<int>& v, std::size_t i) {
#if 0
    return v[i]; // потенційний UB, якщо i поза діапазоном
#else
    return 0;
#endif
}

int LastTwoAverage(const std::vector<int>& v) {
    std::size_t last = v.size();      // ПОМИЛКА в ідеї: last має бути size()-1
    int a = ReadAt(v, last);          // тут і буде «вибух», якщо код увімкнено
    int b = ReadAt(v, last - 1);
    return (a + b) / 2;
}

Тепер уявіть, що ви увімкнули небезпечний рядок (#if 1) і запустили програму під ASan. Ви можете отримати звіт, який у «скелеті» виглядає приблизно так (спрощено, без зайвих подробиць):

==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x...
READ of size 4 at 0x... thread T0
    #0 ReadAt(std::vector<int> const&, unsigned long) main.cpp:8
    #1 LastTwoAverage(std::vector<int> const&)        main.cpp:15
    #2 main                                           main.cpp:42
...

Ось тут і починається головна вправа лекції. Не треба читати все. Ви робите чотири кроки.

Спочатку ви фіксуєте тип: heap-buffer-overflow. Це означає: «ми читаємо або пишемо за межами виділеного буфера в купі». Для std::vector це типова ситуація: його памʼять зазвичай живе в heap. Уже зрозуміло, що в нас проблема з індексом.

Далі ви шукаєте перше посилання на свій файл. У прикладі це ReadAtmain.cpp:8. І тут одразу виникає спокуса: «ага! помилка в ReadAt!». Але ReadAt — це лише «місце виявлення»: він чесно робить те, що його попросили, тобто «читає за індексом i». Справжнє питання таке: чому i виявився неправильним?

І ось тут у гру вступає стек. Наступний рядок #1 LastTwoAveragemain.cpp:15 показує, хто викликав ReadAt і що саме туди передав. Ми повертаємося до LastTwoAverage і бачимо рядок std::size_t last = v.size(). Ось вона, першопричина: ми переплутали «розмір» і «останній індекс». Санітайзер допоміг тим, що показав шлях: ReadAtLastTwoAveragemain.

Дуже корисно уявляти стек як «драбину викликів»:

flowchart BT
    A[main] --> B[LastTwoAverage]
    B --> C[ReadAt]
    C --> D["оператор[] читає памʼять"]

У цей момент ви вже можете сформулювати інваріант: «якщо я збираюся читати v[i], то має бути i < v.size()». Причому цей інваріант стосується не лише ReadAt, а й усіх, хто його викликає.

Виправлення в межах самої ідеї зазвичай елементарне: останній індекс — це v.size() - 1, але лише якщо вектор не порожній. Ми не пишемо тут assert (це тема наступної лекції), але фіксуємо думку: «потрібна перевірка на порожнечу і коректна арифметика індексів».

Приклад з UBSan: ділення на нуль

Після ASan‑звіту UBSan зазвичай виглядає майже дружньо: він часто пише runtime error: ... і показує координати. Але тут є інша підступність: іноді ділення на нуль «народжується» далеко від місця самого ділення, і без стека ви виправлятимете не причину, а симптом.

Зробімо нормалізацію: беремо суму і ділимо елементи на суму. Якщо сума дорівнює нулю (наприклад, усі числа — нулі), це стає проблемою.

#include <vector>

int Sum(const std::vector<int>& v) {
    int s = 0;
    for (int x : v) s += x;
    return s;
}

int NormalizeFirst(const std::vector<int>& v) {
    int total = Sum(v);
#if 0
    return v[0] / total; // UB, якщо total == 0 (зазвичай це діагностує UBSan)
#else
    return 0;
#endif
}

Типовий «скелет» звіту UBSan може виглядати так:

main.cpp:16:17: runtime error: division by zero
    #0 NormalizeFirst(std::vector<int> const&) main.cpp:16
    #1 main                                   main.cpp:45

Тут менше шуму, але читається звіт так само. Спочатку ви фіксуєте тип: division by zero. Отже, ваш інваріант максимально простий: «дільник має бути не нульовим».

Далі ви бачите місце: NormalizeFirst. І вже в цій функції шукаєте, звідки взявся дільник: int total = Sum(v). Це підказка: «першопричина може бути або в даних, або в Sum».

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

Саме тому корисно формулювати інваріанти словами: «нормалізація можлива лише за умови total != 0». Щойно ви це проговорили, одразу стає зрозуміло, де ставити перевірку: до ділення.

«Місце виявлення» і «місце народження»

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

Припустімо, у вас є команда get i, яка повертає елемент за індексом. Індекс надходить від користувача. Ми поки що спеціально не робимо надійне введення й не перевіряємо межі — нам потрібен «сюжет» для стека.

#include <cstddef>
#include <iostream>
#include <string>
#include <vector>

int ReadAt(const std::vector<int>& v, std::size_t i) {
#if 0
    return v[i]; // UB, якщо i >= v.size()
#else
    return 0;
#endif
}

std::size_t ParseIndex(const std::string& s) {
    return static_cast<std::size_t>(std::stoi(s));
}

int ProcessGet(const std::vector<int>& v, const std::string& arg) {
    std::size_t i = ParseIndex(arg);
    return ReadAt(v, i);
}

Як виглядатиме «скелет» звіту ASan? Він майже напевно вкаже на ReadAt. Наприклад:

ERROR: AddressSanitizer: heap-buffer-overflow
    #0 ReadAt(...)      main.cpp:8
    #1 ProcessGet(...)  main.cpp:21
    #2 main             main.cpp:50

Якщо ви зупинитеся на ReadAt і почнете «поліпшувати ReadAt», то можете застрягти в нескінченному рефакторингу: замінити [] на at(), додати друк, додати щось іще. І це іноді корисно, але першопричина все одно залишиться: ParseIndex може повернути що завгодно, а ви не перевірили діапазон.

Ось чому стек — це не «додаткові рядки». Це пряма відповідь на запитання: «хто приніс бомбу».

У реальному коді ви сформулювали б контракт: ProcessGet має або перевіряти i < v.size(), або повертати помилку/повідомлення. Але навіть якщо ви ще не проходили всіх стратегій обробки помилок, сама навичка «піднятися по стеку й знайти місце, де дані стали поганими» — це вже половина дорослої розробки.

5. Корисні прийоми та деталі

Як відрізняти «мої фрейми» від «не моїх» у стеку викликів

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

Прийом тут простий. Ви подумки ділите стек на дві частини: «верхні фрейми» зазвичай ближчі до місця аварії, «нижні» — ближчі до main і запуску процесу. Ваша мета — знайти перший рядок, який вказує на ваш файл (main.cpp, src/..., include/...). Усе, що «навколо», поки що другорядне.

Якщо у звіті є 30 рядків, а ваш код трапляється в рядках #0, #1, #2, то ви вже в хорошій ситуації: ланцюжок короткий, першопричина поруч.

Якщо ваш код трапляється лише на #7, а вище йдуть std::..., libc++, libstdc++, то це зазвичай означає, що ви викликали щось стандартне з неправильними передумовами. Тоді ваш «якір» — фрейм із вашим файлом, а далі ви читаєте стек угору й ставите запитання: «які аргументи я передав і які умови мав забезпечити».

Міні‑шпаргалка з ASan‑деталей

ASan іноді показує багато «мікродеталей»: розмір читання, адресу, «0 bytes to the right», shadow bytes. Новачкові легко в цьому застрягти й почати розбирати байти, як археолог, який знайшов давній носок і намагається зрозуміти культуру за діркою в пʼятці.

Підійдімо до цього спокійно: деталі потрібні, але в помірній кількості.

Якщо ви бачите «READ of size 4», це зазвичай означає читання int (часто 4 байти). Якщо «READ of size 1» — це може бути char або байтовий доступ. Це допомагає зрозуміти, яка операція сталася, але першопричину частіше підказує стек.

Фраза на кшталт «0 bytes to the right of 12-byte region» часто означає: «ви потрапили рівно на перший байт за масивом». Для vector<int> із трьох елементів це класичний випадок: 3 * 4 = 12 байтів, а індекс 3 — це «рівно за межами».

Це корисно як підтвердження гіпотези: «ага, я справді переплутав size і останній індекс».

6. Типові помилки під час читання звіту санітайзера

Помилка № 1: читати звіт суворо зверху вниз, як роман.
Так ви витрачаєте час на службові рядки й втрачаєте «якорі». Звіт треба читати як мапу: спочатку тип проблеми, потім перше посилання на ваш код, потім стек, і лише за потреби — деталі про байти та адреси.

Помилка № 2: лагодити рядок, на який вказав санітайзер, не піднімаючись по стеку.
Санітайзер майже завжди показує «місце виявлення». Але першопричина часто вище: там, де індекс обчислили, дільник отримали, розмір не перевірили. Якщо не пройти ланцюжком #0#1#2, ви ризикуєте без кінця «лікувати симптоми».

Помилка № 3: ігнорувати фразу «runtime error: …» так, ніби це просто попередження.
UBSan пише мʼяко, але зміст у нього жорсткий: сталася дія, після якої мова не гарантує нормальної поведінки. Це не «ну, інколи буває», а сигнал: «у цьому місці не можна продовжувати жити так, ніби нічого не сталося».

Помилка № 4: лякатися фреймів стандартної бібліотеки й робити висновок «зламалася STL».
STL ламається вкрай рідко, а от передати в неї неправильні передумови — вкрай легко. Якщо ваш код у стеку є хоча б один раз, починайте з нього і ставте запитання: «який контракт я порушив?».

Помилка № 5: плутати «вектор порожній» і «вектор замалий».
Перевірка if (!v.empty()) захищає від v[0], але не захищає від ситуації «хочу останні два елементи». Для «останні два» потрібна умова v.size() >= 2. Санітайзер зазвичай покаже вихід за межі, але першопричина буде саме в неправильно сформульованій умові коректності.

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