About the C++ static analyzer as a Clang plugin

WILD

Administrator
Staff member
ADMIN
SELLER
SUPREME
MEMBER
Joined
Jan 21, 2025
Messages
219
Reaction score
632
Deposit
0$
изображение




Данная статья основана на опыте разработки библиотеки memsafe , которая, используя плагин Clang, добавляет в C++ безопасное управление памятью и контроль недействительности ссылочных типов данных во время компиляции исходного кода.


Выбор архитектуры плагинов Clang (AST Matcher против RecursiveASTVisitor)​



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



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



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



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



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



Вывести фрагмент AST​



Независимо от используемой архитектуры плагинов, AST остаётся неизменным. Отличается лишь способ обхода/поиска его узлов. А чтобы что-то найти, нужно знать, что искать и как это связано с другими узлами AST. Для человека, не особенно разбирающегося в этих тонкостях, наличие различных временных или «сахарных» узлов AST может стать настоящей головной болью. Например, для меня наибольшая трудность заключалась в понимании того, из каких узлов AST состоит конкретное выражение.



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



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



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


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




В виде дампа AST это выглядит так:


И функция с одним оператором return.


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




Похоже, дамп AST выглядит примерно так:


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



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



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



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



Пример вывода логов:


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


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




Отслеживание пользовательских атрибутов C++ в исходном коде​



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



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



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



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



Парсер атрибутов проверяет только количество и тип аргументов атрибутов и просто добавляет их к элементам AST, но не анализирует их каким-либо другим способом, что позволяет перенести всю логику проверки в одно место (класс парсера AST).



Хотя это решение и повлекло за собой другую проблему — некоторые атрибуты могут быть пропущены при последующем анализе, — оказалось, что от этого легко защититься. Чтобы случайно не забыть обработать атрибуты, достаточно сохранить информацию об их месте в исходном тексте программы на C++ и установить флаг для их обработки при запуске анализатора. А если какие-то атрибуты всё же пропускаются при анализе, и флаг не сбрасывается (например, не был учтен какой-то сложный сценарий их использования), то при выводе отладочного дампа автоматически будет выведен список пропущенных (необработанных) атрибутов.



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



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



Мне также понравилась цветовая подсветка важных сообщений при запуске плагина, что экономит время на поиске нужной строки в выводе консоли того же типа. Но этот совет относится к категории «каждому своё».
 
Top Bottom