-►
Вычислительные машины и программное обеспечение
В. М. Ицыксон, М. И. Глухих, А. В. Зозуля, А. С. Власовских
Исследование средств построения моделей исходного
кода программ на языках С и С+ +
В последние годы компьютерная индустрия бурно развивается, и ее развитие неразрывно связано с увеличением количества используемых программных продуктов, а также с увеличением их сложности. В настоящее время объем программного кода некоторых продуктов составляет миллионы строк. В то же время известно, что даже при тщательном тестировании в программном коде остаются ошибки. По этой причине в настоящее время актуальны задачи автоматизированного поиска ошибок в программном коде. Особенно это касается языков С и С++, которые являются популярными уже более двадцати лет и включают в себя целый ряд низкоуровневых конструкций, использование которых может увеличить количество ошибок в программном коде.
Для повышения качества программных систем используются различные подходы, основанные как на анализе только исходных кодов (методы статического анализа), так и на использовании информации времени выполнения (методы динамического анализа). Динамические методы (например, тестирование) просты в реализации, не требуют больших вычислительных затрат, но при этом позволяют выявлять ошибки только для конкретных трасс исполнения программы. Методы статического анализа характеризуются высокой вычислительной сложностью, но при этом позволяют обнаруживать ошибки во всех возможных
трассах исполнения. В последнее время с ростом производительности компьютеров актуальность методов статического анализа возрастает.
Общая схема применения статического анализа приведена на рис. 1.
Первым этапом является синтаксический разбор исходного кода с формированием представления, удобного для обнаружения дефектов — далее будем называть такое представление дшг^ль/о исходного кода. На втором этапе построенная модель анализируется и уточняется, с ее помощью происходит обнаружение программных дефектов. Эффективность всего статического анализа и его производительность существенно зависят от характеристик построенной модели.
Цель данной работы — исследование моделей исходного кода, применяемых для проведения статического анализа, и сравнение имеющихся средств построения моделей исходного кода на языках С и С++.
Модель исходного кода должна обеспечивать доступ ко всем объектам программного кода, поддерживать быструю навигацию между связанными объектами, а также снижать трудоемкость применения методов статического анализа. Наиболее распространенные модели исходного кода рассмотрены в первом разделе данной статьи.
Ввиду сложности современных языков программирования реализация синтакси-
Исходный код на языке С/С++
Построение
модели _
яр: ■ шШ
модель Обнаружение
исходного программных
кода дефектов
Рис. I. Общая схема статического анализа
ческого разбора исходного кода довольно трудоемкая задача. Она может решаться как путем разработки собственного парсера', так и с использованием существующих программных средств, позволяющих формировать модели исходного кода на языках С и С++. Рассмотрению этих подходов, анализу их достоинств и недостатков посвящен второй раздел данной статьи.
Модели исходного кода программ
В настоящее время при проведении статического анализа используются следующие модели исходного кода [1,3]:
абстрактное синтаксическое дерево (abstract syntax tree, AST);
абстрактный семантический граф (abstract semantic graph, ASG);
граф потока управления (control flow graph, CFG):
граф зависимостей по данным (data dependency graph. DDG):
представление на основе статического однократного присваивания (static single assignment form, SSA).
Каждая из перечисленных моделей имеет свою область применения, связанную с целью проведения разбора (компиляция, оптимизация, распараллеливание, анализ и т. п.). Наиболее важными для проведения статического анализа являются следующие свойства модели: полнота представления исходного кода; наличие семантических связей, позволяющих проводить навигацию по элементам модели:
наличие информации о типах данных, областях видимости, порядке выполнения инструкций:
наличие связей элементов модели с исходным кодом.
Проанализируем перечисленные модели исходного кода с точки зрения наличия и степени реализации указанных свойств.
Абстрактное синтаксическое дерево Результатом разбора исходного кода согласно формально заданной грамматике является дерево разбора (parse tree) [1]. Его внутренние вершины сопоставлены с нетерминалами формальной грамматики, а листья — с терминалами. Такое представление слишком громоздко для выполнения статического анализа. Поэтому дерево подвергается упрощению за счет отбрасывания нетерминальных узлов с единственным нетерминальным потомком, преобразования части терминалов в атрибуты узлов. Также могут быть введены новые узлы с большей семантической нагрузкой (например. Expression). В результате образуется абстрактное синтаксическое дерево (abstract syntax tree, AST) [1].
Пример AST приведен на рис. 2. AST построено для следующей программы: int main(void) { int n — 10, f= 1: do { f *= n; J while (~n): return f:
}
AST содержит информацию обо всем исходном коде программы с возможностью его
function (main)
parameters
type (int)
declaration
compound statement
type (void)
type (int)
initializer
initializer
iteration (do while)
jump (return)
expr (*=)
expr (--)
Рис. 2. Абстрактное синтаксическое дерево
Здесь и далее под термином "парсер" будем понимать средство синтаксического разбора исходного кода программ.
восстановления. Такой информации достаточно для проведения статического анализа. Несмотря на это. применение его для решения задач статического анализа неэффективно — навигация по дереву требует обхода большого числа вершин, что связано с отсутствием в модели семантической информации. Поэтому перед выполнением статического анализа обычно осуществляется модификация AST с последующим построением других моделей исходного кода.
Абстрактный семантический граф Абстрактный семантический граф (abstract semantic graph. ASG) является расширением AST [1]. ASG по сравнению с AST дополнен различными семантическим дугами, отображающими соответствующие семантические свойства программы и упрощающими навигацию по исходному коду, например:
дуга от места использования переменной к ее объявлению:
дуга от места вызова функции к ее определению:
дуга от текущей инструкции к следующей. Пример ASG приведен на рис. 3.
Данная модель обладает необходимой полнотой для всестороннего анализа исходного кода программы. Для ряда методов статического анализа целесообразно применение упрощенных видов ASG. рассматриваемых как отдельные модели. Подобные модели концентрируются на определенных семантических аспектах, за счет чего размерность и сложность моделей сокращаются.
Граф потока управления Граф потока управления (control flow graph, CFG) —модель программы, представляющая в виде орграфа поток управления в программе [3]. В этой модели сохраняются только инструкции программы, а также информация о возможной передаче управления между инструкциями. В качестве узлов CFG могут использоваться элементарные блоки инструкций (basic blocks). При построении CFG необходимо учитывать следующие языковые конструкции, присущие большинству императивных языков программирования:
безусловные переходы;
ветвления;
циклы:
id(n) с (10) id (f) с(1)
jump (return) -—-1-
type (int)
initializer
initializer
type (int)
compound statement
type (void)
declaration
Рис. 3. Абстрактный семантический граф
При анализе программ на основе А5С обычно решаются задачи определения области видимости переменных, проводятся анализ потока управления, анализ зависимостей по данным и др. Особенности модели АЯС — большое количество типов узлов и связей между ними, а также простота связи с исходным кодом. При необходимости А50 можно расширить за счет включения дополнительной семантической информации.
вызовы функций; исключения.
Пример CFG приведен на рис. 4.
Рис. 4. Граф потока управления
Построение CFG упрощает решение некоторых задач оптимизации и анализа кода. CFG
используется в задачах поиска недостижимых участков кода, поиска неинициализированных переменных и т. д.
Граф зависимостей по данным Граф зависимостей по данным (Data dependency graph. DDG) — модель программы, представляющая в виде направленных дуг зависимости по данным между узлами-инструкциями [I]. Дуга связывает два узла тогда и только тогда, когда между соответствующими инструкциями есть зависимость по данным. Зависимость по данным может иметь один из трех типов: "запись-чтение", "чтение-запись", "запись-запись".
Пример DDG приведен на рис. 5.
Рис. 5. Граф зависимостей поданным
DDG используется при решении задач оптимизации, а также задач автоматизации распараллеливания выполнения. Особенностями данной и предыдущей модели являются относительно небольшое количество типов узлов и связей между ними, простота доступа и навигации.
Таким образом, модели на основе CFG и DDG применимы для отдельных аспектов анализа кода, отражая лишь часть имеющихся зависимостей, и не обладают необходимой полнотой для всестороннего анализа исходного кода.
Представление на основе статического однократного присваивания
Представление на основе статического однократного присваивания (static single assignment, SSA) [4] — представление исходного кода программы, в котором:
каждой локальной переменной значение присваивается только один раз:
вводится версионирование для локальных переменных, которые в исходном коде имеют неоднократные присваивания:
для локальных переменных вводятся ф-функции на выходе условных конструкций, объединяющие несколько ветвей программы и определяющие их окончательное значение;
циклы заменяются инструкциями ветвления и безусловных переходов:
сложные выражения заменяются цепочками выражений в трехоперандной форме.
Данное представление может быть записано ограниченным набором конструкций исходного языка (таких, как if и goto для языка С), или может быть изображено в CFG-форме. Пример SSA-представления в CFG-форме приведен на рис. 6.
| п'=|0 - 1 '1*1 :
(JiiXri 13!
и
1 'ЗЛ? п *] гЗдпМ | 1
Рис. 6. Представление на основе ББА
55А-представление широко применяется при решении различных задач анализа кода: определение неиспользуемого кода, устранение избыточных конструкций, машинно-независимая оптимизация и т. д.
По сравнению с другими моделями исходного кода программ БЗА-представление имеет следующие преимущества:
ф-функции упрощают использование некоторых методов статического анализа (например, в методах на основе ограничений);
трехоперандная форма выражений сокращает число различных типов анализируемых конструкций;
упрощенные циклические конструкции удобны для методов анализа потока управления.
Одним из недостатков использования 88А-представления в алгоритмах статического анализа кода является отсутствие явной информации об областях видимости переменных, что затрудняет поиск используемых неременных в различных точках программы.
По результатам рассмотрения различных моделей можно сделать вывод, что все они удобны при решении частных задач, но в полной мере не удовлетворяют требованиям, предъявляемым к моделям для проведения статического анализа.
На основе анализа достоинств и недостатков рассмотренных моделей для реализации методов статического анализа предлагается использовать универсальную модель, объединяющую достоинства всех рассмотренных моделей. Для построения такой модели необходимо:
преобразование исходного кода к SSA-форме;
построение CFG на основе SSA-формы; дополнение модели недостающей семантической информацией (например, об областях видимости переменных, типах данных и т.п.).
Формирование модели исходного кода
В настоящее время существует несколько альтернативных подходов к формированию моделей исходного кода, отличающихся требованиями к входным данным, видами формируемых моделей, сложностью реализации. Наиболее распространенными являются следующие подходы:
использование генераторов парсеров; использование самостоятельных (standalone) парсеров;
использование парсеров в составе средств разработки:
использование средств компиляции. Данные подходы реализуются различными средствами. При выборе подходящего средства формирования моделей исходного кода ключевыми являются следующие критерии:
совместимость со стандартами С и С++ (ANSI С. С99. ISO С++98);
возможность формирования различных моделей;
полнота построения моделей (наличие информации, достаточной для проведения статического анализа);
наличие открытого исходного кода: наличие поддержки разработчика: производительность и требовательность к ресурсам.
Генераторы парсеров
Одним из широко распространенных подходов к созданию средств анализа исходного кода является применение генераторов парсеров на основе формальных грамматик. Существует большое количество генераторов парсеров для разных типов грамматик языка (JavaCC, ANTLR. Bison, YACC и другие), большинство из них являются средствами с открытым исходным кодом. Обзор современных генераторов парсеров приведен в [2].
Для создания средств работы с исходным кодом необходимо описание грамматики языка в форме, специфичной для конкретного генератора парсеров. Наиболее производительными являются LL(k) и LALR-парсеры, имеющие сложность не выше квадратичной от числа терминалов. Их недостатком является необходимость включать в описание грамматики просмотры вперед (look-ahead) для разрешения неоднозначностей при разборе, что существенным образом усложняет исходную грамматику. Использовать грамматику непосредственно в форме Бэкуса-Наура, обычно приводимой в стандартах языков, позволяют GLR-парсеры. Однако алгоритмы GLR-парсеров характеризуются кубической сложностью, что может быть существенным ограничением при анализе больших программных модулей.
Использование генераторов парсеров оправдано при необходимости создания средств работы с новым или модифицированным языком, для которого не существует готовых парсеров (отдельных или в составе компиляторов). В случае построения моделей языка на основе конкретного стандарта трудоемкость задачи разработки парсера. соответствующего стандарту, довольно высока. Предпочтительным решением является применение готовых парсеров, совместимых со стандартом и разрабатываемых на постоянной основе в рамках отдельных проектов.
Самостоятельные парсеры
Самостоятельные (standalone) парсеры представляют собой законченные программные средства, позволяющие сформировать определенную модель исходного кода (чаше всего AST) по заданному файлу с исходным кодом. Одним из характерных представителей этого класса программных средств является парсер Elsa [8].
Парсер Elsa первоначально был создан в университете Беркли в 2002 году [5]. Позднее активную поддержку проект получил в корпорации Mozilla в рамках проекта Pork [14]. предназначенного для рефакторинга программного кода на языке С++.
Входом Elsa является модуль C/C++, обработанный препроцессором. Выходом является AST модуля в текстовом формате или в формате XML (при этом одному узлу AST соответствует один узел XML); межмодульный анализ не осуществляется. Дополнительно Elsa выполняет некоторые элементы семантического
анализа в частности проводится анализ типов и перегруженных операций. Elsa (за некоторыми исключениями) поддерживает стандарты K.&R С, С90. С99, С++03. а также некоторые расширения GNU.
Elsa основан на лексическом анализаторе Flex и генераторе GLR-парсеров Elkhound (входящем в состав проекта Elsa). Анализ больших проектов может быть выполнен путем подстановки парсера вместо компилятора и последующего запуска стандартной процедуры сборки проекта. > Elsa не предъявляет существенных требо-
ваний к ресурсам и имеет среднюю производительность. Скорость работы парсера составляет несколько сотен строк исходного кода в секунду на компьютере класса Pentium 4.
Основным недостатком Elsa является неполная поддержка заявленных стандартов. Например:
для языка С не вполне корректно поддерживаются символы Unicode (тип wchar_t), имеются проблемы с поддержкой безымянных структур и объединений;
для языка C++ имеется ряд проблем с поддержкой шаблонов и ромбовидного наследования.
Перечисленные недостатки не позволяют i использовать Elsa для полноценного статичес-
кого анализа.
Парсеры в составе средств разработки
Альтернативным подходом к решению задачи разбора исходного кода является использование парсеров. интегрированных в среды разработки. Некоторые среды разработки с открытым исходным кодом (например. NetBeans и Eclipse) предоставляют интерфейс для доступа к сформированным после разбора структурам данных. Рассмотрим эти средства подробнее.
Среда разработки NetBeans [13], разработанная корпорацией Sun Microsystems, включает в себя дополнительный модуль (plug-in) для поддержки языков С и С++ [12]. Данный модуль обеспечивает поддержку процесса разработки проектов на языках С и С++ со всеми стандартными возможностями редактирования (подсветка синтаксиса, поддержка выравнивания, сворачивание и разворачивание функций и т. д.). Для сборки проектов используется внешний компилятор (разработчик рекомендует использовать компилятор GCC).
Модуль поддержки С и С + + в среде NetBeans также предоставляет возможность
написания простых инструментов для статического анализа кода, интегрируемых в исходную среду. Для этого разработчику предоставляется интерфейс ко всем объектам AST построенного в процессе анализа исходного кода. В отличие от парсера Elsa па peep среды NetBeans строит AST для всего проекта в целом, т. е., осуществляет анализ межмодульных связей. В качестве входного языка поддерживается как С, так и С++ (стандарты С99 и С++98. соответственно). В NetBeans версии 6.5 дополнительно реализована возможность построения графа вызовов функций.
Ввиду того, что проект NetBeans разрабатывается на языке Java, он является довольно требовательным к ресурсам. Производительность разбора кода для больших проектов составляет несколько сотен строк исходного кода в секунду на компьютере класса Pentium 4. При использовании NetBeans инструменты статического анализа интегрируются в готовую среду, что позволяет в удобной форме указать местонахождение найденных дефектов.
К сожалению, парсер C/C + + в среде NetBeans имеет серьезные недостатки. Поскольку основная его цель — поддержка оформления программного кода и навигации по программному коду, а не статический анализ, некоторые языковые конструкции разбираются неполностью, например, не осуществляется разбор выражений.
Подобный недостаток делает невозможным реализацию многих видов статического анализа с помощью парсера среды NetBeans — в частности, невозможно проведение интервального анализа, поиска неинициализированных переменных и т. д. Впрочем, данный парсер пока еще находится на стадии разработки, и в будущих версиях среды можно ожидать появления версии парсера. полностью поддерживающей все конструкции исходных языков.
Среда интегрированной разработки C/C++ Development Tools (CDT) [6], основанная на платформе Eclipse, является полноценным средством разработки и отладки C/C++ приложений: ее возможности аналогичны возможностям NetBeans. Платформа Eclipse и среда разработки CDT поддерживаются независимой компанией Eclipse Foundation, основанной IBM в 2001 году.
Для решения ряда вспомогательных задач CDT имеет собственные реализации парсеров языков С и С++. Версия CDT 5.0.1 в своем со-
>
сгаве имеет реализацию нескольких парсеров языков С и С++. Результатом работы парсеров является AST исходного кода модуля (единицы трансляции). Связывание AST модулей не производится. Единица трансляции представляет собой файл исходного кода с включенными заголовочными файлами. Парсеры можно разделить на две группы: DOM-парсеры и LPG-парсеры.
DOM-парсеры (Document Object Model) — исторически первая реализация парсеров в CDT. DOM-парсеры поддерживают стандарты С99. С++98, а также большинство расширений GNU С и GNU С++. Можно выделить следующие ключевые возможности DOM-парсеров:
построение AST и связь его с исходным кодом:
дополнение AST различными семантическими связями;
поддержку отображения содержимого файла с исходным кодом;
возможность расширения AST путем добавления новых вершин;
построение индекса PDOM (Persisted Document Object Model), предназначенного для поддержки навигации по исходному коду;
реализацию шаблона "посетитель" ( Visitor) для обхода AST.
Основным недостатком DOM-парсеров является неполнота реализации стандартов языков и их расширений. На практике этот недостаток проявляется в получении AST с не полностью разобранными вершинами.
Новой реализацией парсеров являются LPG-парсеры. В версии CDT 5.0.1 представлены реализации для стандартов ANSIС99 и UPC (Unified Parallel С). В разработке находится LPG-napcep для С++.
Исходные коды LPG-парсеров в отличие от DOM-парсеров являются результатом работы генератора LALR-парсеров [11]. Исходными данными для генератора LPG является файл грамматики соответствующего языка. Использование формальной грамматики является важным преимуществом LPG-napcepa. поскольку позволяет сделать процесс разбора кода предсказуемым и избавляет от необходимости разбираться в тонкостях реализации парсера. LPG-парсеры имеют те же возможности для расширения, построения семантических связей и обхода AST, что и DOM-парсеры.
CDT-парсеры обладают приемлемой ресур-соемкостью и производительностью, сопоставимой с показателями парсеров среды NetBeans.
Таким образом, парсеры языков С и С++, входящие в состав CDT, — удобное средство при решении задач анализа исходного кода, для которых требование полной поддержки стандартов языков не является критическим.
Использование парсеров в составе средств разработки позволяет абстрагироваться от решения таких второстепенных задач, как реализация пользовательского интерфейса, поддержки работы с проектом (набором файлов исходного кода), обеспечение обратной связи с анализируемым исходным кодом и других.
Парсеры в составе компиляторов
Стандартом де-факто в области кросс-плат-форменной компиляции является семейство трансляторов GCC [9]. Компиляторы GCC непрерывно развиваются, функционируют на различных аппаратных платформах под управлением различных операционных систем и поддерживают основные стандарты языков С и С++ (С90. С99. С++98. С++03 и расширения GNU). Компиляторы включают в себя интерфейс для подключения внешних модулей (plug-ins), позволяющих получить доступ к внутренним структурам компилятора. Примеры таких модулей средства анализа Dehydra и Treehydra. распространяющиеся по лицензии GPL.
Семейство средств анализа Dehydra разрабатывается корпорацией Mozilla [7] для решения внутренних задач по статическому анализу исходного кода браузера Firefox. Основные задачи. решаемые компанией при помощи Dehydra, заключаются в поиске некорректно передаваемых параметров функций и обнаружении неиспользуемого кода. С 2008 года в рамках проекта начата разработка библиотеки Treehydra, позволяющей получать доступ к различным типам древовидных представлений кода.
Средство Dehydra построено как подключаемый модуль для компилятора GCC [9], производящий обратный вызов функций на языке JavaScript из кода компилятора. Пользовательский код по работе с представлениями исходного кода C/C++ создается на JavaScript и интерпретируется модулем Dehydra. При помощи изменения исходных кодов в GCC добавляется функциональность, реализующая взаимодействие с разделяемой библиотекой Dehydra с помощью ее интерфейсных функций. При этом в список проходов GCC добавляется дополнительный проход "plugin", на котором GCC вызывает функции модуля, передавая ука-
затели на узлы AST. Существует возможность выбора прохода, после которого выполняется проход "plugin". Это позволяет получить доступ ко многим представлениям кода, таким, как AST и ряд его упрощенных форм. SSA с информацией о CFG и DDG.
Особенность использования расширений является необходимость изменения исходного кода GCC и, как следствие, зависимость от конкретной версии компилятора.
Использование языка сценариев JavaScript позволяет ускорить процесс разработки средств анализа. В частности, после изначальной сборки GCC с добавлением дополнительного прохода компилятора новых пересборок не требуется. Кроме этого, разработка на JavaScript происходит быстрее, чем на языке С. С другой стороны, использование JavaScript приводит к снижению производительности анализа.
К недостаткам подхода с расширением GCC можно отнести и относительную нестабильность внутренних структур данных и программных интерфейсов работы с компилируемым деревом GCC. Эти интерфейсы описаны в руководстве GCC Internals [10] и в исходных кодах GCC лишь частично, к тому же они меняются от версии к версии.
Реализация средств статического анализа на основе средств Dehydra и Treehydra имеет большой потенциал. К сожалению, оба указанных проекта сейчас находятся в стадии разработки, что несколько ограничивает их использование.
По результатам исследования наиболее распространенных способов формирования моделей исходного кода на языках С и С++ могут быть сделаны следующие выводы.
1. Наиболее полноценную поддержку основных стандартов языка имеют средства анализа на основе компилятора GCC. Подобные средства анализа обеспечивают доступ к таким моделям исходного кода, как CFG. DDG. SSA, в то время как другие ограничиваются построением AST или ASG.
2. Все рассмотренные средства, за исключением IDE NetBeans, обладают полнотой построенной модели.
3. Рассмотренные средства имеют близкую производительность. Dehydra и Treehydra несколько уступают остальным из-за использования интерпретируемого языка для формирования модели.
4. Средства построения моделей на основе генераторов парсеров потенциально могут иметь высокую производительность и могут обеспечить полноту построения модели. Однако реализация на их основе полноценной поддержки стандартов языка является довольно трудоемкой задачей.
Таким образом, по итогам рассмотрения различных моделей исходного кода был сделан вывод о необходимости использования универсальной модели на основе CFG и SSA для реализации комплексных методов статического анализа. Учитывая этот результат, при выборе средства формирования модели важной становится возможность построения различных представлений исходного кода. Такой возможностью обладают средства, создаваемые на основе компилятора GCC — Dehydra и Treehydra.
При реализации простых методов статического анализа может быть достаточно формирования моделей более низкого уровня, таких как AST или ASG. В этом случае целесообразно использовать инструменты на базе сред разработки, например, на базе IDE Eclipse.
Исследование выполнено в рамках работ по государственному контракту № 02.514.11.4081 "Исследование и разработка системы автоматического обнаружения дефектов в исходном коде программного обеспечения" Федерального агентства по науке и инновациям в рамках Федеральной целевой программы "Исследования и разработки по приоритетным направлениям развития научно-технологического комплекса России на 2007-2012 годы".
СПИСОК ЛИТЕРАТУРЫ
1. Ахо А., Сети Р., Ульман Дж. Компиляторы: принципы, технологии и инструменты. Вильяме, 2001. 768 с.
2. Чемоданов И. С., Дубчук Н. П. Обзор современных средств автоматизации создания синтаксических анализаторов // Системное программирование: Сб. статей. СПб.: Изд-во СПбГУ. 2006. Вып. 2. С. 268-296
3. Allen F. E. Control Flow Analysis// Proceedings of a Symposium on Compiler Optimization. 1970. P. 1-19.
4. Cytron R. et al. Efficiently Computing Static Single Assignment Form and the Control Dependence Graph // ACM Transactions on Programming Languages and Systems. ACM New York, 1991. Vol. 13, № 4. P. 451-490.