Pull to refresh

Тонкости анализа исходного кода C/C++ с помощью cppcheck

Reading time 27 min
Views 57K
В предыдущем посте были рассмотрены основные возможности статического анализатора с открытым исходным кодом cppcheck. Он показывает себя не с худшей стороны даже при базовых настройках, но сегодня речь пойдёт о том, как выжать из этого анализатора максимум полезного.

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

Загрузка и установка


Загрузить cppcheck можно с официального сайта, для Windows есть инсталлятор, а для Linux я рекомендую скачать исходный код с гита, так как собирается он очень легко и не имеет зависимостей. Сборка из исходного кода позволит включить экспериментальную возможность, о которой будет рассказано в конце статьи.

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

Собираем для Linux

Сборка в Linux крайне проста: скачать, распаковать, перейти в каталог и выполнить make:

unzip cppcheck-master.zip
cd cppcheck-master
make

Собираем для Windows

Сборка в Windows тоже не должна представлять трудностей — там есть проект для VS. Открываем, собираем. Сам не пробовал, т. к. не имею такой потребности.

Cppcheck как плагин

По части плагинов для IDE — присутствуют плагины под Code::Blocks, CodeLite, Gedit и Eclipse, разумеется. Но этим дело не ограничивается, так как есть плагины для сборочных ферм Hudson, Jenkins, для систем управления версиями Tortoise SVN и Mercurial. Для Visual Studio плагина нет, но есть очень милая фраза на главной странице:

There is no plugin for Visual Studio, but it is possible to add Cppcheck as an external tool. You can also try the proprietary PVS-Studio (there is a free trial), which is oriented for this environment.

В cppcheck присутствует GUI, написанный на Qt. Он ещё более упрощает процесс анализа, но сильно вдаваться в его подробности не будем — суровые программисты не пользуются графическими интерфейсами:) Тем более что GUI полностью повторяет возможности командной строки (а в некоторых случаях — уступает) и разобраться в нём после знакомства с cppcheck труда не составит.

Стоит заметить, что cppcheck распространяется под лицензией GNU GPL. Это позволяет без труда взять исходный код этой программы, утащить в свой репозиторий Git и допиливать там под любые нужды, добавляя свои правила и библиотеки.

Настройка анализатора


Положительным моментом cppcheck является возможность тонкой настройки. Можно и уровень чувствительности настроить, и вывод сообщений форматировать, и фильтровать некоторые надоедливые сообщения.

Справедливости ради отмечу, что всю информацию, изложенную ниже, можно получить из документации или выполнив команду cppcheck --help, но я остановлюсь на наиболее важных нюансах поподробнее (и на русском языке:). Документация по cppcheck становится из года в год всё лучше, поэтому её полезно иногда перечитывать.

Если вам кажется, что я занимаюсь пересказом документации — переходите к чтению следующего раздела, т. к. здесь информация для тех, кто никогда ранее не использовал cppcheck и анализаторы в принципе.

Уровни ошибок

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

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

void f() {
    char *a = malloc(100);
    process_a(a);
}

На первый взгляд, здесь ошибка: нет free. Однако если функция process_a является библиотечной функцией, невозможно с уверенностью сказать, что process_a где-то внутри не делает free для указателя a. Если внутри функции process_a переменная a действительно освобождается — это ложное срабатывание, которое будет мешать анализу. Поэтому cppcheck сначала попробует найти реализацию функции process_a в коде анализируемой программы, убедится, что она не вызывает free, и только в этом случае выдаст ошибку. Если реализация не найдена, cppcheck предполагает наиболее благоприятный сценарий из всех возможных: process_a освобождает память и поэтому не выдаст ошибки. Впрочем, cppcheck можно «научить» распознавать функции библиотек, тем самым увеличивая точность анализа — об этом будет рассказано ниже.

Второй пример:

void f() {
    char *a = malloc(100);
    if(random())
        g_exit(0);
    free(a)
}

Здесь уже явно есть утечка памяти, так как g_exit — обёртка библиотеки GLib над стандартной функцией exit. Но cppcheck не знает, что g_exit прерывает выполнение программы, поэтому анализатор не сможет распознать здесь ошибки. Нужно как-то предоставить cppcheck информацию о том, что функция g_exit может прерывать программу, чтобы анализатор научился распознавать такие ошибки.

Из примеров видно, что cppcheck перестраховывается, причём планка адекватности высока. Большинство проверок cppcheck по умолчанию не включает. Среди них следующие категории проверок, каждая из которых может включаться/выключаться независимо:

  • error — явные ошибки, которые анализатор считает критическими и обычно они приводят к багам (включено по умолчанию);
  • warning — предупреждения, здесь даются сообщения о небезопасном коде;
  • style — стилистические ошибки, сообщения появляются в случае неаккуратного кодирования (больше похоже на рекомендации);
  • performance — проблемы производительности, здесь cppcheck предлагает варианты, как сделать код быстрее (но это не всегда даёт прирост производительности);
  • portability — ошибки совместимости, обычно связано с различным поведением компиляторов или систем разной разрядности;
  • information — информационные сообщения, возникающие в ходе проверки (не связаны с ошибками в коде);
  • unusedFunction — попытка вычислить неиспользуемые функции (мёртвый код), не умеет работать в многопоточном режиме;
  • missingInclude — проверка на недостающий #include (например, используем random, а подключить stdlib.h забыли).

Включаются проверки параметром --enable, список категорий проверок перечисляется через запятую. Например:

cppcheck -q -j4 --enable=warning,style,performance,portability ./source

