Поиск ошибок памяти классическим отладчиком съедает часы: точка останова, осмотр переменных, попытки воспроизвести. Компиляторные санитайзеры перевернули расклад: код инструментируется при сборке, проверки встраиваются в каждое чтение и запись, а скорость остаётся пригодной для юнит-тестов. AddressSanitizer ловит выходы за границы и обращения к освобождённой памяти, UndefinedBehaviorSanitizer замечает переполнение знаковых чисел и сдвиги за пределы разрядности. Оба включаются одним флагом, поддерживаются GCC с версии 4.8 и Clang с версии 3.1, и давно считаются обязательной частью конвейеров зрелых проектов на C и C++. Исходники при этом не меняются: проверки вставляет компилятор на этапе генерации кода.

Теневая память AddressSanitizer и классы ловимых ошибок

Идея элегантная: рядом с обычной памятью живёт теневая, где один байт описывает восемь байт адресного пространства, то есть её объём равен одной восьмой. Компилятор оборачивает каждое обращение к памяти проверкой теневого значения. Вокруг выделений ставятся красные зоны, освобождённая память заполняется ядом, стековые кадры и глобальные переменные размечаются тем же способом. Ловятся heap-buffer-overflow, stack-buffer-overflow, global-buffer-overflow, use-after-free, чтение и запись после выхода из функции при включённой проверке use-after-return, двойное освобождение и утечки через LeakSanitizer. Отчёт содержит адрес, тип нарушения, размер доступа, стек текущего потока и место выделения блока. Красная зона по умолчанию занимает шестнадцать байт, её наращивают параметром redzone=32 ради ловли дальних выходов. Ошибка видна при первом касании плохой памяти: не нужен особый сценарий, достаточно обычного теста. Многопоточный сервис тоже проверяется: стеки в отчёте подписаны номерами потоков, и видно, кто из рабочих нитей коснулся плохого адреса. Ловится и повторное чтение после освобождения внутри цикла: сбой случится ровно там, где в живой системе была бы порча памяти. Выход по отрицательному индексу тоже виден: смещение уходит в чужую память так же, как и положительное. Замедление обычно порядка двух раз, память растёт заметнее из-за теневых страниц и красных зон, и для тестов это приемлемая цена.

Флаги сборки для GCC и Clang и проверки UndefinedBehaviorSanitizer

Минимальный рабочий набор: gcc -fsanitize=address,undefined -fno-omit-frame-pointer -g -O1 main.c -o app. Указатель кадра сохраняет читаемые стеки, флаг -g привязывает их к строкам исходников. UndefinedBehaviorSanitizer включает десятки проверок: переполнение знакового целого, сдвиг за пределы разрядности, деление на ноль, разыменование нулевого указателя, нарушение выравнивания, переполнение при приведении к целому, выход за границы массивов фиксированного размера, несоответствие типов у указателей на функции и повреждение указателя на таблицу виртуальных методов в C++. По умолчанию найденная ошибка печатается, а выполнение продолжается. Для тестов удобнее ронять процесс сразу: флаг -fno-sanitize-recover=all в GCC или переменная UBSAN_OPTIONS=halt_on_error=1. Стеки у UBSan включаются переменной UBSAN_OPTIONS=print_stacktrace=1 и требуют флага -g. Оба санитайзера перечисляются через запятую в одном флаге -fsanitize=address,undefined, и собирать их по отдельности незачем. Для разделяемых библиотек флаги передают и при компиляции, и при линковке, иначе рантайм не подключится к итоговому бинарнику. Набор проверок подгоняют под проект: -fno-sanitize=alignment снимает слишком шумную проверку выравнивания на старом железе, а в Clang есть отдельные группы -fsanitize=integer для переполнений беззнаковых чисел и -fsanitize=implicit-conversion для неявных преобразований, которых в GCC нет. Для встраиваемых платформ без памяти на теневые карты UBSan доступен и без AddressSanitizer. Сборки с уровнем оптимизации -O2 и санитайзерами совместимы, компиляция идёт дольше, а отчёты остаются точными. В make-файлах набор флагов выносят в отдельную переменную и приписывают к флагам компиляции в отладочном профиле, чтобы переключение профилей оставалось одним словом.

