Андрей КарповООО "СиПроВер"
Май 2008
Публикуется с разрешения автора.
Оригинал статьи находится на сайте
www.viva64.com.
Иногда единственным методом отладки является использование протоколирования событий приложения. К недостаткам протоколирования (логирования) можно отнести большой объем кода, который приходится писать вручную для сохранения всей необходимой информации. В статье рассматривается методика, позволяющая построить систему автоматического протоколирования кода на языке Си/Си++.
Несмотря на время, языки Си и Си++ продолжают занимать лидирующие позиции во многих областях программирования. И в ближайшее 10 лет вряд ли что-то существенно изменится. Появилось много красивых и интересных языков, однако на практике часто предпочтение все-таки отдается Си и Си++. Это одни из лучших существующих языков, универсальные, высокоэффективные и с широкой поддержкой. Если вы хотите лучше понять, чем же привлекательны эти языки, то я предлагаю вам познакомиться со статьей "Инструменты и производительность" [
1] и интересной подборкой сообщений из форума comp.lang.c++.moderated "Why do you program in c++?" [
2]. Все это значит, что разработка инструментария для языков Си/Си++ по прежнему актуальна.
Во всех языках разработчики допускают ошибки и языки Си/Си++ здесь не исключение. Более того, как и многие профессиональные инструменты, они требуют большего внимания и аккуратности при их использовании. В результате одной из важнейших задач при разработке приложений является отладка кода, то есть поиск и устранение ошибок.
Способы отладки приложений можно разделить на следующие основные группы:
- Интерактивные средства отладки;
- Runtime-диагностика;
- Визуальные (графические) средства отладки;
- Модульные тесты (Unit Testing);
- Функциональные тесты;
- Протоколирование;
- Отладка по крэш-дампам;
- Визуальный просмотр кода (Code Review);
- Статический анализ кода.
У каждого из перечисленных методов есть свои достоинства и недостатки, с которыми можно познакомиться в статье "Способы отладки приложений" [
3]. Но в рамках нашей статьи мы поговорим о протоколировании (логировании) и методах его автоматизации.
На первый взгляд протоколирование работы приложений может показаться неактуальным. Возможно это атавизм того времени, когда еще было принято выводить результаты работы программы сразу на принтер? Нет, это очень эффективная и часто незаменимая методика, позволяющая отлаживать сложные, параллельные или специфические приложения.
Рассмотрим области, где протоколирование является незаменимым по удобству и эффективности решением:
- Отладка оптимизированных (Release) сборок приложения. Иногда Release версия ведет себя не так как Debug, что может быть связано с ошибками неинициализированной памяти и т.д. Но работать с Release версией в отладчике бывает часто неудобно. А еще, хотя это бывает крайне редко, встречаются ошибки компилятора, которые тоже проявляются только в Release версиях. В таких ситуациях протоколирование представляет собой хорошую замену отладчику.
- Отладка механизмов защиты. Часто разработка приложений с аппаратной защитой (например, на основе ключей Hasp) крайне затруднена, так как отладка невозможна. Протоколирование в этом случае, является, чуть ли не единственным способом поиска ошибок.
- Протоколирование - это единственно возможный вариант отладки для приложения, которое запускается на компьютере у конечного пользователя. Грамотное использование файла протокола позволит разработчикам получить полную информацию, необходимую для диагностики возникающих у пользователя проблем.
- Протоколирование позволяет отлаживать драйверы устройств и программы для встроенных систем.
- Протоколирование позволяет быстрее обнаруживать ошибки после пакетного запуска функциональных или нагрузочных тестов.
- Еще одним интересным способом использования логов является просмотр отличий (diff) двух различных версий. Читателю предлагается самому подумать, где такая возможность была бы ему полезна в его проектах.
- Протоколирование дает возможность удаленной отладки, когда интерактивные средства невозможны или труднодоступны. Это удобно для высокопроизводительных многопользовательских систем, где пользователь ставит свои задачи в очередь, а затем дожидается их выполнения. Такой подход используется и в наши дни в институтах и других организациях, работающих с вычислительными кластерами.
- Возможность отладки параллельных приложений. В таких приложениях ошибки часто происходят при создании большого количества потоков или при наличии проблем с синхронизацией. Ошибки в параллельных программах бывает достаточно сложно отладить. Надежным методом отлова таких ошибок является периодическое протоколирование состояния систем, которые имеют отношение к ошибке, и исследование данных лога после некорректного завершения работы [1].
Основательный список, чтобы не списывать метод протоколирования на пенсию. Хочется надеяться, что статья натолкнет и на друге мысли, как можно с пользой использовать описываемую методологию протоколирования.
Начнем с требований, которым должна обладать современная система протоколирования:
- Код, обеспечивающий протоколирование данных в отладочной версии, должен отсутствовать в конечной версии программного продукта. Во-первых, это связано с увеличением быстродействия и уменьшением размера программного продукта. Во-вторых, не позволяет использовать отладочную информацию для взлома приложения или иных несанкционированных действий. Заметим, что имеется в виду именно конечная версия программы, так как лог могут создавать и Debug и Release версии.
- Интерфейсы системы протоколирования должны быть лаконичны, чтобы не загромождать основной код программы.
- Сохранение данных должно осуществляться как можно быстрее, чтобы вносить минимальное изменение во временные характеристики параллельных алгоритмов.
- Полученный лог должен быть наглядным и легко поддаваться анализу. Должна существовать возможность разделить информацию, полученную от различных потоков, а также варьировать ее уровень подробности.
- Кроме протоколирования непосредственных событий приложения полезно выполнить сбор сведений о компьютере.
- Желательно, чтобы система сохраняла имя модуля, имя файла и номер строки, в которой произошла запись данных. В ряде случаев полезно хранить время наступления события.
Система протоколирования, отвечающая таким качествам, позволяет универсально решать задачи, начиная от разработки механизмов защиты до поиска ошибок в параллельных алгоритмах C++. [
4].
Данная статья, хотя и посвящена системе протоколирования данных, в ней не будет рассматриваться какой-то завершенный вариант такой системы. Универсальный вариант невозможен, так как он сильно зависит от среды разработки, особенностей проекта, предпочтений разработчика и многого другого. Кстати отметим, что существует ряд статей, в которых рассматриваются эти вопросы. Например, "Logging In C++" [
5] и "Способы отладки приложений: Протоколирование" [
6].
Сейчас мы остановимся на рассмотрении ряда технических решений, которые помогут вам создать удобную и эффективную систему протоколирования, если в том возникнет необходимость.
Самым простым способом осуществить протоколирование является использование функции, аналогичной printf, как показано в примере:
int x = 5, y = 10;
...
printf("Coordinate = (%d, %d)\n", x, y);
Естественным недостатком является то, что информация будет выводиться как в отладочном режиме, так и в конечном продукте. Поэтому, следует модернизировать код следующим образом:
#ifdef DEBUG_MODE
#define WriteLog printf
#else
#define WriteLog(a)
#endif
WriteLog("Coordinate = (%d, %d)\n", x, y);
Это уже лучше. Причем обратите внимание, что мы используем для выбора реализации функции WriteLog не стандартный макрос _DEBUG, а собственный макрос DEBUG_MODE. Это позволяет включать отладочную информацию в Release-версии, что важно при отладке на большом объеме данных.
К сожалению, теперь при компиляции не отладочной версии, например, в среде Visual C++ возникает предупреждение: "warning C4002: too many actual parameters for macro 'WriteLog'". Можно отключить это предупреждение, но это является плохим стилем. Можно переписать код, как показано ниже:
#ifdef DEBUG_MODE
#define WriteLog(a) printf a
#else
#define WriteLog(a)
#endif
WriteLog(("Coordinate = (%d, %d)\n", x, y));
Приведенный код не является элегантным, так как приходится использовать двойные пары скобок, что часто забывается. Поэтому внесем новое усовершенствование:
#ifdef DEBUG_MODE
#define WriteLog printf
#else
inline int StubElepsisFunctionForLog(...) { return 0; }
static class StubClassForLog {
public:
inline void operator =(size_t) {}
private:
inline StubClassForLog &operator =(const StubClassForLog &)
{ return *this; }
} StubForLogObject;
#define WriteLog \
StubForLogObject = sizeof StubElepsisFunctionForLog
#endif
WriteLog("Coordinate = (%d, %d)\n", x, y);
Этот код выглядит сложным, но он позволяет писать одинарные скобки. При выключенном DEBUG_MODE этот код превращается в ничто, и его можно смело использовать в критических участках кода.
Следующим усовершенствованием может стать добавление к функции протоколирования таких параметров как уровень детализации и тип выводимой информации. Уровень детализации можно задать как параметр, например:
enum E_LogVerbose {
Main,
Full
};
#ifdef DEBUG_MODE
void WriteLog(E_LogVerbose,
const char *strFormat, ...)
{
...
}
#else
...
#endif
WriteLog (Full, "Coordinate = (%d, %d)\n", x, y);
Этот способ удобен тем, что решение, отфильтровать или не отфильтровать маловажные сообщения можно принять уже после завершения работы программы, используя специальную утилиту. Недостаток такого метода в том, что всегда происходит вывод всей информации, как важной, так и второстепенной, что может снижать производительность. Поэтому можно создать несколько функций вида WriteLogMain, WriteLogFull и подобные им, реализация которых будет зависеть от режима сборки программы.
Мы упоминали о том, что запись отладочной информации должна как можно меньше влиять на скорость работы алгоритма. Этого можно достичь, создав систему накопления сообщений, запись которых происходит в параллельно выполняемом потоке. Сейчас это получило дополнительные преимущества в связи с широким распространением многоядерных (многопроцессорных систем). Схематично этот механизм представлен на рисунке №1.
Рисунок №1. Система протоколирования с отложенной записью данных.
Как можно видеть на рисунке, запись очередной порции данных происходит в промежуточный массив строк фиксированной длины. Фиксированный размер массива и строк в нем позволяет исключить дорогостоящие операции выделения памяти. Это нисколько не снижает возможности такой системы. Достаточно выбрать длину строк и размер массива с запасом. Например, 5000 строк длиной в 4000 символов будет достаточно для отладки практически любой системы. А объем памяти в 20 мегабайт необходимый для этого, согласитесь, не критичен для современных систем. Если же массив все равно будет переполнен, то несложно предусмотреть механизм досрочной записи информации в файл.
Приведенный механизм обеспечивает практически моментальное выполнение функции WriteLog. Если в системе присутствуют ненагруженные процессорные ядра, то и запись в файл будет прозрачна для основных потоков протоколируемой программы.
Преимущество описываемой системы в том, что она практически без изменений способна функционировать при отладке параллельной программы, когда в лог пишут сразу несколько потоков. Следует только добавить сохранение идентификатора потока (thread), чтобы потом можно было узнать, от каких потоков были получены сообщения (смотри рисунок №2).
Рисунок №2. Система протоколирования при отладке многопоточных приложений.
Такая схема может привести к изменению временных характеристик, так как несколько протоколируемых потоков вынуждены ждать друг друга, чтобы осуществить запись информации. Если это является критичным, то можно создать отдельные хранилища сообщений для каждого из потоков, как показано на рисунке №3. Дополнительно, в этом случае следует записывать время событий, чтобы было возможно слить два лога в один.
Рисунок №3. Улучшенная система протоколирования при отладке многопоточных приложений.
Последнее усовершенствование, которое хочется предложить, это организация показа уровня вложенности сообщений при вызове функций или начале логического блока. Это можно легко организовать, используя специальный класс, который в конструкторе записывает в лог идентификатор начала блока, а в деструкторе - идентификатор конца блока. Написав небольшую утилиту, можно трансформировать лог, опираясь на информацию об идентификаторах. Попробуем показать это на примере.
Код программы:
class NewLevel {
public:
NewLevel() { WriteLog("__BEGIN_LEVEL__\n"); }
~NewLevel() { WriteLog("__END_LEVEL__\n"); }
};
#define NEW_LEVEL NewLevel tempLevelObject;
void MyFoo() {
WriteLog("Begin MyFoo()\n");
NEW_LEVEL;
int x = 5, y = 10;
printf("Coordinate = (%d, %d)\n", x, y);
WriteLog("Begin Loop:\n");
for (unsigned i = 0; i != 3; ++i)
{
NEW_LEVEL;
WriteLog("i=%u\n", i);
}
}
Содержимое лога:
Begin MyFoo()
__BEGIN_LEVEL__
Coordinate = (5, 10)
Begin Loop:
__BEGIN_LEVEL__
i=0
__END_LEVEL__
__BEGIN_LEVEL__
i=1
__END_LEVEL__
__BEGIN_LEVEL__
i=2
__END_LEVEL__
Coordinate = (5, 10)
__END_LEVEL__
Лог после трансформации:
Begin MyFoo()
Coordinate = (5, 10)
Begin Loop:
i=0
i=1
i=2
Coordinate = (5, 10)
Мы рассмотрели принципы, на которых может быть реализована система протоколирования. В такой системе можно реализовать все требования, описанные в первой части статьи. Но серьезным недостатком в применении такой системы становится необходимость написания большого количество кода для записи всех необходимых данных. Есть и другие недостатки:
- Невозможность красиво реализовать функции протоколирования для языка Си, так как там отсутствуют классы и шаблоны. В результате код для протоколирования должен выглядеть по разному в Си и Си++ файлах.
- Необходимость помнить о необходимости написания NEW_LEVEL в каждом блоке или функции, если есть желание, чтобы лог выглядел красиво.
- Невозможно автоматически сохранять имена всех вызываемых функций. Необходимость вручную записывать входные аргументы функций.
- Загромождение исходных текстов дополнительными конструкциями, такими как, например NEW_LEVEL.
- Приходится прикладывать усилия, чтобы контролировать, что все конструкции протоколирования будут исключены из конечно версии программы.
- Приходится прописывать функции для инициализации системы протоколирования, прописывать необходимые "#include" и делать иные вспомогательные действия.
Все эти и ряд других неудобств можно избежать, если построить систему автоматического протоколирования испытаний работы приложений.
Реализовать такую систему возможно на основе методологии метапрограммирования, введя в язык Си/Си++ новые конструкции для записи данных.
Метапрограммирование это создание программ, которые создают другие программы [
7]. В метапрограммировании можно выделить два основных направления: генерация кода и самомодифицирующийся код. Нас интересует генерация кода. В этом случае код программы с встроенными механизмами протоколирования не пишется вручную, а создается автоматически программой-генератором на основе другой, более простой программы. Это позволяет получить программу при меньших затратах времени и усилий, чем если бы программист самостоятельно реализовывал весь код.
Существуют языки, для которых метапрограммирование является естественно составной частью. Примером является язык Nemerle, с которым можно познакомится в статье "Метапрограммирование в Nemerle" [
8]. Но в случае с Си/Си++ все сложнее и в них метапрограммирование реализуется следующими двумя путями:
- Шаблоны в Си++ и препроцессор в Си. К сожалению, как было показано выше, этого недостаточно.
- Внешние языковые средства. Язык генератора составляется так, чтобы автоматически или с минимальными усилиями со стороны программиста реализовывать правила парадигмы или необходимые специальные функции. Фактически создается более высокоуровневый язык программирования. Именно этот подход может быть использован для создания системы автоматизации протоколирования программ.
Введя в язык Си/Си++ новые ключевые слова, можно получить гибкую и удобную систему программирования с мощнейшими возможностями протоколирования. Использование метаязыка открывает широкие возможности в выборе оформления записи необходимой информации. Можно протоколировать данные, введя привычный формат функций:
EnableLogFunctionCallForFhisFile(true);
...
ObjectsArray &objects = getObjects();
WriteLog("objects.size = %u", objects.size());
for (size_t i = 0; i != objects.size(); ++i) {
WriteLog("object type = %i", int(objects [i]->getType()));
if (objects[i]->getType() != TYPE_1)
...
Поскольку генератор кода может распознать используемые типы данных, то можно упростить протоколирование переменных и сократить запись. Например.
ObjectsArray &objects = getObjects();
MyLog "objects.size = ", objects.size()
for (size_t i = 0; i != objects.size(); ++i) {
MyLog "object type = ", int(objects [i]->getType())
if (objects[i]->getType() != TYPE_1)
...
Можно шагнуть дальше, так как здесь все зависит от фантазии автора:
thisfile.log.FunctionCall = ON
...
ObjectsArray &objects = getObjects();
for (size_t i = 0;
i != <log>"objects.size = "objects.size()<log>;
++i) {
LOG: "object type = " {as int} objects [i]->getType();
if (objects[i]->getType() != TYPE_1)
...
Новый метаязык можно реализовать в качестве промежуточного звена между препроцессором и компилятором. Что именно должен представлять этот промежуточный компонент для трансляции метапрограммы зависит от среды разработки. В общем случае схему функционирования можно представить, как показано на рисунке №4.
Рисунок №4. Участие транслятора метаязыка в процессе компиляции.
Теперь рассмотрим сам транслятор. Его принципиальная схема представлена на рисунке №5.
Рисунок №5. Принципиальная схема транслятора языка для генерации кода с поддержкой протоколирования.
Остановимся на генераторе кода более подробно. Генератор представляет собой алгоритм обхода дерева и выполнения над ним трех операций. Во-первых, получение типов всех функций и объектов, определение области их видимости. Это позволит автоматически генерировать код для корректной записи аргументов функций и иных объектов. Во-вторых, происходит раскрытие конструкций метаязыка в новые ветви дерева. И последнее - запись обработанных ветвей кода обратно в файл.
Реализовать самостоятельно такой генератор дело достаточное непростое, впрочем, как и синтаксический анализатор. Но существуют соответствующие библиотеки, о которых будет сказано ниже.
Посмотрим, как генерация кода программы решает перечисленные ранее недостатки системы построенной исключительно на основе макросов и шаблонов:
- Конструкции протоколирования в языке Си могут выглядеть также элегантно, как и в Си++. Генератор берет на себя заботу о превращении зарезервированных слов в код для записи сообщений. То, что этот код будет выглядеть по разному, для Си и Си++ файлов скрыто от пользователя.
- Генератор может автоматически вставлять код для выделения начала и конца функций или блоков. Отпадает необходимость использования макроса NEW_LEVEL.
- Можно автоматически сохранять имена всех или части вызываемых функций. Можно автоматически сохранять и значения входных параметров для базовых типов данных.
- Исчезает загромождение текста вспомогательными конструкциями.
- Можно гарантировать полное исчезновение всех функций, ресурсов и объектов из конечной версии программного продукта. Генератору достаточно пропустить все специальные элементы, относящиеся к протоколированию в тексте программы.
- Исчезает необходимость прописывать функции инициализации, вставлять "#include" и совершать другие вспомогательные действия, так как они могут быть реализованы на этапе преобразования кода.
К подводным камням предложенной схемы следует отнести проблему с отладкой таких модифицированных программ с помощью отладчика. С одной стороны, если имеется система протоколирования, то использование отладчика не столь важный компонент разработки приложения. Но все-таки часто отладчик бывает крайне полезным инструментом, отказываться от которого не хочется.
Проблема в том, что после преобразования кода (раскрытия операторов для протоколирования) возникнет проблема навигации по номерам строк. Здесь могут помочь специфические программные меры, которые будут индивидуальны для каждой среды разработки. Можно пойти более простым путем. И применить подход, аналогичный подходу в OpenMP, то есть использовать "#pragma". В этом случае код протоколирования будет выглядеть так:
ObjectsArray &objects = getObjects();
#pragma Log("objects.size = ", objects.size())
for (size_t i = 0; i != objects.size(); ++i) {
#pragma Log("object type = ", objects [i]->getType())
if (objects[i]->getType() != TYPE_1)
...
Это несколько менее красиво из-за лишнего слова "
#pragma", но у такого текста программы имеются большие преимущества. Такой код может быть безболезненно скомпилирован на другой системе, где не используется система автоматического протоколирования. Такой код легко может быть перенесен на другую систему или отдан сторонним разработчикам. И конечно нет никаких препятствий для работы с этим кодом в отладчике.
Автору, к сожалению, не известны инструменты автоматизированного протоколирования на основе метаязыка, близкие по функциональности к описываемой в статье системе. Если дальнейшие исследования покажут отсутствие подобных разработок, то автор, возможно, примет участие в новом проекте, который сейчас находится на этапе проектирования. Можно сказать, что эта статья является своего рода исследованием актуальности создания универсальной системы протоколирования для языков Си/Си++.
Пока же заинтересовавшимся читателям предлагается самостоятельно создать систему протоколирования. Будет это просто набор шаблонов и макросов, или полноценная система генерации кода, зависит от востребованности такой системы и количества ресурсов для ее создания.
Если вы остановитесь на создании метаязыка, то мы подскажем на основе чего можно его создать.
Существует две наиболее подходящие бесплатные библиотеки позволяющие представить программу в виде дерева, преобразовать ее и вновь сохранить в виде программы.
Первая из них
OpenC++ (OpenCxx) [
9]. Эта открытая бесплатная библиотека представляющий собой "source-to-source" транслятор. Библиотека поддерживает метапрограммирование и позволяет создавать на своей основе расширения языка Си++. На основе этой библиотеки созданы такие решения как среда исполнения
OpenTS для языка программирования T++ (разработка Института программных систем РАН) и инструмент
Synopsis для подготовки документации по исходному коду.
Второй библиотекой является
VivaCore, которая представляет собой развитие библиотеки OpenC++, ориентированное на среду разработки Microsoft Visual Studio 2005/2008 [
10]. Эта библиотека также открыта и бесплатна. К основным отличиям можно отнести реализованную поддержку языка Си и поддержку некоторых современных элементов языка Си++. Примером использования библиотеки являются статические анализаторы
Viva64 и
VivaMP.
Если вы захотите использовать эти библиотеки для создания своих расширений, то вам понадобится препроцессор, который должен быть запущен до начала их работы. Скорее всего, встроенные в компилятор средства препроцессирования будут достаточны. Если нет, то вы можете попробовать воспользоваться библиотекой The Wave C++ preprocessor library.
Статья носит теоретический характер, но хочется надеяться, что разработчики найдут в ней много интересных и полезных для себя идей в области протоколирования кода своих программ. Успехов!
Карпов Андрей Николаевич, к.ф.-м.н., научный консультант компании "СиПроВер", специализирующейся на вопросах повышения качества программных систем. Занимается теоретическими и практическими вопросами статического анализа кода. Участвует в проектах по созданию библиотеки VivaCore и анализаторов кода Viva64, VivaMP.