Таким образом я обычно включаю наиболее важные проверки. Существует ключевое слово all, которое включает все перечисленные проверки.

Примечание. Параметры -j и режим проверки unusedFunction несовместимы, поэтому -j выключит проверку unusedFunction, даже если она указана явно.

Пример команды, которая «гоняет» код по всем правилам:

cppcheck -q --enable=all ./source

И это ещё не всё. Если ваша программа безошибочна с точки зрения анализатора, попробуйте запустить cppcheck с параметром --inconclusive. Данный режим действительно включает все возможные проверки, даже ошибки с малой вероятностью, которые cppcheck пропускает по умолчанию.

Таким образом, самый подробный режим проверки:

cppcheck -q --enable=all --inconclusive ./source

Не забывайте о кроссплатформенности!

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

Различные платформы:

  • unix32 — все 32-разрядные *nix (включая Linux);
  • unix64 — все 64-разрядные *nix;
  • win32A — семейство 32-разрядных Windows с кодировкой ASCII;
  • win32W — семейство 32-разрядных Windows с кодировкой UNICODE;
  • win64 — семейство 64-разрядных Windows.

Если нужно проверить код, который был написан для Win32, используя Linux, нужно обязательно указать платформу:

cppcheck --platform=win32A ./source

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

  • posix — для ОС, совместимых со стандартом POSIX (включая Linux);
  • c89 — язык Си, стандарт 89-го года;
  • c99 — язык Си, стандарт 99-го года;
  • c11 — язык Си, стандарт 11-го года (по умолчанию для Си);
  • c++03 — язык Си++, стандарт 03-го года;
  • c++11 — язык Си++, стандарт 11-го года (по умолчанию для Си++).

Можно использовать сразу два стандарта:

cppcheck --std=c99 --std=posix ./source

Полезные аргументы в командной строке

Возможно уже бросается в глаза, что я везде использую параметры -q, -j. Зачем они нужны? Рассмотрим наиболее интересные.

-j — очень полезный параметр, позволяющий запускать проверку в многопоточном режиме. Использовать очень просто — в качестве параметра передаётся количество процессоров и проверка пойдёт веселее.
-q — тихий режим. По умолчанию cppcheck выдаёт информационные сообщения о ходе проверки (которых может быть очень много). Данный параметр полностью выключает информационные сообщения, остаются только сообщения об ошибках.
-f или --force — включить перебор всех вариантов директив ifdef (по умолчанию cppcheck проверяет дюжину вариантов). Что это такое — потом будет рассмотрено отдельно.
-v — режим отладки — cppcheck выдаёт внутреннюю информацию о ходе проверки.
--xml — выводить результат проверки в формате XML.
--template=gcc — выводить ошибки в формате предупреждений компилятора gcc (удобно для интеграции с IDE, поддерживающей такой компилятор).
--suppress — режим подавления ошибок с указанными идентификаторами (нужен повторный анализ).
-h — выдаёт справку по всем параметрам на чистейшем английском языке.

Фильтрация сообщений и исключения

Как любой уважающий себя анализатор cppcheck позволяет при проверке гибко настроить отображение ошибок. Наиболее полезной является возможно отключить определённое предупреждение в определённом файле (и возможно в определённой строке). Отключение сообщений реализовано параметром --suppress, где нужно указать исключение, либо --suppress-file с указанием текстового файла, где содержится список исключений. Чтобы передать несколько исключений в командной строке можно указать несколько параметров --suppress подряд, но лучше для таких целей завести файл с исключениями.

Формат исключений:

id[:file:[line]]

Обязательный параметр id (идентификатор ошибки), следом через двоеточие можно опционально указать имя файла, после имени файла, также опционально, можно указать номер строки.

Например, очень часто всплывает такое предупреждение:

The scope of the variable '%VAR' can be reduced.

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

cppcheck -q -j4 --enable=all --suppress=variableScope ./source

Как видно из этого примера, cppcheck использует понятные человеку идентификаторы ошибок, а не номера, что гораздо проще запомнить.

Узнать список всех возможных ошибок поможет параметр --errorlist, который выдаёт полный список в формате XML. Но я могу посоветовать другой метод определения «неугодных» сообщений. Для этого потребуется изменить формат вывода сообщений с помощью параметра --template:

cppcheck -q -j4 --enable=all --template='{id} {file}:{line} {message}' ./source

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

Пример вывода
variableScope geany/src/document.c:1099 The scope of the variable 'use_ft' can be reduced.
variableScope geany/src/document.c:1257 The scope of the variable 'filename' can be reduced.
variableScope geany/src/document.c:2306 The scope of the variable 'keywords' can be reduced.
variableScope geany/src/document.c:3011 The scope of the variable 'old_status' can be reduced.
variableScope geany/src/editor.c:194 The scope of the variable 'specials' can be reduced.
variableScope geany/src/editor.c:248 The scope of the variable 'ptr' can be reduced.
variableScope geany/src/editor.c:1545 The scope of the variable 'text' can be reduced.
variableScope geany/src/editor.c:4309 The scope of the variable 'tab_str' can be reduced.


И наконец, рецепт автоматического создания файла для использования в параметре --suppress-file. В командной строке это делается в два счёта:

cppcheck -q --enable=all --template='{id}:{file}:{line}' ./source > suppress-list.txt

Теперь полученный файл можно подать на вход cppcheck и в выводе не окажется ни одной ошибки. Это полезно, когда анализ завершён и все срабатывания анализатора — ложные.

