Доводилось ли вам когда-нибудь работать над C++ проектом с такой огромной кодовой базой, что понимание того, что на самом деле происходит во время выполнения программы, требует кучи времени и сил? Откуда и какие функции вызываются? Вам когда-нибудь поручали исправить баг в таком проекте, прилагая лишь логи, которые приближают вас к нахождению первопричины проблемы так же, как Моисей к земле обетованной? А попытки воспроизведения бага не дают вам никакой полезной информации? Если ваш ответ да – пожалуйста, присаживайтесь поудобнее. В какой-то момент я начал размышлять над тем, какими характеристиками должен обладать инструмент, который мог бы помочь мне лучше понять природу возникающих проблем и багов. Кто-то здесь может спросить — “Разве не для этого придуманы дебаггеры?”. Конечно, дебаггеры очень выручают, если у вас есть доступ к среде, в которой вы можете свободно запускать некорректный код и отлаживать его строчка за строчкой, что, по моему опыту, тоже не всегда так радужно. Но во многих случаях проблема воспроизводима только в инфраструктуре заказчика и экспериментировать на живом теле вам не позволят. Или вы не располагаете необходимым тестовым сетапом. Или источник проблемы кроется в сторонней библиотеке, и у вас нет доступа к ее исходному коду. Или проблема просто слишком сложна, чтобы отлаживать ее традиционными способами. Какой бы ни была причина, иногда альтернативный подход является единственным доступным решением. В итоге я сформировал следующие ограничения на инструмент, который мог бы удовлетворить мои потребности: каждый раз, когда вызывается пользовательская функция, это должно быть отражено в логах; функции должны быть в demangled виде и представлены в логах в удобочитаемом для пользователя виде; логирование должно выполняться из коробки — без необходимости засорять код лишними макросами в начале каждой функции; функции из стандартной библиотеки должны быть исключены из логирования — мы хотим избежать ситуации, когда в логах отражены сотни строк выполнения простых операций над контейнерами; в логах должны отражаться имя файла и номер строки откуда вызывается данная функция; каждое вложение функции добавляет отступ, а возврат из функции удаляет его – в результате во время выполнения формируется дерево стека вызовов, дающее наглядное представление о том, какая функция кем вызвана; механизм логирования может быть активирован только в том случае, если он скомпилирован с дебажным определенным флагом – таким образом мы избежим чрезмерных накладных расходов при рядовом выполнении программы; должен работать на gcc; Как бы тщательно я ни шерстил веб, я мог найти решения, которые соответствовали только одному пункту из моих требований. Ни одно из них не могло похвастаться всеми пунктами, поэтому я решил написать свой собственный микро-фреймворк и назвал его Call Stack Logger. Во-первых, для начала нам нужен механизм, который позволит вызывать определенные функции каждый раз по факту входа или выхода из каждой функции. Это может казаться немного похожим на аспектно-ориентированное программирование, но разница здесь в том, что вход и выход из функции имеют сигнатуры, уже определенные компилятором. Я говорю о фиче gcc, называемой инструментирование/инструментация функций (function instrumentation). Чтобы активировать ее, мы должны передать в gcc следующий флаг компиляции: Теперь мы можем определить функции для профилирования кода в любом удобном для нас месте. Я создал файл trace.cpp и поместил туда следующие две функции: Обе функции принимают в качестве аргументов два указателя на void: callee является указателем на функцию, которая вызывается в данный момент, а caller это точка вызова, который указывает на место (строку кода), откуда вызывается функция. attribute((no_instrument_function)) в сигнатуре функции нужен для ограждения данной функции от инструментирования. Другими словами, мы не хотим, чтобы наши функции для профилирования были инструментированы, чтобы избежать бесконечных вызовов, вызывающих ошибки сегментации. Чтобы сделать этот атрибут немного лаконичнее и нагляднее, мы можем определить макрос: Следующим шагом будет реализация простого механизма логирования, сохраняющего историю вызываемых функций: После инициализации указателя на файл мы можем использовать его внутри функции __cyg_profile_func_enter. Пока просто будем записывать туда адреса вызываемых функций: И давайте запустим простенький пример: Файл trace.out будет содержать следующее: Приведенный выше вывод не очень информативен для нас как для человека, так как мы видим только адреса вызываемых функций, что не сильно облегчает нам процесс отладки. Кроме того, мы сложно не обратить внимание на большое количество записей в лог-файле (в результате мы получили целых 232 строки) вместо всего двух — для main и A::foo(). Откуда они все взялись? Чтобы понять это, нам все-таки придется расшифровать эти адреса. Для этого нам понадобится binutils: Также для того чтобы получить информацию о динамических символах в исполняемом файле, нам нужно добавить еще одну опцию gcc: А также залинковать эти две библиотеки: libdl это Dynamic Link Library, которая представляет собой интерфейс для динамического загрузчика и позволяет нам, среди прочего, искать символы, а libbfd это Binary File Descriptor Library, которая нужна для работы с объектными файлами и будет использоваться нами позже. Теперь мы можем расшифровать адреса. Включите заголовки: И теперь вместо наивного логирования адреса функций мы можем определить для __cyg_profile_func_enter следующее тело: После компиляции и запуска программы вы увидите в файле trace.out следующее: И вот мы снова видим 232 строки расшифрованных функций. Почти все эти функции из стандартной библиотеки и связаны они со всеми операциями, которые вызываются при создании, аллокации, инициализации, сортировке и уничтожении вектора, который мы использовали в нашем примере. Мы всего-то написали и вызвали 3-строчную функцию, а в результате получили такой большой объем информации. Это настолько захламляет наши логи, что мы определенно должны что-то с этим сделать, иначе мы просто утонем в этих функциях. Кроме того, в большинстве случаев нас совершенно не интересуют детали того, что делают стандартные функции, поскольку мы можем позволить себе положиться на то, что они делают именно то, что от них ожидается. Мы хотим сосредоточиться на бизнес-логике, которую формируют наши пользовательские функции, и ограничить внимание механизма логирования только ими. К счастью, gcc предоставляет нам специальную опцию инструментирования: в которой мы можем перечислить расположение всех заголовков из стандарта C++, которые мы хотели бы исключить из инструментирования, например, /usr/include, /usr/include/c++, /usr/include/x86_64-linux-gnu/c++/10, и т.д. К сожалению, эти пути могут разниться в зависимости от версии Linux или gcc, поэтому, чтобы упростить задачу, я добавил shell-скрипт в Makefile, чтобы автоматически находить и перечислять все локации заголовков стандартной библиотеки C++ и передавать их в –finstrument-functions-exclude-file-list: С этими изменениями, если мы перекомпилируем и запустим программу, мы получим следующий вывод: Ну разве так не лучше? Это уже намного ближе к тому, чего мы хотим достичь. Теперь, когда мы создали базовое решение, настало время внести дополнительные улучшения. Вместо того, чтобы выводить двоичное имя, мы будем выводить имя файла и номер строки, из которой была вызвана функция, так как это гораздо более полезная информация для поиска первопричины проблемы. Мы также можем добавить таймстемп, когда вызывалась определенная функция. Ниже приведен код, отвечающий за основные функции Call Stack Logger: В приведенном выше коде опущены некоторые части, такие как инициализация BFD, реализация раскрутчика стека, форматирование обработанного фрейма или создание таймстемпа. Исключительно потому, что я не хотел захламлять статью копипастом слишком большого количества кода — ключ в том, чтобы показать вам общую идею и то, как она реализована. Полный код вы можете найти в репозитории, упомянутом в конце этой статьи. Нам также следует изменить функцию __cyg_profile_func_enter: Мы начинаем с функции resolve, принимающей адреса как вызываемой функции (callee), так и вызывающей стороны (caller). Ниже мы, в свою очередь, вызываем отдельные функции для адреса вызываемой функции (resolve_function_name) и адреса вызывающей стороны (resolve_filename_and_line) — так как из первого мы можем расшифровать имя класса и вызываемой функции, а второй позволяет нам получить информацию о точке вызова — точном месте, откуда была вызвана функция. Если бы мы передали функции resolve_filename_and_line адрес вызываемой функции вместо адреса вызывающей стороны, то она указала бы на первую строку определения вызываемой функции. Обе эти функции для извлечения нужной информации используют BFD-объекты. По соображениям эффективности они хранятся в статической map, и всякий раз, когда требуется какая-либо из них, их можно найти и получить к ним доступ без необходимости создавать эти объекты с нуля. Только когда не может быть найден подходящий BFD-объект, он создается и добавляется в map. Если resolve_function_name не удается декодировать имя функции, мы не будем декодировать имя файла и строку, мы просто пропустим эту запись. Также стоит отметить, что адрес вызывающей стороны не может быть преобразован стандартным способом. Это из-за какой-то ошибки в значении счетчика команд, приводящей к возврату неправильного места вызова. Оговорив это, прежде чем передать адрес вызывающей стороны функции resolve_filename_and_line мы должны сначала раскрутить (unwind) n-й фрейм, извлечь его адрес и только потом передать его в resolve_filename_and_line. К счастью, глубина раскрутки постоянна и для текущей реализации равна 6. Чтобы сделать это, раскрутчик использует библиотеку libunwind. Теперь наш файл trace.out будет выглядеть следующим образом: Но есть еще кое-что, что мы могли бы улучшить. Чтобы было намного легче отследить фактический поток выполнения, мы добавим отступы. Мы немного изменим __cyg_profile_func_enter и добавить реализацию __cyg_profile_func_exit: Мы объявляем статическую переменную current_stack_depth которая увеличивается каждый раз, когда фрейм обрабатывается успешно. Мы также храним информацию о том, был ли последний фрейм обработан успешно, что будет использовано для уменьшения переменной current_stack_depth, когда мы выходим из функции. Теперь нам нужно только передать эту переменную в функцию utils::format и отформатировать строку так, как нам нужно, чтобы она выводилась в файле с логами. И еще одно — давайте немного изменим наш пример, чтобы лучше продемонстрировать, как Call Stack Logger выводит записи в файл с логами: И вот окончательный результат: Как мы видим, вывод фреймворка четко визуализирует ход выполнения программы в виде древовидной структуры. Возможно, вы заметили, что в отношении адреса вызывающей стороны мы извлекаем из него только имя файла и номер строки. Мы не декодируем имя вызывающей функции, только имя вызываемой функции, так как древовидная структура явно показывает нам всю иерархию вызовов программы, т.е. нам не нужно ее дополнительно расшифровывать и мы все еще можем сказать какая функция кем была вызвана. Решение, представленное в Call Stack Logger может представлять собой альтернативу стандартной отладке во многих ситуациях, например, когда доступ к производственной среде ограничен, и стоит иметь такой механизм в качестве резервного в нашем пуле инструментов для решения неочевидных проблем. Вы можете найти Call Stack Logger на Github: TomaszAugustyn / call-stack-logger Материал подготовлен в преддверии старта онлайн-курса "C++ Developer. Professional".  Недавно в рамках курса прошел открытый урок, на котором разбирались, какие основные алгоритмы включены в STL. В ходе занятия мы познакомились с алгоритмами поиска и сортировки. Если тема интересна, делимся записью урока.  Редактор