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

Зачем нам нужен межмодульный анализ? Как анализатор от этого выигрывает? Обычно наш инструмент проверяет только один исходный файл за раз. Анализатор не знает о содержимом других файлов проекта. Межмодульный анализ позволяет предоставить анализатору информацию обо всей структуре проекта. Таким образом, анализ становится более точным и качественным. Этот подход похож на оптимизацию времени соединения (LTO). Например, анализатор может узнать о поведении функции из другого файла проекта и выдать предупреждение. Это может быть, например, разыменование нулевого указателя, переданного в качестве аргумента внешней функции.

Реализация межмодульного анализа является сложной задачей. Почему? Чтобы узнать ответ на этот вопрос, давайте сначала углубимся в структуру проектов C++.

Краткое изложение теории компиляции проектов C++

До стандарта C++20 в языке был принят только один сценарий компиляции. Обычно программный код используется совместно заголовочными и исходными файлами. Рассмотрим этапы этого процесса.

  • Препроцессор выполняет предварительные операции над каждым скомпилированным файлом (единицей перевода) перед передачей его компилятору. На этом этапе вместо директив ‘#include’ вставляется текст из всех заголовочных файлов, а макросы разворачиваются. Результатом этого этапа являются так называемые предварительно обработанные файлы.
  • Компилятор преобразует каждый предварительно обработанный файл в файл с машинным кодом, специально предназначенным для компоновки в исполняемый двоичный файл. Эти файлы называются объектными файлами.
  • Компоновщик объединяет все объектные файлы в исполняемый двоичный файл. При этом компоновщик разрешает конфликты, когда символы совпадают. Только в этот момент код, написанный в разных файлах, связывается в единое целое.

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

Примечательной деталью является то, что стандарт C++20 внес изменения в конвейер компиляции. Это связано с новыми модулями, которые сокращают время компиляции проекта. Эта тема является еще одной головной болью и предметом обсуждения для разработчиков инструментов C++. На момент написания этой статьи системы сборки не полностью поддерживали эту функцию. По этой причине давайте придерживаться классического метода компиляции.

Межмодульный анализ в компиляторах

Одним из самых популярных инструментов в мире переводчиков является LLVM — набор инструментов для создания компиляторов и обработки кода. На его основе построены многие компиляторы для таких языков, как C/C++ (Clang), Rust, Haskel, Fortran, Swift и многих других. Это стало возможным благодаря тому, что промежуточное представление LLVM не привязано к конкретному языку программирования или платформе. Межмодульный анализ в LLVM выполняется на промежуточном представлении во время оптимизации времени соединения (LTO). Документация LLVM описывает четыре этапа LTO:

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

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

Отдельно стоит упомянуть GCC — второй по популярности компилятор для языков C/C++. Он также обеспечивает оптимизацию времени ссылки. Но реализованы они несколько иначе.

  • GCC генерирует свое внутреннее промежуточное представление, называемое GIMPLE, для каждого файла. Он хранится в специальных объектных файлах в формате ELF. По умолчанию эти файлы содержат только байт-код. Но если вы используете флаг -ffat-lto-objects, GCC поместит промежуточный код в отдельный раздел рядом с сгенерированным объектным кодом. Это позволяет поддерживать связь без LTO. На этом этапе появляется представление потока данных всех внутренних структур данных, необходимых для оптимизации кода.
  • GCC снова проходит объектные модули с уже записанной в них межмодульной информацией и выполняет оптимизацию. Затем они связываются с одним объектным файлом.

Кроме того, GCC поддерживает режим WHOPR. В этом режиме объектные файлы связываются по частям на основе графа вызовов. Это позволяет второму этапу работать параллельно. В результате мы можем избежать загрузки всей программы в память.

Наша реализация

