Как стать автором
Обновить

Делаем собственный анализатор C++ кода в виде плагина для Clang

Уровень сложностиСредний
Время на прочтение11 мин
Количество просмотров4.5K


Есть много проектов, целью которых является превратить С++ более "безопасный" язык программирования. Но внесение изменений в синтаксис языка обычно нарушает обратную совместимость со старым кодом, который был написан до этого.


Недавно вышла новая версия библиотеки memsafe для языка С++, которая превращает его в Rust с помощью плагина Clang добавляет в С++ безопасное управление динамической памятью и контроль инвалидации ссылочных типов данных во время компиляции приложения.


Но данная статья не о библиотеке, а об особенностях разработки анализатора программы на С++ в виде плагина для Clang.


Можно считать, что это подведение итогов по результатам сравнения нескольких разных способов создания плагина для компилятора С++, а так же очередной Хабрахак для хранения результатов экспериментов и публикации итоговых выводов, которые я решил сохранить не только для себя, но и в виде статьи на Хабре, что бы результатами моего труда могли воспользоваться и другие хорошие люди :-), которым так же может потребоваться погрузиться в дебри парсинга исходного текста программ.


Назначение плагина


Прежде чем перейти к описанию реализации плагина компилятора, нужно рассказать, что и зачем он делает, и тут лучше начать с того, зачем это вообще нужно.


С++ — это язык программирования с возможностью писать код на любом уровне абстракции, от высокоуровневых шаблонов и ООП до самого низкого уровня на ассемблере с неограниченными возможностями для оптимизации (не нужно платить за то, что не используется). Однако ничего не дается даром, и за большие возможности С++ берется плата в виде использования адресной арифметики. Прямое оперирование указателями является фундаментальной концепцией в С++ и применяется при работе с различного рода типами данных: итераторами, массивами, строками, динамическим выделением памяти и т.д.


Но указатель в С++, это обычный адрес оперативной памяти, и у него отсутствует связь с локальными переменными, которые находятся на стеке, и время жизни которых ограничено и управляется компилятором. Вторая, не менее серьезная проблема, которая так же часто приводит к ошибкам — это доступ к одной и той же области памяти из разных потоков выполнения программы одновременно.


Поэтому, мне показалась хорошей идеей написать плагин для компилятора, который бы проверял уже существующий код на предмет потенциальных ошибок при работе с адресными типами данных и при этом не нарушал обратной совместимости со старым С++ кодом. Тем более, что концепция для работы с динамической памятью у меня уже была разработана в рамках создания нового языка программирования, и мне осталось ее только немного адаптировать для использования в С++.


Выбор инструментария


После того, как я принял решение поработать над инструментом для анализа исходных текстов, встал вопрос, с помощью чего это удобнее, проще и быстрее сделать? Выбор хоть и не большой, но все же был, хотя идею полностью самому написать анализатор лексики С++ на базе какого-либо парсера я отбросил сразу, так как понимаю объем необходимой для этого работы.


Я даже пробовал выяснить интерес к этой теме со стороны какой-нибудь коммерческой компании. Но при разговоре с представителями PVS-Studio мне дали понять, что в своем инструменте им подобная фишка не интересна, у них уже есть утвержденный план работ, и ничего лишнего они делать не собираются.


После этого у меня остался только вариант — сделать это в виде плагина для какого нибудь компилятора.


Плагин для GCC построен на идеологии обратных вызовов, у которых параметры функции — это ссылки на void *, которые приводятся к указателям на структуры разных типов, и у каждой из которых свой специфический набор полей. И, судя по найденными мной примерам, некоторые структуры могут даже меняться в зависимости от версии компилятора.


А так как у меня не получилось собрать даже тестовый пример плагина для GCC, то мой выбор остановился на создании плагина под Clang. Тем более, что он имеет пермиссивную лицензию Apache 2 вместо GPL у GCC, и я уже использую Clang для сборки других проектов, да и примеры с разработкой плагина и анализом AST у него документированы гораздо лучше. Тем более, некоторые из этих статей я сам и переводил :-)


Самое важное в реализации плагина для Clang


В интернете можно встретить много примеров создания плагинов для CLang. Но при попытке их реально использовать в каком-то проекте вылезли некоторые особенности, которые в самом начале значительно упростили начало работ, но в последствии послужили серьезным ограничителем, затрудняющими дальнейшее развитие проекта. Фактически повторилась история с выбором парсера, когда более легкие и простые альтернативы делают более дешевым вход, но просят утроенную плату за последующее сопровождение.


Выбор архитектуры плагина (AST matcher vs. RecursiveASTVisitor)