Небольшая программа с ошибками и отчёты при тестовом запуске

Разминка на четыре ошибки в одном файле:

#include <stdlib.h>
#include <stdio.h>

int main(void) {
    int a[4] = {0};
    a[5] = 42;                     /* выход за границы массива */
    int *p = malloc(16);
    free(p);
    printf("%d\n", p[2]);          /* чтение после освобождения */
    int x = 2147483647;
    x = x + 1;                     /* переполнение знакового целого */
    return x;
}

Сборка с флагами и запуск дают отчёт с точным местом и типом нарушения:

==24117==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffc1e0a
WRITE of size 4 at 0x7ffc1e0a thread T0
    #0 0x40113a in main main.c:6
    #1 0x7f2a4d0 in __libc_start_main
SUMMARY: AddressSanitizer: stack-buffer-overflow main.c:6 in main

Для строки с переполнением UBSan печатает своё сообщение: main.c:13:7: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'. Формат предельно точный: файл, строка, столбец, класс ошибки и значения операндов. При настроенном halt_on_error процесс останавливается на первой ошибке, конвейер получает ненулевой код возврата, а сообщение прикладывают к дефекту как есть. С включённой проверкой use-after-return отчёт ловит и возвращённый указатель на локальный массив:

==30221==ERROR: AddressSanitizer: stack-use-after-return on address 0x7f0a12
READ of size 4 at 0x7f0a12 thread T0
    #0 0x4011b1 in use_buf bad_ret.c:9

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

LeakSanitizer для утечек и переменные окружения ASAN_OPTIONS

LeakSanitizer встроен в рантайм AddressSanitizer и на Linux включён по умолчанию: после выхода программы печатаются блоки, на которые больше никто не ссылается, со стеками выделения. Отдельный флаг -fsanitize=leak даёт чистый детектор утечек без остальной инструментации, это работает в Clang. Поведение настраивается переменными окружения: ASAN_OPTIONS=detect_leaks=1:detect_stack_use_after_return=1:halt_on_error=1:log_path=asan.log включает проверки, собирает отчёты в файлы и задаёт реакцию на первую ошибку. Проверку use-after-return свежие версии компиляторов включают сами, а для старых её ставят переменной явно. Для заведомых утечек в чужих библиотеках у LSAN есть собственный параметр suppressions, который подключает файл с шаблонами стека вызовов. Утечки ловятся в тот же прогон без отдельного запуска, что выгодно отличает LSan от классических инструментов, требовавших отдельной проверки. Срабатывание происходит при выходе любым способом: и после main, и при вызове exit из середины программы. Счётчик честен до мелочей: даже один потерянный блок в повторяемом тесте за сутки работы сервиса оборачивается сотнями мегабайт. Формат подавлений напоминает memcheck, но ключи другие, и шаблоны пишут под конкретные кадры стека. Временное отключение ради отладки падений делает переменная ASAN_OPTIONS=detect_leaks=0.

Соседние инструменты MSan и TSan и совместимость с Valgrind

MemorySanitizer с флагом -fsanitize=memory ловит неинициализированные значения точнее трассировки через Valgrind, но требует полностью инструментированных библиотек, включая libc++ и всё, что программа линкует: одна нетронутая библиотека возвращает мусорные вердикты. Происхождение неинициализированного значения показывает -fsanitize-memory-track-origins=2. ThreadSanitizer с флагом -fsanitize=thread ищет гонки в потоках и с AddressSanitizer не совмещается: два рантайма конфликтуют за контроль памяти, ведь каждый тянет собственные теневые карты, поэтому на одну конфигурацию берут один инструмент. TSan замедляет программу сильнее ASan, поэтому его гоняют на сокращённом наборе тестов, зато гонки он показывает вместе с обоими стеками, читающим и пишущим. Valgrind поверх сборки с санитайзерами тоже не запускается по той же причине. Практическое решение: в проекте держат несколько сборочных профилей, нативный, с санитайзерами и под Valgrind, и гоняют тесты на каждом, где это имеет смысл. Сравнение с Valgrind честное и полезное: Valgrind не требует пересборки и работает с готовым бинарником, зато замедляет программу в десятки раз; санитайзеры требуют пересборки, но работают на порядок быстрее и указывают на строки кода точнее. Для разовой приёмки готового бинарника удобнее Valgrind, для ежедневной разработки вне конкуренции санитайзеры.

