Ошибки, сбои и тестирование

Зачем тестировать программное обеспечение? Дело в том, что...

Failure is not an option. It comes bundled with the software.

Сбои не опция. Они идут в комплекте с ПО.

Проверка кода компилятором

Рассмотрим ключи на примере компилятора GNU/GCC. Их достаточно много, поэтому обратим внимание только на самые важные.

Существует несколько стандартов языка, а к ним есть ещё и расширения. Вернёмся к инициализации массива. С расширениями GNU можно сделать так:

Удобно и красиво. Но данный код не скомпилируется под STM8 с проприетарным компилятором от IAR. Если вы пишите универсальную библиотеку, то следует строго следовать стандарту. Используйте ключ -Wpedantic и компилятор не отклонений.

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

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

Другой пример. Как вы думаете, скомпилируется ли данный код?

Да и компилятор не выведет каких-либо предостережений. В момент инициализации (1), никакого массива ещё нет, и x в квадратных скобках будет распознана как константа. В точке (2) область видимости будет перекрыта, т.е. x будет массивом. Укажите ключ -Wshadow и компилятор выдаст сообщение вида:

Каждое предупреждение — это потенциальный баг в прошивке. Стоит ли рисковать дорогим оборудованием оставляя такой код? Ключ -Werror заставляет компилятор расценивать предупреждения как ошибки компиляции. Вы не сможете собрать прошивку, пока не перепишите потенциально опасный участок. Большое количество сообщений может мешать, в таком случае воспользуйтесь ключом -fmax-errors=n, где n — максимальное количество отображаемых ошибок (0 = без ограничений). Ключ -Wfatal-errors заставляет компилятор остановиться, как только он встретит первую ошибку (предупреждение), а не продолжать сборку, отлавливая все остальные.

Подведём итог: пишите код так, чтобы компилятор не выводил вам предупреждений.

Проверка кода утверждениями

В стандартной библиотеке периферии от ST есть специальный макрос,

задача которого проверять переданные в него выражение (expr)1. Если оно истинно, то макрос ничего не делает ((void)0), а если ложно, то вызывается функция assert_failed().

Такие штуки называют «утверждением» (англ. assert). Но зачем всё это нужно?2 Вам никто не мешает передать в качестве аргумента какую-нибудь ерунду или указатель на NULL. Данный макрос позволяет исключить такую ситуацию (при условии, что код вы всё же отлаживаете). Посмотрите на реализацию функции инициализации модуля GPIO из стандартной библиотеки:

Запросто, вместо указателя на GPIO_InitDef можно передать указатель на GPIO_InitTypeDef. Включая проверку, такие фокусы не пройдут.

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

assert_param() — это не придумка создателей библиотеки, такая функциональность уже есть в стандартной библиотеке языка Си и подключается через заголовочный файл <assert.h>3.

Обратите внимание! Смысл макроса assert*() в поиске багов, а не в обработке ошибок. Например, если вы используете динамическую память, то проверка работы malloc() через макрос assert() — не правильное его использование. Программа вылетит в обработчик вместо того, чтобы продолжить работу и пытаться как-то решить возникшую проблему.

Такие проверки эффективны на этапе разработки, но они не нужны в финальной версии прошивки4. Макрос assert_param() отключается удалением определения USE_FULL_ASSERT, а assert() из стандартной библиотеки Си определением макроса NDEBUG или добавлением к компилятору флага -DNDEBUG.

Использование assert(), как ни странно, может навредить. В качестве параметра expr можно передавать что-то, что изменяет состояние системы, например функцию или инкремент переменной.

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

В стандарте c11 появилось ещё одно ключевое слово, _Static_assert. Оно, в отличии от библиотечного макроса assert() работает на этапе компиляции.

Если результат expr равен нулю, то программа не скомпилируется.

Статическое утверждение в случае проверки аргумента функции (известной на этапе компиляции, конечно), подойдёт лучше, так как просто не вызволит скомпилировать код.

Обработка ошибок

Язык Си на прямую не поддерживает обработку ошибок, в нём нет понятия исключений (англ. exception), хотя их и можно на нём реализовать5. Стандартная библиотека предлагает другой способ, через глобальную макрос errno, который ведёт себя по сути как переменная, (модуль <errno.h>) — при инициализации программы туда записывается 0. Если при работе функции произошла какая-нибудь ошибка, то её код помещается туда (справедливо для стандартной библиотеки).

Согласно стандарту, библиотека должна определять только три возможных ошибки (EDOM, EILSEQ и ERANGE), но они расширяется стандартом POSIX. Все эти дополнительные ошибки полезны при работе с файловой системой, сетью и всем тем, что есть в полноценной системе, но не во встраиваемых системах. Поэтому имеет смысл определить собственное перечисление и работать с ним, через значение возврата функции, как это сделано, например, в библиотеке HAL.

