Открыть сервис

Граф потока управления

Граф потока управления (также известный как контрольный граф, CFG от англ. Control Flow Graph) — это абстрактное представление всех возможных путей выполнения программы, используемое в теории компиляции, статическом анализе кода и оптимизации. В терминах теории графов, CFG представляет собой ориентированный граф, вершины которого соответствуют базовым блокам (непрерывным последовательностям инструкций без переходов), а рёбра — возможным переходам между этими блоками. Каждый граф потока управления имеет единственную начальную вершину (вход) и, как правило, одну или несколько конечных вершин (выходов).

История и развитие

Понятие графа потока управления восходит к ранним работам по теории компиляции в 1950–1960-х годах. Формализация CFG связана с развитием методов оптимизации кода, таких как анализ потока данных и обнаружение мёртвого кода. Ключевой вклад внёс американский учёный Фрэнсис Э. Аллен, который в 1970 году опубликовал работу «Control Flow Analysis», заложившую основы современного статического анализа. В 1970-х годах CFG стал стандартным инструментом в компиляторах, таких как GCC и LLVM, а также в инструментах статического анализа, например, в PVS-Studio (Россия) и Coverity.

С развитием языков программирования, поддерживающих исключения, асинхронность и параллелизм, CFG усложнился: появились рёбра для обработки ошибок, точек входа в корутины и синхронизации потоков. В современных компиляторах (например, LLVM 18) CFG используется не только для оптимизации, но и для верификации программ, поиска уязвимостей и обратной разработки.

Основные компоненты

Базовые блоки

Базовый блок — это последовательность инструкций, выполняемых последовательно, без внутренних переходов (кроме последней инструкции, которая может быть условным или безусловным переходом). Вход в базовый блок возможен только с его начала, выход — только с конца. Пример: `` int a = 5; // начало блока int b = a + 3; if (b > 10) { // конец блока, условный переход ``

Рёбра

Рёбра CFG представляют собой возможные переходы между базовыми блоками. Они бывают:

  • Прямые — безусловные переходы (например, goto или вызов функции без возврата).
  • Условные — переходы, зависящие от условия (например, if-else, switch).
  • Циклические — переходы, образующие циклы (например, for, while).
  • Обработка исключений — рёбра, ведущие к блокам catch (в языках с поддержкой исключений, таких как C++ или Java).

Вход и выход

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

Классификация графов потока управления

По структуре

  • Линейные — граф без циклов и условных переходов (редко встречается в реальных программах).
  • Циклические — содержат один или несколько циклов.
  • Ветвящиеся — содержат условные переходы, но без циклов.
  • Смешанные — наиболее распространённый тип, включающий и циклы, и ветвления.

По типу анализа

  • Статические CFG — строятся на этапе компиляции без выполнения программы.
  • Динамические CFG — строятся на основе трассировки выполнения программы (например, для профилирования).

По уровню детализации

  • Глобальный CFG — для всей программы или модуля.
  • Локальный CFG — для отдельной функции или метода.

Применение

Оптимизация кода

Компиляторы используют CFG для анализа потока данных и применения оптимизаций, таких как:

  • Удаление мёртвого кода — удаление блоков, недостижимых из входной вершины.
  • Свёртка констант — вычисление выражений, не зависящих от входных данных.
  • Инвариантное поднятие — вынос вычислений из циклов.
  • Векторизацияпреобразование скалярных операций в векторные инструкции.

Статический анализ

Инструменты статического анализа (например, PVS-Studio, Clang Static Analyzer) строят CFG для поиска потенциальных ошибок:

  • Разыменование нулевых указателей — проверка, что указатель не может быть null в точке разыменования.
  • Утечки памяти — анализ путей, где выделенная память не освобождается.
  • Недостижимый код — выявление блоков, которые никогда не выполняются.
  • Переполнение буфера — проверка границ массивов.

Обратная разработка

В реверс-инжиниринге CFG строится по дизассемблированному коду (например, с помощью IDA Pro или Ghidra) для понимания логики программы, особенно в случае вредоносного ПО. Это позволяет анализировать структуру управления без исходного кода.

Тестирование

CFG используется для генерации тестовых случаев, покрывающих все пути выполнения (например, критерий покрытия ветвей или путей). Инструменты, такие как LLVM’s libFuzzer, применяют CFG для направленного фаззинга.

Пример построения

Рассмотрим простую функцию на C: ``c int factorial(int n) { int result = 1; int i = 1; while (i <= n) { result *= i; i++; } return result; } `` CFG для этой функции будет содержать:

  • Блок 1 (вход): int result = 1; int i = 1; → переход к блоку 2.
  • Блок 2 (условие цикла): while (i <= n) → если истина, переход к блоку 3; если ложь, переход к блоку 4.
  • Блок 3 (тело цикла): result *= i; i++; → переход обратно к блоку 2.
  • Блок 4 (выход): return result; → завершение.

Рёбра: 1→2, 2→3 (истина), 2→4 (ложь), 3→2.

Ограничения и критика

  • Невозможность точного построения — для программ с динамической диспетчеризацией (например, виртуальные функции в C++), рефлексией (Java, C#) или самокодом (обфускация) точный CFG может быть построен только с помощью консервативных аппроксимаций, что приводит к ложным срабатываниям.
  • Проблема остановки — в общем случае невозможно определить, достижим ли данный блок, из-за неразрешимости проблемы остановки (теорема Тьюринга). Поэтому статические анализаторы используют аппроксимации, которые могут пропускать ошибки.
  • Сложность при параллелизме — в многопоточных программах CFG становится неориентированным из-за синхронизации, что требует дополнительных моделей (например, графы потоков управления с блокировками).
  • Масштабируемость — для больших программ (миллионы строк) построение полного CFG может быть ресурсоёмким, хотя современные компиляторы (например, LLVM) используют иерархические подходы.

Интересные факты

  • В компиляторе GCC CFG строится на промежуточном представлении GIMPLE, а в LLVM — на LLVM IR.
  • В 2020 году российские разработчики из компании «Лаборатория Касперского» представили инструмент для анализа CFG вредоносного ПО на основе машинного обучения, что позволило автоматически классифицировать семейства вирусов.
  • В языке Python CFG может быть построен с помощью библиотеки ast (абстрактное синтаксическое дерево) и последующего преобразования в граф, но из-за динамической природы Python точность такого анализа ограничена.
  • CFG лежит в основе техники «графов зависимостей» (DDG), используемой для распараллеливания программ.

Источники

  • Allen, F. E. (1970). Control Flow Analysis. ACM SIGPLAN Notices.
  • Aho, A. V., Lam, M. S., Sethi, R., & Ullman, J. D. (2006). Compilers: Principles, Techniques, and Tools (2nd ed.). Addison-Wesley.
  • Muchnick, S. S. (1997). Advanced Compiler Design and Implementation. Morgan Kaufmann.
  • Документация LLVM: «Control Flow Graph» (llvm.org/docs/ControlFlowGraph.html).
  • Статья «Статический анализ кода: граф потока управления» на Habr (2021, автор — разработчик PVS-Studio).

BFOmetr — база данных и аналитика по компаниям России.

На главную BFOmetr →