Юнит-тесты в uVision Keil (и не только)

КПДВ

Не утихают споры о том, нужны ли юнит-тесты вообще, а если нужны — то как именно их писать. Сначала писать код или сначала писать тесты? Допустимо ли нарушать инкапсуляцию при тестировании или же можно трогать только публичное API? Сколько процентов кода должно быть покрыто тестами?

Тестирование во встраиваемых системах тоже порождает немало споров. Точки зрения разнятся от «покрытие должно быть 100% + нужны испытательные стенды» до «какие еще тесты, я программу написал — значит все работает».

Я не хочу начинать холивар и вооще стараюсь придерживаться некоего разумного баланса. Поэтому для начала предлагаю рассмотреть самые «низко висящие» плоды, которые позволяет сорвать юнит-тестирование применительно к embedded-разработке.


  1. Можно писать код для устройства, которого у вас еще нет. Это может быть датчик с алиэкспресса, который почтой идет две недели или какой-нибудь отечественный девайс со сроком поставки в полгода — это не столь важно.
  2. Можно менять код и не бояться, что ваши изменения сломают что-то, что работало раньше (если это что-то покрыто тестами, разумеется).
  3. Следствие предыдущего пункта: вы можете передавать разработку другому человеку и тоже не бояться, что он сломает что-то, что раньше работало.
  4. Если вы пишете какие-нибудь библиотеки (в минимуме — просто какой-то код, который используется не один раз), то с помощью тестов вы можете синтезировать ситуации, которые «руками» можно ловить очень долго.
  5. В тестовой конфигурации можно использовать всякие дополнительные инструменты для оценки качества/надежности кода — санитайзеры, опции компилятора для защиты от переполнения стека и тому подобные вещи, которые в «боевую» прошивку не влезают

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

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

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

Далее пойдет речь о юнит-тестировании кода для встраиваемых систем — в основном, микроконтроллеров (МК). Предполагается что код на С и С++


Содержание, ссылки в котором, кажется, не работают

На мой взгляд, глобально можно выделить 3 способа:


  1. На обычном ПК (настольном или на тестовом сервере, не суть)
  2. На физическом МК — т.е. на специальной тестовой плате
  3. В симуляторе МК

Давайте разберем плюсы и минусы каждого подхода.


На обычном ПК

Плюсы:


  • тесты выполняются максимально быстро
  • у ПК много памяти, тестовый бинарник может быть существенно больше, чем релизная прошивка
  • не нужна физическая плата; тестовое окружение можно сделать легко воспроизводимым
  • можно использовать дополнительный инструментарий — valgrind, санитайзеры
  • легко выводить результаты тестов — в stdout, в красивое окно, в текстовый файл — на ваш вкус

Спорный момент:

Другой компилятор и другая стандартная библиотека. Это плюс, потому что позволяет отловить платформозависимый код, получить больше предупреждений от нового компилятора. Но не факт, что вы к этому стремитесь. Минус, собственно, в этом же — придется писать платформонезависимый код, скорее всего, оборачиваясь в условную компиляцию; иногда придется подкостыливать код для работы под компилятором, который вам вообще и не нужен для собственно рабочей прошивки.

Минусы:


  • почти наверняка другая архитектура процессора — т.е. какие-то вещи могут работать не так, как в релизной прошивке на целевом устройстве (например, какие-нибудь тонкие отличия между программной реализацией плавающей точки в МК и аппаратной у х86)
  • нужно держать параллельный проект для другого компилятора/системы сборки. Трудоемкость этого зависит от системы сборки, которую вы выберете.
  • трудно тестировать прерывания
  • трудно тестировать работу с ОСРВ


На физическом МК

Плюсы:


  • архитектура та же, что у целевого устройства
  • тесты выполняются достаточно быстро
  • легко тестировать прерывания и работу с ОСРВ
  • можно тестировать работу с внешними устройствами

Спорный момент все тот же, только с другой стороны — компилятор, стандартная библиотека и рантайм такие же, как и в релизе.

Минусы:


  • мало памяти — как для кода, так и оперативной
  • нужна физическая тестовая плата, источник питания и т.д.
  • нужен какой-то интерфейс для связи с ПК или с чем-то еще, чтобы выводить результаты тестирования
  • короче, нужен тестовый стенд


В симуляторе