Cppcheck также позволяет писать исключения внутри комментариев, но приведённый выше пример позволит избежать захламления исходного кода, собрав все исключения в одном месте, а не раскидывать их по всему проекту.

Разбираемся с include и define

Cppcheck понимает некоторые параметры компиляторов, что позволяет уточнить, по которому пути идёт проверка. Так как cppcheck не пользуется услугами компилятора, он имеет свой собственный препроцессор. Данный препроцессор не требует ни наличия всех заголовочных файлов, ни корректности исходного кода. Если где-то встречается неизвестный include — cppcheck его просто не обрабатывает.

Неизвестность может сыграть злую шутку. Обычной практикой в библиотеке GLib является проверка аргументов:

void f(gchar *s1, gchar *s1) {
    g_return_if_fail(s1);
    gchar *a = g_strdup(s1);
    g_return_if_fail(s2);
    gchar *b = g_strdup(s2);
}

Всё хорошо за исключением того, что g_return* — это макросы, которые прерывают выполнение функции в случает ошибки. Таким образом, если первый аргумент функции f окажется корректным, а второй — нет, возникает утечка памяти. Cppcheck об этом не догадывается, так как считает g_return_if_fail по умолчанию «хорошей функцией», а не макросом.

Поведение cppcheck можно изменить, если подключить необходимые заголовочные файлы, чтобы препроцессор сделал всю необходимую работу: он найдёт реализацию макроса g_return_if_fail, раскроет его, а cppcheck увидит условный return без free, что является паттерном утечки памяти.

Для того чтобы заставить препроцессор работать как следует, нужно указать пути, где искать заголовочные файлы. За это отвечает параметр -I, который аналогичен одноименному параметру компилятора gcc. Для GLib и Linux это вполне предсказуемый путь:

cppcheck -q -I/usr/include/glib-2.0/ ./source

Интересная особенность (которая сильно увеличивает время анализа) — перебор ifdef-вариантов. Если в программе есть один ifdef — cppcheck сделает два варианта препроцессинга и просканирует оба варианта исходного кода. Чем больше в исходном коде ifdef-ветвлений, тем больше вариантов нужно перебирать. Управлять этим поведением можно параметрами -D и -U. Параметр -DA означает, что макрос A определён. Параметр -UB означает, что макрос B не определён.

По умолчанию проверяется только дюжина конфигураций. Изменить это число можно параметром --max-configs. Чтобы проверять только одну конфигурацию, можно задать проверку одной конфигурации. Параметр --force проверит все конфигурации (очень медленно).

Cppcheck не отличает макросов из заголовочных файлов от макросов, определённых в исходном коде. Если анализ усилить препроцессингом всех #include, указав каталог с заголовочными файлами — приготовьтесь к очень длительному анализу — cppcheck будет шелушить все макросы из всех заголовочных файлов, до которых дотянулся препроцессор.

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

Пишем реализации функций самостоятельно


Стоит заметить, что параметр -I лишь сообщает cppcheck, где искать заголовочные файлы и подключает их только в случае, если в исходном файле есть соответствующий #include. Можно использовать чуть более затратный альтернативный вариант: реализовать наиболее частые макросы и функции вручную и подключить их ко всем файлам проекта. Придётся немного поработать над созданием такого файла, но анализ пойдёт значительно быстрее и точнее. Более того, можно использовать некоторые интересные трюки с макросами.

Подключение файла с реализацией функций реализуется параметром --append. Указанный файл автоматически вставляется в конец каждого файла проекта.

Подключение файла с макроопределениями реализуется параметром --include. Указанный файл автоматически вставляется в начало каждого файла проекта.

Например, программа использует библиотеку GLib и нужно сообщить cppcheck, что g_return_if_fail — это макрос.

Попробуем проанализировать такой код:

void f(char *s1, char *s1) {
    g_return_if_fail(s1);
    char *a = g_strdup(s1);
    g_return_if_fail(s2);
    char *b = g_strdup(s2);
    free(a);
    free(b);
}

Запускаем cppcheck:

cppcheck -q test.c

Ничего.

Создаём файл gtk.h следующего содержания:

#define g_return_if_fail(expr) do{if(!(expr)){return;}}while(0)

Так как это макрос, его нужно включать в начало:

cppcheck -q test.c --include=gtk.h

Хм. Снова ничего? Если присмотреться к коду в примере, можно заметить функцию g_strdup, о которой cppcheck пока ничего не знает. Попробуем написать простейшую реализацию (файл gtk.c):

char * g_strdup(const char *s) {
	return strdup(s);
}

Обратите внимание, что это функция, а не макрос.
Анализируем. Файл с реализацией функции вставляется параметром --append;

cppcheck -q test.c --include=gtk.h --append=gtk.c
[test.c:4]: (error) Memory leak: a

Готово! Теперь cppcheck научился обнаруживать новые утечки памяти. Умный анализатор залез внутрь кода функции g_strdup, сделал трассировку возвращаемого значения и обнаружил там strdup, пометив указатели как указатели на память, которую нужно освободить.

Данный пример взят не с потолка: это одна из самых частых ошибок в GLib-программах, которые я нахожу постоянно.

Трюки с макросами и реализациями


Механизм подключения файлов к проекту — очень гибкий инструмент. С его помощью можно проделывать такие вещи как исключение функций с известным поведением, подмена аргументов функций, определение недостающих typedef-ов.

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

Нестандартное выделение памяти

Предположим, есть функция my_alloc, которая выделяет память внутри своих аргументов:

