Разрабатываем собственный анализатор C++ программы в виде плагина для Clang
Есть много проектов, целью которых является превратить С++ более «безопасный» язык программирования. Но внесение изменений в синтаксис языка обычно нарушает обратную совместимость со старым кодом, который был написан до этого.
Недавно вышла новая версия библиотеки 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 разработчики Clnag предлагают использовать язык запросов и использовать утилиту clang-query для вывода дампа узлов AST и тестирования условий поиска. Предлагаемый инструмент не плохой, но использовать его неудобно, так как приходится переключаться между исходным кодом плагина, исходным кодом, который анализируется, и языком запросов для AST Matcher.
Для себя я решил эту проблему путем установки в анализируемом коде метки для начала и окончания вывода дампа AST. Плагин при нахождении первой метки переключается в режим вывода дампа и отключается после нахождения второй. Такой способ вывода отдельных фрагментов AST более удобный, так как не требуется применения сторонних инструментов, и не приходится искать нужный фрагмент в портянке полного AST дампа.
Вывод сообщений и логов для отладки
В первой версии плагина я был под впечатлением простоты использования libTooling
, так как его разработчики продолжают придумывать новые инструменты для работы с меньшими усилиями. Кроме архитектуры сопоставлений (поиска узлов AST по заданным условиям) я использовал Rewriter
из RefactoringTool
для модификации базового исходного кода для вывода различных сообщений при обнаружении нужных узлов в AST.
Это хороший и простой подход, но у него так же вылезла неожиданная проблема. Вывод сообщений всегда с привязан к нужному месту в исходном файле (что кажется вполне логичным), но следует разделять сообщения пользователю и отладочные сообщения для разработчика самого плагина. И вот для вывода сообщений последнего типа, гораздо удобнее оказалось сгруппировать их в самом конце вывода.
Так удобнее разделить управление уровнем сообщений пользователю и детализацией отладочного вывода, стало гораздо проще автоматизировать тесты (все нужные сообщения сгруппированы в одном месте, а не перемешаны с выводом самого Clnag`а), а проблемы с позицией сообщения в исходном коде решаются выводом места его генерации, чтобы IDE могла перейти в нужное место исходного файла по клику мышкой на отладочном сообщении.
Так же мне показалась хорошей идеей привязывать отладочные сообщения к конкретному месту в исходном файле, если такая маркировка не будет изменяться каждый раз после изменения числа строк выше по исходнику. Правда пришлось учесть, что у макросов 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). Хотя такое решение повлекло за собой другую проблему — часть атрибутов может быть пропущена при последующем анализе, но защититься от подобных накладок оказалось не очень сложно.
Что бы случайно не забыть обработать атрибуты, достаточно сохранять информацию о месте их указания в тексте программы, а при работе анализатора, устанавливать маркер их обработки. Если какие-то атрибуты при анализе будут пропущены (например, не учли какой-то хитрый сценарий их использования), то при выводе отладочного дампа, автоматически будет выводится и список пропущенных (не обработанных) атрибутов.
Оставшиеся мелочи
При разработке плагина для Clnag плохая идея использовать для трассировки вывод std: cout или std: cerr. Из-за разных настроек кеширования, сообщения могут выводится с разными непонятными нюансами (особенно, если компилятор при отладке плагина завершает работу по ошибке, что иногда бывает). И при смешивании потоков вывода для отладочных сообщений можно очень глубоко закопаться. А выход очень простой, при отладке плагина нужно использовать только llvm: outs () или llvm: errs () в качестве потоков вывода.
Так же мне понравилось выделять цветом важные сообщения при запуске плагина, что позволяет не распылять внимание на поиск нужной строки в однотипной портянке консольного вывода. Но это совет из области «на вкус и цвет — фломастеры разные».
В заключение
Наверное, это наиболее важные нюансы, которые мне показались таковыми при разработке плагина для Clnag. Возможно я что-то пропустил или посчитал само собой разумеющимся, но в любом случае исходники доступны на GitHub memsafe/memsafe_clang.cpp at main · rsashka/memsafe · GitHub, которыми можно всегда воспользоваться.
Habrahabr.ru прочитано 11262 раза