Плюсы:


  • архитектура примерно та же, что и на целевом устройстве
  • не нужна плата или тестовый стенд
  • симулированной памяти может быть много
  • если симулятор сможет, то можно тестировать прерывания и работу с ОСРВ

Спорный момент все тот же.

Минусы:


  • нужен симулятор
  • самый медленный вариант из трех
  • симулятор не совсем идентичен физическому МК, это может породить тонкие проблемы

Следующий интересный вопрос — что покрывать тестами, а что нет?

С одной стороны, некоторые вещи тестить легко, а некоторые — тяжело. С другой, некоторые вещи как будто сильнее нуждаются в тестах, а другие — слабее.

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

А, скажем, код для управления BLDC-двигателем тестируется тяжело, потому что для этого нужна, фактически, виртуальная модель двигателя. Или, скажем, попиксельное рисование на каком-нибудь OLED-экране гораздо проще проверять визуально.

А вот насколько сильно вещи нуждаются в тестах, сильно зависит от задачи.

Что же выбрать? Тут я, опять-таки, не берусь давать универсальных советов, а вместо этого попытаюсь обосновать наш выбор с исторической точки зрения. Если вам это не очень интересно, то можете следующую главу смело пропустить.

Сперва специфика. Тут я не придумал, как можно эти факты упорядочить или сгруппировать, поэтому просто перечислю.

Сперва вещи, над которыми программисты не властны:


  • проектов (т.е. изделий) много, их разработка часто происходит одновременно. Длительность проектов от полугода до полутора лет; что-то более долгоиграющее попадается редко
  • каждое изделие содержит одну, но чаще несколько плат с микроконтроллерами, эти платы связываются друг с другом какими-нибудь интерфейсами, поскольку должны взаимодействовать
  • изделия сами по себе разные, но в них есть повторяющиеся или похожие «куски», как-то:
    • общение по интерфейсам связи между платами
    • управление DC и BLDC двигателями
    • общение с разнообразными датчиками и прочими покупными изделиями
    • работа с матричными клавиатурами и матрицами светодиодов
  • некоторые изделия должны быть выполнены на отечественной элементной базе

А остальное — просто «исторически сложилось», иногда без всякой рациональной причины:


  • почти всегда действует правило «одна прошивка — один разработчик». С одной стороны это снижает трудозатраты на слияния, но с другой стимулирует людей «окукливаться» в своем мирке, не обмениваться кодом и переизобретать велосипеды
  • вся команда сидела под Windows, в качестве IDE использовался uVision Keil. Тут нет рациональных причин, только рационализации :)
  • МК — только ARM Cortex; в основном — STM32, для «отечки» — Миландр. Во многом дело вкуса.
  • изначально разработка велась на чистом С, но очень медленно перешла на С++
  • никто из команды (включая меня) не учился на программиста
  • никаких аджайлов, скрамов и прочих методов выпаса кошачьих не применялось
  • начиналась вся эта история примерно в 2013 году — C++11 появился совсем недавно, Keil был версии 4.20 вроде бы и С++11 не поддерживал

К чему же все это привело? А вот к чему.

Разработчики разобщены и стремятся закуклиться, поэтому любые инновации нужно подавать постепенно; они не должны требовать установки 10 программ и полной смены рабочего процесса. Поэтому отпадает вариант «тестирование на ПК» — это потребовало бы ставить какую-то другую IDE, другой компилятор, вести в нем параллельный проект — слишком сложно.

Поэтому же отпадают все варианты, которые требуют ставить Linux (например, запуск тестов в QEMU).

Использование тестовой платы отпадает по умеренно-объективным причинам — нужна плата (и желательно не одна), просто слишком много возни по сравнению с чисто программными вариантами.

Таким образом историческая неизбежность приводит нас к запуску тестов в симуляторе Keil. А отсутствие хороших програмистстких практих подталкивает к велосипедостроению :)


Выбор фреймворка

Я, честно говоря, уже плохо помню, чем я руководствовался, когда выбирал фреймворк, слишком много лет прошло. Кажется, я успел посмотреть на GoogleTest, но счел его слишком большим и сложным, фреймворки на чистом С были страшненькими и либо нуждались в ручной регистрации каждого теста, либо опирались на сторонние скрипты, с которыми связываться было не очень охота.
А у CppUTest был пример для IAR’a (а это почти как Keil).

В целом, это не очень важно. Главное, что CppUTest достаточно легко скомпилировался в Keil’e; для этого пришлось создать всего один файл, наполненный платформозависимыми функциями. Практически все оттуда я передрал из аналогичного файла для IAR, если честно.

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