my_alloc(char **a);

Следующий пример не будет распознан как утечка памяти:

void f(){
	char *a, *b;
	my_alloc(&a, &b);
}

Добавим теперь реализацию:

void my_alloc(char **a, char **b)
{
	*a = malloc(13);
	*b = malloc(42);
}

и проверка выдаст следующее:

[test.c:4]: (error) Memory leak: a
[test.c:4]: (error) Memory leak: b

Исключаем функцию из проверки

Если есть какая-то функция, которую мы не хотим проверять, её легко спрятать:

#define unused_func(arg...)

Выделение памяти с флагом ошибки

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

void f() {
	int is_ok;
	char *a = my_alloc(&is_ok);
	if(is_ok)
		free(a);
}

Однако если в базе cppcheck my_alloc значится как функция выделения памяти, результатом проверки будет ошибка. Чтобы её избежать, можно использовать следующую хитрость:

char *my_alloc(int *ok)
{
	char *a = malloc(42);
	if(a) *ok = 1;
	else *ok = 0;
	return a;
}

Анализ с учётом особенностей библиотек


Большинство приложений используют какие-либо библиотеки, в частности, glibc. Анализатору порой нужна некоторая информация о библиотечных функциях. Например, malloc выделяет память, а free — освобождает. Если есть malloc, но нет free, зачастую это означает утечку памяти. Данные функции входят в glibc и являются частью стандарта, поэтому анализатор (да и компилятор) о них знать просто обязан.

Что же касается всего остального, то в идеале для каждой используемой библиотеки анализатор должен иметь некоторое описание её особенностей, чтобы уметь обнаруживать ошибки, допущенные не только при использовании glibc.

Почему это так важно? Если встречается неизвестная функция — cppcheck предполагает, что она находится где-то в сторонней библиотеке и строит относительно неё самые благоприятные прогнозы (она освобождает за собой память, не прерывает выполнение программы и т. п.). Если где-то был обнаружен паттерн утечки памяти или неинициализированной переменной, но внутри находится функция неизвестного назначения, cppcheck заблокирует сообщение об ошибке.

Рассмотрим простой пример. В ОС Linux многие графические приложения используют библиотеку GLib. Данная библиотека практически дублирует glibc, в том числе она имеет свои реализации malloc/free. Обычно код, использующий GLib, выглядит так:

gchar *s = g_strdup("test");
gint *a = g_malloc(sizeof(int) * 10);

То, что после g_strdup и g_malloc память нужно освобождать, человек догадается интуитивно, даже не имея ранее опыта работы с GLib. Чего не скажешь об анализаторе: реализация функции g_malloc спрятана внутри двоичного кода библиотеки — кто её знает, что она там делает?

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

Самое приятное в cppcheck то, что он имеет такую пополняемую базу данных. Это означает, что сообщество может улучшать cppcheck просто добавляя всё новую информацию о стандартных библиотеках, которую cppcheck потом будет использовать при анализе.

Cppcheck не загружает автоматически необходимые библиотеки, это нужно делать вручную. Библиотеки указываются параметром --library, можно указывать несколько библиотек, разделяя их запятыми. cppcheck сначала ищет файл с именем библиотека.cfg в текущем каталоге (удобно так хранить библиотеку для своего проекта), потом пытается найти её в своей базе библиотек. Если библиотеки нигде не нашлось, cppcheck выдаст ошибку.

Пример анализа проекта с использованием библиотеки gtk:

cppcheck -q --library=gtk ./source

Можно явно указать, в каком файле лежит библиотека (надо сказать, не посмотрев в исходники, я бы не догадался, что так можно делать):

cppcheck -q --library=my/path/mylib.xml ./source

На сегодняшний день cppcheck поддерживает совсем немного библиотек:

  • gtk (на самом деле, glib, gtk там не поддерживается)
  • Qt
  • windows (всякие scanf_s...)
  • posix
  • glibc (стандартная библиотека перестаёт быть hard-coded и постепенно вытесняется в базу)

Список явно небогат. Более того, от содержимого этой базы хочется пустить скупую слезу, вот, например, база POSIX:

<?xml version="1.0"?>
<def>
  <function name="usleep"> <noreturn>false</noreturn> <arg nr="1"><not-bool/><valid>0-999999</valid></arg> </function>
  <function name="_exit"> <noreturn>true</noreturn> </function>
</def>

Из этого файла можно почерпнуть только одно: cppcheck для расширения своей базы использует XML.

Что на данный момент cppcheck умеет «выуживать» из базы данных библиотеки:

  • список функций выделения памяти и соответствующий список освобождения, после описания может ловить утечки памяти и несоответствие конструктора/деструктора — на мой взгляд самая полезная возможность;
  • функция, прерывающая выполнение (вроде exit);
  • функция, которую нужно пропускать при проверке информационного потока утечки памяти (например, функция strlen никак не модифицирует полученный указатель и её можно не учитывать при проверке на утечку памяти);
  • проверка корректности аргументов функции, включая передачу на вход функции неинициализированной переменной;
  • проверка строки-формата и списка аргументов на соответствие (как в printf);

Формат базы данных (снова немного документации)

База формируется как XML-файл, который кладётся в каталог cfg проекта cppcheck. Вот почему я рекомендовал в начале статьи загрузить исходный код программы. cfg-файлы прибиты гвоздями к подкаталогу cfg рядом с исполняемым файлом и это пока не исправили. Внутри каталога cfg уже есть несколько библиотек, которыми можно пользоваться как примерами для создания своей библиотеки — документации по данной теме не очень много.