В результате нескольких итераций кода плагина я пришел к выводу, что, несмотря на то, что поиск данных в AST с помощью AST matcher делается меньшим объемом кода, но общее итоговое решение выходит гораздо сложнее, так как порой невозможно учесть взаимные зависимости кода в разных ветках (условиях) нескольких сопоставителей.


При поиске узлов с помощью AST Matcher MatchCallback вызываются только для найденных данных, но создавать и очищать контекстную информацию при каждом вызове становится очень накладно, а чтобы использовать глобальное состояние плагина при анализе, нужно делать последовательный обход всех узлов AST, что бы динамически создавать и очищать уже не нужную контекстную информацию.


Из-за этого после нескольких неудачных экспериментов с AST Matcher я решил сделать плагин по старинке, как шаблон RecursiveASTVisitor. Кроме этого, RecursiveASTVisitor позволяет прерывать, повторять или выполнять альтернативную ветку анализатора в зависимости от параметров работы алгоритма, контекстной информации или настроек (опций), находящихся в исходном коде программы.


И, наверно, самое главное. Matcher обрабатывает только конкретно указанные узлы AST, запись которых происходит не всегда самым очевидным образом, и в случае появления нетривиальных условий поиска некоторые из них могут быть пропущены, тогда как RecursiveASTVisitor последовательно обходит все узлы AST, и пропущенный узел легко детектируется во время отладки и тестирования.


Вывод дампа отдельного фрагмента AST


Какая бы архитектура плагина ни была использована, само AST от этого не изменяется. Отличается только способ обхода/поиска узлов. Другими словами, чтобы что-то найти, нужно знать, что искать и как это соотносится с другими узлами. И для человека, глубоко не погруженного в эти тонкости, наличие различных временных или сахарных узлов AST может стать реальной головной болью. Ведь при анализе AST самой большой сложностью стало понять (по крайней мере, для меня), из каких узлов состоит то или иное выражение.


Для архитектуры плагина AST Matcher разработчики Clang предлагают использовать специальный язык запросов и с помощью утилиты clang-query тестировать условия поиска. Это инструмент не плохой, но использовать его неудобно, так как приходится переключаться между исходным кодом плагина, исходным кодом, который анализируется, и утилитой clang-query для проверки условий поиска на языке запросов.


Для себя я решил эту проблему путем установки в анализируемом коде метки для начала и окончания вывода дампа AST. Плагин при нахождении первой метки переключается в режим вывода дампа и отключает его после нахождения второй. Такой способ вывода отдельных фрагментов AST более удобный, так как не требуется применения сторонних инструментов, и не приходится искать нужный фрагмент в портянке полного AST дампа.


Так, например, определение структуры SharedArrayInt:


MEMSAFE_PRINT_AST("*"); // Начало вывода дампа AST
struct SharedArrayInt : public Shared<std::vector<int> > {
};
MEMSAFE_PRINT_AST("");  // Отключить вывод дампа

