Поиск программных ошибок до того, как они произойдут

… Или превращение ошибок времени выполнения в ошибки времени компиляции.

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

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

Важность статического анализа кода

Вы читали книгу Исаака Азимова Я, робот? В этой книге есть история под названием Escape!, в которой две компании соревнуются за разработку Hyperatomic Drive, двигателя космического корабля, который позволит людям выжить в космических прыжках.

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

Когда одна из компаний - Consolidated Robots - заставляет свой суперкомпьютер начать вычисления, происходит некоторая ошибка и суперкомпьютер разрушается.

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

У конкурента - U.S. Роботы и механики - команда разработчиков соглашается запустить ценную программу Consolidated на своем суперкомпьютере Brain, но они с подозрением относятся к тому, что их конкурент пытается заставить их потерять свою машину.

Доктор Сьюзан Кэлвин, один из экспертов из US Robots, придумала способ заставить Brain анализировать данные, не будучи уничтоженными. Когда доктор Кальвин разговаривает с Брэйном, она инструктирует машину обрабатывать каждый лист кода один за другим, и в случае обнаружения потенциально опасной инструкции просто не паникуйте и остановитесь:

«Теперь вы следите за этим. Когда мы подходим к листу, который означает повреждение или даже смерть, не волнуйтесь. Видишь ли, Брэйн, в данном случае мы не возражаем - даже насчет смерти; мы совсем не против. Итак, когда вы подойдете к этому листу, просто остановитесь, отдайте его - и все. Вы понимаете? "

"Да, конечно. Черт возьми, смерть людей! О боже! »

Айзек Азимов, Побег! (1945)

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

Но доктор Кальвин не пытается это исправить. Она не добавляет проверки во время выполнения, чтобы корректно останавливать систему в случае ошибки. Вместо этого она выбирает гораздо более умную стратегию: она использует статический анализ кода или, заставляя машину проверять код задолго до его запуска, когда машина собирает код для построения из него программы - то есть, когда он компилирует код.

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

Как я мог получить такую ​​огромную мощность?

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

enumeration value ‘MyAppState_WaitingUserInput’ not handled in switch [-Wswitch] my_app.c line 42 C/C++ Problem

вы получаете намек на то, что ваш код может быть неправильным. По умолчанию это просто предупреждение, и компиляция может продолжаться. Но вы можете указать GCC обработать это как ошибку и остановить процесс компиляции с помощью флага -Werror в стиле доктора Кальвина - , когда вы перейдете к этому листу, просто остановитесь, верните его - и все.

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

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

Тогда как мне получить управляемую часть этой огромной силы?

Вы можете получить точный контроль над статическим анализом кода, используя статические утверждения. Это статические проверки, которые вы включаете в свои строки и вызовут ошибку компиляции, если указанное условие не выполняется. Раньше они были макросами в форме:

STATIC_ASSERT(condition); // Error if condition doesn’t hold

По какой-либо причине я часто не понимаю, когда утверждение вызовет ошибку компиляции, когда условие оценивается как истинное или ложное? Вот почему я использую для написания собственного макроса, который вызывает ошибку компиляции при выполнении условия:

COMPILE_ERROR_IF(bad_condition); // Self explanatory ;-)

Теперь, когда я упоминаю условие внутри проверки, я должен сказать, что компилятор должен иметь возможность вычислить его до выполнения кода, поэтому bad_condition должно быть постоянным выражением. Если вы попробуете это, ничего не получится:

#define MAX_AMOUNT (4215)
const uint16_t FORBIDDEN_VALUE = 5;
uint16_t increase(uint16_t amount){
  // This is wrong. Not a constant expression
  COMPILE_ERROR_IF(amount > MAX_AMOUNT);
  // Wrong. A const marked variable doesn't
  // make the expression to be constant
  COMPILE_ERROR_IF(amount == FORBIDDEN_VALUE);
  amount++;
  return amount;
}

Давайте посмотрим на несколько примеров использования макроса COMPILE_ERROR_IF:

Пример 1: проверка размера

Проверка того, что буфер достаточно большой для хранения данных, является одним из приложений макроса COMPILE_ERROR_IF. Допустим, у вас есть буфер размером 128 байт, и по какой-либо причине вы должны скопировать в него структуру типа UserData.

typedef struct{
  uint32_t hash;
  bool likes_bananas;
  char name[60]; 
  char email[35];
}UserData;

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

uint8_t buffer[128];
int main(){
  UserData user_data = {.likes_bananas = true};                           
  memcpy(buffer, &user_data, sizeof(buffer));                         
  return 0;
}

