>
>
Зачем нужен динамический анализ кода, е…

Илья Гайнулин
Статей: 7

Зачем нужен динамический анализ кода, если есть статический?

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

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

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

Статический анализ кода — это процесс выявления ошибок и недочетов в исходном коде программ. Для его выполнения не нужно запускать программу, весь анализ будет выполнен на имеющейся кодовой базе. Самая ближайшая аналогия, которую можно провести со статическим анализом кода, это так называемый процесс code review, только автоматизированный (выполняемый программой-роботом).

К основным преимуществам статического анализа можно отнести:

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

К объективным недостаткам статического анализа кода относятся следующие факторы:

  • Неизбежное появление так называемых ложно-позитивных срабатываний. Статический анализатор кода может указывать на те места, где на самом деле нет никаких ошибок. Разрешить подобную ситуацию и дать понять анализатору, что он дал ложное срабатывание, может только программист, тем самым потратив своё рабочее время.
  • Статический анализ, как правило, слаб в диагностике утечек памяти и параллельных ошибок. Чтобы выявлять подобные ошибки, фактически необходимо виртуально выполнить часть программы. Это крайне сложно реализовать. Также подобные алгоритмы требуют очень много памяти и процессорного времени. Как правило, статические анализаторы ограничиваются диагностикой простых случаев. Более эффективным способом выявления утечек памяти и параллельных ошибок является использование инструментов динамического анализа.

Отметим, что использование статического анализа кода не ограничивается только выявлением ошибок в программе. Например, используя инструменты статического анализа, можно получать рекомендации по оформлению кода. Некоторые статические анализаторы позволяют проверять, соответствует ли исходный код принятому в компании стандарту оформления кода. Имеется в виду контроль количества отступов в различных конструкциях, использование пробелов/символов табуляции и так далее. Помимо этого, статический анализ можно использовать для подсчёта метрик. Метрика программного обеспечения — это мера, позволяющая получить численное значение некоторого свойства программного обеспечения или его спецификаций. Если вас интересует, каким ещё образом можно использовать статический анализатор кода, вы можете обратиться к этой статье.

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

Используя динамическое тестирование, можно получить следующие метрики и предупреждения:

  • Используемые ресурсы: время выполнения программы в целом или ее отдельных модулей, количество внешних запросов (например, к базе данных), количество используемой оперативной памяти и других ресурсов.
  • Степень покрытия кода тестами и другие метрики программы.
  • Программные ошибки: деление на ноль, разыменование нулевого указателя, утечки памяти, "состояние гонки".
  • Детектировать некоторые уязвимости.

К основным преимуществам динамического анализа кода относят:

  • Возможность проводить анализ программы без необходимости доступа к её исходному коду. Здесь стоит сделать оговорку, так как программы для динамического анализа различают по способу взаимодействия с проверяемой программой (подробнее с этим можно ознакомиться в этой статье). Например, распространён способ проведения динамического анализа путём предварительного инструментирования исходного кода, то есть добавления специального кода в исходный текст приложения для обнаружения ошибок. В этом случае доступ к коду проверяемой программы будет необходим.
  • Возможность обнаружения сложных ошибок, связанных с работой с памятью: выход за границу массива, обнаружение утечек памяти.
  • Возможность проводить анализ многопоточного кода непосредственно в момент выполнения программы, тем самым обнаруживать потенциальные проблемы, связанные с доступом к разделяемым ресурсами, возможные deadlock ситуации.
  • В большинстве реализаций появление ложных срабатываний исключено, так как обнаружение ошибки происходит в момент ее возникновения в программе; таким образом, обнаруженная ошибка является не предсказанием, сделанным на основе анализа модели программы, а констатацией факта ее возникновения.

Перечислим недостатки, которые присущи динамическому анализу кода:

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

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

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

Это пример кода из проекта Clang:

MapTy PerPtrTopDown;
MapTy PerPtrBottomUp;
void clearBottomUpPointers() {
  PerPtrTopDown.clear();
}
void clearTopDownPointers() {
  PerPtrTopDown.clear();
}