Мы не можем применить описанный выше подход к инструменту PVS-Studio. Главное отличие нашего анализатора от компиляторов в том, что он не формирует промежуточное представление, абстрагированное от языкового контекста. Следовательно, чтобы прочитать символ из другого модуля, инструмент должен снова его преобразовать и представить программу в виде структур данных в памяти (дерево синтаксического анализа, граф потока управления и т. д.). Анализ потока данных также может потребовать разбора всего графа зависимостей по символам в разных модулях. Такая задача может занять много времени. Итак, мы собираем информацию о символах (в частности, при анализе потоков данных) с помощью семантического анализа. Нам нужно заранее как-то сохранить эти данные отдельно. Такая информация представляет собой набор фактов для конкретного символа. Мы разработали следующий подход, основанный на этой идее.

Три этапа межмодульного анализа в PVS-Studio:

  • Семантический анализ каждой отдельной единицы перевода. Анализатор собирает информацию о каждом инструменте, по которому обнаружены потенциально интересные факты. Затем эта информация записывается в файлы в специальном формате. Такой процесс можно выполнять параллельно, что отлично подходит для многопоточных сборок.
  • Объединение символов. На этом этапе анализатор объединяет информацию из разных файлов с фактами в один файл. Кроме того, инструмент разрешает конфликты между символами. На выходе получается один файл с информацией, необходимой для межмодульного анализа.
  • Выполняется диагностика. Анализатор повторно просматривает каждую единицу трансляции. Все же есть отличие от однопроходного режима с отключенным анализом. Во время диагностики информация о символах загружается из объединенного файла. Стала доступна информация о фактах по символам из других модулей.

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

Как попробовать межмодульный анализ

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

Как запустить на Linux/macOS

pvs-studio-analyzer помогает анализировать проекты на Linux/macOS. Чтобы включить режим межмодульного анализа, добавьте флаг — intermodular в команду pvs-studio-analyzer analysis. Таким образом, анализатор формирует отчет и сам удаляет все временные файлы.

Плагины для IDE также поддерживают межмодульный анализ, доступный в JetBrains CLion IDE для Linux и macOS. Установите соответствующий флажок в настройках плагина, чтобы включить межмодульный анализ.

Важно: если поставить галочку IntermodularAnalysis с включенным добавочным анализом, подключаемый модуль сообщит об ошибке. Еще одно уведомление. Запустите анализ всего проекта. В противном случае, если вы запустите анализ определенного списка файлов, результат будет неполным. Анализатор уведомит вас об этом в окне предупреждения: V013: Межмодульный анализ может быть неполным, так как он выполняется не на всех исходных файлах. Плагин также синхронизирует свои настройки с глобальным файлом Settings.xml. Это позволяет установить одинаковые настройки для всех IDE, в которые вы интегрировали PVS-Studio. Поэтому вы можете вручную включить в нем несовместимые настройки. При попытке запустить анализ плагин сообщает об ошибке в окне предупреждения: Ошибка: Флаги — инкрементальный и — межмодульный нельзя использовать вместе.

Как запустить в Windows

Вы можете запустить анализ в Windows двумя способами: с помощью консольных утилит PVS-Studio_Cmd и CLMonitor или с помощью плагина.

Чтобы запустить анализ с помощью утилит PVS-Studio_Cmd / CLMonitor, установите true для тега ‹IntermodularAnalysisCpp› в файл конфигурации Settings.xml.

Этот параметр включает межмодульный анализ в подключаемом модуле Visual Studio:

Что мы нашли с помощью межмодульного анализа

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

zlib

V522 Может иметь место разыменование нулевого указателя. Нулевой указатель передается в функцию _tr_stored_block. Проверьте второй аргумент. Проверьте строки: trees.c:873, deflate.c:1690.

// trees.c
void ZLIB_INTERNAL _tr_stored_block(s, buf, stored_len, last)
    deflate_state *s;
    charf *buf;       /* input block */
    ulg stored_len;   /* length of input block */
    int last;         /* one if this is the last block for a file */
{
    // ....
    zmemcpy(s->pending_buf + s->pending, (Bytef *)buf, stored_len);      // <=
    // ....
}
// deflate.c
local block_state deflate_stored(s, flush)
    deflate_state *s;
    int flush;
{
    ....
    /* Make a dummy stored block in pending to get the header bytes,
     * including any pending bits. This also updates the debugging counts.
     */
    last = flush == Z_FINISH && len == left + s->strm->avail_in ? 1 : 0;
    _tr_stored_block(s, (char *)0, 0L, last);                            // <=
    ....
}

