Отладка и диагностика: gdb, valgrind, санитайзеры, чтение core dump
В языке с исключениями и управляемой памятью падение — это событие с описанием: тип ошибки, сообщение, стек вызовов, номер строки. Рантайм сам поймал нарушение и рассказал о нём. В C такого посредника нет. Если вы пишете за границу массива, ничего не происходит: вы меняете байты, которые вам не принадлежат, и программа продолжает работать. Она может дойти до конца и выдать правильный ответ. Может упасть через сорок минут в другом месте. Может падать только на проде и только под нагрузкой.
Отсюда главный тезис: в C отладка — не «включить дебаггер, когда сломалось», а дисциплина, которая начинается на этапе сборки. Вы заранее строите систему так, чтобы ошибка проявилась близко к своей причине, громко и воспроизводимо. Дебаггер — последний шаг, а не первый. Разберём четыре слоя: подготовку бинаря и работу в gdb, динамические детекторы (санитайзеры и valgrind), чтение посмертных дампов и отдельно — охоту на неопределённое поведение, из-за которого «на моей машине работает» в C особенно опасная фраза. Предполагается, что вы прошли https://courses.digitable.life/post/systems-programming/02-memory-and-pointers/ и https://courses.digitable.life/post/systems-programming/04-dynamic-memory/: здесь те же понятия, но с другой стороны — не «как написать», а «как узнать, что написано неверно».
Разрыв между ошибкой и симптомом
Ключевое понятие для C — латентность бага: расстояние во времени и по коду между моментом, когда программа сделала недопустимую вещь, и моментом, когда это стало заметно.
// latency.c — иллюстрация разрыва между причиной и следствием
#include <stdlib.h>
#include <string.h>
struct node {
char name[8];
void (*on_free)(struct node *); // указатель на функцию — лакомая цель для затирания
};
static void release(struct node *n) { (void)n; }
int main(void) {
struct node *a = malloc(sizeof *a), *b = malloc(sizeof *b);
a->on_free = release;
b->on_free = release;
strcpy(a->name, "overflow!!!"); // ОШИБКА ЗДЕСЬ: 12 байт в поле на 8
/* ... сотни строк логики между причиной и следствием ... */
b->on_free(b); // а падает — ЗДЕСЬ, если раскладка кучи «удачно» совпала
free(a); free(b);
return 0;
}
Разрыв между strcpy и падением может быть произвольно большим, а зависит он от раскладки кучи — то есть от версии glibc, размера окружения процесса и даже от того, запущен ли отладчик. Все инструменты ниже решают одну задачу: сжать латентность до нуля, заставив программу упасть ровно в строке strcpy.
неверный ответ, утечка"] --> B{"Воспроизводится
детерминированно?"} B -- нет --> C["Сузить: стресс-запуск, фиксация seed,
TSan, rr record --chaos"] C --> B B -- да --> D["Минимизировать вход:
delta debugging, creduce"] D --> E["Пересобрать: -g -Og
-fsanitize=address,undefined"] E --> F{"Санитайзер
сработал?"} F -- да --> G["Читать отчёт: стек доступа +
стек malloc + стек free"] F -- нет --> H["valgrind memcheck / helgrind
или gdb с watchpoint по адресу"] G --> I["Гипотеза о причине"] H --> I I --> J{"Объясняет ВСЕ
наблюдения?"} J -- нет --> I J -- да --> K["Починить, добавить тест,
прогнать под санитайзером в CI"]
Узел «объясняет ВСЕ наблюдения» — не формальность. Самая частая ошибка отладки: остановиться на объяснении, покрывающем 80% фактов. Оставшиеся 20% обычно и есть настоящий баг. Метод подробно разобран у Андреаса Целлера в «The Debugging Book» (https://www.debuggingbook.org/).
Подготовка бинаря: флаги, без которых отладка не работает
gcc -std=c17 -Wall -Wextra -Wpedantic -Wshadow \
-g3 -Og -fno-omit-frame-pointer -fstack-protector-strong \
-o app app.c
-g3 против -g. Уровень 3 кладёт в DWARF ещё и макросы препроцессора: в gdb работают info macro FOO и macro expand FOO(x). Для C, где половина логики живёт в макросах, это экономит часы.
-Og против -O0. -O0 даёт идеальное соответствие «строка ↔ инструкция», но код ведёт себя иначе: неинициализированные переменные чаще случайно оказываются нулём, гонки не проявляются, UB не «выстреливает». -Og — оптимизации, не мешающие отладке. Правило: разработка на -Og, воспроизведение продового бага — на флагах прода. Отдельно -D_FORTIFY_SOURCE=3 (требует -O1 и выше, на -O0 молча ничего не делает) подменяет memcpy, strcpy, sprintf вариантами с проверкой размера назначения, когда размер выводим компилятором.
-fno-omit-frame-pointer. На x86-64 GCC по умолчанию не хранит %rbp как указатель кадра, и разворачивание стека идёт по CFI-таблицам секции .eh_frame. Это работает, пока таблицы верны; в ассемблерных вставках, обработчиках сигналов и JIT-коде они часто врут, и bt обрывается. Фрейм-поинтер стоит 1–2% производительности и возвращает надёжные bt и perf — Fedora и Ubuntu включают его в системных пакетах именно поэтому.
Отладочная информация в проде
В прод уходит бинарь без символов, но выбрасывать их нельзя — иначе core dump нечитаем. Стандартное решение: раздельная отладочная информация, связанная с бинарём по build-id.
gcc -g -O2 -o app app.c
objcopy --only-keep-debug app app.debug # вынести символы
objcopy --strip-debug --add-gnu-debuglink=app.debug app
readelf -n app | grep -A1 'Build ID' # ключ связи бинарь ↔ символы ↔ core
app.debug кладётся в артефакт-хранилище или в /usr/lib/debug/.build-id/xx/yyyy.debug — gdb находит его сам. Тот же механизм у debuginfod (https://sourceware.org/elfutils/Debuginfod.html): переменная DEBUGINFOD_URLS превращает бесполезный ?? in libc.so.6 в читаемые кадры без установки debug-пакетов.
gdb: не «пошагово», а «задать вопрос»
Новички используют gdb как пошаговый отладчик: next, next, посмотреть переменную. Это самый медленный способ. Зрелая работа — формулировка вопросов к процессу: «остановись, когда переменная станет нулём», «остановись на 5000-й итерации», «покажи весь список».
(gdb) break parser.c:142 if len > 4096 # условная точка: только при истинном условии
(gdb) break process_record
(gdb) ignore 1 4999 # по счётчику: остановиться на 5000-м вызове
# Watchpoint: остановиться при ИЗМЕНЕНИИ значения. Аппаратный (регистры DR0-DR3)
# почти бесплатен, но их максимум 4 и не более 8 байт каждый.
(gdb) watch node->refcount
(gdb) watch -l *(int *)0x55555576a2c0 # -l фиксирует АДРЕС, а не выражение
(gdb) rwatch config->timeout # на чтение; awatch — на любой доступ
(gdb) catch syscall write # ловушки на события, а не на код
(gdb) catch signal SIGSEGV
# Осмотр состояния
(gdb) p *node # структура целиком
(gdb) p node->items[0]@10 # 10 элементов подряд — синтаксис @
(gdb) ptype struct node # напомнить определение типа
(gdb) x/16xb node # 16 байт в hex: увидеть выравнивание и паддинг
(gdb) x/4gx $rsp # 4 восьмибайтовых слова со стека
(gdb) x/5i $pc # 5 инструкций от текущей — что реально исполняется
(gdb) bt full # backtrace вместе с локальными каждого кадра
(gdb) thread apply all bt # все потоки — обязательный первый шаг при зависании
(gdb) info proc mappings # карта памяти: указатель битый или просто не туда?
Watchpoint по адресу — самый недооценённый приём. Сценарий «структура портится, непонятно кем» решается так: остановиться там, где структура ещё цела, взять адрес поля, поставить watch -l, продолжить. gdb встанет ровно на инструкции, которая пишет мусор.
info proc mappings (он же /proc/PID/maps) отвечает на вопрос «указатель мусорный или указывает не туда»: если адреса нет ни в одном отображении — это мусор; если он в куче, но не в том объекте — логическая ошибка. Про устройство отображений см. https://courses.digitable.life/post/operating-systems/04-memory-management/.
Всё, что делается руками дважды, стоит записать в .gdbinit проекта — gdb это язык, а не только консоль:
define plist
set $n = $arg0
while $n != 0
printf "node %p: id=%d\n", $n, $n->id
set $n = $n->next
end
end
set print pretty on
set pagination off
set debuginfod enabled on
Для своих контейнеров (списки, хеш-таблицы, арены из https://courses.digitable.life/post/systems-programming/04-dynamic-memory/) пишутся pretty-printer’ы на Python, превращающие p *table из простыни цифр в содержимое. Неинтерактивный режим для CI: gdb -batch -ex run -ex 'bt full' --args ./app in.dat.
Обратная отладка
gdb умеет идти назад: record full пишет историю выполнения, дальше работают reverse-step и reverse-continue. Встроенная запись замедляет в десятки раз; практичнее rr от Mozilla (https://rr-project.org/) — накладные расходы 1.2–2x, детерминированное воспроизведение и полная навигация назад.
rr record ./app --config prod.conf
rr replay
(rr) continue # до падения
(rr) watch -l state.counter
(rr) reverse-continue # кто записал плохое значение — за один шаг назад
Для гейзенбагов схема «rr record в цикле, пока не поймается, потом rr replay сколько угодно раз» часто единственная работающая: запись детерминирована, поэтому повторный прогон воспроизводит ту же самую последовательность планирования потоков и те же адреса.
Как это устроено: ptrace
Отсюда практические следствия. Точка останова — это запись в код чужого процесса, поэтому нужны права: в Ubuntu /proc/sys/kernel/yama/ptrace_scope = 1 запрещает gdb -p PID к не-потомку, а в Docker нужен --cap-add=SYS_PTRACE. Аппаратных watchpoint’ов ровно четыре, потому что регистров DR0–DR3 четыре. Сообщение «Hardware watchpoint deleted because the program has left the block» означает, что наблюдаемая локальная переменная перестала существовать.
Санитайзеры: сдвинуть ошибку к её причине
Санитайзеры — инструментация на этапе компиляции: компилятор вставляет проверки вокруг каждого обращения к памяти, каждой арифметической операции, каждого приведения типа. Родом из Google, живут в LLVM, поддержаны и в GCC. AddressSanitizer ставит вокруг каждого выделения красные зоны, а параллельно основной памяти держит теневую — один байт на каждые восемь байт приложения:
$ gcc -g -O1 -fsanitize=address -fno-omit-frame-pointer -o app latency.c && ./app
==31245==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000018
WRITE of size 12 at 0x602000000018 thread T0
#0 0x7f2a in strcpy (/lib/x86_64-linux-gnu/libasan.so.8+0x8d0e0)
#1 0x55d1 in main /home/user/latency.c:20
0x602000000018 is located 0 bytes to the right of 8-byte region [0x602000000010,0x602000000018)
allocated by thread T0 here:
#0 0x7f2a in malloc (/lib/x86_64-linux-gnu/libasan.so.8+0xb1d7f)
#1 0x55d1 in main /home/user/latency.c:14
SUMMARY: AddressSanitizer: heap-buffer-overflow latency.c:20 in main
Ценность отчёта — три вещи сразу: место доступа, место аллокации и классификация ошибки. Для use-after-free добавляется стек free — вы видите, кто освободил объект, которым продолжают пользоваться. Ни один дебаггер такого не даст. Именно ради этого ASan держит освобождённые блоки в карантине и не переиспользует адреса немедленно.
ASan ловит выходы за границу кучи, стека и глобалов, use-after-free, use-after-return, use-after-scope, двойное освобождение, несогласованные malloc/delete и утечки (встроенный LeakSanitizer). Не ловит чтение неинициализированной памяти (это MSan), гонки (TSan) и переполнение одного поля структуры в соседнее.
Поведение настраивается переменной окружения: ASAN_OPTIONS="detect_leaks=1:detect_stack_use_after_return=1:strict_string_checks=1:abort_on_error=1:disable_coredump=0:log_path=/var/log/asan", известные утечки в чужих библиотеках глушатся через LSAN_OPTIONS="suppressions=lsan.supp". Пара abort_on_error=1 плюс disable_coredump=0 даёт ещё и core dump в момент срабатывания — состояние можно потом препарировать в gdb; log_path отделяет отчёты от stdout приложения, что важно в CI.
UndefinedBehaviorSanitizer проверяет то, что стандарт объявил неопределённым, стоит 5–20% и включается почти всегда:
$ gcc -g -O1 -fsanitize=undefined -fno-sanitize-recover=all -o app app.c && ./app
app.c:14:12: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
app.c:21:5: runtime error: load of misaligned address 0x5591f2a0c00d for type 'int', which requires 4 byte alignment
app.c:33:9: runtime error: index 10 out of bounds for type 'int [10]'
app.c:41:16: runtime error: shift exponent 35 is too large for 32-bit type 'int'
-fno-sanitize-recover=all критично: по умолчанию UBSan печатает сообщение и продолжает выполнение, поэтому в CI тест не падает и никто ошибку не замечает.
| Санитайзер | Что ловит | Замедление | Память | Флаг |
|---|---|---|---|---|
| ASan | ошибки доступа, UAF, double free | ~2x | ~3x | -fsanitize=address |
| UBSan | переполнение, сдвиги, выравнивание | 1.05–1.2x | ~1x | -fsanitize=undefined |
| TSan | гонки данных | 5–15x | 5–10x | -fsanitize=thread |
| MSan | чтение неинициализированного | ~3x | ~2x | -fsanitize=memory (Clang) |
| LSan | утечки | ~1x | ~1x | входит в ASan |
Комбинировать можно address + undefined. Нельзя address + thread, address + memory, thread + memory — они претендуют на одну теневую память, значит в CI нужны отдельные джобы. MSan вдобавок требует, чтобы вся программа, включая стандартную библиотеку, была собрана под ним, иначе он тонет в ложных срабатываниях. TSan разбирается в https://courses.digitable.life/post/systems-programming/09-threads-and-sync/; здесь важно одно: он ловит гонку, даже если в конкретном прогоне она не «выстрелила», потому что анализирует отношение happens-before, а не совпадение по времени.
valgrind: другой подход к той же задаче
Valgrind — не компилятор, а виртуальная машина динамической трансляции: берёт готовый бинарь, поднимает его в промежуточное представление VEX, вставляет проверки и исполняет результат. Отсюда все его свойства.
$ valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes \
--error-exitcode=1 ./app input.dat
==4711== 1,024 bytes in 1 blocks are definitely lost in loss record 1 of 1
==4711== at 0x4848899: malloc (vg_replace_malloc.c:381)
==4711== by 0x1091AB: load_config (config.c:57)
==4711== by 0x1092E4: main (main.c:19)
Категории утечек различаются по смыслу: definitely lost — указателей на блок не осталось, чинить; indirectly lost — блок достижим только из потерянного (узлы за потерянной головой списка), чинится вместе с ним; possibly lost — есть указатель на середину блока, иногда легитимно; still reachable — просто не освободили перед выходом, формально не утечка, но в долгоживущем сервисе часто утечка по сути. Инструменты пакета:
- memcheck (по умолчанию) — доступ к невыделенной памяти, use-after-free, утечки и, уникально, чтение неинициализированных данных: он ведёт для каждого бита два теневых бита (адресуемость и определённость), а
--track-origins=yesпоказывает, откуда взялось неопределённое значение. - helgrind и DRD — гонки и ошибки использования pthreads.
- massif — профилировщик кучи: кто и сколько держит в каждый момент (
ms_print). - cachegrind / callgrind — моделирование кэшей и граф вызовов, визуализация в
kcachegrind; применение к оптимизации — в https://courses.digitable.life/post/performance/04-memory/. - DHAT — сколько байт выделено и сколько из них реально прочитано.
- ASan быстрее в 5–20 раз и применим к тестам и части нагрузочных прогонов; valgrind замедляет в 20–50 раз — под ним не живёт ничего интерактивного, зато он сериализует потоки (и потому маскирует часть гонок).
- ASan требует пересборки, valgrind — нет. Если баг в стороннем бинаре или только в релизной сборке, которую нельзя тронуть, valgrind единственный вариант; он же видит неинициализированные чтения без пересборки всего мира.
- ASan сильнее на стеке и глобалах: для valgrind стековые байты всегда «адресуемы», поэтому переполнение локального массива он почти не замечает.
Рабочая связка в проекте: -fsanitize=address,undefined на каждый PR, memcheck и helgrind в ночном прогоне, TSan отдельным джобом. Про организацию таких пайплайнов — https://courses.digitable.life/post/testing/13-tests-in-ci/.
Core dump: посмертный разбор
При фатальном сигнале (SIGSEGV, SIGABRT, SIGBUS, SIGFPE, SIGILL) ядро может записать снимок памяти — core dump. Это ELF-файл типа ET_CORE: сегменты PT_LOAD с содержимым памяти плюс PT_NOTE с регистрами каждого потока (NT_PRSTATUS), сведениями о процессе (NT_PRPSINFO) и картой отображённых файлов (NT_FILE). Кода в дампе обычно нет — он подтягивается из бинаря на диске, поэтому бинарь и core обязаны совпадать по build-id.
ulimit -c unlimited # часто по умолчанию 0 = дампы выключены
# в systemd-юните это LimitCORE=infinity
cat /proc/sys/kernel/core_pattern # |/usr/lib/systemd/systemd-coredump %P %u %g %s %t %c %h
sudo sysctl -w kernel.core_pattern='/var/crash/core.%e.%p.%t'
coredumpctl list # если дампы забирает systemd
coredumpctl info 12345 # сигнал, команда, backtrace
coredumpctl debug 12345 # сразу открыть в gdb
В контейнерах core_pattern — параметр ядра хоста, общий для всех контейнеров. Если он указывает на systemd-coredump, дамп из контейнера уедет на хост и обычно потеряется. Практика: направлять core_pattern в путь на volume либо писать backtrace изнутри процесса.
(gdb) file ./app
(gdb) core-file /var/crash/core.app.4711
(gdb) thread apply all bt # обязательный первый шаг
(gdb) p $_siginfo._sifields._sigfault.si_addr # по какому адресу упали
(gdb) info proc mappings # была ли эта область вообще отображена
(gdb) frame 2 # перейти в нужный кадр; дальше info locals / p *ptr
Диагностический словарь по адресу неудачного доступа:
| Адрес | Наиболее вероятная причина |
|---|---|
0x0 |
разыменование NULL |
малое: 0x8, 0x10, 0x18 |
NULL плюс смещение поля: p->field, где p == NULL |
0xffffffffffffffff |
не проверили код ошибки ((void*)-1 от mmap) |
похож на ASCII: 0x6f6c6c6548 |
строка записана поверх указателя — переполнение буфера |
0xdeadbeef, 0xbaadf00d |
отладочная заливка аллокатора — использование освобождённого |
| адрес есть в maps, данные бессмысленны | висячий указатель на переиспользованную память |
| чуть ниже вершины стека | глубокая рекурсия, переполнение стека |
Backtrace вида #0 0x0000000000000000 in ?? () — почти всегда вызов по испорченному указателю на функцию или возврат по затёртому адресу возврата (см. схему кадра стека выше).
Backtrace без дебаггера
В проде дампа можно не дождаться — нужен след прямо в лог:
// crashlog.c — печать стека из обработчика сигнала; собирать с -rdynamic
#define _GNU_SOURCE
#include <execinfo.h>
#include <signal.h>
#include <string.h>
#include <unistd.h>
static void handler(int sig) {
void *frames[64];
int n = backtrace(frames, 64);
(void)!write(STDERR_FILENO, "\n*** fatal signal\n", 18); // только write: printf запрещён
backtrace_symbols_fd(frames, n, STDERR_FILENO); // без malloc, в отличие от backtrace_symbols
signal(sig, SIG_DFL);
raise(sig); // упасть заново — чтобы ядро сделало core
}
void install_crash_handler(void) {
static char altstack[64 * 1024]; // отдельный стек: иначе при его переполнении
stack_t ss = { .ss_sp = altstack, .ss_size = sizeof altstack, .ss_flags = 0 };
sigaltstack(&ss, NULL); // обработчик просто не запустится
struct sigaction sa;
memset(&sa, 0, sizeof sa);
sa.sa_handler = handler;
sa.sa_flags = SA_ONSTACK | SA_RESETHAND; // RESETHAND: повторный сигнал уже не зациклит
sigemptyset(&sa.sa_mask);
sigaction(SIGSEGV, &sa, NULL); sigaction(SIGBUS, &sa, NULL); sigaction(SIGABRT, &sa, NULL);
}
Оговорка важная: backtrace() при первом вызове может дёрнуть dlopen и malloc, то есть теоретически способен зависнуть, если сигнал пришёл внутри аллокатора. Промышленный вариант — libunwind с предварительным «прогревом» или Google Breakpad/Crashpad, которые пишут минидамп из отдельного процесса, не трогая упавший, и потому не зависят от состояния упавшей кучи. Полные правила безопасности обработчиков — в https://courses.digitable.life/post/systems-programming/08-processes-and-signals/ и в man 7 signal-safety.
Неопределённое поведение: почему «работает на моей машине» не аргумент
Выше мы искали ошибку в готовой программе. UB опаснее: оно рвёт связь между исходным текстом и тем, что исполняется. Компилятор вправе предполагать, что UB не происходит, и строить оптимизации на этом предположении.
// ub-nullcheck.c
int read_or_fail(int *p) {
int x = *p; // разыменование => компилятор ЗНАЕТ, что p != NULL
if (p == NULL) // ...значит проверка всегда ложна
return -1; // ...значит ветку можно удалить целиком
return x;
}
$ gcc -O0 -S -masm=intel -o - ub-nullcheck.c | grep -A12 'read_or_fail:' # cmp ..., 0 на месте
$ gcc -O2 -S -masm=intel -o - ub-nullcheck.c | grep -A3 'read_or_fail:'
read_or_fail:
mov eax, DWORD PTR [rdi]
ret
На -O2 проверки на NULL нет вообще. Программа, аккуратно возвращавшая -1 на -O0, на -O2 падает — и ни одно предупреждение об этом не скажет, формально компилятор прав. Второй канонический случай — знаковое переполнение:
int count_up(int n) {
int c = 0;
for (int i = 0; i <= n; i++) // при n == INT_MAX условие ВСЕГДА истинно,
c++; // но UB разрешает считать, что i не переполнится,
return c; // значит цикл конечен => можно раскрутить
}
$ gcc -O2 -fsanitize=undefined -fno-sanitize-recover=all -o ub ub-overflow.c && ./ub
runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
$ gcc -O2 -fwrapv -o ub ub-overflow.c # -fwrapv: обещание, что переполнение = wrap-around
# Что смотреть в готовом бинаре:
$ objdump -d --disassemble=read_or_fail -M intel app # только нужная функция
$ objdump -dS app | less # ассемблер с исходником (нужен -g)
$ nm -C app | grep ' T ' # экспортируемые символы
Дисциплина против UB: (1) отладочная конфигурация всегда с -fsanitize=undefined — это дёшево; (2) не «чинить» баг понижением оптимизации — исчезновение проблемы на -O0 есть маскировка, не решение; (3) не считать наблюдаемое поведение спецификацией: «у меня int переполняется в отрицательное» верно только для этой версии компилятора с этими флагами; (4) -fwrapv, -fno-strict-aliasing, -fno-delete-null-pointer-checks — не «правильные флаги», а костыли для легаси, который нельзя переписать (Linux собирается с ними именно поэтому); (5) приведение float* к int* и чтение — нарушение strict aliasing, правильно через memcpy, который компилятор всё равно свернёт в один mov.
Глубокий разбор — в https://courses.digitable.life/post/systems-programming/11-undefined-behavior/. Обязательное чтение: Крис Латтнер, «What Every C Programmer Should Know About Undefined Behavior» (https://blog.llvm.org/2011/05/what-every-c-programmer-should-know.html) и Джон Регер, «A Guide to Undefined Behavior in C and C++» (https://blog.regehr.org/archives/213).
Тактики, когда прямой путь не сработал
Минимизация входа. Большой вход — плохой репро. Сокращайте пополам, пока падение не исчезнет (delta debugging); creduce/cvise (https://github.com/csmith-project/creduce) ужимают файл-репродюсер до десятка строк автоматически, сохраняя падение.
git bisect. Если раньше работало, вопрос не «где баг», а «какой коммит его внёс»: git bisect start HEAD v1.4.0 и git bisect run sh -c 'make -s && ./run-repro.sh' находят виновника из тысячи коммитов за 10–12 шагов. Механика — в https://courses.digitable.life/post/git/07-recovery/ и git help bisect.
Fuzzing как генератор репро. Санитайзер ловит только исполненные пути — фаззер даёт входы:
// fuzz_parser.c — точка входа libFuzzer
// clang -g -O1 -fsanitize=fuzzer,address,undefined -o fuzz_parser fuzz_parser.c parser.c
// ./fuzz_parser corpus/ -max_total_time=300
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
int parse(const char *s, size_t n);
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
char *buf = malloc(size + 1); // копия точного размера: иначе за границей окажется
if (!buf) return 0; // валидная память фаззера и ASan промолчит
memcpy(buf, data, size);
buf[size] = '\0';
parse(buf, size);
free(buf);
return 0;
}
Стресс и повторение. Гейзенбаг, воспроизводящийся раз в 200 запусков, ловится циклом for i in $(seq 1 500); do ./app test.dat || break; done либо сразу под записью: rr record --chaos ./app test.dat рандомизирует планирование потоков. Дифференциальная отладка. Одна сборка работает, другая нет — сравнивайте не код, а окружение: diff <(gcc -Q -O2 --help=optimizers) <(gcc -Q -O0 --help=optimizers), ldd app, env, версии библиотек.
Печать в лог тоже инструмент. Не стыдитесь fprintf(stderr, ...), но с временной меткой, идентификатором потока и обязательным fflush — иначе при падении последние, самые важные строки останутся в буфере. Механика буферизации разбирается в следующей статье трека.
Ассерты: самая дешёвая диагностика
_Static_assert(sizeof(void *) == 8, "код рассчитан на 64-битную платформу"); // бесплатно, всегда
void push(struct stack *s, int v) {
assert(s != NULL);
assert(s->size < s->cap); // инвариант входа
s->data[s->size++] = v;
assert(s->size <= s->cap); // постусловие
}
Две ловушки. Никогда не помещайте побочные эффекты внутрь assert: assert(read(fd, buf, n) == n) в релизе исчезнет вместе с чтением. И помните, что assert выключается макросом NDEBUG, который CMake добавляет в Release автоматически — то, что обязано остаться в релизе, оформляйте собственным макросом CHECK(cond), который печатает #cond, __FILE__, __LINE__ и зовёт abort(). Именно abort(), а не exit(1): он даёт SIGABRT, core dump и точное место. Продолжать работу с испорченным состоянием опаснее, чем упасть.
Типичные ошибки и рабочие конфигурации
- Отлаживать сборку, отличную от сломанной. Баг на
-O2, отладка на-O0— это другая программа. - Верить, что баг «там, где упало». В C место падения и место ошибки связаны слабо.
- Чинить симптом. Проверка на NULL там, где упало, без выяснения, откуда NULL, просто переносит падение.
- Менять несколько вещей за раз. Тогда неизвестно, что помогло, и «починка» может оказаться случайным сдвигом раскладки памяти.
- Игнорировать предупреждения.
-Wall -Wextraбесплатно находит то, на что потом уходят дни;-Werrorв CI — не занудство. - Считать «прошло под ASan» синонимом «корректно». Санитайзер видит только исполненные пути.
CFLAGS_COMMON = -std=c17 -Wall -Wextra -Wpedantic -Wshadow -Wconversion \
-Wstrict-prototypes -Wwrite-strings -Wcast-qual
debug: CFLAGS = $(CFLAGS_COMMON) -g3 -Og -fno-omit-frame-pointer
asan: CFLAGS = $(CFLAGS_COMMON) -g -O1 -fsanitize=address,undefined \
-fno-sanitize-recover=all -fno-omit-frame-pointer
tsan: CFLAGS = $(CFLAGS_COMMON) -g -O1 -fsanitize=thread
release: CFLAGS = $(CFLAGS_COMMON) -g -O2 -DNDEBUG -D_FORTIFY_SOURCE=3 \
-fstack-protector-strong -fstack-clash-protection -fcf-protection
В release тоже стоит -g: отладочная информация не влияет на скорость и не попадает в загружаемые сегменты, а без неё core dump из прода нечитаем. Символы выносятся objcopy и хранятся отдельно.
Итог
- В C ошибка и симптом разнесены во времени и по коду; вся отладочная инфраструктура существует ради сокращения этого расстояния.
- Флаги сборки — первая линия обороны:
-Wall -Wextra -g3 -Og -fno-omit-frame-pointerплюс-fsanitize=address,undefinedв отладочной конфигурации. - gdb — язык запросов к процессу: условные точки останова, watchpoint по адресу,
thread apply all bt, скрипты. Обратная отладка черезrrменяет правила игры для трудных багов. - Санитайзеры дешевле valgrind и точнее на стеке, valgrind не требует пересборки и видит неинициализированные чтения — это дополняющие, а не конкурирующие инструменты.
- Core dump читается, если сохранены символы и совпадает build-id:
thread apply all bt,$_siginfo,info proc mappings. - UB — не редкий случай, а штатный способ, которым C ломается тихо. Разница между
-O0и-O2не мистика, а следствие права компилятора считать, что UB не происходит. - Инвариант, проверенный ассертом, дешевле любого дебаггера. Fail fast:
abort()даёт core dump и точное место.
Источники
- Debugging with GDB — https://sourceware.org/gdb/current/onlinedocs/gdb.html
- Valgrind User Manual — https://valgrind.org/docs/manual/manual.html
- Clang Sanitizers — https://clang.llvm.org/docs/index.html (разделы Address/Undefined/ThreadSanitizer)
- K. Serebryany et al. AddressSanitizer: A Fast Address Sanity Checker, USENIX ATC 2012 — https://www.usenix.org/conference/atc12/technical-sessions/presentation/serebryany
- A. Zeller. The Debugging Book — https://www.debuggingbook.org/; J. Regehr. A Guide to Undefined Behavior in C and C++ — https://blog.regehr.org/archives/213
- rr: deterministic record and replay — https://rr-project.org/;
man 5 core,man 7 signal-safety,man 1 coredumpctl
Что дальше
Мы научились смотреть, что происходит внутри процесса. Дальше — граница между процессом и операционной системой: как программа на C просит ядро о работе, почему write() не значит «записано на диск» и во что обходится каждый переход в ядро.
Системные вызовы и ввод-вывод: файлы, дескрипторы, буферизация