Каждый файл начинается с заголовка:

<?xml version="1.0"?>

Вся база оборачивается в тег def. Различных тегов внутри def может быть три:

  • memory — информация о функциях, работающих с памятью;
  • resource — аналогично, но для ресурсов (открыт/закрыт), типичный представитель — open/close;
  • function — просто функция.

Что касается memory и resource, внутренняя структура у них одинаковая:

  • alloc — внутри тега размещается аллокатор, можно добавить атрибут тега init=«true|false» — указывает, инициализирует ли аллокатор память;
  • dealloc — внутри тега размещается соответствующая функция освобождения памяти всем функциям выделения памяти в блоке (можно комбинировать несколько alloc/dealloc в одном блоке);
  • use — функция, которая должна использовать объект, выделенный аллокатором (теоретически, так как возможность не описана в документации и не реализована в исходниках). У меня предположение, что это будет использоваться для счётчиков ссылок.

Каждый блок memory и resource нужно дублировать для разных групп функций. Например, если память, выделенную функцией malloc нужно освобождать при помощи free, а g_malloc — при помощи g_free, их нужно попарно разместить в разных тегах memory.

Функция (function) — обладает наибольшим количеством возможностей. Имя функции указывается как атрибут name тега function. Внутри тега можно использовать:

  • noreturn — если true, данная функция не прерывает выполнение;
  • leak-ignore — непартный тег, означает, что данная функция определённо не освобождает указатели и её можно игнорировать при проверке на утечки памяти;
  • arg — проверка аргумента, номер аргумента уточняется атрибутом nr (подробно описано в документации).

Примечание. Тег noreturn для функции нужно ставить только тогда, когда она безусловно прерывает выполнение. Может показаться, что этот тег описывает не-void функции, но это не так. Пример такой функции — exit. Из-за этой особенности при составлении правил для библиотеки GLib возникали большие странности.

Описываем библиотеку GLib

GLib — библиотека языка Си, лежащая в основе GTK+ и реализующая объектно-ориентированное программирование в Си (а с недавних пор появилась интроспекция). На ней построено очень много проектов: GIMP, GNOME, Xfce, даже Chromium/Firefox в той или иной мере её используют.

GLib является кроссплатформенной, поэтому даже такие функции как malloc/free или printf продублированы внутри GLib, чтобы не зависеть от конкретной реализации или версии glibc поддерживаемых платформ. Как следствие, в GLib десятки функций, работающие с памятью, свой обработчик ошибок, в целом, всё то, о чём cppcheck не подозревает.

В cppcheck лишь сравнительно недавно появились зачатки поддержки библиотеки GLib. Например, такие явные утечки памяти cppcheck ловить умеет:

void f() {
    g_malloc(42);
}

cppcheck -q test.c --library=gtk
[test.c:3]: (error) Return value of allocation function g_malloc is not used.

Однако в GLib сотни функций, и такое хитросплетение cppcheck уже не возьмёт:

void f() {
    gchar *a = g_strdup(s);
    g_strdown(a);
}

Всё потому, что функция g_strdown неизвестна. Вдруг она освобождает память сама?

Библиотеку GLib можно условно разбить на составные части:

  • функции-аллокаторы общего назначения, все они освобождаются одной функцией g_free;
  • конструкторы и деструкторы объектов;
  • функции, «впитывающие» в себя указатели (хэш-таблицы, списки) — для таких не нужно рапортовать об утечках памяти — будет много ложных срабатываний;
  • всего одна функция, прерывающая выполнение — g_exit;
  • все остальные функции, которые можно игнорировать. Этот список наиболее важный, так как именно он сообщает cppcheck не прятать ошибку из-за функции, которая ничего не делает с указателями.

Приступим к формированию списка правил. В GLib и GTK+ более четырёх тысяч функций, логично, что вручную их собрать практически невозможно. К счастью, в новых версиях GLib появилась интроспекция, что позволит без усилий вытащить все методы из XML-файла. К сожалению, разработчики GLib не думали, что кому-то будет интересно знать, нужно ли освобождать память после той или иной функции, поэтому искать конструкторы и деструкторы пришлось вручную, читая документацию.

Благо, всю эту работу (для семейства GLib/GTK+) проделывать не придётся, так как она уже была выполнена и результат находится в моём репозитории.

Так как на XML у меня аллергия, я сделал упрощённый вариант формата описания функций, которые потом склеиваются в гигантский XML-файл небольшим парсером, написанным на питоне. Чтобы получить XML файл, достаточно просто набрать make. Исходников два: gtk.rules, где вручную перечислены функции, которые невозможно парсить автоматически, и gtk-functions.rules — автоматически генерируемый файл на основе XML-интерфейсов GLib/GTK+. Парсер написан так, чтобы функции не повторялись.

На данный момент библиотека более-менее стабильна, её можно забрать отсюда и положить в каталог cppcheck/cfg, заменив старую. После этого можно анализировать любые проекты, написанные на GLib/GTK+. После тестирования и устранения ложных срабатываний я постараюсь пропихнуть Pull Request в cppcheck с этой базой, так что есть шанс увидеть её в ближайших релизах.

Дополнительно я сделал заголовочный файл, в котором перечислены популярные макросы g_return_*. С его помощью cppcheck не пропустит ошибки памяти, вызванные вставкой этих макросов. Данный файл нужно прикрепить при помощи параметра --include.

Анализ исходного кода Thunar


