Вступление
Создание любого ПО сопровождается ошибками. Программисты ошибаются в выборе типов, ошибаются в реализации алгоритмов. Аналитики ошибаются в формулировке требований к ПО, и из этих ошибок рождаются ошибки в функционировании готового продукта. Любой (ну ладно, почти любой) производитель программных продуктов хочет обезопасить себя от ошибок в выпускаемом ПО. Для того чтобы избежать типовых ошибок, придумали различные стандарты (типа MISRA C) и утилиты для анализа написанного кода (например, Lint). Но корректно написанный код - это половина проблемы, вторая половина - это насколько верно и полно код реализует требования к программе, и нет ли в программе того, чего там быть не должно. Ответ на эти вопросы в свою очередь дает тестирование и проверка покрытия кода (например, gcov).
Иногда, в случае если программное обеспечение является частью системы, имеющей повышенные требования к безопасности, например, в авионике, требуется доказать, что весь код на уровне исполняемого образа программы корректен и не содержит «лишнего». Вот тут-то и необходимо задействовать весь выше озвученный арсенал средств и засесть за анализ того, что получилось из исходного кода.
Анализ кода и варианты сбора данных о покрытии.
Прежде всего давайте рассмотрим, какие у нас вообще есть наиболее употребимые варианты анализа кода. Их два: динамический и статический.
Статический анализ производится различными утилитами и не предполагает исполнения анализируемого кода. Это отдельная большая тема, и мы ее здесь затрагивать не будем.
Динамический анализ кода - это способ анализа работы программы на основании результатов, собранных непосредственно при ее выполнении.
Сам процесс динамического анализа может быть разделен на этапы:
Подготовка исходных данных - тестовых векторов для программы;
Инструментирование - добавление к коду программы дополнительного функционала, позволяющего собрать требуемые данные;
Проведение тестового запуска программы на основании тестовых векторов, сформированных в пункте 1;
Сбор результатов исполнения и их анализ.
То есть программа должна быть собрана, дополнена инструментированием и запущена. В ходе выполнения программы на основе тестовых векторов будут собраны и сохранены данные, которые в дальнейшем могут быть использованы чтобы понять, как работает эта программа.
Динамический анализ может преследовать различные цели. Он может быть использован для обнаружения «узких мест» в исполнении программы на конкретном железе, для оценки внутренних механизмов и путей исполнения (трассы), для реверс-инжиниринга и для оценки полноты и достаточности кода для реализации требуемого функционала.
Вот как раз о последнем применении хочется поговорить подробнее.
Динамическое покрытие кода и его анализ.
В идеальном мире программа - это инженерный продукт, служащий для выполнения определенных функций. Необходимый функционал определяется требованиями к программному обеспечению. Процесс создания ПО - это воплощение программистами требований в некий код, который выполняется на ЭВМ.
Дальше, для простоты, я буду рассматривать ситуацию, когда программный код написан на компилируемом языке высокого уровня (допустим С).
Инструментировать и собирать данные о покрытии в этом случае можно на нескольких уровнях. Например, на уровне самого языка. Таким образом, можно увидеть, какие конструкции покрыты, какие не покрыты, какие условия оценивались, а какие нет и т.д. Можно инструментировать на уровне объектного кода или промежуточного ассемблера. Каждый из этих методов имеет свои особенности, преимущества и недостатки. Но пока давайте вернемся к процессу получения знаний о том, как наш код работает.
Итак, программа собрана, запущена и вроде как даже делает то, что надо. Часто на данном этапе все участники счастливы (поляна накрыта, документы подписаны).
Однако, в случае если, допустим, управляемая ПО система предъявляет какие-нибудь мало-мальски серьезные требования к безопасности и/или надежности, то как раз на этом этапе начинается самое интересное. Могут возникнуть вопросы:
А выполняет ли программа все предъявленные требования?
А выполняет ли программа ТОЛЬКО то, что продиктовано требованиями?
Ответ на эти вопросы как раз и дает динамический анализ исполнения программы (не только он, конечно, но сейчас речь именно о нем).
Хорошо написанная программа делает только то, что должна и не делает того, что не должна. Соответственно, в идеальном случае мы выполняем действия из списка выше, т.е. готовим тестовые вектора на основе требований (что мы хотим, чтобы ПО делало), добавляем код, который будет отмечать прохождение контрольных точек в программе (инструментируем), прогоняем тесты и собираем трассы, анализируем. Тут есть несколько вариантов развития событий:
Весь код, который мы инструментировали, исполнился хотя бы раз - 100% покрытие. Мы молодцы - мы написали хороший код и правильно собрали набор тестовых векторов для проверки;
-
Не весь код покрылся. Тут тоже возможны варианты:
Мы собрали плохой набор тестовых данных - не учли какие-то нюансы дизайна программы (воплощения требований программистом);
-
Мы плохо написали программу - в ней есть «мертвый код» - инструкции, которые никогда не будут выполнены процессором ввиду ошибок программиста. Например:
uint a = 0;
if (a == 0) {
a++;
if (a > 0) {
//do this
} else {
//do that
}
}.Код «do that» никогда не будет выполнен, он избыточен;
Мы все сделали правильно. Но все равно покрыто не все...
К слову, случай, когда мы все сделали правильно, а покрытие все равно не 100%, довольно редко встречается при сборе покрытия непосредственно для высокоуровневого (в нашем примере для языка С) кода. В данном случае мы оцениваем то, что написал человек и, исключив человеческие ошибки, вполне можно добиться заветных 100%. Но в случае, когда нужно доказать, что все покрывается на уровне исполняемого кода, все становится намного сложнее. Потому что он (исполняемый код) - только косвенно дело рук человеческих.
Дальше я приведу ряд упрощенных примеров, в основе которых реальные ситуации, встреченные в ходе анализа одного довольно крупного программного продукта с кучей легаси-кода.
Здравствуйте, я - ваш компилятор.
Редко, когда мы собираем программу, избегая возможностей современных компиляторов по оптимизации написанного нами высокоуровневого кода. Различных вариантов того, как компилятор модифицирует написанное человеком - превеликое множество. Все эти модификации как правило направлены на ускорение и уменьшение размеров результирующего исполняемого кода. Однако эти положительные моменты могут иногда существенно затруднить понимание того, как работает программа.
Давайте рассмотрим вот такой вроде-бы тривиальный случай:
#include <stdio.h>
#include <unistd.h>
volatile int v;
int foo(int arg) {
int i, rc;
for (i = 0; i < arg; i++) {
printf("Got PID %d!\n", getpid());
}
printf("SKIPPED");
return arg;
}
int main(void) {
return foo(10);
}
gcc -O2 main.c -o tst_app
Функция foo(), будет иметь непокрытый код (см фиг. 1).
Переход 34 -> 61 (номера строк) не покрыт. Давайте разберемся, откуда он вообще взялся.
Строка 30 ассемблерного кода «movl 16(%esp), %esi» помещает значение аргумента функции (arg) в регистр ESI и в строке 33 проверяет его «testl %esi, %esi». Если значение меньше или равно 0, то происходит переход на метку .L2, расположенную за телом цикла. Но мы же ничего такого не писали, верно? Да, но компилятор добавил эту проверку, дабы избежать ненужного вхождения в цикл при нулевом значении аргумента функции. Теперь для того, чтобы получить 100% покрытия, нам нужно добавить тестовый вектор, который вызовет foo() с 0.
Следующий пример показывает, насколько болезненными могут быть «мелкие» ошибки в дизайне, допущенные программистом. Например, неверный выбор типа для переменной.
volatile int a_signed_value; // Counter, valid values 0…10K.
int main(void)
{
int ret_val = -(a_signed_value / 4096);
return ret_val;
}
Вроде все линейно. Попробуете сами угадать что тут не покроется и найти причину?
Компилятор стремится привести операцию деления на кратное 2 число к операции битового сдвига. Разумно. Но проведение такой операции несколько отличается для отрицательных и положительных чисел, поэтому для случая, когда у числа есть знак, появляется дополнительный код (17 строка на графе).
Дополнительное условие, которое, в случае если переменная используется для хранения натуральных чисел (и нуля) никогда не покроется, если не дать заведомо невалидные входные значения. Конечно, это ошибка программиста, некорректно использовавшего знаковый тип для переменной, пропущенная на ревью, но бывает, что такие вещи просачиваются даже в самый проверенный код.
Компилятор создает код, который должен обслуживать любое развитие событий в программе, поэтому часто он создает пути, которые не всегда можно предположить на первый взгляд (однако логичные при детальном рассмотрении).
Скрытая проблема в том, что то, что кажется хорошо продуманным и правильным, скажем, на С, на ассемблере может потребовать дополнительных объяснений.
Например, есть вот такая конструкция:
int com;
switch (com) {
case 1:
...
break;
case 2:
...
break;
case 4:
... break;
case 3:
...
break;
case 19:
case 20:
...
break;
default:
...
break;
}
На вход этой функции при тестировании подавали следующие значения: 1, 2, 3, 4, 19, 20, 1000. В этом случае покрытие ассемблерного кода может быть следующим:
Эквивалентный код выглядит вот так:
if (com == 3) goto L3;
if (com > 3) goto L4;
if (com == 1) goto L5;
if (com != 2) goto L2;
…
if (com == 4) goto L7;
if (com – 19 <= 1) goto L41;
L2:
seterr();
Таким образом, код, который обслуживает дефолтный кейс считает, что значения могут лежать как выше, так и ниже диапазона валидных значений switch(). Если входные значения подобного кода могут быть заданы непосредственно, то это проблема решается расширением набора тестов, однако иногда такие конструкции используют входные значения, которые возвращаются другими функциями, ограниченными по набору возвращаемых значений, что приводит к необходимости все это объяснять в анализе.
Вы конечно можете возразить, что приведенные выше примеры - это случай, когда дали писать код интернам и вообще такого в реальной жизни не бывает. Все, кто пишет для embedded, а тем более для ответственного embedded, таких ошибок никогда не делают. Это не так. Люди есть люди.
К счастью, приведенные выше примеры не содержат никаких критических проблем для приложения, в котором они используются. И мы с чистой совестью можем написать, что непокрытые участки являются не более чем занятными курьёзами или артефактами компилятора.
Однако, и это тоже не всегда так. Поэтому иногда требуется детальный анализ покрытия, и, как я надеюсь дал понять выше, покрытие, собранное для ассемблерного кода, зачастую может существенно отличаться от того, что можно ожидать от покрытия, собранного для высокоуровневого языка.
Иллюстративный материал подготовлен с помощью gcc (GCC) 7.1.0 (примеры собраны с -O2), и небольшого приложения на питоне созданного для инструментирования С кода, о котором я расскажу в следующей статье.