Железо

Не все ошибки могут быть напрямую связаны с кодом программы. Когда вы пишите драйвер для какого-нибудь датчика, то вы скорее всего не думаете о том, что его можно физически оторвать от платы6. Если создаваемая вами система принимает решение на основе данных с датчика, то позволять устройству выходить в рабочий режим при его отсутствии неправильно и скорее всего не безопасно7. Запуск атомного реактора без обратной связи почти наверняка приведёт к аварии, как и отказ каких-либо датчиков прямо во время работы.

Например, популярный температурный датчик DS18B20 перед приёмом команды, если он присутствует на шине, отправляет импульс приветствия. Для простоты реализации драйвера проверку присутствия можно опустить, но это совершенно неправильно для критически важных систем8.

Модульное тестирование

Данная подглава — галопом по Европам. Тема тестирования настолько обширна, что на английском языке существует целая книга — Test Driven Development for Embedded C (далее TDDFEC), — объёмнее чем вся эта. Если вы владеете языком, обратите своё внимание к ней. Здесь мы заявим о проблеме и рассмотрим базовые подходы к решению.

31 декабря 2008 года все mp3-плееры Zune (1-го поколения) от Microsoft замолчали9. Почему? Как оказалось, код в модуле календаря при определённых условиях сваливался в вечный цикл. Изучите код ниже (взят с сайта bit-player.org):

На первый взгляд может показаться, что всё хорошо, но если вы подставите число 366 в переменную days, а год будет високосным (скажем 2008), то войдя в первое условие переменная days не будет изменена и программа уйдёт на следующую итерацию. Так будет продолжаться пока не наступит следующий день (если days обновляется асинхронно). Через утверждения такую ошибку можно было быстро выявить написав пару строк кода.

Проблема в коде Zune вполне безобидна. Да, конечно, не приятно, что устройство не работает один день в четыре года, но от него не зависит человеческая жизнь, да и цена устройства относительно копеечная. Ранее приводились другие примеры, из космической отрасли, где отсутствие тестирования приводило к потере дорогостоящего аппаратуры. Стоит ли рисковать, материально и репутационно, не предпринимая никаких дополнительных действия для поиска и исправления ошибок?

Для простоты предположим что days и year — это глобальные переменные, а функция, которая содержит приведённый выше код, назовём её calculate(), ничего не принимает и ничего не возвращает.

Мы точно знаем как должна вести себя функция при переходе: за 31 декабря 2008 должно идти 1 января 2009 года. Напишем тестовую функцию.

К сожалению, это не лучший пример, ведь до assert() код не дойдёт, застряв в calculate(), но сам факт того, что мы запустили код с данными которые могут привести к ошибки — хорошо. Проверить нужно не только момент перехода, но и некоторые промежуточные значения: високосный год, days больше 366; високосный год, days меньше 366; и т.д. Перебирать все возможные пары входных и выходных данных неправильно и невозможно. Если функция возвращает true или false, тест как минимум должен содержать одну проверку правильности получения true и одну для получения false (зависит от внутренней логики и входных данных).

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

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

Такое тестирование называется модульным, или юнит-тестированием (англ. unit testing); его цель разбить программу на кусочки и проверить их работоспособность. Будут ли всё работать вместе — задача интегрального тестирования.

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

Вокруг тестов возникла целая методология — разработка через тестирование (англ. Test-Driven Development, TDD). Тесты пишутся до реализации необходимой функциональности. Таким образом, весь код будет протестирован просто по факту его наличия. Однако, со встраиваемыми системами есть проблемы. Первая это ресурсы, они ограничены. Введение дополнительного проверяющего кода занимает место в памяти, т.е. его может не хватить. Плюс, если вы используете assert(), то запуск теста не будет удобным: а) код свалится при первой же ошибке и не покажет другие проблемы; б) у вас не будет удобного текстового вывода (конечно, можно выводить через UART) для анализа. Вторая проблема в том, что программа взаимодействует с железом и реальным миром. Решив, первую проблему, перенеся тестирование на хост-устройство (компьютер), мы лишаемся возможности читать и писать в регистры10.

При тестировании часто применяют так называемые «затычки», или mock-объекты (в ООП), т.е. временные сущности (объекты или функции), симулирующие реальное поведение. В книге TTDFEC при написании теста модуля светодиода предлагается создать «виртуальны порт», т.е. простую 16-битную переменную, в которую можно записывать биты, как в регистр. Такая переменная — mock-объектом (он может быть намного сложнее).

