Привет, Хабр! Меня зовут Анастасия Черникова, я занимаюсь разработкой компиляторов и инструментов на базе LLVM в Синтакоре.

Неопределенное поведение (undefined behavior, UB) по-разному выглядит с точки зрения компилятора и разработчика. Для первого оно, как правило, открывает дополнительные возможности для оптимизации. Для программиста же UB может стать проблемой, особенно если оно остается незамеченным и не учитывается при разработке.

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

В этой статье рассмотрим подход к поиску UB с использованием статического анализа. В качестве примера я использую clang-tidy: сначала разберу, как устроены существующие чекеры и как работают AST matchers, а затем покажу, как расширять их и добавлять собственные проверки, если стандартных возможностей оказывается недостаточно.

Простое UB, которое видно почти всем

Рассмотрим следующий пример:

#include <iostream>

void foo(int a) {
  auto func = [](auto x) -> decltype(auto) {
    return std::forward<decltype(x)>(x);
  };

  auto &&x = func(a);
  std::cout << "x: " << x << std::endl;
}

int main() {
  foo(5);
}

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

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

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

$ clang++ main.cc -o program
  main.cc:5:42: warning: reference to stack memory associated with parameter 'x' returned [-Wreturn-stack-address]
  return std::forward<decltype(x)>(x);
                             	   ^

AddressSanitizer сообщает об использовании памяти после возврата из функции:

$ clang++ main.cc -o program -fsanitize=address
$ ./program
  ==6358==ERROR: AddressSanitizer: stack-use-after-return on address ...
  READ of size 4 at ...
    #0 ... in foo(int) main.cc:11:28
    #1 ... in main main.cc:15:5
    ...
  Address 0x7fe4a5800060 is located in stack of thread T0 at offset 32 in frame
    ...
  ==6358==ABORTING

Valgrind также фиксирует проблему, хотя и формулирует ее иначе:

$ clang++ main.cc -g -o program
$ valgrind --leak-check=full --track-origins=yes ./program
  ==8124== Conditional jump or move depends on uninitialised value(s)
  ...
  ==8124==	by 0x10919B: foo(int) (test.cc:10)
  ==8124== Uninitialised value was created by a stack allocation
  ==8124==  at 0x10918E: foo(int) (test.cc:10)
  ==8124== ERROR SUMMARY: 6 errors from 4 contexts (suppressed: 0 from 0)

Статический анализ с помощью clang-tidy тоже указывает на проблему:

$ clang-tidy main.cc -checks=*
  main.cc:5:9: warning: Address of stack memory associated with local variable 'x' returned to caller
  [clang-analyzer-core.StackAddressEscape]
    return std::forward<decltype(x)>(x);
    ^
  main.cc:5:42: warning: reference to stack memory associated with parameter 'x' returned
  [clang-diagnostic-return-stack-address]
    return std::forward<decltype(x)>(x);
                                     ^

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

Далее мы рассмотрим ситуацию, в которой диагностика уже не так очевидна и требует более точной настройки анализатора.

UB, которое никто не видит

Рассмотрим другой пример:

void modify(const int* p) {
  void* vp = (void*)p;
  int* np = (int*)vp;
  *np = 42;
}

int main() {
  const int p = 0;
  modify(&p);
}

Здесь мы «снимаем» const через приведение к void*, а затем пытаемся изменить объект, который изначально объявлен как константный. Это тоже неопределенное поведение.

Но, в отличие от предыдущего примера, стандартные инструменты здесь почти не помогают. Компилятор молчит, AddressSanitizer и Valgrind не дают внятной диагностики. У динамического анализа здесь есть естественные ограничения: он проверяет только те пути, которые реально выполняются. Если ошибка не проявляется на конкретных входных данных или завязана на тонкости семантики языка, он может ничего не заметить.

Статический анализатор по умолчанию тоже не указывает на суть проблемы. Например, clang-tidy при запуске с типичными проверками сообщает о чем угодно, кроме самого UB:

$ clang-tidy pointers.cc -checks=*
  pointers.cc:1:24: warning: parameter name 'p' is too short, expected at least 3 characters
  [readability-identifier-length]
    void modify(const int* p) {
         ^
  pointers.cc:2:16: warning: C-style casts are discouraged; use static_cast/const_cast/reinterpret_cast
  [google-readability-casting]
    void* vp = (void*)p;
                ^
  pointers.cc:4:11: warning: 42 is a magic number; consider replacing it with a named constant
  [cppcoreguidelines-avoid-magic-numbers,readability-magic-numbers]
    *np = 42;
          ^

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

Именно такие случаи нас и интересуют. Здесь уже недостаточно просто «включить все чекеры» — анализатор нужно научить находить нужные конструкции. Разберемся, как это сделать на примере clang-tidy.

Анатомия clang-tidy

Для начала разберемся, на чем основан статический анализ.

В основе статического анализа лежит AST — абстрактное синтаксическое дерево. После разбора исходного кода Clang представляет программу не в виде текста, а как дерево узлов: объявления, выражения, операторы, типы и т. д.

На практике достаточно ориентироваться в трех основных группах сущностей:

  • Decl — объявления: переменные, функции, поля, классы.

  • Stmt — операторы и выражения.

  • Type — типы.

Например, если построить AST для приведенного выше кода, можно увидеть, как строка

void* vp = (void*)p;

раскладывается на набор узлов: VarDecl, CStyleCastExpr, CompoundStmt, а также соответствующие типы указателей.

Посмотреть AST можно с помощью команды:

$ clang -Xclang -ast-dump -fsyntax-only pointers.cc
  |-FunctionDecl used modify 'void (const int )'
  | |-ParmVarDecl used p 'const int '
  | -CompoundStmt
  |   |-VarDecl used vp 'void ' cinit
  |   | -CStyleCastExpr 'void ' <NoOp>
  |   `-VarDecl used np 'int *' cinit

Для работы с узлами нам понадобятся matchers.

AST matchers — это способ описать интересующий паттерн

Matchers в Clang позволяют описывать шаблоны узлов AST, которые вы хотите найти, и связывать найденные узлы с именами.

Например:

someMatcher().bind("my_matcher");

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

Result.Nodes.getNodeAs<VarDecl>("my_matcher");

В clang-tidy используется декларативный подход: сначала вы описываете, какой паттерн нужно найти, а затем — что делать при его обнаружении.

Обычно matcher решает три задачи:

  • находит узлы нужного типа,

  • уточняет выборку узлов с помощью дополнительных условий,

  • описывает связи между узлами дерева.

Например, можно искать объявления, исключая функции:

declaratorDecl(unless(functionDecl())).bind("my_matcher");

Для быстрых экспериментов удобно использовать clang-query: он позволяет писать matcher-выражения и сразу видеть, какие участки кода им соответствуют:

$ clang-query pointers.cc
  clang-query> match varDecl(hasType(pointerType(pointee(voidType()))),
    hasInitializer(cStyleCastExpr(hasType(pointerType(pointee(voidType()))))))
  Match #1:
  pointers.cc:2:3: note: "root" binds here
  void* vp = (void*)p;
  ^~~~~~~~~~~~~~~~~~~

В нашем примере подозрительный паттерн выглядит так:

  • есть указатель const T*,

  • его приводят к void*,

  • затем void* приводят к T* без const,

  • после этого через полученный указатель можно модифицировать объект.

То есть нас интересует не отдельное приведение типов, а определенная последовательность действий, которая может привести к UB.

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

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

Дорабатываем существующий чекер

Необязательно писать новый чекер с нуля — бывает достаточно расширить уже существующий. В качестве основы возьмем cppcoreguidelines-pro-type-const-cast, который умеет диагностировать использование const_cast. Наша задача — научить его находить аналогичный сценарий, записанный через C-style cast.

Изменения вносим в двух местах:

  • registerMatchers — здесь опишем интересующие нас паттерны.

  • check — здесь выполняется логика после срабатывания matcher.

void ProTypeConstCastCheck::registerMatchers(MatchFinder *Finder) {
  // Оставляем существующую проверку const_cast(C++) — bind "cast"
  Finder->addMatcher(cxxConstCastExpr().bind("cast"), this);
  // Отслеживаем C-style касты и varDecl, инициализированные C-style кастом
  Finder->addMatcher(cStyleCastExpr().bind("cStyleCast"), this);
  Finder->addMatcher(varDecl(hasInitializer(cStyleCastExpr())).bind(
                               "varInitWithCStyleCast"), this);
}

Существующую обработку const_cast оставляем без изменений. Дополнительно мы начинаем отслеживать:

  • сами CStyleCastExpr;

  • объявления переменных, инициализированных C-style cast.

Дальше в метод check добавляем логику:

  • если переменная инициализируется приведением из const T* в void*, мы запоминаем ее;

  • если затем встречается приведение из void* в T* без const, проверяем, не связано ли оно с ранее зафиксированным случаем;

  • если связь подтверждается — выдаем диагностику.

Подробнее посмотреть код можно на GitHub

После этого на тестовом примере появляется ожидаемое предупреждение:

$ clang-tidy pointers.cc -checks='-*,cppcoreguidelines-pro-type-const-cast'
  pointers.cc:3:15: warning: removing const via intermediate void* produces undefined behavior
  [cppcoreguidelines-pro-type-const-cast]
    int* np = (int*)vp;
      	  	  ^~~~~~~~

Это именно та диагностика, которой не хватало изначально.

При этом важно понимать, что сама по себе цепочка const T* → void* → T* еще не обязательно приводит к UB. Проблема возникает в тот момент, когда через полученный указатель пытаются модифицировать объект, изначально объявленный как const. Поэтому такой чекер можно дальше уточнять и развивать, уменьшая число ложных срабатываний.

Пишем свой чекер с нуля

Если дорабатывать существующий чекер неудобно, в clang-tidy есть скрипт для генерации заготовок нового:

clang-tools-extra/clang-tidy/add_new_check.py cppcoreguidelines my-new-perfect-check

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

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

Где заканчивается магия статического анализа

Статический анализ не способен обнаружить все возможные ошибки — у него есть объективные ограничения.

Например, разыменование нулевого указателя внутри цикла. В простом случае clang-tidy может доказать наличие проблемы. В примере ниже при limit = 2, предупреждение все еще есть, а при limit >= 3 диагностика может уже не сработать. Это связано с ограниченной глубиной анализа и необходимостью контролировать вычислительную сложность.

// limit = 2  -> предупреждение еще есть
// limit = 3+ -> диагностика уже может исчезнуть

void f(int p, int limit) {
  for (int i = 0; i < limit; ++i) {
    if (i == 2) {
      p = 1;
    }
  }
}

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

Заключение

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

Готовые проверки clang-tidy — это хорошая база, но важнее то, что их можно адаптировать под конкретные задачи. Это позволяет не просто получать предупреждения, а формализовать собственные требования к качеству кода.

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