Небольшой пример, как влияет на анализ использование библиотеки. В качестве подопытного сегодня будет Thunar версии 1.6.3, глубокого анализа не будет (он не потребуется для демонстрации), просто сканирование со стандартными настройками.

Сначала анализ «чистым» cppcheck:

cppcheck -q -j4 --max-configs=1 ./Thunar-1.6.3
[Thunar-1.6.3/thunar/thunar-chooser-dialog.c:450]: (error) Uninitialized variable: app_info

Довольно небольшой улов.

Подключаем библиотеку:

cppcheck -q -j4 --max-configs=1 --library=gtk --include=gtk.h ./Thunar-1.6.3

Вывод команды
[Thunar-1.6.3/plugins/thunar-sendto-email/main.c:410]: (error) Memory leak: tmpdir
[Thunar-1.6.3/plugins/thunar-uca/thunar-uca-provider.c:166]: (error) Memory leak: dialog
[Thunar-1.6.3/plugins/thunar-uca/thunar-uca-editor.c:150]: (error) Memory leak: label
[Thunar-1.6.3/plugins/thunar-uca/thunar-uca-editor.c:168]: (error) Memory leak: label
[Thunar-1.6.3/plugins/thunar-uca/thunar-uca-editor.c:211]: (error) Memory leak: label
[Thunar-1.6.3/plugins/thunar-uca/thunar-uca-editor.c:251]: (error) Memory leak: label
[Thunar-1.6.3/plugins/thunar-uca/thunar-uca-editor.c:391]: (error) Memory leak: align
[Thunar-1.6.3/plugins/thunar-uca/thunar-uca-editor.c:395]: (error) Memory leak: label
[Thunar-1.6.3/plugins/thunar-uca/thunar-uca-editor.c:399]: (error) Memory leak: align
[Thunar-1.6.3/plugins/thunar-uca/thunar-uca-editor.c:433]: (error) Memory leak: align
[Thunar-1.6.3/plugins/thunar-uca/thunar-uca-editor.c:446]: (error) Memory leak: label
[Thunar-1.6.3/plugins/thunar-uca/thunar-uca-editor.c:458]: (error) Memory leak: align
[Thunar-1.6.3/plugins/thunar-wallpaper/twp-provider.c:301]: (error) Memory leak: escaped_file_name
[Thunar-1.6.3/plugins/thunar-uca/thunar-uca-model.c:1520]: (error) Memory leak: command_line
[Thunar-1.6.3/plugins/thunar-uca/thunar-uca-model.c:1521]: (error) Memory leak: command_line
[Thunar-1.6.3/plugins/thunar-uca/thunar-uca-model.c:1522]: (error) Memory leak: command_line
[Thunar-1.6.3/thunar/thunar-column-editor.c:576]: (error) Memory leak: dialog
[Thunar-1.6.3/thunar/thunar-chooser-dialog.c:450]: (error) Uninitialized variable: app_info
[Thunar-1.6.3/thunar/thunar-device-monitor.c:829]: (error) Mismatching allocation and deallocation: devices
[Thunar-1.6.3/thunar/thunar-folder.c:686]: (error) Mismatching allocation and deallocation: attrs
[Thunar-1.6.3/thunar/thunar-file.c:1568]: (error) Mismatching allocation and deallocation: argv
[Thunar-1.6.3/thunar/thunar-list-model.c:1084]: (error) Memory leak: old_order
[Thunar-1.6.3/thunar/thunar-properties-dialog.c:446]: (error) Memory leak: spacer
[Thunar-1.6.3/thunar/thunar-properties-dialog.c:534]: (error) Memory leak: spacer
[Thunar-1.6.3/thunar/thunar-shortcuts-model.c:2210]: (error) Mismatching allocation and deallocation: bookmarks
[Thunar-1.6.3/thunar/thunar-standard-view.c:1911]: (error) Returning/dereferencing 'file' after it is deallocated / released
[Thunar-1.6.3/thunar/thunar-window.c:2028]: (error) Memory leak: checksum
[Thunar-1.6.3/thunar/thunar-window.c:2028]: (error) Memory leak: tooltip


27 новых ошибок — уже кое-что!

Действительно ли это баги или куча ложных срабатываний?

Утечка памяти в случае проверки ошибки — наиболее распространённый тип утечек памяти:

  tmpdir = g_strdup ("/tmp/thunar-sendto-email.XXXXXX");
  if (G_UNLIKELY (mkdtemp (tmpdir) == NULL))
    {
      error = g_error_new_literal (G_FILE_ERROR, g_file_error_from_errno (errno), g_strerror (errno));
      tse_error (error, _("Failed to create temporary directory"));
      g_error_free (error);
      return FALSE;
    }
  /* где-то очень далеко */
  g_free(tmpdir);

Аналогичные примеры
  escaped_file_name = g_shell_quote (file_name);
  switch (desktop_type)
    {
      case DESKTOP_TYPE_XFCE:
        ...
        break;

      case DESKTOP_TYPE_NAUTILUS:
     ...
        break;

      default:
        return; /* как, уходите? А как же break??? */
        break;
    }
  g_free (escaped_file_name);

  GString            *command_line = g_string_new (NULL);
  GList              *lp;
  gchar              *dirname;
  gchar              *quoted;
  gchar              *path;
  gchar              *uri;

  g_return_val_if_fail (THUNAR_UCA_IS_MODEL (uca_model), FALSE);
  g_return_val_if_fail (iter->stamp == uca_model->stamp, FALSE);
  g_return_val_if_fail (error == NULL || *error == NULL, FALSE);