Нулевой указатель (char*)0 попадает в memcpy в качестве второго аргумента через функцию _tr_stored_block. Похоже, что реальной проблемы нет — копируются нулевые байты. Но стандарт четко утверждает обратное. Когда мы вызываем такие функции, как memcpy, указатели должны указывать на допустимые данные, даже если их количество равно нулю. В противном случае приходится иметь дело с неопределенным поведением.

Ошибка была исправлена в ветке разработки, но не в релизной версии. Прошло 4 года с тех пор, как команда проекта выпустила обновления. Изначально ошибку нашли санитайзеры.

mc

V774 Указатель w использовался после освобождения памяти. editcmd.c 2258

// editcmd.c
gboolean
edit_close_cmd (WEdit * edit)
{
    // ....
    Widget *w = WIDGET (edit);
    WGroup *g = w->owner;
    if (edit->locked != 0)
        unlock_file (edit->filename_vpath);
    group_remove_widget (w);
    widget_destroy (w);                          // <=
    if (edit_widget_is_editor (CONST_WIDGET (g->current->data)))
        edit = (WEdit *) (g->current->data);
    else
    {
        edit = find_editor (DIALOG (g));
        if (edit != NULL)
            widget_select (w);                   // <=
    }
}
// widget-common.c
void
widget_destroy (Widget * w)
{
    send_message (w, NULL, MSG_DESTROY, 0, NULL);
    g_free (w);
}
void
widget_select (Widget * w)
{
    WGroup *g;
    if (!widget_get_options (w, WOP_SELECTABLE))
        return;
    // ....
}
// widget-common.h
static inline gboolean
widget_get_options (const Widget * w, widget_options_t options)
{
    return ((w->options & options) == options);
}

Функция widget_destroy освобождает память по указателю, делая его недействительным. Но после вызова widget_select получает указатель. Затем он попадает в widget_get_options, где этот указатель разыменовывается.

Исходный виджет *w берется из параметра edit. Но перед вызовом widget_select вызывается find_editor — он перехватывает переданный параметр. Переменная w, скорее всего, используется только для оптимизации и упрощения кода. Поэтому фиксированный вызов будет выглядеть как widget_select(WIDGET(edit)).

Ошибка в ветке master.

коделит

V597 Компилятор мог удалить вызов функции memset, которая используется для сброса текущего объекта. Функцию memset_s() следует использовать для удаления личных данных. аргументы.с 269

Вот интересный случай с удалением memset:

// args.c
extern void eFree (void *const ptr);
extern void argDelete (Arguments* const current)
{
  Assert (current != NULL);
  if (current->type ==  ARG_STRING  &&  current->item != NULL)
    eFree (current->item);
  memset (current, 0, sizeof (Arguments));  // <=
  eFree (current);                          // <=
}
// routines.c
extern void eFree (void *const ptr)
{
  Assert (ptr != NULL);
  free (ptr);
}

Оптимизация LTO может удалить вызов memset. Это связано с тем, что компилятор может определить, что eFree не вычисляет никаких полезных данных, связанных с указателем — eFree вызывает только функцию free, которая освобождает объем памяти. Без LTO вызов eFree выглядит как неизвестная внешняя функция, поэтому memset останется.

Вывод

Межмодульный анализ открывает для анализатора многие недоступные ранее возможности по поиску ошибок в программах на языках C, C++. Теперь анализатор обращается к информации из всех файлов проекта. Имея больше данных о поведении программы, анализатор может обнаружить больше ошибок.

Вы можете попробовать новый режим прямо сейчас. Он доступен начиная с PVS-Studio v7.14. Зайдите на наш сайт и скачайте его. Обратите внимание, что когда вы запрашиваете пробную версию по данной ссылке, вы получаете расширенную пробную лицензию. Если у вас есть какие-либо вопросы, не стесняйтесь пишите нам. Мы надеемся, что этот режим будет полезен для исправления ошибок в вашем проекте.