Системная отладка или Как искать баги: при чем тут дедукция и цепочка Целлера

Меня зовут Кирилл Кайданов, я старший инженер-программист в компании «Гарда». Я несколько лет занимался исключительно багами, достаточно редко брал какие-либо другие задачки. И так получилось, что в целом эта сфера по-прежнему осталась любимой частью работы.
Представьте археолога на раскопках. Он не начинает хаотично перебирать все найденные предметы в надежде сразу обнаружить ценную находку. Сперва он изучает местность, формирует гипотезы, постепенно сужая таким образом область поиска. Каждый найденный фрагмент помогает ему понять, где искать дальше, а каждая неподтвердившаяся гипотеза — исключить очередное заблуждение.
С поиском причин багов примерно та же история. Их легко превратить в хаотичные попытки переписывания кода. Но отладка — это системный инженерный процесс, основанный на дедукции. Здесь нет места игре в угадайку. Важно сформировать гипотезы и последовательно отсекать те области, где проблем гарантированно нет.
Делюсь мануалом, который помогает мне быстрее вычислять баги. Хотя основная часть моей практики связана с C++, но предлагаемый в статье подход универсален и не зависит от языка программирования или стека. Гарантирую, что он будет работать даже в ситуации, когда нужно разбираться с системой на незнакомом языке.
Зачем нам нужна цепочка Целлера
В книге «Why Programs Fail» Андреас Целлер (Andreas Zeller) сформулировал базовую модель распространения вирусов ошибок. Чтобы отладка не превращалась в гадание на кофейной гуще, при каждом разборе кода важно разграничивать три понятия:
ошибка (Defect) — непосредственная ошибка в коде, своего рода неверное условие, логический огрех;
заражение (Infection) — некорректное внутреннее состояние программы после выполнения дефекта. В этом случае переменная или структура данных уже содержит неверные значения, но программа пока еще продолжает работать;
сбой (Failure) — видимое проявление проблемы, которое может выражаться в падении процессов, некорректном ответе в API, применении дефолтных настроек вместо пользовательских и т. п.
Эту последовательность удобно записывать в виде цепочки Целлера.

