Программа, которая годами работала на сервере без единого сбоя, после обновления компилятора начинает падать раз в неделю, и стектрейс ведёт на строку, где нет ничего подозрительного. Проверка переполнения, написанная десять лет назад, молчит, потому что оптимизатор её выбросил. Указатель, который годами гулял на шаг за край массива, вдруг начинает портить соседние переменные. Это не случайность: это неопределённое поведение, контракт между программистом и компилятором, который кто-то нарушил первым, а счёт оплатил продакшен. Ниже разбор того, что именно стандарты C и C++ считают неопределённым, где такие ловушки прячутся в живом коде и как вытащить их на свет санитайзерами, не переписывая проект за одну ночь.

Почему компилятор имеет право выбросить проверку переполнения

Неопределённое поведение в языке это не "плохой результат" и не гарантированный крах. Это случай, когда стандарт не накладывает на программу вообще никаких требований. Компилятор, встретив такую конструкцию, волен делать что угодно, и на практике он поступает хуже всего: строит оптимизации на молчаливом предположении, что запрещённой ситуации в программе не существует. Классика жанра:

int check_overflow(int a, int b) {
    if (a > 0 && a + b < a)
        return 1;  // overflow protection
    return 0;
}

На -O0 ветка честно проверяет сумму. На -O2 и GCC, и Clang удаляют её целиком: знаковое переполнение запрещено, значит его не бывает, значит сумма двух положительных чисел всегда не меньше слагаемого, условие ложно всегда. Автор писал защиту, компилятор прочитал её как доказательство отсутствия проблемы и убрал заодно и проблему, и защиту.

Тот же механизм съедает проверки нулевых указателей. Если выше по коду p уже разыменовали, то проверка if (!p) ниже исчезает: разыменование нуля это тоже неопределённое поведение, значит p не нулевой, значит проверка не нужна. Так безобидная на вид функция теряет половину веток после обычного обновления оптимизатора.

Хуже то, что поведение меняется между версиями компилятора. Проект годами собирали с -O1, новая версия GCC подняла агрессивность инлайнера, тела функций начали подставляться друг в друга, и следствия нарушенного контракта всплыли в трёх местах, которые никто не трогал. Тесты на непрерывной интеграции с нулевой оптимизацией не ловят ничего: неопределённое поведение проявляется именно там, где оптимизатор смеет рассуждать.

Знаковое переполнение целых чисел и исчезнувшие проверки корректности

Для беззнаковых типов стандарт гарантирует арифметику по модулю два в степени разрядности. Для знаковых переполнение запрещено полностью, и UBSan печатает это прямо в консоль:

app.cpp:7:5: runtime error: signed integer overflow:
2147483647 + 1 cannot be represented in type 'int'

Ловушки этим не исчерпываются. abs от INT_MIN возвращает INT_MIN, унарный минус для INT_MIN тоже запрещён, деление INT_MIN на -1 на архитектуре x86 вызывает прерывание процессора, сдвиг отрицательного числа и сдвиг на ширину типа одинаково неопределённы. Сюда же относится изменение одной переменной дважды в пределах одного выражения без упорядочивания, классика собеседований, которая встречается и в боевом коде. Каждая из этих операций годами работала в коде без оптимизаций, пока не встречала инлайнер и развернувший цикл оптимизатор.

Отдельная забава это повышения целых типов. Сложение двух значений типа int в переменную типа long переполняется до приведения, потому что операция выполняется в int, а не в long. По этой схеме десятилетиями ломался классический двоичный поиск:

int mid = (lo + hi) / 2;       // overflow on large arrays
int mid = lo + (hi - lo) / 2;  // correct

Ошибка жила даже в промышленных реализациях, всплывала только на больших массивах и чинилась одной строкой, которую потом искали ещё месяц. Отдельный жанр это счётчики:

for (int i = 0; i <= limit; ++i) {
    process(i);
}