в виде дампа AST выгладит следующим образом:
_cycles.cpp:87:12  struct SharedArrayInt : public Shared<std::vector<int>>   dump:
CXXRecordDecl 0x7ab1e3aaeda8 <_cycles.cpp:87:5, line:88:5> line:87:12 struct SharedArrayInt definition
|-DefinitionData aggregate standard_layout can_const_default_init
| |-DefaultConstructor exists non_trivial needs_implicit
| |-CopyConstructor simple non_trivial needs_overload_resolution
| |-MoveConstructor exists non_trivial needs_overload_resolution
| |-CopyAssignment simple non_trivial has_const_param needs_implicit implicit_has_const_param
| |-MoveAssignment exists simple non_trivial needs_overload_resolution
| `-Destructor simple non_trivial needs_implicit
|-public 'Shared<std::vector<int>>':'memsafe::Shared<std::vector<int>>'
|-CXXRecordDecl 0x7ab1e3a62a78 <col:5, col:12> col:12 implicit struct SharedArrayInt
|-CXXConstructorDecl 0x7ab1e3a62bd0 <col:12> col:12 implicit SharedArrayInt 'void (SharedArrayInt &)' inline default noexcept-unevaluated 0x7ab1e3a62bd0
| `-ParmVarDecl 0x7ab1e3a62d08 <col:12> col:12 'SharedArrayInt &'
|-CXXConstructorDecl 0x7ab1e3a62ee8 <col:12> col:12 implicit constexpr SharedArrayInt 'void (SharedArrayInt &&)' inline default_delete noexcept-unevaluated 0x7ab1e3a62ee8
| `-ParmVarDecl 0x7ab1e3a63028 <col:12> col:12 'SharedArrayInt &&'
`-CXXMethodDecl 0x7ab1e3a630c8 <col:12> col:12 implicit operator= 'SharedArrayInt &(SharedArrayInt &&)' inline default noexcept-unevaluated 0x7ab1e3a630c8
  `-ParmVarDecl 0x7ab1e3a631f8 <col:12> col:12 'SharedArrayInt &&'    

А функция с единственным оператором возврата


MEMSAFE_PRINT_AST("*"); // Начало вывода дампа AST
memsafe::Shared<int> memory_test_9() {
    return Shared<int>(999);
}
MEMSAFE_PRINT_AST(""); // Отключить вывод дампа

превращается вот в такой вывод:
_example.cpp:169:26  memsafe::Shared<int> memory_test_9()   dump:
FunctionDecl 0x7435ed31f4e8 <_example.cpp:169:5, line:171:5> line:169:26 memory_test_9 'memsafe::Shared<int> ()'
`-CompoundStmt 0x7435ed31f8a8 <col:42, line:171:5>
  `-ReturnStmt 0x7435ed31f898 <line:170:9, col:31>
    `-ExprWithCleanups 0x7435ed31f880 <col:16, col:31> 'Shared<int>':'memsafe::Shared<int>'
      `-CXXFunctionalCastExpr 0x7435ed31f858 <col:16, col:31> 'Shared<int>':'memsafe::Shared<int>' functional cast to Shared<int> <ConstructorConversion>
        `-CXXBindTemporaryExpr 0x7435ed31f838 <col:16, col:31> 'Shared<int>':'memsafe::Shared<int>' (CXXTemporary 0x7435ed31f838)
          `-CXXConstructExpr 0x7435ed31f800 <col:16, col:31> 'Shared<int>':'memsafe::Shared<int>' 'void (const int &)'
            `-MaterializeTemporaryExpr 0x7435ed31f7b8 <col:28> 'const int' lvalue
              `-ImplicitCastExpr 0x7435ed31f7a0 <col:28> 'const int' <NoOp>
                `-IntegerLiteral 0x7435ed31f780 <col:28> 'int' 999
_example.cpp:170:9  return Shared<int>(999)  dump:
ReturnStmt 0x7435ed31f898 <_example.cpp:170:9, col:31>
`-ExprWithCleanups 0x7435ed31f880 <col:16, col:31> 'Shared<int>':'memsafe::Shared<int>'
  `-CXXFunctionalCastExpr 0x7435ed31f858 <col:16, col:31> 'Shared<int>':'memsafe::Shared<int>' functional cast to Shared<int> <ConstructorConversion>
    `-CXXBindTemporaryExpr 0x7435ed31f838 <col:16, col:31> 'Shared<int>':'memsafe::Shared<int>' (CXXTemporary 0x7435ed31f838)
      `-CXXConstructExpr 0x7435ed31f800 <col:16, col:31> 'Shared<int>':'memsafe::Shared<int>' 'void (const int &)'
        `-MaterializeTemporaryExpr 0x7435ed31f7b8 <col:28> 'const int' lvalue
          `-ImplicitCastExpr 0x7435ed31f7a0 <col:28> 'const int' <NoOp>
            `-IntegerLiteral 0x7435ed31f780 <col:28> 'int' 999

Вывод сообщений и логов для отладки


В первой версии плагина я был под впечатлением простоты использования libTooling, так как его разработчики продолжают придумывать новые инструменты для работы с меньшими усилиями. Кроме архитектуры сопоставлений (поиска узлов AST по заданным условиям) я использовал Rewriter из RefactoringTool для модификации исходного кода и вывода различных сообщений при обнаружении нужных узлов в AST.


Это хороший и простой подход, но у него так же вылезла неожиданная проблема. Вывод сообщений всегда с привязан к нужному месту в исходном файле (что кажется вполне логичным), но следует разделять сообщения пользователю и отладочные сообщения для разработчика самого плагина. И вот для вывода сообщений последнего типа, гораздо удобнее оказалось сгруппировать их в самом конце вывода.


Так удобнее разделить управление уровнем сообщений пользователю и детализацией отладочного вывода, стало гораздо проще автоматизировать тесты (все нужные сообщения сгруппированы в одном месте, а не перемешаны с выводом самого Clang`а), а проблемы с позицией сообщения в исходном коде решаются выводом места его генерации, чтобы IDE могла перейти в нужное место исходного файла по клику мышкой на отладочном сообщении.