Здесь статический анализ укажет на то, что тела двух функций абсолютно идентичны. Конечно, нельзя с абсолютной уверенностью говорить, что если тела функций одинаковы, то это ошибка. Однако существует вероятность, что это был результат копипаста, совмещённый с невнимательностью разработчика, что уже и приведёт к непредвиденному поведению программы. В данном случае, внутри метода clearBottomUpPointers должен был быть осуществлён вызов PerPtrBottomUp.clear. В приведённом примере динамический анализ кода не сможет увидеть ничего подозрительного, ведь с его точки зрения это абсолютно рабочий код.

Другой пример. Представим, что имеется следующий код:

size_t index = 0;
....
if (scanf("%zu", &index) == 1)
{
  ....
  DoSomething(arr[index]);
}

В приведённом выше коде может произойти выход за границу массива arr в случае, если пользователем программы будет введено значение, превышающее максимально допустимый индекс массива arr. На первый взгляд можно предположить, что статическому анализатору будет не по зубам обнаружение подобных ошибок. Ведь узнать, какое число введёт пользователь, можно только при фактическом выполнении программы. Однако современные статические анализаторы реализуют внутри себя достаточно сложную логику, в том числе опирающуюся и на механизм аннотирования. Аннотации предоставляют различную информацию об аргументах, возвращаемом значении и внутренних особенностях функций, которые не могут быть выяснены в автоматическом режиме. Программист путём аннотирования известных и широко используемых функций даёт понять анализатору, чего можно ожидать от того или иного вызова функции. Таким образом статические анализаторы могут мыслить в терминах "небезопасных входных данных" (tainted data) и отслеживать, может ли полученное значение привести к ошибке.

Из приведённого выше примера кода анализатор может понять, что переменная index получила своё значение из проаннотированной функции scanf. Основываясь на том, что значение переменной index может получиться большим чем размер массива arr, анализатор выдаст предупреждение. Оно будет сообщать о том, что перед обращением к значению массива arr по индексу index, эту переменную следует предварительно проверить. Например, в приведённом ниже коде перед обращением к значению массива по индексу, производится соответствующая проверка переменной index. Анализатор это понимает и не выдаёт предупреждение.

size_t index = 0;
....
if (scanf("%zu", &index) == 1)
{
  ....
  if (index < arraySize)
    DoSomething(arr[index]);
}

Подобное диагностическое правило, предупреждающее о том, что данные, полученные извне, были использованы без предварительной проверки, уже реализовано в статическом анализаторе PVS-Studio и имеет номер V1010.

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

Взглянем на пример следующего кода:

void OutstandingIssue(int number)
{
    int array[10];
    unsigned nCount = MathLibrary::MathFunctions::Abs(number);

    memset(array, 0, nCount * sizeof(int));
}

Abs это некий статический метод из используемой нами библиотеки MathLibrary, доступа к исходному коду которой у нас нет. Если в этот метод закралась ошибка, и при определённом значении number может вернуться число, превышающее размер массива arr, то в функции memset произойдёт выход за границу массива. Как статический анализатор может понять, что метод Abs может вернуть число, которое может превышать размер массива? Аннотирование незнакомого метода Abs из никому неизвестной библиотеки MathLibrary произведено не было - всех методов не проаннотируешь. Выдавать предупреждение на все места, где статический анализатор не уверен во входных данных - это дорога в один конец с огромным количеством ложных срабатываний (подробнее о философии статического анализатора PVS-Studio можно прочитать в этой статье). В свою очередь, динамический анализатор (при правильном наборе входных данных) смог бы легко указать на то, что в данной программе есть ошибка при работе с памятью.

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

Не хочется казаться чрезмерно предвзятым и как-то особенно выделять технику статического анализа, но в последнее время именно о ней всё больше говорят и, что более важно, внедряют в свои CI процессы многие компании. Статический анализ выступает как один из этапов так называемого барьера или ворот качества (quality gates) к построению надёжного и качественного программного обеспечения. Рекомендую обратить внимание на интересную лекцию по этой теме тут. Нам кажется, что статический анализ через пару лет станет стандартной практикой при разработке программ, такой же, как когда-то стало юнит-тестирование.

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

Дополнительные ссылки: