Утечка редко заметна сразу: тест живёт секунды и успевает всё освободить, а сервис, рассчитанный на недели работы, распухает медленно и в один момент получает отказ системы при выделении памяти. Valgrind стал эталоном в этой области задолго до компиляторных санитайзеров: он запускает программу на собственной виртуальной машине и прослеживает судьбу каждого байта. Ниже собран практичный порядок работы с Memcheck: подготовка сборки, правильные флаги, чтение отчёта и подавление шума от системных библиотек. Сборка при этом не меняется: инструмент работает с готовым бинарником, и в этом он ближе к отладчику, чем к компиляторным проверкам. Свежая ветка 3.27 вышла в мае 2026 года, и порядок действий в ней не изменился.
Механика Memcheck и его отличие от статических анализаторов
Внутри Valgrind программа исполняется на синтетическом процессоре: инструмент декодирует машинные инструкции и выполняет их сам, ведя теневую карту для каждого байта памяти. Состояний три: выделено и размечено, неинициализировано, освобождено и заполнено ядом. Отсюда возможности: проверка каждого чтения и записи, слежение за значением от рождения до использования и точный стек вызовов в момент выделения блока. Отчёты пишутся обычным текстом, поэтому их удобно прикладывать к дефектам и разбирать в код-ревью. Статический анализатор лишь предполагает поведение по исходникам, а Memcheck видит реальные исполнения с реальными аргументами, включая ветки, до которых тесты не доходят. Цена высока: программа замедляется в десятки раз и потребляет заметно больше памяти, поэтому такие прогоны делают в тестах и на стендах, а не в проде. Сам Valgrind не подключается к уже работающему процессу: программа живёт под его управлением с самого старта, и это честная плата за полноту картины. Проект Julian Seward выпустил в 2000 году, и за десятилетия вокруг ядра вырос целый набор инструментов на разные случаи жизни. Потоки в нём исполняются на одном виртуальном процессоре, поэтому гонки под Memcheck не проявляются: их ловит отдельный инструмент Helgrind.
Подготовка сборки и запуск с нужными флагами
Сборка выглядит обычно: gcc -g -O0 main.c -o app. Флаг -g обязателен, без него отчёт покажет адреса вместо строк кода. Оптимизация допустима и на уровне -O1, а то и -O2: ошибки ловятся те же, но трассировки читаются хуже, поскольку часть переменных компилятор уложил в регистры. Память уходит на теневые карты и служебные структуры, поэтому тяжёлые тесты гоняют на стендах с запасом. Типовой запуск выглядит так:
valgrind --leak-check=full --show-leak-kinds=all \
--track-origins=yes --num-callers=30 ./app
Флаг --leak-check=full печатает стек для каждого потерянного блока, --show-leak-kinds=all добавляет к сводке и достижимые блоки, --track-origins=yes отыскивает место рождения неинициализированного значения ценой дополнительного замедления, а --num-callers расширяет стек со стандартных двенадцати кадров до пятисот. Вывод удобно направлять в файл через --log-file=vg.log, чтобы прогонять сразу пакет тестов и разбирать отчёты по очереди. Имена функций в программах на C++ разворачиваются в читаемый вид автоматически, так что отчёты читаются без дополнительных настроек. Многопоточные программы поддерживаются, и каждое сообщение помечается номером потока. Под сценарием на скриптовом языке инструмент проверит сам интерпретатор, но не логику приложения: объекты языка памятью управляет среда исполнения.
Четыре вида утечек в итоговой сводке
В конце работы печатается сводка LEAK SUMMARY. Строка definitely lost означает, что на блок больше не ссылается ни один указатель, память потеряна навсегда. indirectly lost относится к блокам, доступным только через другой потерянный блок, типичная картина для деревьев и списков. possibly lost появляется, когда остался лишь указатель внутрь блока, например на середину: так ведут себя некоторые библиотечные таблицы. still reachable значит, что указатели живы, но free перед выходом никто не вызвал; для одноразовой инициализации это норма, а в повторяемом коде повод разобраться. Характерный пример: буферы стандартной библиотеки ввода живут до самого конца процесса, и их достижимые остатки в одноразовой утилите дефектом не считаются. Пример записи отчёта:
==18432== 48 bytes in 1 blocks are definitely lost in loss record 7 of 12
==18432== at 0x4846828: malloc (vgpreload_memcheck.so)
==18432== by 0x4011A9: load_config (config.c:31)
==18432== by 0x4011D4: main (main.c:12)
Перед сводкой утечек печатается и общий итог кучи: HEAP SUMMARY с числом блоков и байтов, оставшихся занятыми на выходе. Читается запись сверху вниз: сколько байт в скольких блоках, затем место выделения. Если рядом стоят сто байт definitely lost и восемьсот indirectly lost, поиск начинают с головы цепочки: один потерянный указатель тянет за собой всю структуру. Практическое правило: сначала закрывают definitely lost, потом indirect, а возможные и достижимые блоки рассматривают по обстоятельствам. Сводка суммируется одной строкой после завершения, и по динамике чисел между прогонами видно, растёт ли проблема.
Классические паттерны утечек и работа над отчётом
Часть ошибок повторяется из проекта в проект. Первая: строка p = realloc(p, 128). Если перевыделение не удалось, старый блок остаётся занятым, а единственный указатель на него затёрт значением NULL, и память потеряна. Правильный вариант выглядит так, и Memcheck ловит разницу между ними мгновенно:
char *p = malloc(64);
p = realloc(p, 128); /* старый блок потерян при неудаче */
char *tmp = realloc(p, 128);
if (tmp == NULL) {
free(p);
return -1;
}
p = tmp;
Такой кадр в отчёте экономит час чтения чужого кода: видно и место выделения, и место, где указатель затёрли. Вторая: strdup и fopen, которые возвращают новую память и поток с буфером; забытые free и fclose обнаруживаются в сводке. Третья: связные структуры, где последний указатель на голову хранился внутри самого блока, и вместе с ним ушла вся цепочка. Четвёртая: динамический массив указателей на строки, где realloc увеличил сам массив, а старые строки никто не освободил при замене. Пятая: указатель на середину блока. Смещение пересчитать легко, а освободить такой адрес нельзя, и повторное выделение через него уже не сработает. Шестой случай: два владельца одного блока. Освободил первый, второй не в курсе, и при своей очистке он делает повторное освобождение, которое тут же попадает в отчёт. Отдельного внимания заслуживают циклы очистки: узел из списка удаляют, а его содержимое оставляют жить до конца программы.
Ошибки вне утечек и происхождение неинициализированных значений
Утечки лишь часть отчёта. Invalid write of size 4 сигнализирует о выходе за границу выделения, invalid free указывает на повторное освобождение, mismatched free() / delete ловит смешение аллокаторов в C++, а перекрытие источника и приёмника в memcpy подсвечивается отдельно. Обращение по адресу 0x0 выдаёт разыменование нулевого указателя раньше, чем процесс успел бы упасть в живой системе. Проверяются даже параметры системных вызовов: Memcheck убедится, что буфер для read() действительно существует и нужного размера. Самая коварная категория: использование неинициализированной памяти. Классика жанра выглядит так: локальная переменная объявлена, но не заполнена, и её значение уходит в условный переход. Memcheck отслеживает биты и печатает Conditional jump or move depends on uninitialised value(s). С флагом --track-origins=yes добавляется строка вида Uninitialised value was created by a stack allocation с указанием функции, где значение родилось:
==19100== Conditional jump or move depends on uninitialised value(s)
==19100== at 0x40113F: parse_line (util.c:18)
==19100== Uninitialised value was created by a stack allocation
==19100== at 0x4010F2: parse_line (util.c:11)
Ограничение честное: границы массивов на стеке Memcheck проверяет частично, кадры функций не размечаются, как куча, поэтому выход за локальный массив иногда остаётся незамеченным. Эту нишу занимают компиляторные санитайзеры, размечающие стековые кадры красными зонами. Плавающие баги от мусорных значений здесь находят особенно охотно: под Memcheck они проявляются в каждом прогоне, а в живой системе то появляются, то исчезают. Даже падение с сигналом инструмент сопровождает точным стеком, так что фатальные сбои в нём разбираются удобнее, чем в живой системе.
Файлы подавлений и шум от системных библиотек
Идеальных прогонов не бывает: glibc и другие системные библиотеки дают заведомо известные срабатывания. Блок подавления снимают с боевого отчёта одной командой --gen-suppressions=all, либо вариантом --gen-suppressions=yes, который предлагает сохранить шаблон после каждой ошибки. Готовый набор подключают файлом --suppressions=noise.supp. В пакете уже лежит default.supp с десятками записей для известных случаев, и его содержимое подсказывает формат. Запись выглядит просто:
{
glibc-init-noise
Memcheck:Leak
match-leak-kinds: possible
fun:calloc
fun:_dl_init
}
Подавления стоит держать узкими: сначала точные имена функций, и лишь затем расширенные шаблоны. Широкие маски прячут настоящие ошибки, и отчёт снова перестаёт быть правдой. Записи чувствительны к стеку вызовов: обновление библиотеки меняет цепочку кадров, и шаблон перестаёт срабатывать, поэтому подавления пересматривают при обновлениях. Если шум идёт только от определённого вида записей, сокращают вывод флагом --show-leak-kinds=definite и работают с самым важным.
Автоматизация в сборке и работа с отладчиком
Флаг --error-exitcode=99 превращает любую найденную ошибку в ненулевой код возврата, и сборочная система сразу видит провал. Для машинной обработки отчётов есть --xml=yes. Прогон ключевых юнит-тестов под Valgrind в ночной сборке стоит копейки и ловит проблемы задолго до прода. Когда отчёт непонятен, подключается отладчик: valgrind --vgdb=yes --vgdb-error=0 останавливает программу на первой ошибке и ждёт, а gdb -ex "target remote | vgdb" ./app открывает сессию прямо на месте события, со всеми переменными и стеком. Рядом живут и другие инструменты того же пакета: Massif профилирует потребление кучи, его файл massif.out с номером процесса читает ms_print и рисует ступенчатый график, Helgrind ищет гонки в потоках, Callgrind считает стоимость функций, а callgrind_annotate суммирует результат текстом. Все они запускаются той же командой с заменой --tool.
Программа может и сама общаться с инструментом. В заголовке memcheck.h живут запросы вида VALGRIND_DO_LEAK_CHECK, запускающий проверку утечек в середине работы, и VALGRIND_MAKE_MEM_DEFINED, размечающий участки, которые библиотека обрабатывает хитрыми способами. Пара таких меток в обвязке сокращает шум сильнее любого файла подавлений.
Порядок внедрения в проект выглядит компактно:
- Собрать программу с флагом -g и хранить символы вместе с артефактом;
- Прогнать тесты под valgrind с полным набором флагов и ненулевым кодом ошибки;
- Разобрать сводку, начиная с definitely lost, и закрывать по одному блоку за раз;
- Подавить известный шум системных библиотек узкими записями;
- Включить ночной прогон в конвейере и не допускать регрессий в сводке.
Valgrind не заменяет быстрые прогоны: замедление в десятки раз для каждой сборки никто не потянет. Зато он видит программу глубже санитайзеров: там, где те роняют процесс на первом нарушении, Memcheck спокойно доводит тест до конца и складывает полную картину в отчёт. Новые версии подтягивают поддержку свежих системных вызовов ядра, поэтому на узлах с новыми дистрибутивами ставят свежий пакет. Пара привычек закрепляет результат: каждый исправленный блок проверяют повторным прогоном, а нулевая сводка в конвейере становится таким же обязательным признаком зелёной сборки, как успешные тесты. На больших кодовых базах помогает очередь: записи сводки сортируют по числу потерянных байт и закрывают сверху вниз. Час, потраченный на чтение сводки, возвращает себя с процентами: блок, потерянный на ранних стадиях, в проде оборачивается днями разбора графиков памяти.