Встраивание в конвейер и границы разумного применения

Рецепт для конвейера сборки:

  1. Завести отдельную конфигурацию с флагами санитайзеров, чтобы не мешать релизным настройкам;
  2. Прогонять юнит-тесты на этой конфигурации при каждой сборке и ронять процесс на первой ошибке;
  3. Собирать отчёты в артефакты вместе с логами тестов и прикладывать к дефектам;
  4. Раз в неделю запускать тяжёлые интеграционные сценарии, до которых быстрые тесты не доходят;
  5. Периодически обновлять компилятор, потому что новые проверки приносят находки без лишних затрат.

В CMake флаги передают через CMAKE_C_FLAGS и CMAKE_CXX_FLAGS в отдельном пресете, а каталог сборки для инструментированной версии заводят отдельно, чтобы чистая и проверенная конфигурации не мешали друг другу. Сборка с санитайзерами идёт дольше обычной, и это учитывают при разбиении тестов на этапы конвейера. Быстрый старт для существующего проекта: включить флаги в отладочной конфигурации, собрать, прогнать вручную типовой сценарий и разобрать первые отчёты. Нередко первая же сборка приносит десятки находок, и среди них попадаются настоящие дефекты, жившие в коде годами. На проде санитайзерные сборки обычно не выкатывают из-за оверхеда и падений на первой ошибке, хотя отдельные компании держат на них часть сервисов годами после тщательной настройки подавлений. В ядре Linux есть собственная сборка UBSan через CONFIG_UBSAN, так что приём применим даже к драйверам. Релиз при этом собирается чисто: флаги просто не включают, и рантайм в бинарник не попадает.

Ограничения подхода и типичные ловушки при сборке

Все библиотеки придётся пересобрать: одна неинструментированная разделяемая библиотека снижает точность, а смешение рантаймов от GCC и Clang запрещено, версии проверяются при старте. Рантайм из libasan обязан совпадать с версией компилятора, и обновление одного без другого даёт загадочные сбои на запуске. Для отдельных функций инструментацию выключают атрибутом no_sanitize со значением address, когда проверка мешает низкоуровневым трюкам. Сборки без флага -g оставляют отчёты без строк исходников, и ценность проверки резко падает. Для статически слинкованных бинарников рантайм включают в файл флагом -static-libasan в GCC. Отдельный источник сюрпризов: проверки границ контейнеров в C++ работают лишь с библиотеками, помеченными аннотациями, у libc++ это реализовано для std::vector. Дублирующиеся глобальные символы при неправильной сборке ловятся детектором нарушений правила одного определения, и сообщение об этом стоит читать, а не глушить. Код с ассемблерными вставками проверками не покрывается: инструкции, написанные вручную, компилятор не оборачивает, и такие места тестируют отдельно. В контейнерах с жёсткими лимитами памяти теневые страницы тоже учитываются, и тестовую среду планируют с запасом. Наконец, санитайзеры не проверяют логику и не заменяют код-ревью: они превращают каждое касание плохой памяти в громкий сигнал, но до касания ещё нужно дойти в тесте. Инструментация делает каждую ошибку громкой и точной, а место нарушения показывает с точностью до строки. Зелёный тест под санитайзерами весит больше зелёного теста без них: он подтверждает не только результат, но и чистоту работы с памятью.