Порой приходится слышать: «Отладчики бесполезны, гораздо целесообразнее иметь дело с логированием и модульными тестами». Подозреваю, что многие из разделяющих такое мнение думают, будто отладчик только и может, что расставлять точки останова на определённых строках, пошагово просматривать код и проверять значения переменных. Притом, что любой годный отладчик действительно всё это может, на самом деле это только верхушка айсберга. Задумайтесь: мы уже вполне можем наткнуться на код, которому около 40 лет; наверняка же с тех пор что-то изменилось?
Tl;dr — в этом эпизоде дедовского нытья вы узнаете, что хороший отладчик поддерживает различные виды точек останова, предлагает широкие возможности визуализации данных, имеет среду REPL для выполнения выражений, может показывать зависимости между потоками и контролировать их выполнение, может подхватывать изменения в исходном коде и применять их без перезапуска программы. Также он может проходить код от конца к началу и перематывать состояние программы до любого момента в истории её выполнения. Можно даже записать весь ход управления программы и визуализировать поток управления и историю потока данных.
Должен отметить, что идеального отладчика не существует. Разные инструменты поддерживают разные возможности и имеют разные ограничения. Как обычно, нет одного решения на случаи жизни, но важно понимать, что теоретически возможно, и к чему следует стремиться. В этой статье я опишу различные характеристики и возможности отладчика. Также мы обсудим имеющиеся продукты/инструменты, в которых такие продукты и инструменты предлагаются.
Дисклеймер. В этой статье в качестве примеров упоминаются различные бесплатные и коммерческие продукты. Я не получаю вознаграждения или какого-либо иного поощрения от компаний, стоящих за этими продуктами (хотя, кхе-кхе, и не откажусь от сувенирчика на память, если решите мне что-нибудь подарить). Моя цель — повысить осведомлённость аудитории в теме и оспорить расхожее убеждение, будто «отладчики бесполезны, сразу переходим к printf».
Точки останова, ах, точки останова
Начнём с азов: поговорим о точках останова. Они использовались с начала времён и поддерживаются в любом отладчике. Поставьте точку останова в какой-нибудь строке кода, и, как только выполнение программы дойдёт до этой точки, программа остановится. Вот так, если максимально просто. Но современные отладчики способны на гораздо большее.
Указание точек останова по вертикали. Вы знали, что точки останова можно ставить не только на конкретной строке, а в «строке+столбце»? В единственной строке исходного кода содержится множество выражений (например, вызовы функций foo() + bar() + baz())
, то можно поставить точку останова посреди строки и переходить прямо к точке выполнения. Уже некоторое время эта возможность поддерживается в LLDB, но поддержка на уровне IDE может отсутствовать. В Visual Studio есть команда Step into specific (пошаговое выполнение выбранной функции), решающая схожую задачу — позволяет выбрать, в какую функцию войти, если в одной строке кода имеется множество вызовов.
Условные точки останова. Как правило, есть комплект дополнительных опций, которые можно задавать для точек останова. Например, можно указать условие “hit count” (количество совпадений), чтобы точка останова срабатывала только по достижении определённого количества попаданий или после каждой N-ной итерации. Есть даже более мощный феномен, условные выражения, чтобы инициировать точку останова, когда приложение будет в определённом состоянии. Например, можно задать срабатывание точки останова лишь в том случае, когда попадание случается в главном потоке и monster->name == "goblin"
. Отладчик Visual Studio также поддерживает условные выражения типа «когда изменится» — инициировать останов, когда значение monster->hp изменится по сравнению с предыдущим случаем попадания в эту точку останова.
Точки трассировки. Но что, если в точках останова программа останавливаться не должна? Без лишних слов, можно не останавливать выполнение, а напечатать сообщение в выводе. И речь не о простом строковом литерале вида “got here lol” (дошли сюда, хаха); сообщение может содержать выражения, например, для вычисления и встраивания значений из программы, скажем, «итерация #{i}, действующий монстр – это {монстр->имя}». В сущности, мы внедряем вызовы printf в случайных точках нашей программы, не пересобирая и не перезапуская её. Красиво, правда?
Точки останова по данным. Также точки останова необязательно должны находиться на конкретной строке, по адресу или в функции. Во всех современных отладчиках поддерживаются точки останова по данным, то есть, программа может остановиться в тот момент, когда происходит запись в конкретный участок памяти. Не можете понять, почему монстр внезапно погибает? Задайте точку останова по данным в monster->hp и получайте уведомление всякий раз, когда это значение изменится. При отладке это особенно помогает в случаях, когда некоторый код пишет информацию в ту область памяти, в которую писать не должен. Сочетайте эту практику с выводом сообщений в консоль, и получится мощный механизм логирования, чьи возможности несравнимы с printf!
Визуализация данных
Ещё одна элементарная отладочная возможность — проверка данных (data inspection). Любые отладчики могут показывать значения переменных, но в хороших отладчиках предлагаются широкие возможности для создания собственных визуализаторов. В GDB есть инструменты структурной печати (pretty printers), в LLDB — инструменты форматирования данных, а в Visual Studio есть NatVis. Все эти механизмы весьма гибкие, и с их помощью вы можете при визуализации объектов делать практически что угодно. Эта возможность бесценна при проверке сложных структур данных и непрозрачных указателей. Например, не приходится беспокоиться о внутреннем представлении хеш-таблицы, будет достаточно просмотреть список записей «ключ/значение».
Эти визуализаторы исключительно полезны, но хорошие отладчики способны даже на большее. Если у вас есть GUI, зачем же ограничиваться «текстовой» визуализацией? Отладчик может показывать таблицы с данными и диаграммы (например, результаты SQL-запросов), отображать картинки (например, иконки или текстуры), воспроизводить звуки и многое другое. Здесь графический интерфейс открывает безграничные возможности, и такие варианты визуализации даже не очень сложно реализовать.
Вычисление выражений
В большинстве современных отладчиков поддерживается вычисление выражений. Идея такова: можно ввести выражение (как правило, на том же языке, на котором написана ваша программа), и отладчик интерпретирует его, воспользовавшись состоянием программы в качестве контекста. Например, вы вводите monsters[i]->get_name()
, и отладчик показывает вам "goblin"
(где monsters
и i – это переменные в текущей области видимости). Очевидно, это огромное минное поле, и реализация может значительно отличаться в разных отладчиках и на разных языках.
Например, отладчик Visual Studio для C++ реализует достаточно широкое подмножество C++ и рассчитан даже на вызовы функций (с некоторыми ограничениями). Применяемый в нём подход основан на использовании интерпретатора, так что этот отладчик весьма быстр и «безопасен», но не позволяет выполнять по-настоящему произвольный код. То же самое делает GDB. С другой стороны, LLDB использует настоящий компилятор (Clang), при помощи которого собирает выражение вплоть до машинного кода, а затем выполняет его в программе (хотя, в некоторых случаях может применять интерпретацию в качестве оптимизации). Таким образом удаётся выполнять практически любой действительный C++!
(lldb) expr
Enter expressions, then terminate with an empty line to evaluate:
1: struct Foo {
2: int foo(float x) { return static_cast<int>(x) * 2; }
3: };
4: Foo f;
5: f.foo(3.14);
(int) $0 = 6
Вычисление выражений — очень мощная фича, открывающая множество возможностей для анализа программы и для экспериментов. Вызывая функции, можно исследовать, как ваша программа действует в различных ситуациях и даже влиять на её состояние и выполнение. Отладчики также часто используют вычисление выражений для поддержки других возможностей, например для расстановки условных точек останова, операций по отслеживанию и форматированию данных.
Конкурентность и многопоточность
Многопоточные приложения сложно как разрабатывать, так и отлаживать. Многие баги, связанные с конкурентностью, сложно воспроизводить. Также сплошь и рядом случается, что в новом отладчике та же программа проявит себя совершенно иначе. Но всё-таки хорошие отладчики в такой ситуации могут очень помочь.
Например, отличный пример, в котором отладчик может сэкономить вам массу времени — отладка взаимных блокировок. Если вам удалось поймать ваше приложение в состоянии взаимной блокировки, то вам повезло! Хороший отладчик покажет все стеки вызовов для всех потоков, а также зависимости между ними. Не составляет труда увидеть, какие потоки дожидаются каких ресурсов (например, просмотреть мьютексы), и кто эти ресурсы отжирает. Некоторое время назад автор написал статью об отладке взаимных блокировок в Visual Studio, можете сами убедиться, насколько это просто.
Очень распространённая проблема при разработке и отладке многопоточных приложений — в том, что сложно проконтролировать, когда именно выполняются какие потоки и в каком порядке. Многие отладчики придерживаются политики «всё или ничего», то есть, по достижении точки останова стопорится вся программа (т.е., все её потоки). Если нажать “continue” (продолжить), то все потоки снова запустятся. Это нормально работает, если потоки у вас в программе не перекрываются, но по-настоящему раздражает, если одни и те же потоки выполняются в разных точках останова, причём попадания в эти точки происходят в случайном порядке.
Хороший отладчик может замораживать и размораживать потоки. Можно выбирать, какие потоки должны выполняться, а какие — спать. В результате очень и очень упрощается отладка сильно распараллеленного кода, а также удаётся эмулировать различные условия гонок и взаимные блокировки. В Visual Studio можно замораживать и растапливать потоки в UI, а в GDB предусмотрен так называемый нон-стоп режим. В RemedyBG есть очень удобный UI, в котором можно быстро переключаться в «соло»-режим и выходить из него (демo, смотреть с 2:00).
Выше я уже упоминал, что отладчики могут показывать зависимости между потоками. Хороший отладчик также поддерживает корутины (зелёные потоки, задачи, т.д.) и предлагает ряд инструментов для визуализации актуального состояния программы. Например, в Visual Studio есть возможность под названием параллельные стеки. В окне «Parallel Stacks» выводится быстрый обзор всего состояния программы, и можно просмотреть, какой код выполняется в различных потоках.
Горячая перезагрузка
Представьте себе, как проходит типичный сеанс отладки. Вы запускаете программу, загружаете данные, выполняете над ними некоторые операции и, наконец, выуживаете баг. Вы шаг за шагом расставляете точки останова и вдруг осознаёте, что конкретное условие “if” написано с ошибкой: в нём должно быть >=, а не >. Что делать дальше? Остановить программу, исправить это условие, пересобрать программу, запустить её, загрузить данные, выполнить некоторые действия… но, подождите. На дворе 2023 год, так что же вы будете делать дальше?
Вы исправите условие и сохраните файл. Пару мгновений — и программа подхватит изменения, внесённые в код! Никакого перезапуска и никакой потери состояния, выполнение возобновится ровно в той точке, в которой выполнение было прекращено. Вы сразу же увидите, что правка была неверной, и на самом деле здесь должно стоять ==. Ещё некоторые исправления — и, вуаля, бага больше нет.
Такая возможность сродни волшебству, и называется она горячая перезагрузка; хороший отладчик может подхватить изменения, внесённые в исходный код, и применить их прямо в выполняемой программе, без перезапуска оной. Многие, кто работает с динамическими языками или языками, завязанными на виртуальные машины (например, JavaScript, Python или Java) знают, насколько это классно, но не каждый осознаёт, что так можно делать и в компилируемых языках, например, в C++ или Rust! Например, Visual Studio поддерживает горячую перезагрузку для C++ при помощи Edit and Continue (Отредактировать и продолжить). Да, здесь есть длинный список ограничений и неподдерживаемых изменений, но во многих распространённых сценариях этот механизм всё равно работает вполне хорошо (демо).
Ещё одна потрясающая технология называется Live++; пожалуй, это наилучшее из имеющихся сегодня решений для перезагрузки. Live++ поддерживает различные компиляторы и системы сборки, может использоваться с любыми IDE или отладчиками. Список неподдерживаемых сценариев здесь гораздо короче, и многие из этих ограничений не фундаментальные: если приложить достаточные усилия, то горячая перезагрузка работает почти с любыми вариантами изменений.
Горячая перезагрузка не сводится к применению изменений в действующей программе. Хорошая реализация горячей перезагрузки может помочь при восстановлении после фатальных ошибок, например при нарушении правил доступа, или поменять уровни автоматизации (и потенциально — любые другие флаги компилятора) для различных модулей компилятора. Это также можно делать удалённо и для множества процессов одновременно. Можете посмотреть следующее экспресс-демо Live++ от @molecularmusing:
Во многих ситуациях горячая перезагрузка незаменима и, честно говоря, сложно представить сценарий, в котором она бы не помогла. Зачем перезапускать приложение, если это необязательно?
Путешествие во времени
Вы когда-нибудь сталкивались с такой проблемой: пробираетесь через код и случайно заступили лишнего? Самую чуточку, но, уф, ущерб уже причинён. Хорошо, давайте перезапустим программу и попробуем ещё раз… пробуем снова и после рестарта программы всё хорошо, но… ох. Ничего страшного, давайте попробуем отступить назад несколько раз. Такой приём кажется ещё более волшебным, чем горячая перезагрузка, но хороший отладчик, фактически, позволяет путешествовать во времени. Сделайте всего шаг назад или поставьте точку останова и воспроизводите код в обратном порядке, пока не наткнётесь на неё, то есть, отлаживайте как в 2023, а не в 1998.
Многие отладчики так или иначе поддерживают эту возможность. GDB реализует путешествия во времени, записывая все изменения регистров и памяти, вносимые каждой инструкцией, благодаря чему отмена таких изменений становится тривиальной. Правда, такой подход сопряжён с серьёзными издержками в производительности, так что в неинтерактивном режиме он может быть не слишком практичен. Другой популярный подход основан на следующем наблюдении: большей частью выполнение программы детерминировано. Всякий раз, когда происходит какое-либо недетерминированное событие (системный вызов, операция ввода/вывода, т.д.), можно сделать мгновенный снимок программы, а затем просто реконструировать состояние программы в любой момент, просто перемотав её состояние до ближайшего мгновенного снимка и выполнив код, начиная оттуда. В принципе, именно это и делают UDB, WinDBG и rr.
Путешествия во времени и обратное выполнение в частности неизмеримо полезны при отладке аварийных завершений. Например, рассмотрим типичный аварийный сценарий — нарушение прав доступа или ошибку сегментирования. При помощи обычных инструментов можно вывести стектрейс, когда кто-нибудь попытается разыменовать нулевой указатель. Но в данном случае стектрейс может быть не столь полезен, так как мы хотим разобраться, почему интересующий нас указатель равен нулю. Путешествуя во времени, можно поставить точку останова на значении указателя и воспроизвести программу в обратном направлении. Теперь при срабатывании точки останова можно доподлинно выяснить, почему указатель оказался равен нулю, и исправить ошибку.
При путешествиях во времени возникают определённые издержки производительности, но в некоторых ситуациях они совершенно оправданы. Из сценариев, в которых они нужны, сразу приходит на ум выполнение тестов. Разумеется, лучше быстрые тесты, чем медленные, но способность воспроизвести и исследовать развитие определённого провала при тестировании исключительно экономит время. Особенно в случаях, когда тест хрупкий, а для воспроизведения теста требуется много времени и везения. На самом деле, инструмент rr был исходно разработан компанией Mozilla для записи и отладки тестов Firefox.
Бывает, что путешествия во времени удаётся реализовать очень эффективно, если они глубоко интегрированы в саму экосистему, и, следовательно, позволяют предвосхищать определённые вещи и сглаживать углы. Например, если большую часть памяти в программе занимают неизменяемые ресурсы, загруженные с диска, то отслеживать их гораздо проще, а мгновенные снимки можно делать очень компактными. Замечательный пример такой интегрированной среды для разработки и отладки — Tomorrow Corporation Tech Demo. Если вы ещё не видели это демо — посмотрите не откладывая!
Всеведущая отладка
На закуску сегодня я приберёг историю о новинке, полностью меняющей правила игры в отладочном деле. Вы даже не поверите, что позволяет сделать ваша программа! У традиционной отладки много недостатков, которые, пожалуй, вам хорошо известны. Запись с воспроизведением — огромный шаг вперёд, но что если также можно было бы предвычислять все отдельные состояния программы, хранить их в базе данных и выстраивать индексные списки для повышения эффективности запросов? Кажется невозможным, но на самом деле такая задача на удивление осуществима. Оказывается, все состояния программы очень хорошо поддаются сжатию и могут занимать <1 бит дискового пространства на инструкцию!
Такой подход называется «всеведущая отладка» (omniscient debugging) и не только позволяет решить целый ворох проблем, от которых страдают традиционные отладчики (например, таких, как размотка стека), но и открывает перед нами возможности, ранее казавшиеся немыслимыми. Когда вся история программы записана и проиндексирована, можно задавать такие вопросы как «сколько раз и где именно была записана эта переменная?», «какой поток высвободил этот фрагмент памяти?» или даже «как именно был отрисован данный конкретный пиксель?».
Если я вас ещё не убедил, посмотрите это видео — Debugging by querying a database of all program state от Кайла Хью. В нём по-настоящему хорошо объяснено, как всё это возможно, и почему следует присмотреться к этой возможности. Разумеется, здесь есть ограничения, но многие из них — это просто детали реализации, а не фундаментальные барьеры. Также рекомендую посмотреть видео The State Of Debugging in 2022 от Роберта O’Каллахана (автора rr), где отлично аргументировано, почему за всеведущей отладкой будущее, и почему стоит требовать от наших инструментов большего.
Всеведущая отладка пока в самом начале пути, хотя эта идея была сформулирована уже несколько десятилетий назад (см. Debugging Backwards in Time (2003) от Била Льюиса). Сама идея очень проста, но эффективно и практично реализовать её сложно. Даже при этом потенциал её головокружителен. Отличный образец современного всеведущего отладчика — Pernosco. Список поддерживаемых в нём возможностей и вариантов использования велик, и даже простые демки выглядят почти невероятно. Можете опробовать его сами — и добро пожаловать в будущее!
Ещё один восхитительный инструмент, который стоит попробовать — WhiteBox. Он компилирует, выполняет и «отлаживает» код по мере того, как вы этот код пишете, позволяя вам как следует рассмотреть состояние программы в любой момент времени. Пока он всё ещё в состоянии бета-версии, и мне не терпится увидеть, что же из него получится. Именно таким я хотел бы видеть будущее и уверен, что когда-нибудь мы в нём окажемся :D.
Отлаживать или не отлаживать?
У любого из имеющихся отладчиков есть свои сильные и слабые стороны; серебряной пули не существует, но для вас это не новость. В некоторых ситуациях удобнее применять логирование, тогда как в других случаях отладчик с функцией путешествий во времени может сократить поиски бага с дней до минут. Технологии отладки прошли долгий путь и, пусть даже некоторые вещи не столь впечатляют, как вы, возможно, ожидали, всё равно есть множество интересных возможностей, с которыми определённо стоит познакомиться. Просьба: пользуйтесь отладчиками и жалуйтесь, если что-нибудь не работает. Требуйте большего от вендора, у которого приобретаете логический отладчик; только в таком случае ситуация будет постепенно улучшаться.
Автор забыл упомянуть вашу любимую возможность отладчика? Сообщите ему! Статья совершенно не является исчерпывающей, так, в ней не затронуты такие интересные аспекты, как отладка ядра или драйверов. Поделитесь интересными историями из жизни, расскажите, как отладчик вас спас либо самым возмутительным образом подвёл.