Встраиваемые системы широко применяются в бытовой электронике, промышленной автоматике, транспортной инфраструктуре, телекоммуникациях, медицинском оборудовании, а также в военной, аэрокосмической технике и т. д. Хотя последствия любой ошибки проектирования обходятся дорого, ошибку в ПО для ПК или в большом корпоративном приложении обычно относительно легко исправить. А если дефект будет во встраиваемом ПО (далее – ВПО) электронного блока управления тормозной системой автомобиля, то это может вызвать массовый и дорогостоящий отзыв продукции.
Сфера применения встраиваемых систем постоянно расширяется, сложность выполняемых ими задач растет. Это в свою очередь повышает риск внесения ошибок в процессе разработки, что увеличивает вероятность весьма дорогостоящих дефектов в ПО.
Одной из наиболее популярных методологий улучшения качества разрабатываемых приложений является Test-driven development (TDD). Но эффективна ли методология TDD для разработки встраиваемых систем? Ответ на этот вопрос будем искать под катом.
Эффективность TDD
Все большее число разработчиков придерживаются мнения, что методология TDD имеет ряд преимуществ над Test-last development (TLD). При этом TDD понимается как процесс итеративного, непрерывного написания тестов и рабочего кода с обязательными фазами рефакторинга.
Схема итеративного процесса разработки TDD
В результате применения TDD можно выделить следующие улучшения при проектировании приложений:
- TDD позволяет сконцентрироваться на дизайне и понять, как сделать его лучше;
- TDD избавляет разработчиков от страха внесения изменений в код: при внесении некорректных изменений будет легко «отловить» ошибку с помощью запуска тестов;
- хорошо спроектированные тесты выступают в роли показательного примера использования программного модуля, что заменяет документирование кода;
- TDD улучшает покрытие кода тестами;
- завершенные тесты демонстрируют прогресс разработки: каждый реализованный тест-кейс указывает на то, что очередной функционал завершен и работает;
- уменьшается количество багов.
В настоящее время довольно просто писать тесты. Многие среды разработки позволяют добавлять тест в проект за пару кликов мыши или с помощью сочетания клавиш, что экономит немало времени, разработчику остается только самостоятельно заполнить тело теста. При этом не нужно тратить время на настройку среды разработки или скачивание дополнительных фреймворков и их подключение. Например, в Android Studio это довольно простой и быстрый процесс.
Однако среды разработки для создания ВПО микроконтроллеров (далее – МК) не так стремительно развиваются, в некоторых из них отсутствует возможность создавать и запускать тесты, а также быстро получать результат их выполнения. К тому же один и тот же разработчик может иметь дело с разными аппаратными платформами, т. е. вести разработку с помощью разных интегрированных сред разработки (Integrated Development Environment – IDE). Поэтому возникают следующие вопросы:
Как запускать тесты для embedded, будь то TLD или TDD?
Будем ли мы наблюдать указанные выше улучшения в результате применения TDD при разработке встраиваемых систем?
На эти вопросы мы попытаемся ответить в данной статье.
Особенности разработки в embedded
При проектировании встраиваемых систем необходимо учитывать специфику разработки ВПО:
- ВПО запускается на МК, у которых могут быть ограниченный объем памяти, своя архитектура и т. д.;
- ВПО выполняется в среде со специфичной аппаратной поддержкой, т. е. имеет множество библиотек для взаимодействия с различными аппаратными модулями.
Как правило, для проектирования ВПО используется специализированная IDE. Обычно разработчик может загрузить с сайта производителя библиотеки для работы с аппаратными модулями конкретного МК – Hardware Abstraction Level (HAL). Но далеко не каждая IDE предоставляет инструменты для написания тестов ВПО. Кроме того, использование библиотек или написание собственных драйверов для взаимодействия с периферией МК вносит аппаратные зависимости в разрабатываемый код прошивки. Такой код будет работать только на конкретном МК (или на определенной серии МК).
Так, для применения TDD при проектировании встраиваемых систем нужно ответить на ряд вопросов:
- Каким образом писать и запускать тесты?
- Что делать с аппаратными зависимостями?
- Как организовать непрерывную и итеративную разработку?
Мы попробуем ответить на эти вопросы, реализовав конкретный пример ВПО для МК с помощью методологии TDD. В завершение мы приведем плюсы и минусы применения этой методологии для разработки встраиваемых систем. Конечно, ответы на все эти вопросы не будут рассматриваться в рамках одной статьи, поэтому мы запланировали к публикации небольшой цикл статей.
Планируемый цикл статей
- В первой части (вы сейчас ее читаете) мы определим цель и инструменты разработки, затем напишем простейший тест, запустим его и представим результат.
- Во второй части рассмотрим процесс разработки ВПО по методологии TDD, реализуем основную платформонезависимую логику нашего проекта и применим методы для разрешения аппаратных зависимостей с целью тестирования нашего кода.
- В третьей части допишем платформозависимый код (драйвер) и запустим ВПО на МК STM32F103C8, подведем итоги, учитывая материалы всего цикла статей, и перечислим плюсы и минусы применения TDD при разработке ВПО для МК.
Все исходники проекта выложены на GitLab.
Разрабатываемый функционал
Многие embedded-устройства могут подключаться к ПК для конфигурирования каких-либо параметров, т. е. в таком устройстве содержатся настройки, которые можно считывать или записывать. Мы приведем пример реализации именно такого функционала. Для подключения к ПК будем использовать UART-интерфейс, а в качестве энергонезависимой памяти – флеш-память МК. Таким образом, нам необходимо реализовать следующий функционал:
- подключение устройства к ПК по UART-интерфейсу;
- сохранение параметров в энергонезависимой памяти с помощью ПК по UART-интерфейсу;
- считывание параметров из энергонезависимой памяти с помощью ПК по UART-интерфейсу.
Выбор аппаратной платформы
Для реализации нашего проекта мы выбрали отладочную плату с МК STM32F103C8, потому что МК STM32 одни из самых популярных в настоящее время, а отладочная плата стоит недорого и ее легко приобрести.
В качестве энергонезависимой памяти в выбранном МК может быть использована флеш-память. Однако следует помнить о том, что код ВПО также хранится во флеш-памяти, которая разделена на страницы. Количество и размер страниц варьируется в зависимости от линейки МК (подробно описано в Programming manual).
Перед записью во флеш-память необходимо убедиться, что страница была предварительно стерта.
Инструменты разработки
Для создания тестов и основной логики проекта мы выбирали IDE на свой вкус и цвет, потому что в первую очередь разрабатывали платформонезависимый код, который можно скомпилировать и запустить на локальном ПК. Для разработки ВПО чаще всего используется либо «чистый» C, либо С++, поэтому для написания тестов ВПО нужно использовать соответствующий фреймворк для тестирования. В результате мы выбрали следующие инструменты для написания тестов и платформонезависимой бизнес-логики:
- В качестве IDE – Visual Studio, потому что нам нравится ее внешний вид, удобство отладки и рефакторинга кода. Данная IDE также подходит для написания кода на «чистом» C.
- CppUTest – простой в настройке и в освоении фреймворк для модульного тестирования, который может использоваться для написания любых unit-тестов на C/C++.
Создание и настройка проекта в Visual Studio
С целью написания тестов и кода нашей бизнес-логики в первую очередь мы создали новое решение в Visual Studio, добавили в него первый проект на Visual C++ с именем проекта Tests и типом «консольное приложение Windows». В этом проекте содержатся только код тестов и дополнительные программные модули для тестирования (например, spies, mocks, stubs и т. д.).
- Заходим в Properties -> C/C++ -> General -> Additional Include Directories и добавляем строки:
— $(CPP_U_TEST)\include
— $(SolutionDir)..\Firmware\Project\Include (путь к заголовочным файлам тестируемого кода)
- Заходим в Properties -> Linker -> Input и добавляем строки:
— $(CPP_U_TEST)\lib\cpputestd.lib
— $(SolutionDir)Debug\ProductionCodeLib.lib
Где $(CPP_U_TEST) – переменная среды Windows, в которой содержится путь к папке cpputest (см. скриншот).
Добавляем в проект файл Tests.cpp с содержанием:
#include "CppUTest/CommandLineTestRunner.h"
int main(int argc, char** argv)
{
return RUN_ALL_TESTS(argc, argv);
}
Далее в этом же решении создали второй проект с именем ProductionCodeLib, тип – статическая библиотека Visual C++. В этот проект мы будем добавлять код бизнес-логики, который планируем запустить на «железе», т. е. код, компилируемый в файл прошивки для STM32F103C8.
Добавляем пути к заголовочным файлам, используемым для создания ВПО:
- заходим в Properties -> C/C++ -> General -> Additional Include Directories и добавляем строку \$(SolutionDir)..\Firmware\Project\Include\
После настройки впервые запустили проект, нажав на кнопку «Run», и увидели отчет о том, что ни одного теста не было выполнено:
OK (0 tests, 0 ran, 0 checks, 0 ignored, 0 filtered out, 0 ms)
На этом настройка завершилась, можно приступить к итеративной разработке.
Разработка ВПО по методологии TDD
Мы решили использовать «чистый» C, при этом старались сохранить применение базовых принципов ООП. Такой подход обычно называют псевдо-ООП, потому что «чистый» С не поддерживает классы. В соответствии с целью нашего проекта мы создали класс Configurator
, в котором реализовали следующую логику:
- обработка команд ПК по UART-интерфейсу;
- чтение/запись во флеш-память;
- стирание страницы флеш-памяти.
Конечно, в первую очередь мы создавали список тестов для будущего класса. Для этого брали блокнот и ручку (клавиатуру и текстовый редактор) и описывали простыми словами, какая логика нам была нужна. Такой процесс для нашего модуля занял около 5 минут. Ниже приведен тест-лист для класса Configurator.
Тест-лист
1. При получении команды read
возвращаются данные, размещенные по указанному адресу на флеш-памяти.
2. При получении команды write
производится запись данных по указанному адресу во флеш-память.
3. При получении команды erase
производится стирание страницы с указанным номером.
4. При получении команды help
выводится список поддерживаемых команд.
5. При получении неизвестной команды
возвращается сообщение об ошибке.
Для простоты и наглядности мы решили использовать строковый формат команд в кодировке ASCII.
Основы CppUTest и первый тест
Для реализации тестов создали файл ConfiguratorTests.cpp
в проекте Tests
, который затем постепенно наполняли новыми тестами в соответствии с методологией TDD.
Для написания тестов с помощью CppUTest
используется простая структура.
Структура написания тестов для CppUTest:
TEST_GROUP(TestGroupName)
{
void setup()
{
}
void teardown()
{
}
};
TEST(TestGroupName, TestName)
{
}
Где:
- TEST_GROUP – блок кода, в котором могут содержаться методы setup() и teardown(), а также другие вспомогательные методы или переменные;
- setup() – функция, вызываемая перед запуском каждого теста;
- teardown() – функция, вызываемая в завершении каждого теста;
- TEST – блок кода, в котором реализуется один тест, таких тестов может быть много;
- TestGroupName – название группы тестов обычно совпадает с именем класса, для которого предназначены тесты;
- TestName – название теста.
Каждый тест должен работать независимо от любых других тестов. Поэтому перед каждым запуском теста следует создавать объект, а в завершение теста удалять его. Так, для нашего класса Configurator
в простейшем случае в setup()
создается экземпляр, а в teardown()
удаляется. Чтобы убедиться в том, что объект успешно создается, мы добавили простейший тест для проверки значения указателя. Если объект по каким-то причинам не был создан, то указатель будет равен значению NULL
. Назвали тест ShouldNotBeNull.
Реализация теста ShouldNotBeNull:
// ConfiguratorTests.cpp
TEST_GROUP(Configurator)
{
Configurator * configurator = NULL;
void setup()
{
configurator = Configurator_Create();
}
void teardown()
{
Configurator_Destroy(configurator);
}
};
TEST(Configurator, ShouldNotBeNull)
{
CHECK_TRUE(configurator);
}
Первый тест был готов, но еще возвращал ошибки компиляции, потому что на данном этапе не были реализованы методы Configurator_Create
и Configurator_Destroy
. Для успешного завершения теста оставалось написать лишь эти два метода. И только на этом шаге мы написали первые строчки с реализацией функционала ВПО в проекте ProductionCodeLib
. Для этого создали заголовочный файл Configurator.h
и файл Configurator.c
, в котором содержится реализация бизнес-логики. В Configurator.h
добавили прототипы двух перечисленных методов. А в файл Configurator.c
сначала добавили заглушки, т. е. оставили тело каждого метода пустым. Это было нужно для того, чтобы скомпилировать проект и запустить тесты.
Реализация заглушек для теста ShouldNotBeNull:
// Configurator.h
typedef struct ConfiguratorStruct Configurator;
Configurator * Configurator_Create(void);
void Configurator_Destroy(Configurator * self);
// Configurator.c
#include "Configurator.h"
typedef struct ConfiguratorStruct
{
char command[32];
} ConfiguratorStruct;
Configurator * Configurator_Create(void)
{
return NULL;
}
void Configurator_Destroy(Configurator * self)
{
}
В соответствии с методологией TDD следует убедиться, что тест запускается, но завершается с ошибкой (т. к. тело метода Configurator_Create
на данный момент было пустым). Пробуем запустить и получаем статус выполнения теста failed
, что и следовало ожидать. Это означает, что мы успешно выполнили фазу test-fails.
Вывод ошибки на экран при запуске теста:
d:\\exampletdd\\tests\\tests\\configuratortests.cpp(27): error: Failure in TEST(Configurator, ShouldNotBeNull)
CHECK_TRUE(configurator) failed
.
Errors (1 failures, 1 tests, 1 ran, 1 checks, 0 ignored, 0 filtered out, 2 ms)
Для перехода на следующую фазу test-passes необходимо было заполнить тело конструктора. Мы добавили в метод Configurator_Create выделение памяти и возврат указателя на объект, этого достаточно для успешного выполнения теста ShouldNotBeNull. Также следовало освободить выделенную память в завершение теста, поэтому заполнили тело деструктора Configurator_Destroy.
В итоге Configurator_Create и Configurator_Destroy выглядят так:
Configurator * Configurator_Create(void)
{
Configurator * self = (Configurator*)calloc(1, sizeof(ConfiguratorStruct));
return self;
}
void Configurator_Destroy(Configurator * self)
{
if (self == NULL)
{
return;
}
free(self);
self = NULL;
}
В результате запустили тест и получили положительный результат:
.
OK (1 tests, 1 ran, 1 checks, 0 ignored, 0 filtered out, 0 ms)
Это означает, что фаза test-passes завершилась. Далее следует фаза рефакторинга, в которой, как правило, производится улучшение дизайна, читаемости кода и т. д. В нашем случае кода еще совсем мало, поэтому мы только заменили «магическое» число 32
на константу с помощью #define (можно использовать enum
или const
вместо define
).
Убираем антипаттерн («магическое» число) с помощью #define:
// Configurator.h
#define SERIAL_RECEIVE_BUFFER_SIZE 32
// Configurator.c
typedef struct ConfiguratorStruct
{
char command[SERIAL_RECEIVE_BUFFER_SIZE];
} ConfiguratorStruct;
Итог
Подведем промежуточные итоги опыта, описанного в этой статье:
- Мы определили цели проекта, выбрали аппаратную платформу и инструменты для проектирования.
- Подготовили ПК для локальной разработки тестов и кода ВПО без применения специализированных IDE для конкретного МК.
- Создали простейший тест, для запуска которого достаточно нажать на кнопку «Run» (или соответствующую горячую клавишу), чтобы мгновенно получить результат выполнения на экране.
В следующей статье мы напишем всю платформонезависимую логику нашего проекта по методологии TDD в соответствии с разработанным выше тест-листом. Если тебе интересны вопросы «железной» разработки и безопасного кода, присоединяйся к нашей команде. Так что продолжение следует…
Ссылки
- TDD для микроконтроллеров. Часть 2: Как шпионы избавляют от зависимостей
- Проект на GitLab
- CppUTest
- Visual Studio
- STM32CubeMX
- Atollic TrueStudio
Литература
- Test Driven Development for Embedded C, James Grenning
- STM32F103C8 Programming manual
- STM32F103C8 Reference manual
Raccoon Security – специальная команда экспертов НТЦ «Вулкан» в области практической информационной безопасности, криптографии, схемотехники, обратной разработки и создания низкоуровневого программного обеспечения.
lingvo
Все это к сожалению далеко от практической плоскости разработки для embedded.
Во первых вы не указали, что для того, чтобы писать тесты, в первую очередь нужны спецификации — т.е. формализованные требования, которые бы указывали, что ваша железяка вместе с ПО должна делать. Без них вы можете написать хоть тысячу тестов, которые не будут тестировать то, что вам надо.
Во-вторых проблема эмбеддед разработки в том, что требования выше обычно предъявляются к комбинации ПО+железо, а не к чистому ПО. Потому что сделать спецификацию на ПО многим откровенно лень, да и тестировать нужно все вместе — никому не нужно встраиваемое ПО, которое прекрасно работает в симуляторе, но не работает на реальном железе.
Ну и в третьих, если учитывать первые два пункта, разработка тестов для такого рода задач становится уже не тривиальной задачей написания кода за 5 минут, а вполне себе отдельным проектом, в котором код сочетается с тестовыми стендами и эмуляторами, которые имитируют внешние воздействия и считывают реакцию системы. И часто процесс разработки такого TDD стоит гораздо дороже разработки самого встраиваемого ПО и поэтому
в четвертых — обычный стол, пара кнопочек, светодиоды, мультиметр и осциллограф — наши друзья. Иногда ручной тестовый стенд. А автоматические тесты заменяем ручками :-) И это не потому, что так хорошо тестировать встраиваемое ПО, а потому, что по другому гораздо сложнее.
lamerok
В корне с вами не согласен.
Скорее всего вы путаете системное тестирование (черный ящик) и юнит тестирование (белый ящик) .
Для юнит тестирование не нужны требования, нужна детальная архитектура. Т.е. описание (спецификация) функции, метода, класса. Вам все равно придется это делать. К вашей конкретной функции никаких требований никто не предъявляет, кроме вас самого… Таким образом, вы вначале сами описываете, что должна делать функция, входные, выходные параметры и можете либо сразу реализовать это, а потом покрыть функцию юнит тестами, либо вначале сделать юнит тесты, а потом сделать реализацию под эти тесты.
Опять таки, скорее всего путаница между тестированием черного ящика, и белого. Ваш код функции по сути и есть спецификация функции и чтобы убедиться, что она делает, то, что вы задумали, вы и пишите юнит тест.
А вот как работает софт и железо вместе — уже пусть системные тесты проверяют, но если у вас нет требований, то тогда и проверять то нечего :)
Она точно такая же задача, как и написание кода. Вы можете точно также писать функцию, потом прошивать микроконтроллер, потом смотреть осцилографом, потом понять, что функция работает не так, снова менять функцию...(по сути будете делать ручной юнит тест). Вопрос зачем и сколько вы на это потратите время? Если все можно сделать юнит тестом?
Да еще и если потом кто-то поменяет вашу функцию, и она перестанет работать, так как вы задумали, вы даже об этом и не узнаете… Вообще замечательно.
С последним вашим абзацем почти согласен, юнит тесты используются, чтобы автоматизировать и проверить, что ничего с вашей функцией не случилось, что она работает так как вы задумали.
Или каждый раз прошивать и проверять, или просто запустить юнит тесты. Профит со всех сторон, экономия времени, ресурсов и затрат.
lingvo
Да, я говорю о системном тестировании, поскольку слово юнит-тестирование в статье не встречается ни разу, а применимость TDD обсуждается в контексте подхода к разработке встраиваемого ПО в целом (типа встраиваемого ПО блока управления тормозной системой, а не одной из функций этого ПО)
То есть вы хотите сказать, что автор забыл упомянуть этот момент?
lamerok
Дак автор описывает CppUTest — который как бы намекает на то, что это Unit Test Framework.
RaccoonSecurity Автор
Во-первых, без формализованных требований сложно разработать что-либо конкретное, не говоря уже о тестах. В статье требования формализованы.
Во-вторых и в третьих, Обычно для теста разработанной системы «ПО + железо» используют системное тестирование. Действительно, это отдельная задача. В этой статье говорится о методологии TDD с целью разработки бизнес-логики, которая будет работать в конечном продукте. А тестирование системы «ПО + железо» в реальных условиях будем проводить в третьей части цикла статей вручную.
F0iL
Для этого давно уже придумали разделение по слоям абстракции. Так часть, которая завязана на взаимодействие с железом (HAL) — да, она тестируется отдельно на стендах, а то, что напрямую на железо не завязано (автоматы состояний, математические вычисления, парсеры коммуникационных протоколов, структуры для хранения архивов и логов и т.д.) — отлично тестируется даже без живой железки. И естественно, второе к первому в коде не должно быть прибито гвоздями.
Да, и это нормально. У известной библиотеки SQLite объем тестов в 600 раз (!) превышает объем кода самой библиотеки.
Тут уже все зависит от того, что вам нужно получить и как оно будет развиваться. Если у вашего продукта нету требований к надежности работы, если он расширяется и обновляется довольно редко или вообще никогда (сделал и забыл), то да, можно посадить пару студентов за копейки ручками тестить. А если у вас что-то сколь-менее сложное по внутреннему устройству, причем активно развивающееся и постоянно обновляющееся — затраты на постоянные ручные тестирования и цены упущенных ошибок из-за человеческого фактора могут сильно превысить денежно-временные затраты инженеров на написание тестов.