1. Швидка орієнтація у звіті
Коли ви вперше бачите вивід AddressSanitizer або UndefinedBehaviorSanitizer, виникає бажання закрити термінал і піти в ліс вирощувати картоплю. Там хоча б «out of range» пишуть людською мовою. Але насправді звіт санітайзера такий довгий не з лихого наміру: він спеціально дає максимум контексту, щоб ви знайшли першопричину, а не просто «місце, де все вибухнуло».
Головна ідея лекції дуже практична: звіт санітайзера треба читати не зверху вниз, а як мапу. На цій мапі є кілька опорних точок: тип проблеми, координати (файл/рядок), стек викликів і іноді додаткова діагностика (наприклад, «на скільки байтів ви вийшли за межі»). Якщо навчитися швидко вихоплювати ці опорні точки, то навіть найдовший звіт перетворюється на короткий сценарій розслідування: «що сталося → де це впіймали → хто приніс погані дані → чому він їх приніс».
На що дивитися в перші 10 секунд
Будь-який хороший звіт санітайзера, навіть якщо він має різний вигляд у GCC/Clang і в різних ОС, майже завжди містить одні й ті самі смислові блоки. Ваше завдання — навчитися впізнавати ці блоки, як дорожні знаки: «обережно, поворот», «тупик», «тут можна розвернутися».
Нижче — зручна «мапа» (таблиця) того, що зазвичай важливо саме новачкові. Це не довідник з усіх полів звіту, а набір «маячків», які допомагають швидко зрозуміти, куди дивитися.
| Блок у звіті | Як зазвичай виглядає | Що це означає для вас |
|---|---|---|
| Тип проблеми | |
Це «назва хвороби». Із цього й починаємо: що саме порушено. |
| Місце виявлення | |
Це місце, де санітайзер помітив проблему. Воно не завжди збігається з місцем, де ви обчислили неправильний індекс або дільник. |
| Стек викликів | |
Це відповідь на запитання «як ми сюди прийшли». Саме тут майже завжди ховається першопричина. |
| Додаткові деталі (ASan) | |
Це підказки про масштаб проблеми й тип доступу до памʼяті. Корисно, але другорядно. |
| Службові фрейми | Згадки про 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. Уже зрозуміло, що в нас проблема з індексом.
Далі ви шукаєте перше посилання на свій файл. У прикладі це ReadAt … main.cpp:8. І тут одразу виникає спокуса: «ага! помилка в ReadAt!». Але ReadAt — це лише «місце виявлення»: він чесно робить те, що його попросили, тобто «читає за індексом i». Справжнє питання таке: чому i виявився неправильним?
І ось тут у гру вступає стек. Наступний рядок #1 LastTwoAverage … main.cpp:15 показує, хто викликав ReadAt і що саме туди передав. Ми повертаємося до LastTwoAverage і бачимо рядок std::size_t last = v.size(). Ось вона, першопричина: ми переплутали «розмір» і «останній індекс». Санітайзер допоміг тим, що показав шлях: ReadAt → LastTwoAverage → main.
Дуже корисно уявляти стек як «драбину викликів»:
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. Санітайзер зазвичай покаже вихід за межі, але першопричина буде саме в неправильно сформульованій умові коректності.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