1. Быстрая ориентация в отчёте
Когда вы впервые видите вывод AddressSanitizer или UndefinedBehaviorSanitizer, возникает желание закрыть терминал и уйти в лес выращивать картошку. Там хотя бы «out of range» пишут на человеческом. Но на самом деле отчёт санитайзера длинный не из вредности: он специально даёт максимум контекста, чтобы вы нашли первопричину, а не просто «место, где всё взорвалось».
Главная идея лекции очень практичная: отчёт санитайзера нужно читать не «сверху вниз», а как карту. В этой карте есть несколько опорных точек: тип проблемы, координаты (файл/строка), стек вызовов и иногда дополнительная диагностика (например, «на сколько байт вы вылезли за границу»). Если научиться выхватывать эти опорные точки, то даже самый длинный отчёт превращается в короткий сценарий расследования: «что случилось → где это поймали → кто принёс плохие данные → почему он их принёс».
Что искать глазами в первые 10 секунд
Любой хороший отчёт санитайзера, даже если он выглядит по‑разному в GCC/Clang и на разных ОС, почти всегда содержит одни и те же смысловые блоки. Ваша задача — научиться узнавать эти блоки, как дорожные знаки: «осторожно, поворот», «тупик», «здесь можно повернуть назад».
Ниже — удобная «карта» (таблица) того, что обычно важно именно новичку. Это не справочник по всем полям отчёта, а именно набор «маячков», которые помогают быстро понять, куда смотреть.
| Блок в отчёте | Как обычно выглядит | Что это значит для вас |
|---|---|---|
| Тип проблемы | |
Это «название болезни». С него начинаем: что именно нарушено. |
| Место обнаружения | |
Это место, где санитайзер заметил проблему. Оно не всегда равно месту, где вы придумали неправильный индекс/делитель. |
| Stack trace | |
Это «как мы сюда пришли». По нему почти всегда ищется первопричина. |
| Доп. детали (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 size = " << data.size() << '\n'; // data size = 3
std::cout << "Type command: last2avg / norm / exit\n";
std::string cmd;
while (std::cin >> cmd && cmd != "exit") {
std::cout << "Unknown command\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. Полезные приёмы и детали
Как отличать «мои фреймы» от «не моих» в stack trace
Когда вы начинаете работать с санитайзерами, стек вызовов часто содержит не только ваш код, но и половину стандартной библиотеки, рантайм, точки входа процесса и даже какие‑то загадочные адреса. На этом месте мозг новичка говорит: «я ничего не понимаю, значит это не моя вина». Спойлер: почти всегда ваша.
Смысловой приём здесь простой. Вы мысленно делите стек на две части: «верхние фреймы» обычно ближе к месту аварии, «нижние» ближе к 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. Санитайзер обычно покажет выход за границы, но первопричина будет именно в неверно сформулированном условии корректности.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