Если limit равен INT_MAX, последнее увеличение счётчика выводит его за границу, а переполнение запрещено, значит цикл либо бесконечен, либо код после него недостижим и подлежит удалению. Оба вывода компилятор делает совершенно легально. Авторы санитайзера знают про приём со сравнением a + b < a и предусмотрели отдельный флаг, исключающий такую проверку из инструментирования, но по умолчанию проверка инструментируется и честно ругается на переполнение.

Честных путей три. Первый, беззнаковая арифметика для индексов и размеров, она заворачивается по определению. Второй, встроенная в компилятор проверка:

int sum = 0;
if (__builtin_add_overflow(a, b, &sum)) {
    // handle the overflow
}

Третий, флаг -fwrapv, запрещающий компилятору делать выводы из знакового переполнения. Ядро Linux исторически собирается с -fno-strict-overflow, близким по смыслу: там слишком много кода, который сознательно полагается на заворачивание.

Строгий алиасинг и чтение объекта через указатель другого типа

Второй по коварности контракт звучит просто: обращаться к объекту через указатель несовместимого типа нельзя. Совместимы тип с самим собой, знаковые и беззнаковые варианты одной ширины, и любой объект можно читать через указатель на char. Всё остальное, включая популярный трюк с разбором числа на байты, формально запрещено:

float value = 1.5f;
uint32_t raw = *(uint32_t*)&value;   // aliasing violation
uint32_t bits;
memcpy(&bits, &value, sizeof bits);  // legal, one move instruction

GCC включает рассуждения об алиасинге начиная с -O2, Clang применяет их всегда. Проявление коварное: компилятор доказывает, что запись через bits не могла изменить value, и выбрасывает перечитывание. Значение в регистре устаревает, функция возвращает мусор, и только в релизной сборке. На -O0 оба выражения работают одинаково, поэтому баг месяцами живёт незамеченным.

Пути починки известны давно: memcpy, которую компилятор превращает в одну инструкцию перемещения, union в C, а в C++ остающийся расширением, хотя оба компилятора его поддерживают, и std::bit_cast из C++20 для типов одинакового размера. Предупреждение -Wstrict-aliasing входит в -Wall, но видит лишь очевидные случаи: приведение указателя через два уровня с немедленным разыменованием он замечает, сохранение указателя в переменную и использование тремя функциями позже уже нет.

Реальные истории с алиасингом похожи одна на другую. Код разбора сетевых заголовков интерпретирует буфер байтов как структуру с полями коротких целых, на старом компиляторе работало, после перехода на свежий GCC с -O2 половина полей читается из кэшированного регистра. Или сериализация упаковывает значение типа double в целое через указатель, и в релизе получается сдвиг на пару байтов. Каждый такой баг чинится одной строкой с memcpy, но без санитайзера его ищут неделями.

Выход за границы массива и обращение к освобождённой памяти

Третья семья ловушек не нуждается в помощи оптимизатора: достаточно чтения или записи мимо выделенной памяти. Вектор при обращении через operator[] не проверяет индекс, at() проверяет и кидает исключение, а цикл, забегающий на единицу за размер, в тестах часто не замечает никто: порча попадает в соседнюю переменную и проявляется далеко не сразу.

Апрель 2014 года, OpenSSL, уязвимость CVE-2014-0160, известная как Heartbleed. Функция проверки пульса читала из памяти процесса до 64 килобайт за один запрос, потому что доверяла заявленной в пакете длине, не сверив её с фактическим буфером. Утекали ключи, пароли и куки сессий. Это был именно out-of-bounds read, чтение за границей, то самое, которое в тестах оборачивается максимум мусорным значением.

Обращение к освобождённой памяти, use after free, ещё тише. Объект удалён, указатель на него сохранился в коллекции, память выдана под новый объект, и старый указатель читает уже чужие поля. Тесты зелёные, продакшен падает раз в месяц, и стектрейс указывает на несвязанное место, потому что порча случилась намного раньше.

Обе проблемы AddressSanitizer ловит в момент обращения. Идея простая: вокруг каждой локальной переменной и каждой аллокации добавляются красные зоны, а теневая память помечает каждый байт как доступный или запрещённый. Обращение мимо границы упирается в помеченный байт и роняет процесс с точным отчётом: адрес, размер, тип операции, стек вызовов.