Несоответствие конструктора/деструктора. На самом деле это утечка памяти, так как внутренняя структура массива не освобождается. Код ниже будет прекрасно работать без ошибок, но будет течь в элементах массива:

  gchar     **attrs;
...
  attrs = g_file_info_list_attributes (info1, NULL);
...
  g_free (attrs);

Указатель возвращается после освобождения:

  GtkTreePath *path = NULL;
  GtkTreeIter  iter;
  ThunarFile  *file = NULL;

  path = (*THUNAR_STANDARD_VIEW_GET_CLASS (standard_view)->get_path_at_pos) (standard_view, x, y);
  if (G_LIKELY (path != NULL))
    {
      gtk_tree_model_get_iter (GTK_TREE_MODEL (standard_view->model), &iter, path);
      file = thunar_list_model_get_file (standard_view->model, &iter);

      if (!thunar_file_is_directory (file) && !thunar_file_is_executable (file))
        {
          g_object_unref (G_OBJECT (file)); /* Здесь объект может быть удалён! */
          gtk_tree_path_free (path);
          path = NULL;
        }
    }
  return file;

Это спорное предупреждение, так как g_object_unref считает ссылки и будет ли удалён объект неизвестно, но к ошибке стоит присмотреться.

Ложные срабатывания. Были и такие — некоторые функции gtk позволяют отчуждать объект, который уничтожится автоматически вместе с родителем, если их не исключить — cppcheck будет ругаться.

Примеры
static void
manage_actions (GtkWindow *window)
{
  GtkWidget *dialog;

  dialog = g_object_new (THUNAR_UCA_TYPE_CHOOSER, NULL);
  gtk_window_set_transient_for (GTK_WINDOW (dialog), window);
  gtk_widget_show (dialog);
}

  label = g_object_new (GTK_TYPE_LABEL, "label", _("Appears if selection contains:"), "xalign", 0.0f, NULL);
  gtk_table_attach (GTK_TABLE (table), label, 0, 2, 2, 3, GTK_EXPAND | GTK_FILL, GTK_FILL, 0, 0);

Здесь используется другая функция освобождения, что всё равно корректно, так как g_object просто считает ссылки:

  dialog = g_object_new (THUNAR_TYPE_COLUMN_EDITOR, NULL);
...
  gtk_widget_destroy (dialog);

От этого ложного срабатывания сложно избавиться, так как g_new часто используется чтобы выделить массив строк:

  devices = g_new0 (gchar *, length + 2);
...
  g_strfreev (devices);

Тут cppcheck запутался в условиях. Баг в cppcheck:

  /* be sure to not overuse the stack */
  if (G_LIKELY (length < 2000))
    {
      old_order = g_newa (GSequenceIter *, length);
      new_order = g_newa (gint, length);
    }
  else
    {
      old_order = g_new (GSequenceIter *, length);
      new_order = g_new (gint, length);
    }
...
  /* clean up if we used the heap */
  if (G_UNLIKELY (length >= 2000))
    {
      g_free (old_order);
      g_free (new_order);
    }


Но прелесть этих ложных срабатываний в том, что их легко исправить, пропатчив XML-файл с библиотекой. Таким образом, удалось сократить количество ошибок до 14. Со временем интроспекция в GLib будет улучшена и можно будет в автоматическом режиме собрать информацию о библиотеке.

Пишем правила для cppcheck


Напоследок — самое вкусное. Здесь пойдёт речь о том, как реализовать свои собственные проверки для cppcheck. Наверняка, вы уже находили у себя какой-то баг и хотели бы проверить весь проект на наличие аналогичных ошибок. Другая ситуация — вы тимлид и программисты в вашей команде регулярно лепят типовые ошибки, которые хотелось бы пресекать в автоматическом режиме. Держать свой собственный код «в узде» полезно, чтобы разрабатывать эффективные и безопасные программы. Нашли ошибку — написали к ней не только регрессионный тест, но и правило анализатора.

Что под капотом?

Для начала пара слов о том, как работает cppcheck. Перед непосредственно анализом cppcheck прогоняет препроцессор, аналогично компилятору, после чего следует этап упрощения исходного кода. То есть: убираются все лишние отступы и пробелы, каждая лексическая конструкция языка разделяется ровно одним пробелом. Все константы, которые могут быть упрощены во время препроцессинга, — вычисляются. Везде расставляются блоки {}, даже если они опущены. Если присутствует объявление или присвоение переменной внутри блока if/for/while — оно будет вынесено снаружи этого блока.

Так как стиль кодирования у всех разный, cppcheck стремится сделать так, чтобы код был приведён к своего рода «нормальной форме». Например, один программист пишет цикл так:

for(int i = 0; i < 10; i++)
    if(i % 2)
        printf("%d\n", i);

А другой — так:

int i;
for(i = 0; i < 10; i++) {
    if(i % 2)
        printf("%d\n", i);
}

Cppcheck приведёт это всё к виду:

int i ; for ( i = 0 ; i < 10 ; i ++ ) { if ( i % 2 ) { printf ( "%d\n" , i ) ; } }

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

Cppcheck использует несколько уровней упрощения: простой, нормальный и исходный. Например, на «простом» уровне оператор sizeof раскрыт как число, а обычном уровне он остаётся оператором. Это иногда полезно для поиска ошибок, связанных с конкретным оператором. Исходный уровень — это код в первозданном виде, над которым ещё не поработал препроцессор и он только приведён к нормальной форме.

