Введение
Всем известна польза юнит-тестирования. Прежде всего, написание тестов одновременно с кодом позволяет раньше выявлять ошибки и не тратить впоследствии время на трудоемкую комплексную отладку. В случае embedded-разработки у юнит-тестирования есть особенности, связанные, во-первых, с тем, что код выполняется где-то глубоко в недрах устройства и взаимодействовать с ним довольно сложно, и, во-вторых, код сильно завязан на целевое железо.
Если в проекте есть фрагменты, не зависящие от аппаратуры и при этом реализующие достаточно сложную логику, для них применение модульных тестов даст наибольшую выгоду. Например, это может быть реализация какого-то протокола передачи данных, различные расчеты или управляющий конечный автомат.
Существует три способа запуска юнит-тестов для встраиваемых платформ:
- Запуск непосредственно на целевой платформе. В этом случае можно работать с аппаратурой устройства, и код будет работать точно так же, как и в боевых условиях. Однако для тестирования будет нужен физический доступ к устройству. Кроме того, цикл тестирования получится достаточно долгим из-за необходимости постоянно загружать код в устройство.
- Запуск на эмуляторе. Данный способ хорош в основном тем, что позволяет работать, даже когда целевая платформа недоступна (например потому, что ее еще не сделали). Недостатки – ограниченная точность воспроизведения поведения железа (и окружающего мира), а также трудность создания такого эмулятора.
- Запуск на хост-машине (локально). Не получится работать с аппаратурой (можно вместо этого использовать тестовые заглушки), зато тесты будут быстро запускаться и отрабатывать, и не нужен доступ к целевому устройству. Хороший пример для использования этого способа – тестирование реализации на микроконтроллере какого-нибудь вычислительного алгоритма, который сам по себе не зависит от аппаратуры, но использует данные датчиков устройства. Тестировать алгоритм с реальным источником данных будет очень неудобно, гораздо лучше один раз записать эти измерения и гонять тесты уже на сохраненных данных. Этот сценарий с локальным запуском тестов и будет рассматриваться далее.
В этой публикации приведен способ настройки юнит-тестов в среде STM32CubeIDE, основанной на Eclipse и предназначенной для разработки для контроллеров семейства STM32. Язык разработки – С, но сами тесты пишутся на С++. Тесты будут запускаться на хост-машине c Windows с использованием Cygwin. В качестве тестового фреймворка используется Google Test. Результаты будут отображаться в специальном окне плагина для юнит-тестирования, и их можно будет запустить одной кнопкой из проекта для STM32:
Описанный способ подойдет и для других сред разработки на основе Eclipse, если конечно добрые производители не слишком сильно их урезали в угоду удобству разработчиков. Также этот метод будет работать и c CubeIDE под Linux, при этом не потребуется возиться с Cygwin.
Вам понадобятся
- Cygwin 3.0.7 x86 (поскольку тесты для 32-битного микроконтроллера, будем и на 64-битной платформе использовать 32-битное окружение)
- STM32CubeIDE 1.0.2 для Windows.
- Google Test Framework 1.8.1
Установка Cygwin и STM32CubeIDE
Cygwin
Устанавливаем Cygwin, версия x86. В инсталляторе выбираем дополнительные пакеты: gcc-core, g++, binutils, automake, autoconf, cmake, libtool, gdb, make. Можно ставить последние стабильные версии пакетов.
Также нужно прописать переменные среды:
PATH: …;C:<path_to_Cygwin>Cygwinbin; C:<path_to_Cygwin>Cygwinlib
classpath: C:<path_to_Cygwin>Cygwinlib
STM32CubeIDE
Среда устанавливается как обычно. Желательно ставить CubeIDE после Cygwin, потому что в этом случае Cube сам подхватит существующий Cygwin тулчейн.
Сначала создадим проект С++ для x86 Cygwin платформы. Он нам понадобится, чтобы, во-первых, проверить работоспособность тулчейна, а во-вторых, мы будем использовать его как «донора» конфигурации сборки для основного проекта.
Выбираем File > New > C/C++ Project. Выбираем C++ Managed Build. Создаем проект типа hello world для тулчейна Cygwin GCC:
Далее нужно будет выбрать, какие конфигурации сборки создавать. Достаточно только Debug.
Теперь можно проверить, что проект собирается, выбрав Project > Build All. Также желательно проверить и отладку под Cygwin, запустив Run > Debug As > Local C/C++ Application. Приложение выведет «Hello world» в консоль внутри CubeIDE.
Для того, чтобы отладчик мог показывать исполняемые строки в файлах исходного кода, нужно настроить отображение путей. В окне Window > Preferences во вкладке С/С++ > Debug нужно выбрать Source Lookup Path и добавить новое отображение: Add > Path Mapping. В окне нужно назвать как-нибудь новое отображение и добавить строчки для дисков, которые есть в системе:
- cygdrivec — C:
- cygdriveg — G:
Для красивого запуска тестов нам также понадобится плагин для Eclipse с поддержкой юнит-тестов для С++. Он ставится прямо из STM32CubeIDE: меню Help > Install New Software, далее выбрать репозиторий Eclipse Repository и установить плагин С/С++ Unit Testing Support.
Сборка библиотеки Google Test
Исходный код библиотеки можно взять по ссылке: https://github.com/google/googletest/tree/release-1.8.1
Распаковываем исходники, заходим в директорию googletest-release-1.8.1 с помощью Cygwin terminal, и запускаем:
cmake .
make
После успешной сборки файл статической библиотеки будет лежать в ./googlemock/lib/libgtest.a, а заголовочные файлы будут находиться в каталоге ./googletest/include/gtest/. Их нужно будет скопировать в наш проект (или прописать путь к этим файлам в настройках проекта).
Создание проекта для STM32
Проект для отладочной платы STM32L476G-DISCO. Пример будет не слишком изощренным – на плате есть два светодиода, пусть показывают двоичный счетчик от 00 до 11. Реализуем для счетчика отдельный модуль, описанный в паре .h и .c файлов, и напишем для него тест.
Проект можно создавать как обычно, с помощью конфигуратора Cube, главное убедиться, что выводы PB2 и PE8 настроены как цифровые выходы. При создании проекта лучше будет указать тип – С++, это понадобится для компиляции тестов (основной код будет по-прежнему компилироваться С-компилятором). Сконвертировать проект из C можно будет и позже, нажав на название проекта ПКМ и выбрав «Convert to C++».
Для компиляции под МК и для тестов нам понадобятся две разные конфигурации сборки. В этих конфигурациях будут собираться разные наборы файлов – в основную попадут модули для работы с железом и тестируемые модули, а в тестовую – те же тестируемые модули и файлы тестов. Поэтому создадим в корне проекта разные каталоги – Application c кодом приложения для МК (можно просто переименовать директорию Src, которую создал Cube), Common для модулей, не зависящих от железа (которые мы будем тестировать) и Tests для тестов. Директории можно исключать из сборки, кликнув ПКМ по их названию, меню Resource Configuration > Exclude from build.
Добавим в каталог Common наш модуль счетчика:
(led_counter.h):
#ifndef LED_COUNTER_H_
#define LED_COUNTER_H_
#include <stdint.h>
void Led_Counter_Init();
uint8_t Led_Counter_Get_Next();
#endif /* LED_COUNTER_H_ */
led_counter.cpp:
#include "led_counter.h"
static uint8_t led_cnt_state = 0;
void Led_Counter_Init()
{
led_cnt_state = 0;
}
uint8_t Led_Counter_Get_Next()
{
if(++led_cnt_state > 3)
led_cnt_state = 0;
return led_cnt_state;
}
Директории Common и Tests нужно добавить в путь поиска include-файлов: свойства проекта (Properties) > С/С++ General > Paths and Symbols > Includes.
Добавим в main работу со светодиодами
main.c:
…
/* USER CODE BEGIN Includes */
#include "led_counter.h"
/* USER CODE END Includes */
…
int main(void)
{
…
/* USER CODE BEGIN WHILE */
Led_Counter_Init();
uint8_t led_state = 0;
while (1)
{
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
led_state = Led_Counter_Get_Next();
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_2, led_state & (1<<0));
HAL_GPIO_WritePin(GPIOE, GPIO_PIN_8, led_state & (1<<1));
HAL_Delay(500);
}
/* USER CODE END 3 */
…
}
Проект должен компилироваться и запускаться, а светодиоды – мигать.
Написание тестов
Теперь то, ради чего все затевалось.
Создадим новую конфигурацию сборки через свойства проекта – Properties > C/C++ Build > Settings > Manage Configurations. CubeIDE просто так не даст создать конфигурацию для сборки под Cygwin, поэтому скопируем ее из проекта, который мы создали ранее:
Теперь нужно переключиться на эту конфигурацию и настроить пути к файлам исходников и заголовочным файлам. В свойствах проекта во вкладке Paths and Symbols прописываем (при добавлении записи лучше ставить галку в поле «add to all languages»):
- Includes – Tests/Inc, Common/Inc
- Libraries – gtest
- Library Paths – Tests/Lib
- Source Location — /<prj_name>/Common и /<prj_name>/Tests (заменить <prj_name> на имя проекта)
Далее копируем в проект библиотеку gtest – файл .a в директорию Tests/Lib, а заголовочные файлы в папке gtest – в папку Tests/Inc. В папке Tests создаем новый файл main.cpp, в котором будут запускаться тесты. Его содержимое стандартное:
main.cpp:
/*
* Unit tests main file
*/
#include "gtest/gtest.h"
int main(int argc, char *argv[])
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
Также для проверки работы сетапа создадим один тест, который будет проверять, что в нашем окружении размер указателя 32 бита (мы хотим убедиться, что он такой же, как и на микроконтроллере, для этого мы ставили 32-битный Cygwin).
Создаем такой файл теста test_platform.cpp:
#include "gtest/gtest.h"
TEST(PlatformTest, TestPointerSize)
{
//Check pointer size is 32 bit
ASSERT_EQ(sizeof(void*)*8, 32U);
}
Теперь, если проект запустить как обычное С++ Application, в отладочном выводе будет сообщение от Google Test о том, что все тесты пройдены.
Структура проекта должна иметь примерно такой вид:
Теперь напишем тесты для нашего модуля светодиодного счетчика. Файлы тестов можно расположить в папке Tests:
#include "gtest/gtest.h"
extern "C" {
#include "led_counter.h"
}
// Test fixture
class LedCounterTest: public ::testing::Test
{
protected:
void SetUp()
{
Led_Counter_Init();
}
};
// Check initial value
TEST_F(LedCounterTest, TestInitialValue)
{
Led_Counter_Init();
ASSERT_EQ(Led_Counter_Get_Next(), 1);
}
// Check how value is incremented
TEST_F(LedCounterTest, TestIncrementValue)
{
Led_Counter_Init();
unsigned int val = Led_Counter_Get_Next();
for(int i=0;i<1;i++)
{
ASSERT_EQ(Led_Counter_Get_Next(), ++val);
}
}
// Check how value return to 0 after 3
TEST_F(LedCounterTest, TestZeroCrossing)
{
Led_Counter_Init();
for(int i=0;i<3;i++)
{
Led_Counter_Get_Next();
}
ASSERT_EQ(Led_Counter_Get_Next(), 0);
}
Чтобы результаты тестов отображались в красивом окошке, нужно создать новую конфигурацию запуска в меню Run > Debug Configurations. Установленный плагин позволяет создавать конфигурации типа C/C++ Unit. Создадим ее, назовем Run Tests, выберем используемую конфигурацию сборки «Test» и снимем галку «stop on startup at» на вкладке Debugger. После этого конфигурацию можно запустить.
Для появления окна с результатами его нужно выбрать в Window > Show View > Other > C/C++ > C/C++ Unit.
Готово! Теперь проект можно компилировать и запускать под целевой МК как обычно. Когда нужно будет запустить локальные тесты, при запуске конфигурации Run Tests проект автоматически будет пересобран под x86, среда выполнит тесты и покажет результат.
Литература
- J. Grenning. Test-Driven Development for Embedded C. – фундаментальный труд про модульное тестирование embedded-систем и про применение методологии TDD.
- https://uncannier.com/unit-testing-of-embedded-firmware-part-1-software-confucius/ — Unit-тестирование микроконтроллерного кода на x86 в Code Composer Studio от Texas Instruments, фреймворк CppUTest
- http://blog.atollic.com/why-running-your-embedded-arm-cortex-code-on-a-host-pc-is-a-good-thing — статья о том, почему может быть полезно запускать код для микроконтроллера на десктопной платформе
Автор: amvasil-v