Может показаться, что написание «виртуального порта» — чушь. Это не совсем так. Возможно, пример не самый лучший. Представьте себе лучше следующую ситуацию (прим. автора: в которой я как-то оказался): вам нужно написать драйвер для микросхемы flash-памяти работающей по SPI. Если с SPI вам все более или менее понятно, то вот с организацией данных во flash у вас есть вопросы. Вы не можете записывать 1 в произвольную ячейку, её можно только устанавливать в 0. Для записи единицы, нужно затереть страницу целиком (блок памяти, например 1 кб) — так работает данный тип памяти. Само устройство к вам придёт только через неделю, а срок реализации неделя плюс один день. Можно созерцать потолок и думать как всё успеть за 24 часа, а можно написать симулятор микросхемы — это просто массив с определёнными правилами работы с его ячейками. Через неделю, когда в ваших руках окажется устройство, код драйвера будет практически готовым (и протестированным!), останется лишь заменить пару функций, отвечающих за запись/чтение по SPI.

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

Приведённый в самом начале главы макрос, assert_param(expr), довольно хорош, так как использует __FILE__ и __LINE__. Передав их в printf() в обработчике можно вывести название файла и строчку, где была замечена проблема. Однако, это не самый информативный вывод. Тест будет не один, плюс узнать, что получилось в expr можно только в режиме отладки.

К счастью для языка Си уже написан не один фремфорк. Для встраиваемых систем хорошо подходят Unity и CPPUnit (используется в книге TDDFEC). Мы рассмотрим только первый.

Unity состоит всего из трех файлов (unity.c, unity.h и unity_internals.h) и содержит множество предопределённых утверждений на все случаи жизни.

Для создания теста пишется функция с префиксом test_ или spec_11. Перепишем ранее созданный тест для функции sum().

Это далеко не всё, что умеет делать данный фремворк. По идее каждый модуль нужно тестировать отдельно. Поэтому вам стоит создать отдельный файл для его тестирования. В нём, помимо тестовых функций, содержатся setUp() и tearDown(), которые выполняются перед и после каждого теста. Опять же, если используются глобальные переменные — задать их значение можно там. Далее идут сами тесты, а в самом конце функция main(). Таким образом, каждый тестовый модуль автономен и может компилировать без основного проекта, т.е. не вносит в него никаких накладных расходов.

Для запуска теста, однако, нужно вызвать не саму функцию, а передать указатель на неё в макрос RUN_TEST(). Это нужно для того, чтобы фреймворк смог запустить функции setUp и tearDown, а так же знал из какого __FILE__ и __LINE__ она была вызвана. Макросы UNITY_BEGIN() и UNITY_END() выводят дополнительную информацию (например, сколько тестов было запущено, сколько из них удачных и т.д.).

Скомпилируем наш тест и посмотрим, что получилось.

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


1 Для активации нужно определить макрос-метку USE_FULL_ASSERT, в противном случае проверки не будет делаться.
2 Может показаться, что использование утверждений не нужно и вы полностью понимаете поведение кода. Вот вам пример от Mozilla: добавления одного утверждения выявило 66 багов — FrameArena::~FrameArena should assert that it's empty.
3 Реализация стандартная библиотеки для микроконтроллера обычно отличается от реализации для универсального компьютера. В частности assert() из стандартной библиотеки Си ведёт к вызову функции abort() и завершению программы. В любом случае, лучше определить собственный макрос и пользоваться им.
4 Утверждения можно использовать для принудительной перезагрузки устройства, если во время выполнения программы оно натыкается на неправильное состояние системы. Для этого совершите программный сброс в функции обработчике.
6 Космос достаточно агрессивная среда. Высокоэнергетические частицы из другой галактики (ха-ха), запросто прошьют насквозь микросхему и устроят там короткое замыкание.
7 При
8 Во всех критических системах, будь то атомный реактор, самолёт или космический аппарат применяют резервирование по мажоритарной схеме. То есть, используется не один датчик, а скажем три. При этом решение принимается на основании суммы выходов: если показания двух из трёх датчиков совпадают, то таким данным можно доверять. Резервирование можно встретить и в биологических системах: у вас два глаза, уха, лёгких и две почки.
9 Данный случай широко известен и приведён в самом начале книги TDD. Детальное рассмотрение с решением бага можно найти в блоге bit-player.org.
10 В среде от IAR, в меню отладчика можно выбрать симулятор, который позволяет "залить" прошивку в виртуальный микроконтроллер и отлаживать программу. Вы можете читать и писать в регистры. Так же можно использовать эмулятор QEMU, но он поддерживает ограниченное количество устройств.
11 На самом деле это не обязательно. Префикс используется Ruby скриптом, для автоматического генерирования функции main() с вызовом всех тестов. Мы рассмотрим ручной режим формирования.