Санитайзер видит и стек. Локальный массив из десяти элементов, в который функция копирует вход без проверки длины, роняет процесс в момент копирования, а не после того, как затёртый соседний счётчик разорвёт чужой цикл. Сборку для проверок обычно делают с оптимизацией -O1: на нулевом уровне проверки тоже работают, но этот уровень быстрее и ближе к релизному поведению.

Санитайзеры UBSan и ASan во флагах и режимах ежедневной сборки

Включение в сборку укладывается в одну строку:

clang++ -fsanitize=address,undefined -fno-sanitize-recover=undefined -g -O1 app.cpp
UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1 ./app

UBSan по умолчанию печатает отчёт и продолжает работу, что удобно для сбора полной картины за один прогон. Флаг -fno-sanitize-recover=undefined превращает первый отчёт в немедленное завершение процесса, это режим для непрерывной интеграции. Есть и режим -fsanitize-trap, встраивающий ловушку прямо в код: рантайм не нужен вовсе, процесс падает по сигналу SIGILL или SIGTRAP.

AddressSanitizer стоит примерно вдвое по скорости и втрое по памяти, поэтому его включают в тестовые и отладочные сборки, а не в боевые серверы. UBSan заметно дешевле, его оставляют и в сборках для стейджинга. Ложных срабатываний авторы не обещают: если отчёт кажется ошибкой санитайзера, почти всегда это настоящая ошибка программы, и искать стоит снова.

Группа undefined не включает беззнаковое переполнение, оно легально по стандарту, но проверки -fsanitize=unsigned-integer-overflow и -fsanitize=implicit-integer-sign-change подключаются явно и ловят просто опасное, например неявную смену знака при приведении типов. Для проекта с многолетней историей это удобный первый шаг: включить наблюдение, собрать отчёты и оценить масштаб до любых правок.

Для осознанных нарушений предусмотрен атрибут no_sanitize, которым помечают отдельные функции. Рядом живут MSan, ловящий чтения неинициализированной памяти и требующий инструментировать весь код вместе с библиотеками, и TSan для гонок. Valgrind memcheck ловит часть того же без перекомпиляции, но заметно медленнее.

Порядок внедрения санитайзеров в проект с многолетним наследием

Рабочая последовательность для живого проекта выглядит так:

  1. собрать проект с флагами -fsanitize=address,undefined и отладочными символами, прогнать весь набор тестов и снять полный список отчётов за один прогон;
  2. включить строгий режим через -fno-sanitize-recover=undefined, чтобы процесс останавливался на первой ошибке, и повторить прогоны для проверки стабильности;
  3. прогнать под AddressSanitizer нагрузочные и интеграционные сценарии, именно там всплывают use after free и порча кучи, невидимые в модульных тестах;
  4. осознанные нарушения пометить атрибутом no_sanitize с комментарием, объясняющим причину, и не расширять список исключений без нужды;
  5. закрепить санитайзерную сборку в непрерывной интеграции отдельной задачей рядом с обычными тестами и держать её зелёной;
  6. чинить находки маленькими коммитами, начиная с мест, на которые указывают отчёты, а не с мест, которые кажутся подозрительными на глаз.

Первый прогон реального проекта почти всегда выдаёт десятки отчётов, и это норма: большая часть чинится за день-два, потому что адрес и стек уже известны. Приоритет расставляется просто: сначала память, выходы за границы и гонки, они роняют продакшен сегодня, потом переполнения и алиасинг, которые ронят его при смене компилятора.

Неопределённое поведение это не теория и не придирки комитета по стандартизации, а контракт, который компилятор соблюдает буквально, с фантазией и без жалости. Нарушенный контракт оплачивается ночными дежурствами и разбором стектрейсов, в которых нет виноватого. Санитайзеры делают нарушение видимым в момент совершения, а не через месяц после релиза, и именно за это их ценят команды, которым дорог сон.