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, связанные не столько с «памятью как контейнером», сколько с «операциями языка»: деление на ноль, переполнение signed-типа, некорректные сдвиги и подобные вещи.

Чтобы не превращать это в список «пятьдесят оттенков 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(), диапазоны типов) всё равно остаётся вашей обязанностью.

1
Задача
C++ SELF, 35 уровень, 1 лекция
Недоступна
Средний шаг
Средний шаг
1
Задача
C++ SELF, 35 уровень, 1 лекция
Недоступна
Справочная будка
Справочная будка
1
Задача
C++ SELF, 35 уровень, 1 лекция
Недоступна
Датчик пяти
Датчик пяти
1
Задача
C++ SELF, 35 уровень, 1 лекция
Недоступна
Сдвиг шифра
Сдвиг шифра
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