Таким образом, cppcheck строит на основе исходного кода некоторую модель (класс Tokenizer), где все токены просто разделены пробелом, позволяя анализирующим модулям легко пользоваться этими токенами. Анализирующий модуль может в свою очередь строить свою модель, если ему это необходимо, перемещаться по токенам, определять тип токена и т. п. На данный момент есть базовая модель (разбиение на токены, она используется практически везде), модель ValueFlow — для проверки утечек памяти и экспериментальная модуль AST (синтаксическое дерево). Разработчики правил могут использовать любую из этих моделей.

Чтобы не ломать голову, как cppcheck оптимизирует те или иные конструкции, можно воспользоваться отладочным режимом, в котором cppcheck выдаст на экран упрощённую версию кода:

cppcheck --debug ./file.cpp

Правила на основе регулярных выражений

Cppcheck позволяет расширять свои возможности при помощи регулярных выражений. Схема работы такова: производится упрощение исходного кода, он склеивается в одну строку, после чего к полученной строке применяется регулярное выражение. Если совпадение найдено — cppcheck выдаст настоящее предупреждение и сообщит строку кода, в которой сработало правило.

Перед тем как пользоваться данной возможностью, нужно перекомпилировать cppcheck (вот почему я рекомендую загрузить версию исходников из гита), включив экспериментальную поддержку регулярных выражений. Для этого потребуется библиотека pcre. Компилируется всё так же просто:

make HAVE_RULES=yes

Теперь у cppcheck появится два новых параметра: --rule — можно задать регулярное выражение прямо в командной строке и --rule-file — XML-база с вашими собственными правилами проверки.

Наглядно познакомиться с новой возможностью можно следующим образом. Тестовый пример:

void f() {
	if(a) free(a);
}

Далее проверка. Составим регулярное выражение, которое подминает под себя всё подряд:

cppcheck -q --rule=".*" test.c
[test.c:1]: (style) found ' void f ( ) { if ( a ) { free ( a ) ; } }'

Отлично! Теперь понятно, какой вид является «упрощённым».

Теперь напишем правило, которое позволяет искать ненужные проверки переменной перед освобождением:

cppcheck -q --rule='if \( (\w+) \) { free \( \1' test.c
[test.c:2]: (style) found 'if ( a ) { free ( a'

Паттерн работает. Осталось записать его в базу данных. Создадим файл rules.xml, окончательно оформив регулярное выражение:

<?xml version="1.0"?>
<rule>
	<pattern>if \( (\b\w+\b) \) { (?:g_)?free \( \b\1\b \) ; }</pattern>
	<message>
		<severity>style</severity>
		<summary>Redundant condition. It is valid to free a NULL pointer.</summary>
	</message>
</rule>

Теперь можно проверить какой-нибудь исходник, используя этот файл как базу правил:

cppcheck -q --rule-file=rules.xml test.c
[test.c:2]: (style) Redundant condition. It is valid to free a NULL pointer.

Формат XML-файла вполне очевиден. Иногда может потребоваться другая версия упрощённого кода, например, raw — исходный код без упрощений. Тогда достаточно в правило добавить тег:

<tokenlist>raw</tokenlist>

Внутри тега tokenlist можно использовать:

  • raw — код с минимальным упрощением
  • normal — режим по умолчанию
  • simple — максимально упрощённый код
  • define — для проверки директив препроцессора, cppcheck не исключает из исходника макросы

Если удалить тег summary — можно увидеть, какая строка попадает под регулярное выражение — такой режим дебаггинга.

Несколько примеров правил:

Функция без аргументов, но без void
В Си хорошим тоном считается объявление функции без аргументов с void:

void f() {}     /* плохо */
void f(void) {} /* хорошо */

<rule>
	<tokenlist>raw</tokenlist>
	<pattern>\( \) {</pattern>
	<message>
		<severity>style</severity>
		<summary>Always specify void even if a function accepts no arguments</summary>
	</message>
</rule>


Ловим инкремент/декремент внутри sizeof
<rule>
	<tokenlist>raw</tokenlist>
	<pattern>sizeof \( [^)]*(?:\w+ [+-]{2}|[+-]{2} \w+)[^)]* \)</pattern>
	<message>
		<severity>warning</severity>
		<summary>Operands to the sizeof operator should not contain side effects</summary>
	</message>
</rule>


Не использовать константы внутри mktemp
<rule>
	<pattern>mktemp \( "[^"]+" \)</pattern>
	<message>
		<severity>error</severity>
		<summary>The mktemp() function modifies its string argument</summary>
	</message>
</rule>


В принципе избегать функции gets
<rule>
	<pattern> gets \( \w+ \)</pattern>
	<message>
		<severity>error</severity>
		<summary>The gets() function is obsolescent, and is deprecated</summary>
	</message>
</rule>


Как известно, на XML у меня аллергия, поэтому я создал репозиторий с немного упрощённым форматом базы и компилятором на Питоне. XML-база, собранная мной, содержит основные рекомендации CERT хорошего тона программирования на Си (не Си++!) и потихоньку пополняется. Как говорится, contibutions are welcome.

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

Если хочется узнать побольше, по теме создания правил для cppcheck есть неплохая документация: раз, два, три (третья часть посвящена разработке правил на языке Си++). Вы можете сильно помочь проекту, отправляя разработчикам патчи, сообщения о ложных срабатываниях, багах, и не стесняйтесь делать PR (нет, не тот, а Pull Request:).
Tags:
Hubs:
+59
Comments 15
Comments Comments 15

Articles