В тасках и баг-репортах мы всегда видим Failure. Задача разработчика в том, чтобы раскрутить эту цепочку в обратном направлении и докопаться до истинного источника проблемы.
Как ведут себя баги в «дикой природе»
Прежде чем начинать раскопки выдвигать теории о причинах возникновения того или иного бага, сперва нужно понять, как именно он себя ведет.
Я пытаюсь определить, насколько ошибка вообще «постоянна». Это сильно сужает круг возможных причин.Так и работает дедукция.
Если баг воспроизводится механически, ситуация относительно простая. Подаешь те же входные данные — получаешь стабильно одинаковый результат. Это означает, что перед нами детерминированный баг. Условия эксперимента можно зафиксировать, а дальше проверять гипотезы одну за другой, постепенно сужая область поиска.
С недетерминированными ошибками (Heisenbugs) подобная стратегия уже не прокатывает. Ведь один и тот же сценарий то приводит к сбою, то нет. На результат может влиять всё что угодно: тайминги, переключение контекста ОС, порядок выполнения потоков или текущая нагрузка. Приходится смотреть не только на сам сбой, но и на то, что меняется от запуска к запуску.
Есть баги-хамелеоны, которые словно подстраивают свое поведение под окружение. Например, на одной версии компилятора ошибка есть, а на другой нет. На одном дистрибутиве ОС всё работает как швейцарские часы, а на другом — жестко тормозит. В этом случае среда сама становится частью расследования.
От чего зависит успех «раскопок» и выбор инструментов отладки
Любая отладка начинается еще до активного вмешательства в процесс. По логам, стектрейсам, описанию задачи и любым первичным данным уже можно выстраивать первые гипотезы. Главная цель на этом этапе — сузить область поиска до подозрительного модуля, функции или участка пайплайна. А дальше многое будет зависеть от того, что мы знаем о баге и что можем с ним сделать.
Ниже разберу пять типичных сценариев поиска багов:
ошибка воспроизводится, и код можно исследовать вживую;
баг произошел в окружении, к которому нет прямого доступа;
баг недетерминированный и зависит от порядка выполнения потоков или таймингов;
баг зависит от конкретной среды выполнения (ОС, архитектуры или конфигурации);
баг воспроизводится на окружении QA, но не воспроизводится локально.
Сценарий 1. Баг можно воспроизвести, и есть прямой доступ к тестированию
Рассмотрим первый сценарий, когда код и тесты можно запускать напрямую. Это сильно упрощает инженеру жизнь, но не отменяет анализ кода. Считаю, что эти два подхода обязательно должны работать в параллели. Читая код, мы формируем гипотезу, а запуская его, можем быстро понять, насколько она вообще имеет отношение к проблеме.
В зависимости от ситуации применяются разные приемы:
анализ кода и трассировка;
модуляция ситуации;
инструментальный контроль (GDB, LLD; Valgrind, санитайзеры);
точечное логирование.
Однако универсальной последовательности действий здесь нет. Набор приемов для поиска багов во многом зависит от характера конкретной задачи.
Иногда достаточно пройти по веткам if/else в месте, где проявляется проблема. С карандашом или в уме посмотреть, какие условия приводят к сбою. В другом случае полезнее специально искусственно воспроизвести подозрительное состояние. Например, если кажется, что в расчеты где-то просочился ноль, пустая строка или nullptr, можно не ждать, пока такое значение случайно появится. Достаточно намеренно подставить его в код или через дебаггер и посмотреть, что произойдет дальше.
GDB и LLDB, в свою очередь, позволяют выполнять код пошагово, ставить условные брейкпоинты (break fn if x == 0), ватчпоинты на измененные адреса памяти (watch *ptr) и подменять значения на лету прямо во время выполнения (set variable x = 0).
Valgrind и санитайзеры вроде ASan, TSan и MSan выручают, когда нужно поймать ошибку, прежде чем она превратится в видимый сбой. Например, с их помощью можно отловить момент Infection в памяти (out-of-bounds, use-after-free, uninitialized read) до того, как произойдет видимый Failure.
Если же полноценная инструментальная отладка не нужна, часто достаточно точечного логирования. Временный вывод промежуточного состояния на границах вызовов иногда дает ровно ту информацию, которой не хватало для следующей гипотезы.
Рассмотрим пример с расхождением ключей в std::unordered_map
Сервис начал игнорировать пользовательские настройки профиля и загружать дефолтный конфиг.
Ниже исходный код:
C++
struct MetricKey {
uint32_t sensor_id;
uint16_t region_code;
// 2 байта padding для выравнивания до 8 байт
bool operator==(const MetricKey& other) const {
return sensor_id == other.sensor_id && region_code == other.region_code;
}
};
template <>
struct std::hash<MetricKey> {
std::size_t operator()(const MetricKey& k) const {
// Ошибка: хешируется вся память структуры, включая неочищенный padding
std::string_view bytes(reinterpret_cast<const char*>(&k), sizeof(MetricKey));
return std::hash<std::string_view>{}(bytes);
}
};Размотка цепочки Целлера
Infection 2.
Registry.find(key)возвращаетend(). То есть ключ, который точно должен находиться в контейнере, почему-то не находится. С этого и начинаем раскручивать цепочку назад.Infection 1. Смотрим на сами ключи. Key1 (на стеке) и key2 (в куче) равны по
operator==, но дают разный хеш. Значит, где-то между формированием ключа и поиском появляется расхождение.Проверка гипотезы в GDB. Здесь уже можно не гадать, а сделать небольшой эксперимент. Подставляем сохраненный при записи ключ напрямую в
find().тот ключ, который сохранили непосредственно при записи. Поиск проходит успешно. Следовательно, с контейнером всё в порядке.Побайтовый инспект. Теперь смотрим, чем эти два ключа отличаются на самом деле. Проверяем содержимое памяти побайтово:
Plaintext (gdb) x/8xb &key1 0x7fffffffe000: 0x2a 0x00 0x00 0x00 0x07 0x00 0xab 0xab <-- Мусор в padding (gdb) x/8xb &key2 0x555555700100: 0x2a 0x00 0x00 0x00 0x07 0x00 0x00 0x00 <-- Зануленный paddingDefect:
std::hashсчитался по сыромуsizeof, зацепляя мусор выравнивания.
Сценарий 2. Прямого доступа к тестированию нет
Часто баг происходит на закрытом контуре или в продакшене, где нет возможности перезапустить процесс под дебаггером. В такой ситуации приходится восстанавливать картину по тем следам, которые система уже успела оставить.
И тут возможны варианты…
Дамп памяти (core.dump)
Большая удача, если процесс успел сохранить дамп. В нем остается состояние программы на момент падения, и с этим уже можно работать.
Сначала загружаем дамп: gdb ./app core.dump
Затем смотрим стек падения (gdb) thread apply all bt и анализируем состояние. Для этого переходим в нужный кадр (frame N) и проверяем аргументы и локальные переменные (info locals, p *ptr).
Анализ «на бумаге» (по логам и коду)
Если дампа нет, работаем исключительно с кодом и текстовыми логами.
Начинаем с фиксации временных рамок. Для этого выстраиваем хронологию событий по тайм-штампам.
Следующим шагом строим граф вызовов (Call Graph). На основе точек входа восстанавливаем дерево вызовов функций в подозреваемой области.
Ну, и завершаем анализ обратной трассировкой (Backward Slicing). Берем точку в коде, где зафиксирована Infection, и иду вверх по графу вызовов. Ветки, которые физически не могли повлиять на состояние скомпрометированных данных, можно отбросить. На стыках модулей заодно проверяем контракты (Pre/Post-conditions).
Сценарий 3. Ошибка «плавает»: гонки и недетерминированные баги (Race Conditions)
Для плавающих ошибок, которые зависят от порядка выполнения потоков или задержек I/O обычный пошаговый брейкпоинт бесполезен, так как остановка потока меняет тайминги. В итоге баг маскируется, исчезает ровно в тот момент, когда мы начинаем его искать. Но не стоит сдаваться, ведь есть способы поймать такие ошибки.
Подход 1. Анализ системных вызовов и блокировок
Если процесс начинает зависать под нагрузкой, сначала смотрим, что происходит на уровне ОС: strace -p \<PID> -f -e trace=futex.
Если в выводе массово появляется futex (..., FUTEX_WAIT_PRIVATE), это говорит о заблокированных потоках. Подключаемся через GDB (thread apply all bt) и смотрим, где находятся потоки и в каком порядке они захватывают мьютексы. Так можно добраться до Deadlock и понять, какая блокировка удерживает остальные потоки.
Подход 2. Модуляция времени (Forced Race Condition)
Иногда нужно сделать наоборот: не пытаться поймать гонку в естественном состоянии, а специально создать для нее более удобные условия. Если у нас есть гипотеза о нарушении Happens-Before, мы искусственно расширяем временное окно, в котором потоки могут столкнуться:
void worker_thread() {
// Искусственная задержка для проверки гипотезы о race condition
std::this_thread::sleep_for(std::chrono::milliseconds(10));
shared_resource->update();
}Если после добавления задержки плавающий баг вдруг начинает воспроизводиться стабильно (с вероятностью 100%), это сильный аргумент в пользу гипотезы о гонке. Дальше ее уже можно проверять инструментально. В C++ для автоматического поиска подобных проблем используем TSan (-fsanitize=thread).
Сценарий 4. Зависимость от среды
Бывают ситуации, когда код безупречно работает на Linux, но падает либо ведет себя некорректно на Windows, macOS или конкретном дистрибутиве Linux.
Так может происходить по самым разным причинам. Для себя я обычно выделяю три ключевых:
Размерность типов и дата-модели. Например, LP64 в Linux или LLP64 в Windows, где
sizeof(long)равен 8 и 4 байтам соответственно.Специфика выравнивания (Alignment): x86 прощает невыровненное чтение памяти, а некоторые ARM-архитектуры генерируют SIGBUS.
Контракты системных вызовов. Например, разное поведение мультиплексирования ввода-вывода (
epollв Linux vskqueueв macOS vsIOCPв Windows), обработка сигналов или флагов сокетов (MSG_NOSIGNAL, O_CLOEXEC).
Как пример возьмем: падение на ARM из-за выравнивания памяти
C++
void process_payload(const uint8_t* buffer) {
// Работает на x86_64, но вызовет SIGBUS / некорректное чтение на некоторых ARM
const uint64_t* header = reinterpret_cast<const uint64_t*>(buffer + 1);
uint64_t val = *header;
// ...
}Для отладки багов среды в первую очередь необходимо провести сравнение системных вызовов. На этом этапе снять strace (Linux) или truss / dtruss (BSD/macOS) на обеих системах и сравнить возвращаемые коды ошибок системных функций. Затем уже можно приступать к изоляции в контейнере / VM: когда мы конфигурируем точное окружение целевой ОС (версия ядра, glibc, лимиты ulimit -n). Ну, и финальное телодвижение — проверка флагов компиляции. Мы оцениваем разницу флагов оптимизации (-O2, -O3) и системных макросов компилятора.
Сценарий 5. Баг есть у QA, но локально не воспроизводится
Баг может «плавать» и не воспроизводиться на рабочем окружении. И в этом случае просто отдавать задачу обратно в QA смысла мало. Порядок действий должен быть следующим:
Проверяем истории коммитов (Git History). Бывает, что на первый взгляд причина прячется совсем не там, где проявляется баг, поэтому смотрим не только сам код, но и то, что происходило рядом. Например, запросто могли поменяться смежный сервис, библиотека или структура БД.
Сверяем конфигурации тестового стенда и локальной машины, чекаем версии зависимостей, переменные окружения, состояние и данные в БД. Если не находим явного расхождения, уже вместе с QA смотрим, как именно баг воспроизводится на реальном стенде.
Если совместный прогон с QA тоже ничего не прояснил, возвращаемся к шагам воспроизведения и уточняем их. Если баг появляется, фиксируем, что именно изменилось по сравнению с предыдущей попыткой. Иногда именно это отличие и оказывается недостающим звеном.
И только если обычного воспроизведения по-прежнему недостаточно, переходим к стресс-тесту и нагрузке: запускаем подозрительный сценарий в параллельных потоках или под высокой нагрузкой и смотрим, удастся ли таким образом заставить плавающий баг проявиться.
Что помогает не сбиться с пути
Магии не существует, у сбоя всегда есть причина.
В отладке легко увлечься поиском той самой строки и начать действовать хаотично. На этот случай у меня есть небольшой личный чек-лист, который помогает не свернуть в сторону, когда кажется, что ответ уже найден.

Суть отладки — системное сужение гипотез
Отладка больше похожа на археологические раскопки, чем на поиск ошибки по чек-листу. Сначала есть только гипотеза и область поисков довольно большая. Однако проверяя один участок за другим, снимая слой за слоем, мы каждый раз решаем, куда копать дальше. И чем больше фактов собираем, тем меньше остается сомнений в истинной причине бага. В конце концов остается конкретная строка или условие, которое и вызывает ошибку.

А как ищете баги вы? И какой самый упрямый случай приходит на ум: когда пришлось идти по цепочке от Failure до Defect дольше всего?
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.