UtestPlatform.cpp
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

#include "CppUTest/TestHarness.h"
#include "CppUTest/PlatformSpecificFunctions.h"
#include "CppUTest/TestRegistry.h"

// скопировано из ИАРа

static jmp_buf test_exit_jmp_buf[10];
static int jmp_buf_index = 0;

int PlatformSpecificSetJmp(void (*function) (void* data), void* data)
{
    if (0 == setjmp(test_exit_jmp_buf[jmp_buf_index])) {
        jmp_buf_index++;
        function(data);
        jmp_buf_index--;
        return 1;
    }
    return 0;
}

void PlatformSpecificLongJmp()
{
    jmp_buf_index--;
    longjmp(test_exit_jmp_buf[jmp_buf_index], 1);
}

void PlatformSpecificRestoreJumpBuffer()
{
    jmp_buf_index--;
}

void PlatformSpecificRunTestInASeperateProcess(UtestShell* shell, TestPlugin* plugin, TestResult* result)
{
   printf("-p doesn't work on this platform as it is not implemented. Running inside the process\b");
   shell->runOneTest(plugin, *result);
}

TestOutput::WorkingEnvironment PlatformSpecificGetWorkingEnvironment()
{
    return TestOutput::vistualStudio;
}

///////////// Time in millis

static long TimeInMillisImplementation()
{
    return 12345;
}

static long (*timeInMillisFp) () = TimeInMillisImplementation;

long GetPlatformSpecificTimeInMillis()
{
    return timeInMillisFp();
}

void SetPlatformSpecificTimeInMillisMethod(long (*platformSpecific) ())
{
    timeInMillisFp = (platformSpecific == 0) ? TimeInMillisImplementation : platformSpecific;
}

///////////// Time in String

static const char* TimeStringImplementation()
{
    return "Keil time needs work";
}

static const char* (*timeStringFp) () = TimeStringImplementation;

const char* GetPlatformSpecificTimeString()
{
    return timeStringFp();
}

void SetPlatformSpecificTimeStringMethod(const char* (*platformMethod) ())
{
    timeStringFp = (platformMethod == 0) ? TimeStringImplementation : platformMethod;
}

int PlatformSpecificAtoI(const char*str)
{
   return atoi(str);
}

size_t PlatformSpecificStrLen(const char* str)
{
   return strlen(str);
}

char* PlatformSpecificStrCat(char* s1, const char* s2)
{
   return strcat(s1, s2);
}

char* PlatformSpecificStrCpy(char* s1, const char* s2)
{
   return strcpy(s1, s2);
}

char* PlatformSpecificStrNCpy(char* s1, const char* s2, size_t size)
{
   return strncpy(s1, s2, size);
}

int PlatformSpecificStrCmp(const char* s1, const char* s2)
{
   return strcmp(s1, s2);
}

int PlatformSpecificStrNCmp(const char* s1, const char* s2, size_t size)
{
   return strncmp(s1, s2, size);
}
char* PlatformSpecificStrStr(const char* s1, const char* s2)
{
   return (char*) strstr(s1, s2);
}

int PlatformSpecificVSNprintf(char *str, size_t size, const char* format, va_list args)
{

    char* buf = 0;
    int sizeGuess = size;

    int result = vsnprintf( str, size, format, args);
    str[size-1] = 0;
    while (result == -1)
    {
        if (buf != 0)
            free(buf);
        sizeGuess += 10;
        buf = (char*)malloc(sizeGuess);
        result = vsnprintf( buf, sizeGuess, format, args);
    }

    if (buf != 0)
        free(buf);
    return result;

}

PlatformSpecificFile PlatformSpecificFOpen(const char* filename, const char* flag)
{
   return fopen(filename, flag);
}

void PlatformSpecificFPuts(const char* str, PlatformSpecificFile file)
{
   fputs(str, (FILE*)file);
}

void PlatformSpecificFClose(PlatformSpecificFile file)
{
   fclose((FILE*)file);
}

void PlatformSpecificFlush()
{
  fflush(stdout);
}

int PlatformSpecificPutchar(int c)
{
  return putchar(c);
}

void* PlatformSpecificMalloc(size_t size)
{
   return malloc(size);
}

void* PlatformSpecificRealloc (void* memory, size_t size)
{
   return realloc(memory, size);
}