Пример вывода лога:
#memsafe-log
_cycles.cpp:17:11: #log #102 Detected shared type 'ns::Ext' registered at _cycles.cpp:17:11
_cycles.cpp:18:19: #log #103 Shared class definition 'ns::A' used from another translation unit.
_cycles.cpp:17:11: #log #102 Class 'ns::Ext' checked for cyclic references
_cycles.cpp:30:22: #log #1003 Field with reference to structured data type 'cycles::CircleSelf'
_cycles.cpp:29:12: #log #1002 Detected shared type 'cycles::CircleSelf' registered at _cycles.cpp:29:12
_cycles.cpp:30:22: #err #1003 Class cycles::CircleSelf has a reference to itself through the field type cycles::CircleSelf
_cycles.cpp:30:22: #err #1003 Field type raw pointer
_cycles.cpp:33:12: #log #1006 Detected shared type 'cycles::CircleShared' registered at _cycles.cpp:33:12
_cycles.cpp:34:30: #err #1007 Class cycles::CircleShared has a reference to itself through the field type cycles::CircleShared
_cycles.cpp:38:37: #log #1011 Field with reference to structured data type 'cycles::CircleSelf'
_cycles.cpp:37:12: #log #1010 Detected shared type 'cycles::CircleSelfUnsafe' registered at _cycles.cpp:37:12
_cycles.cpp:30:22: #log #1003 Field with reference to structured data type 'cycles::CircleSelf'
_cycles.cpp:30:22: #err #1003 The class 'cycles::CircleSelfUnsafe' has a circular reference through class 'cycles::CircleSelf'
_cycles.cpp:38:37: #warn #1011 UNSAFE field type raw pointer
...

Так же мне показалась хорошей идеей привязывать отладочные сообщения к конкретному месту в исходном файле, если такая маркировка не будет изменяться каждый раз после изменения числа строк выше по исходнику. Правда пришлось учесть, что у макросов SourceLocation указывается место определения макроса, а не место его использования, из-за чего приходится делать как-то вот так:


    SourceLocation getLocation(Decl * decl){
        if (decl->getLocation().isMacroID()) {
            return CI.getSourceManager().getExpansionLoc(decl->getLocation());
        } else {
            return decl->getLocation();
        }
    }

Трассировка пользовательских атрибутов в C++ коде


Маркировка объектов в исходном коде и настройка параметров работы плагина выполняется с помощью C++ атрибутов, которые и предназначены для расширений языка. Они имеют стандартный синтаксис и поэтому полностью совместимы с лексикой С++.


Но так как атрибуты могут использоваться в C++ коде практически везде и применяться практически ко всему: к типам, переменным, функциям, именам, блокам кода или целым единицам трансляции, то плагин должен уметь не только разрешать использование новых атрибутов и проверять корректность их аргументов, но и контролировать правильность их применения.


Плагин для Clang внутри содержит две сущности: парсер атрибутов, который и отвечает за их проверку, и непосредственно плагин, который выполняет анализ AST. Это два совершенно разных класса, которые отвечают каждый за свою функциональность.


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


Парсер атрибутов проверяет только количество и тип аргументов атрибута и просто добавляет их к элементам AST, но больше никак их не анализирует, что позволяет перенести всю логику проверок в одно место (анализатор AST). Хотя такое решение повлекло за собой другую проблему — часть атрибутов может быть пропущена при последующем анализе, но защититься от подобных накладок оказалось не очень сложно.


Что бы случайно не забыть обработать атрибуты, достаточно сохранять информацию о месте их указания в тексте программы, а при работе анализатора, устанавливать маркер их обработки. Если какие-то атрибуты при анализе будут пропущены (например, не учли какой-то хитрый сценарий их использования), то при выводе отладочного дампа, автоматически будет выводится и список пропущенных (не обработанных) атрибутов.


Оставшиеся мелочи


При разработке плагина для Clang плохая идея использовать для трассировки вывод std::cout или std::cerr. Из-за разных настроек кеширования, сообщения могут выводится с разными непонятными нюансами (особенно, если компилятор при отладке плагина завершает работу по ошибке, что иногда бывает). И при смешивании потоков вывода для отладочных сообщений можно очень глубоко закопаться. А выход очень простой, при отладке плагина нужно использовать только llvm::outs() или llvm::errs() в качестве потоков вывода.


Так же мне понравилось выделять цветом важные сообщения при запуске плагина, что позволяет не распылять внимание на поиск нужной строки в однотипной портянке консольного вывода. Но это совет из области "на вкус и цвет — фломастеры разные".


В заключение


Наверное, это наиболее важные нюансы, которые мне показались таковыми при разработке плагина для Clang. Возможно я что-то пропустил или посчитал само собой разумеющимся, но в любом случае исходники плагина доступны на GitHub, которыми можно всегда воспользоваться.

Теги:
Хабы:
+19
Комментарии3

Публикации

Работа

QT разработчик
7 вакансий
Программист C++
98 вакансий

Ближайшие события