Но что, если через несколько недель кто-то решит изменить структуру UserData, потому что требуется дополнительное поле статуса длиной 40 байт? Memcpy по-прежнему не переполняется, но, возможно, данные теперь копируются наполовину. Это ошибка, которая может быть слишком незаметной, чтобы ее можно было заметить, потому что, когда вы тестируете свою новую программу, вы пишете тестового пользователя со строкой состояния длиной 15 байт, и она отлично работает. ..

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

Вы можете увеличить буфер. Или лишнее поле поменьше. Сколько байтов занимает bool? Что, если вы измените целевую архитектуру?

Просто добавьте этот COMPILE_ERROR_IF в функцию, которая будет выполняться (хорошее место в начале основной функции), и все:

// Compile error if the buffer cannot hold the data
COMPILE_ERROR_IF(sizeof(buffer) < sizeof(UserData));

Теперь вы можете быть уверены, что memcpy не переполнит буфер и что UserData поместится в буфер. В противном случае исполняемый файл не может быть построен.

Пример 2: Проверка конфигурации определяет

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

#define NUM_BUTTONS (5)
#define MAX_NUM_BUTTONS (8) // Don't touch this
#if(NUM_BUTTONS > MAX_NUM_BUTTONS)
#error "Too many buttons."
#endif

Для запоминания статуса кнопки нужно немного (нажата или не нажата). Поскольку MAX_NUM_BUTTONS равно 8, вы можете использовать переменную uint8_t для хранения состояния всех кнопок.

uint8_t buttons_state;

Время идет, и проект развивается. Теперь вам нужна дополнительная кнопка. Поскольку вы знаете, что делаете, вы увеличиваете MAX_NUM_BUTTONS, несмотря на сообщение «не трогайте это сообщение», потому что вы писали это, когда были моложе, а теперь стали намного мудрее. Но где-то в вашем коде есть цикл for от 0 до NUM_BUTTONS, проверяющий каждый байт button_state. Упс.

Вы можете защитить себя от ошибок этого типа с помощью:

COMPILE_ERROR_IF(sizeof(buttons_state) < MAX_NUM_BUTTONS);

Пример 3: «Но вы предполагаете, что int type имеет ширину 4 байта»:

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

COMPILE_ERROR_IF(sizeof(int) != 4);

Кстати, я бы предпочел использовать вместо него int32_t.

Где мне взять эту штуку COMPILE_ERROR_IF?

С момента появления C11 вы можете реализовать COMPILE_ERROR_IF с помощью ключевого слова _Static_assert (не забудьте заключить условие в круглые скобки, чтобы гарантировать, что логическое НЕ применяется ко всему условию):

#define COMPILE_ERROR_IF(condition) \
                        _Static_assert(!(condition), "")

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

#define COMPILE_ERROR_IF(condition, msg) \
                       _Static_assert(!(condition), msg)

Что делать, если мой компилятор не поддерживает ключевое слово _Static_assert?

Если вы не используете стандарт C11 или ваш компилятор не поддерживает ключевое слово _Static_assert, вы можете использовать препроцессорные хаки вроде этого:

#define COMPILE_ERROR_IF(condition) \
                  (void)(sizeof(char[1-2*!!(bad_condition)]))

который ловко использует недопустимый факт для определения массива отрицательного размера. Когда я впервые увидел его, меня поразила его элегантность.

Но у подобных хаков препроцессора есть некоторые предостережения. Например, иногда GCC компилируется без уведомления, если bad_condition не является постоянным выражением.

Например, GCC 7.4.0 не будет жаловаться на код, который я перечислил ранее, если вы реализуете макрос COMPILE_ERROR_IF с хаками препроцессора на основе размера массива, как показано выше:

// Negative array size based preprocessor hack.
#define COMPILE_ERROR_IF(condition) \
                  (void)(sizeof(char[1-2*!!(bad_condition)]))
#define MAX_AMOUNT (4215)
const uint16_t FORBIDDEN_VALUE = 5;
uint16_t increase(uint16_t amount){
  // Not a constant expression but GCC 7.4.0 won't complain.
  COMPILE_ERROR_IF(amount > MAX_AMOUNT);
  // A const marked variable doesn't
  // make the expression to be constant
  // But GCC 7.4.0 won't complain.
  COMPILE_ERROR_IF(amount == FORBIDDEN_VALUE);
  amount++;
  return amount;
}

Последние мысли

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

Вам следует использовать ключевое слово _Static_assert, а не хаки препроцессора. Хаки - это круто, но по определению они не предназначены для такого использования, поэтому не будут работать во всех случаях. В конце концов, эта статья о том, как уменьшить количество ошибок в программном обеспечении, а не о введении новых.