void PlatformSpecificFree(void* memory)
{
   free(memory);
}

void* PlatformSpecificMemCpy(void* s1, const void* s2, size_t size)
{
   return memcpy(s1, s2, size);
}

void* PlatformSpecificMemset(void* mem, int c, size_t size)
{
    return memset(mem, c, size);
}

double PlatformSpecificFabs(double d)
{
   return fabs(d);
}

int PlatformSpecificIsNan(double d)
{
    return isnan(d);
}

int PlatformSpecificVSNprintf(char *str, unsigned int size, const char* format, void* args)
{
    while(1);
    return 0;
   //return vsnprintf( str, size, format, (va_list) args);
}

char PlatformSpecificToLower(char c)
{
    return tolower(c);
}

Помимо этого файла, во всем проекте пришлось разрешить исключения (ключом --exceptions) и выделить достаточно много памяти в стеке и в куче (килобайт по 15 примерно). Чтобы не засорять этим сборку, для тестов я создал отдельную конфигурацию — в Keil это называется «target».

Ну и, конечно же, пришлось убрать ключ --c99, прописанный в опциях компилятора, потому что с ним Keil даже.срр-файлы пытался компилировать как сишные.

В последних версиях Keil’a этой проблемы нет, поскольку режим С99 включается галочкой, которая действует только на файлы с расширением.с.


Вывод результатов

Вывод CppUTest делает просто printf’ами в терминал, но ведь у МК нет терминала. Как же быть?

На отладочной плате можно было бы воспользоваться выводом в UART, а результаты на компе смотреть, скажем, в putty —, но симулятор Keil’a далеко не для всех МК поддерживает симуляцию UART’a.

К счастью, у многих МК на ядре Cortex есть специальный отладочный интерфейс ITM. Если пользоваться полноразмерным разъемом JTAG (или если вы пользуетесь отладчиком STLink через разъем SWD, то у этого разъема должна быть подключена нога SWO) и ваш аппаратный отладчик этот самый ITM поддерживает, то в stdout можно в него и перенаправить.

При этом, поскольку ITM является периферией уровня ядра, симулятор Keil’a поддерживает его всегда! Вывод при этом появляется в отладчике, в окне View->Serial windows->Debug (printf).

CppUTest printf

Чтобы сделать это перенаправление, потребуется переопределить несколько функций стандартной библиотеки. Это я делал методом проб и ошибок, на полноту и правоту не претендую, но вроде бы работает без нареканий. Перенаправлял я только stdout, поскольку в stdin и stderr нужды не испытывал.


retarget
#if __CC_ARM || ( (__ARMCC_VERSION) && (__ARMCC_VERSION >= 6010050) )

    // armclang
    #if ( (__ARMCC_VERSION) && (__ARMCC_VERSION >= 6010050) )

        asm(".global __use_no_semihosting_swi\n");

    // armcc    
    #elif __CC_ARM

        #pragma import(__use_no_semihosting_swi)

        namespace std { struct __FILE { int handle;} ; }

    #endif

    #include 
    #include 
    #include 

    std::FILE std::__stdout;
    std::FILE std::__stdin;
    std::FILE std::__stderr;

    extern "C"
    { 
        int fputc(int c, FILE *f)
        {
            return ITM_SendChar(c);
        }

        int fgetc(FILE *f)
        {
            char ch = 0;

            return((int)ch);
        }

        int ferror(FILE *f)
        {
            /* Your implementation of ferror */
            return EOF;
        }

        void _ttywrch(int ch)
        {
            ITM_SendChar(ch);            
        }

        char *_sys_command_string(char *cmd, int len)
        {
            return NULL;
        }

        // вызывается после main
        void _sys_exit(int return_code) 
        {
            while(1) { __BKPT(0xAA); }
        }        
    }
#endif


Сами тесты

Тут, на самом деле, никаких отличий от обычных тестов в CppUTest нету, но я все же приведу пример для наглядности

TEST(Loader, FirmwareCrcCheck_ErrorOnCheck)
{
    makeLoaderReadyForBlockAddress(testLoader);

    sendSuccessfulWriteRandomDataBlock(testLoader, test_chunk_size);

    mockSender.isSent = false;

    // теперь флешер скажет, что срс не сходится
    mockFlasher.curMode = MockFlasher::Modes::ALL_FW_ERR;

    uint32_t crc = 0x11223344;
    uint32_t fwEnd = mockFlasher.getFreeFlashAddrMin() + Loader_Data_Block_Size;

    sendFwCrcCheck(testLoader, crc, fwEnd);

    checkIfError();

    CHECK( mockFlasher.isAllFwChecked == true);

    // мы записывали только один блок
    CHECK( mockFlasher.fwStart == mockFlasher.getFreeFlashAddrMin() );
    CHECK( mockFlasher.fwEnd == fwEnd);
    CHECK( mockFlasher.fwCrc == crc);
}


Впечатления от CppUTest

Плюсы:


  • быстро заработал в симуляторе
  • не требуется ручная регистрация тестов

Минусы:


  • требует С++, все тесты компилируются как код на С++ (но этот минус нивелируется переходом на С++)
  • относительно много файлов в фреймворке
  • требуется динамическая память и исключения. Справедливости ради скажу, что вместо исключений можно использовать setjmp/longjmp, но уж лучше исключения!
  • поскольку исключения используются постоянно, отладка по шагам превращалась в очень увлекательное путешествие по библиотечному коду с внезапными прыжками туда-сюда
  • поскольку тесты — это методы, названия тестов должны быть валидными идентификаторами. То есть CamelCase или snaking_case, но NeitherOfThemIsVeryReadable when_test_name_is_long_enough, а названия тестов хочется делать подробными.
  • макросов для сравнения как-то уж слишком много:
    • CHECK — окей, все ясно
    • CHECK_TEXT — хорошо, проверка с поясняющим текстом
    • CHECK_FALSE — хмм, но почему бы не написать CHECK( .. == false)?
    • CHECK_EQUAL — э?
    • STRCMP_EQUAL — стоп, серьезно? Но я ведь сам могу strcmp написать…
    • STRNCMP_EQUAL — да ладно
    • LONGS_EQUAL — CppUTest остановись, ты пьян!
    • UNSIGNED_LONGS_EQUAL — аааа!!!
      Нет, я все понимаю, у специализированных макросов обычно более понятный вывод, но это уже явно перебор! Да и Catch2 как-то справляется с одним REQUIRE.
  • к тому же, мне весь этот более понятный вывод был до лампочки, поскольку я почти сразу начал практиковать TDD; в 90% случаев я заранее знал, какая именно проверка должна провалиться
  • когда тест валился, в терминал выводилось сообщение типа такого:
src\Core\Loader\Bootloading\loader_test.cpp(815): error: Failure in TEST(Loader, SuccessfulWriteBlockWithoutErase)
        CHECK(mockFlasher.isBlockWritten != true) failed

Errors (1 failures, 76 tests, 76 ran, 1942 checks, 0 ignored, 0 filtered out, 0 ms)

и потом провалившуюся проверку приходилось отыскивать по имени теста и номеру строки


  • несколько раз тесты падали, потому что где-то в глубинах фреймворка не хватало стека или кучи, но понять это было очень сложно; неопытному мне из прошлого код казался очень запутанным

Разумеется, почти все эти минусы совершенно субъективные. У CppUTest большая и преданная аудитория, которую, вероятно, все устраивает. Но я решил на этом не останавливаться и двинулся дальше. А дальше мне на глаза попался munit.


Munit

Как я уже упоминал когда-то давно в своем посте про юнит-тесты на чистом С, munit — это самый маленький фреймворк для юнит-тестирования; настолько маленький, что его можно привести прямо в тексте статьи целиком:

#define mu_assert(message, test) do { if (!(test)) return message; } while (0)
#define mu_run_test(test) do { char *message = test(); tests_run++; \
                              if (message) return message; } while (0)
extern int tests_run;

Для меня в тот момент это было просто откровение! Никакого С++, никаких классов для тестов, произвольные строки в качестве названий и просто return 0, если все проверки пройдены! Никакой чехарды с исключениями!

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

Этим фреймворком мы даже успели попользоваться какое-то время, но после относительно масштабного проекта, так же написанного на С, но в ООП стиле, мы, вдоволь нахлебавшись виртуального наследования вручную, сказали ХВАТИТ ЭТО ТЕРПЕТЬ и решили переходить на С++.

Ну, а раз вся разработка переходит на С++, то можно и тестовый фреймворк обновить и избавиться от этой мути с BOOST_PP_COUNTER.


UmbaCppTest

И так был рожден новый велосипедный фреймворк для юнит-тестирования! Ееее! Кратко пробегусь по ключевым фишкам:


  • автоматическая регистрация тестов (легко, когда есть конструкторы)
  • названия тестов — произвольные строки
  • не требуются исключения
  • не требуется динамическое выделение памяти
  • минимум макросов для проверок — фактически, только UMBA_CHECK( cond, text ), но text — опциональный
  • фреймворк из двух файлов (один.срр и один .h)
  • «киллер-фича»: если проверка провалилась, то отладка останавливается на проблемной строке! Это тоже легко, симулятор корректно выполняет ассемблерную инструкцию BKPT; очень удобно!
  • киллер-фича опциональная, можно как обычно — просто вывести в терминал сообщение, что тест провалился и выполнять тесты дальше
  • поскольку фреймворк самодельный, его можно было легко и быстро дорабатывать по ходу дела, допиливать фичи, которые нужны только нам

Вот он на гитхабе.

Пример теста:

UMBA_TEST("Check timer with no overflow - one-shot timer shall be handled")
{
    using namespace time_service;

    for( uint32_t i=0; i<1000; i++ )
    {
        time_service::setCurTime_mcs( Microsec{ i*1000 } );
        time_service::setCurTime_ms( i );

        testLwipTask.work( i );

        if( mockCallbacks.flag == true )
            break;
    }

    UMBA_CHECK( mockCallbacks.flag == true );

    return 0;
}

А запуск всех тестов выглядит как-то так:

int main()
{

    #ifdef USE_TESTS

        umba::runAllTests();

        __BKPT(0xAA);
        while(1) {}

    #endif

При этом пользователь должен создать конфигурационный файл для фреймворка, который обязан называться umba_cpp_test_config.h и определить в нем несколько вещей:

#pragma once

#include 

// включить логирование
#define UMBA_TEST_LOGGING_ENABLED        1

// выключить подвисание на упавшем тесте
#define UMBA_TEST_HANG_ON_FAILED_TEST_ENABLED      1

#define UMBA_TEST_DISABLE_IRQ()  __disable_irq()

namespace umba
{
    // максимальное количество тестов в одной группе
    const uint32_t tests_in_group_max = 200;

    // максимальное количество групп тестов
    const uint32_t groups_max = 100;

}

Конечно, фреймворк очень простой, поэтому минусов у него тоже полно:


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

Как уже было сказано вначале, одним из самых удобных и полезных для юнит-тестирования вещей являются модули связи — всякие парсеры протоколов, модули общения с датчиками и тому подобное.

Как же их тестировать?

Возможны варианты. Для начала, что вообще нужно от интерфейса связи коду, который им пользуется? Например, от UART’a в самом минимуме нужно не так уж много:


  • принять байт
  • отправить байт

Принятый байт можно «скармливать» пользовательскому коду как параметр у функции, а для отправки пользоваться одним коллбэком.
Но отправлять один байт не всегда удобно, хочется отправлять массив. Окей, не проблема, для этого тоже хватит одного коллбэка.
Потом оказывается, что иногда нужно можно отправлять массив блокирующе (т.е. колбэк может не возвращать управление, пока не отправит массив целиком), а иногда нельзя.
Иногда массив локальный — и тогда в неблокирующем варианте его приходится куда-то копировать —, а иногда он слишком большой, чтобы его куда-то копировать, но зато статический.

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

Короче, передавать 10 разных коллбэков по одному как-то глупо. Само собой напрашивается решение — сделать класс «обертка над UART’ом», а в нем методы. И тогда для каждого UART’a мы просто создаем экземпляр такой обертки.

Допустим. Но как же их тогда тестировать? Нам нужно, чтобы в тестовой сборке методы делали одно, а в релизной — другое.

По факту, нам нужен полиморфизм. И вот тут начинаются вопросы.


  • С одной стороны, для этого не нужен полиморфизм времени выполнения, т.е. можно обойтись ifdef’ами
  • С другой, это как-то не красиво; у класса фактически поменяется вся реализация от одного ifdef’a. Некрасиво.
  • С третьей, есть относительно идиоматичное решение — CRTP, которое дает полиморфизм на этапе компиляции с помощью шаблонов.
  • А с четвертой есть «обычный» полиморфизм из С++ — через виртуальное наследование.

Отметая вариант с ifdef’ами по эстетическим соображениям, давайте посмотрим на CRTP.
Для тех, кто не в курсе, выглядит это как-то так:

template 
class Amount
{
public:
    int getValue() const
    {
        return static_cast(*this).getValue();
    }
};

class Constant42 : public Amount
{
public:
    int getValue() const { return 42; }
};

Соответственно, класс Amount тут является статическим интерфейсом, а класс Constant42 его реализует.

К сожалению, у такого подхода есть 2 большие проблемы:


  • Amount — это не класс, это шаблон класса. Соответственно, мы не можем передать указатель на него в клиентский код. Мы вынуждены делать клиентский код шаблонным! И вот это реально проблема, потому что Keil не очень хорошо позволяет отлаживать шаблоны и прочий код в заголовочных файлах — на него иногда невозможно поставить точку останова, невозможно прошагать в отладке. И насколько мне известно, у многих IDE такая проблема есть, в той или иной степени.
  • Нельзя забывать, что мои коллеги в тот момент, когда эти вопросы в первый раз поднимались, только-только начали переползать на С++. Обычные шаблоны выглядят страшно, а ЭТО вызывало легкую панику.

Соответственно, остается вариант с обычным полиморфизмом времени выполнения.
Но как же так, возможно вскричали сейчас матерые разработчики? Ведь виртуальные вызовы это очень дорого, это таблица виртуальных методов, это дополнительный оверхед при вызове!

И да, действительно, это несколько дороже, чем хотелось бы. Тем не менее:


  • таблица виртуальных методов — если этих методов не слишком много — весит пару сотен байт.
  • виртуальный вызов дороже на несколько десятков команд. Поскольку у МК обычно нет кэш-памяти, виртуальный вызов не ведет к кэш-промаху, соответственно время вызова увеличивается незначительно.

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

У такого подхода, как ни странно, есть менее очевидный минус. В Keil’e есть простая оптимизация, которая очень существенно сокращает размер бинарника — выбрасывание неиспользуемых функций.

И вот она-то от наличия таблицы виртуальных методов ломается — ведь на метод есть указатель! Значит, он используется. С этим можно бороться, но как показала практика — особого смысла в этом нет.

Если бы мы пользовались слабыми МК с небольшим количеством памяти и низкой тактовой частотой, то разговор скорее всего был бы совсем иной. Но даже весьма бюджетные модели STM вполне справляются. Да, средний размер бинарника вырос — ну… и ладно? До тех пор, пока в условия технического задания мы вписываемся — кому какая разница?

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

Соответственно, ответ на вопрос раздела простой (но на всякий случай я его проговорю) — для каждого интерфейса связи делаем интерфейс (каламбур не намеренный) — т.е. класс с чисто-виртуальными методами.
Во всем пользовательском коде используем только этот интерфейс.

В релизной прошивке в пользовательский код передаются объекты нормального класса, а в тестовом — моки (mock). Скажем, в мок для UART’a можно передать массив, который пользовательский класс потом «примет» через метод для чтения входящих байт.

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

Собственно, для этого почти во всех тестовых фреймворках используются функции setup и teardown; которые инициализируют и деинициализируют объект соответственно.

Как можно инициализировать объект?


  • В конструкторе
  • В методе init

Казалось бы, предпочтительнее инициализация в конструкторе — ее невозможно забыть, т.е. невозможно создать объект в невалидном состоянии.

К сожалению, в embedded иногда приходится создавать глобальные объекты — в основном, чтобы взаимодействовать с прерываниями. А выполнять какой-то сложный код в конструкторе глобального объекта чревато static initialization order fiasco, т.е. можно нарваться на зависимость от какого-то другого глобального объекта из другой единицы трансляции.

Но на самом деле не так важно, как объект инициализировать; в функциии setup мы просто создадим объект (и может быть вызываем для него метод init). А вот как его деинициализировать?


  • В деструкторе
  • В методе deInit

И вот тут возникает неприятность. В embedded очень многие объекты имеют бесконечное время жизни — потому что main никогда не завершается. Прошивка должна работать, пока на устройстве есть питание;, а когда питания нет — уже завершаться поздно.

Поэтому большинству объектов не нужны ни деструкторы, ни методы для деинициализации. А раз они не нужны — писать их специально для тестов как-то не очень хочется.
При этом вполне вероятно, что объект не владеет ресурсами, которые реально нужно деинициализировать, было бы вполне достаточно просто сконструировать объект заново.

Как сконструировать объект заново?


  • просто вызвать init еще раз
  • создать новый объект, а старый удалить

Подход с методом init обладает неприятным моментом — таким образом не получится заново заполнить константные или ссылочные поля. А чтобы создавать и удалять объекты, нужна динамическая память — традиции ембедеров не велят мне использовать ее… Впрочем, в тестовой сборке это вполне допустимо, а иногда даже обязательно — например, CppUTest без кучи работать не хочет.

Тем не менее, если вам тоже неприятно использовать динамическую память, есть другой способ переконструировать объект — placement new! Это относительно малоизвестная фича С++, которая, по-факту, позволяет вызывать конструктор явным образом для заранее выделенного куска памяти.

У placement new есть свои подводные камни (скажем, не совсем понятно, как таким образом конструировать массивы), но для наших целей это не существенно — вполне достаточно создать статический объект файле с тестами, а перед каждым тестом (или после каждого теста) переконструировать его in place.

Это удобно делать с помощью небольшой вспомогательной функции (которую, кажется, невозможно в общем виде написать без С++11):

template< typename T, typename ... Args >
void inplace_new( T & object, Args && ... args )
{
    auto t = &object;
    new (t)T{ std::forward(args)... };
}

A a(1,2,3);

inplace_new( a, 3,4,5 );

У такого варианта тоже есть один неприятный момент — компилятор armcc (он же Arm Compiler 5), который все еще является компилятором по-умолчанию при создании проекта в Keil’e, поддерживает С++11 очень странно.
Из языковых конструкций поддерживается почти все, но вот стандартная библиотека осталась от С++03. Поэтому std::forward в нем использовать не получится — только если свой писать.

Соответственно, в Keil’e придется или использовать placement new напрямую, или перелезать на «шестую версию компилятора», которая на самом деле clang, или обходиться без forward’a. Ну или написать свой forward.

Если у объекта все-таки есть необходимость в какой-то деинициализации, то при таком подходе нужно не забывать явно вызывать деструктор!

На случай, если кто-то не в курсе: ассерты — это проверки условий, которые должны быть истинными; проваленный ассерт как правило означает ошибку в логике программы.
Как правило, ассерт программу экстренно завершает с помощью вызова std::terminate, exit(1) или чего-то вроде того.

Зачем нужны ассерты? В основном для проверок предусловий (т.е. корректности входных параметров у функций) — и постусловий (корректности результатов).

Для встраиваемых систем возникают обычные вопросы:


  • что делать, если ассерт провалился?
  • допустимо ли оставлять ассерты в «релизной» прошивке?
  • допустимо ли их вообще писать или все ошибки должны обрабатываться?
  • какой ассерт использовать?
  • нужно ли их тестировать и, если да, то как?


Что делать, если ассерт провалился

Самое простое, что можно сделать — это пустой бесконечный цикл:

while(1) {}

Насколько я знаю, примерно так и поступают почти всегда, потому что сходу непонятно, что еще можно сделать — даже вызов std::terminate в конце концов упрется в пустой цикл, который сгенерировал компилятор.

Пустой цикл не очень хорош по двум причинам:


  • не сразу понятно, что ассерт сработал, даже под отладкой
  • прерывания могут прерывать этот пустой цикл и продолжать что-то делать

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

#define UMBA_ASSERT( statement )     do { if(! (statement) ) { __disable_irq(); while(1){ __BKPT(0xAC); if(0) break;} }  } while(0)


Допустимо ли оставлять ассерты в «релизной» прошивке

Тут каждый решает сам, в зависимости от сроков и серьезности проекта. Я часто оставляю, потому что:


  • лучше пусть сработает ассерт, чем прошивка продолжи выполнение с бредовыми данными
  • от зависания намертво помогает watchdog
  • далеко не всегда есть способ просигнализировать об ошибке хоть как-то


Допустимо ли вообще писать ассерты или все ошибки должны обрабатываться

Ответ практически повторяет предыдущий пункт, но к этому можно добавить следующую мысль.

Если используются велосипедные библиотеки (или просто какой-то общий код), то в них ассерты становятся нужны для гарантии правильного использования.


Какой ассерт использовать


Ассерт времени выполнения:

Чисто теоретически, есть заголовочный файл assert.h, но как-то я особо не видел, чтобы им в embedded пользовались.
Вероятно, потому что реализация поведения при срабатывании ассерта будет зависимой от компилятора.

Поэтому — опять велосипеды. Но это ведь С++, тут все привыкли, что у каждо

© Habrahabr.ru