TDD для микроконтроллеров. Часть 1: Первый полет
TDD для микроконтроллеров. Часть 2: Как шпионы избавляют от зависимостей
TDD для микроконтроллеров. Часть 3: Запуск на железе
В предыдущей статье мы начали освещать тему эффективности применения методологии TDD для микроконтроллеров (далее – МК) на примере разработки прошивки для STM32. Мы выполнили следующее:
- Определили цель и инструменты разработки.
- Настроили IDE и фреймворк для написания тестов.
- Написали тест-лист для разрабатываемого функционала.
- Создали первый простой тест и запустили его.
В этой статье расскажем, как мы применили методологию TDD для реализации тестов из тест-листа и написания кода прошивки для их успешного выполнения. При написании тестов будем использовать специальные тестовые объекты для ликвидации зависимостей разрабатываемой логики от других программных модулей. В конце статьи мы представим бизнес-логику проекта и проанализируем особенности применения методологии TDD для реализации прошивки МК. Подробности – под катом.
Тест-лист
Вспомним тест-лист из прошлой статьи:
1. При получении команды read возвращаются данные, размещенные по указанному адресу на флеш-памяти.
2. При получении команды write производится запись данных по указанному адресу во флеш-память.
3. При получении команды erase производится стирание страницы с указанным номером.
4. При получении команды help выводится список поддерживаемых команд.
5. При получении неизвестной команды возвращается сообщение об ошибке.
Теперь приступим к реализации этих тестов с помощью CppUTest.
Цели проекта
Цель проекта – реализация возможности напрямую работать с энергонезависимой памятью МК: считывать, записывать значения ячеек и стирать страницы флеш-памяти с помощью UART-интерфейса. Команды будут передаваться по UART-интерфейсу в виде строк с кодировкой ASCII. Вышеперечисленный функционал будет использоваться непосредственно для работы с флеш-памятью МК. При этом пользователь должен помнить об основах работы с флеш-памятью: для корректной записи данных необходимо предварительно произвести стирание.
Основная идея проекта – привести простой пример использования методологии TDD для разработки прошивки МК. В нашем проекте сценарии достаточно просты, для каждого из них мы привели только один тест (мы не рассматривали тестирование граничных случаев). Этого может быть недостаточно для более сложных проектов.
Тест для команды help
Начнем с простого теста – теста № 4 для команды help. Для обработки help на МК достаточно отправить строку со списком поддерживаемых команд по UART-интерфейсу.
За основу написания тестов мы взяли алгоритм обработки команд от ПК по интерфейсу UART:
- Прием команды от ПК.
- Обработка команды.
- Отправка ответа на ПК.
P.S. Этот алгоритм идентичен для каждой команды.
Исходя из этого, бизнес-логика всего проекта заключается в обработке полученной от ПК команды с помощью вызова обработчика: основного метода класса Configurator
.
Прием и отправку данных можно реализовать в отдельном модуле Serial
, однако для разработки такого модуля следует использовать платформозависимый код для конкретного МК (в нашем случае – STM32F103C8T6). Но мы решили на данном этапе разработать платформонезависимую логику, для достижения этой цели использовали тестовый шпион SerialSpy
.
Написание теста для команды help:
TEST(Configurator, ShouldHandleHelpCommand)
{
// Arrange – установка входящей команды от ПК в буфер приема UART
char helpCommand[] = "help\r\n";
LONGS_EQUAL(OK, SerialSpy_SetReceiveBuffer(serial, helpCommand, sizeof(helpCommand)));
// Act – вызов обработчика команд
Status status = Configurator_Handler(configurator);
// Assert – проверка статуса обработки
LONGS_EQUAL(OK, status);
// проверка ответа, отправленного обратно на ПК по UART
char * sendBufferPtr = NULL;
LONGS_EQUAL(OK, SerialSpy_GetSendBuffer(serial, &sendBufferPtr));
STRCMP_EQUAL(HELP_OUTPUT, sendBufferPtr);
}
Структура теста:
- в блоке Arrange для установки буфера приема команды по UART используется тестовый шпион
SerialSpy
, а именно методSerialSpy_SetReceiveBuffer
; - в блоке Act вызывается обработчик
Configurator_Handler
, в котором будет производиться обработка входящей команды, полученной из буфера приема по UART. Далее отправляется ответ с помощью функции отправки по UART; - в блоке Assert выполняется проверка статуса обработки команды. Для проверки ответа получаем указатель на буфер отправки с помощью метода
SerialSpy_GetSendBuffer
, а затем с помощью макросаSTRCMP_EQUAL
сравниваем строкуHELP_OUTPUT
и строку в буфере отправки.
Как видно, в тесте три нереализованных метода: SerialSpy_SetReceiveBuffer
, Configurator_Handler
и SerialSpy_GetSendBuffer
. Поэтому для завершения этапа test fails необходимо написать пустые реализации этих методов (заглушки). Добавляем метод Configurator_Handler
в наш класс Configurator
и оставляем тело метода пустым.
Методы SerialSpy_SetReceiveBuffer
и SerialSpy_GetSendBuffer
реализуются с помощью тестового шпиона, который обычно применяется для проверки логики тестируемого объекта без использования зависимого объекта. Тестовый шпион заменяет зависимый объект, поэтому основной его функцией является запись данных или вызовов, поступающих из тестируемого объекта, с целью последующей проверки корректности взаимодействия тестируемого и зависимого объектов.
Мы использовали SerialSpy
, чтобы убрать зависимость основной логики класса Configurator
от драйвера Serial
. В файл SerialSpy.h
добавили прототипы методов, а в SerialSpy.c
– реализацию этих методов. Тестовые шпионы, как правило, содержат только минимально необходимый код для реализации тестов. В случае с SerialSpy
мы использовали два буфера:
•receiveBuffer
для входящих сообщений от ПК по UART;
•sendBuffer
для исходящих сообщений на ПК по UART.
Эти два буфера используются каждый раз при исполнении теста в методе Configurator_Handler
: в теле обработчика считывается и обрабатывается входящая команда, затем отправляется ответ на ПК. Поэтому с целью тестирования мы реализовали два метода:
• SerialSpy_SetReceiveBuffer
для установки входящей команды;
• SerialSpy_GetSendBuffer
для получения указателя на буфер исходящих сообщений, чтобы проверить корректность ответа.
// Common.h
typedef enum
{
OK = 0,
FAIL = -1,
INVALID_PARAMETERS = -4,
OUT_OF_BOUNDS = -13,
} Status;
// Configurator.h
Status Configurator_Handler(Configurator * self);
// Configurator.c
Status Configurator_Handler(Configurator * self)
{
}
// SerialSpy.h
#include "Common.h"
Status SerialSpy_SetReceiveBuffer(Serial * self, char * data, uint32_t len);
Status SerialSpy_GetSendBuffer(Serial * self, char ** bufferPtr);
//SerialSpy.c
#include "SerialSpy.h"
typedef struct SerialStruct
{
char receiveBuffer[SERIAL_RECEIVE_BUFFER_SIZE];
char sendBuffer[SERIAL_SEND_BUFFER_SIZE];
} SerialStruct;
Serial * Serial_Create(void)
{
Serial * self = (Serial*)calloc(1, sizeof(SerialStruct));
return self;
}
void Serial_Destroy(Serial * self)
{
if (self == NULL)
{
return;
}
free(self);
self = NULL;
}
Status SerialSpy_SetReceiveBuffer(Serial * self, char * data, uint32_t len)
{
if (self == NULL || data == NULL)
{
return INVALID_PARAMETERS;
}
if (len > SERIAL_RECEIVE_BUFFER_SIZE)
{
return OUT_OF_BOUNDS;
}
memcpy(self->receiveBuffer, data, len);
return OK;
}
Status SerialSpy_GetSendBuffer(Serial * self, char ** bufferPtr)
{
if (self == NULL || bufferPtr == NULL)
{
return INVALID_PARAMETERS;
}
*bufferPtr = self->sendBuffer;
return OK;
}
Можно сказать, что SerialSpy
симулирует работу драйвера UART. Подобные методы часто используются в TDD и unit-тестировании для быстрого написания и запуска тестов на локальном ПК. Это необходимо для обеспечения непрерывной итеративной разработки. Такие шпионы позволяют тестировать логику класса Configurator
с помощью локального ПК без использования отладочной платы с МК STM32. При этом нет необходимости заливать прошивку, чтобы убедиться в корректности работы написанной бизнес-логики, достаточно нажать на кнопку «Запуск» в Visual Studio и получить результат.
Из test-fails в test-passes
Для перехода к test-passes нужно заполнить тело метода Configurator_Handler
. При каждом его вызове будем проверять наличие входящих данных от ПК с помощью метода приема данных по UART Serial_ReceiveCommand
и в случае получения новых данных будем производить их обработку. Для команды help достаточно проверить, что в буфер поступила строка help
, после этого следует отправить ответ с помощью метода Serial_SendResponse
.
Взаимодействие основной логики класса Configurator
по UART осуществляется с помощью Serial
, поэтому в этот класс мы добавили поле Serial, а в конструктор Configurator_Create
– параметр Serial*
. После этого обновили блоки кода setup()
и teardown()
в соответствии с перечисленными изменениями.
// Configurator.h
// Добавляем вывод команды `help` и в конструктор параметр `Serial`
#include "Serial.h"
#define HELP_OUTPUT "Command list:\r\n - help\r\n - read: <flash_address_in_hex>\r\n - write: <flash_address_in_hex> <data_to_write>\r\n - erase: <flash_page_number_to_erase>\r\n>"
Configurator * Configurator_Create(Serial * serial);
// Configurator.c
// Для приема и отправки с помощью UART добавим Serial в Configurator
typedef struct ConfiguratorStruct
{
char command[SERIAL_RECEIVE_BUFFER_SIZE];
Serial * serial;
} ConfiguratorStruct;
static const char helpCommand[] = "help";
// Добавим инициализацию Serial в конструктор (и не забудем про деструктор)
Configurator * Configurator_Create(Serial * serial)
{
if (serial == NULL)
{
return NULL;
}
Configurator * self = (Configurator*)calloc(1, sizeof(ConfiguratorStruct));
if (self == NULL)
{
return NULL;
}
self->serial = serial;
return self;
}
// Реализуем тело метода Configurator_Handler
Status Configurator_Handler(Configurator * self)
{
if (self == NULL)
{
return INVALID_PARAMETERS;
}
// Получаем команду из буфера приема UART
Status status = Serial_ReceiveCommand(self->serial, self->command);
if (status != OK)
{
// При отсутствии данных UART вернется статус NO_DATA
return status;
}
// Обработка полученной команды
if (strstr(self->command, helpCommand) == NULL)
{
return UNSUPPORTED;
}
Status status = Serial_SendResponse(self->serial, HELP_OUTPUT);
return status;
}
// ConfiguratorTests.cpp
TEST_GROUP(Configurator)
{
Configurator * configurator = NULL;
Serial * serial = NULL;
void setup()
{
serial = Serial_Create();
configurator = Configurator_Create(serial);
}
void teardown()
{
Configurator_Destroy(configurator);
}
};
На данном этапе нам необходимо было реализовать методы приема и отправки драйвера Serial
, т. к. они используются в обработчике Configurator_Handler
. Мы реализовали их в SerialSpy
, чтобы исключить платформозависимый код и запускать тесты на локальном ПК. Для SerialSpy.c
и драйвера Serial.c
на конкретном МК используется один и тот же заголовочный файл – Serial.h
:
#pragma once
#include "Common.h"
#define SERIAL_RECEIVE_BUFFER_SIZE 32 // Размер буфера приема команды
#define SERIAL_SEND_BUFFER_SIZE 256 // Размер буфера для отправки ответа
typedef struct SerialStruct Serial;
Serial * Serial_Create(void);
void Serial_Destroy(Serial * self);
// Проверка наличия данных в буфере приема UART
// self – указатель на объект типа Serial
// commandPtr – указатель на буфер, в который будут скопированы все принятые данные
// Возвращаемые значения:
// OK – в случае наличия данных в буфере приема
// NO_DATA – при отсутствии данных в буфере приема
Status Serial_ReceiveCommand(Serial * self, char * commandPtr);
// Отправка данных по UART
// self – указатель на объект типа Serial
// responsePtr – указатель на данные для отправки
// Возвращаемые значения:
// OK – в случае успешной отправки, иначе – FAIL
Status Serial_SendResponse(Serial * self, char * responsePtr);
// Очистка буфера приема UART
// self – указатель на объект типа Serial
// Возвращаемые значения:
// OK – в случае успешного выполнения, иначе – FAIL
Status Serial_Clear(Serial * self);
Мы добавили файл Serial.h
в проект ProductionCodeLib. В проект Tests добавили файл SerialSpy.c
и в нем реализовали методы Serial_ReceiveCommand
и Serial_SendResponse
, которые позволяют симулировать работу драйвера UART с целью последующей проверки корректности обработки команды.
// ... some code
static bool IsEndOfString(char * buffer)
{
for (int i = 0; i < SERIAL_RECEIVE_BUFFER_SIZE; i++)
{
if (buffer[i] == '\n')
{
return true;
}
}
return false;
}
Status Serial_ReceiveCommand(Serial * self, char * commandPtr)
{
if (self == NULL || commandPtr == NULL)
{
return INVALID_PARAMETERS;
}
// Проверяем наличие данных в буфере приема UART
uint32_t commandLen = strlen(self->receiveBuffer);
if (commandLen == 0)
{
return NO_DATA;
}
// Проверяем наличие символа новой строки `\n`
bool isEndOfString = IsEndOfString(self->receiveBuffer);
if (isEndOfString == false)
{
return NO_DATA;
}
// При завершении приема очередной строки с символом `\n` копируем полученную строку в буфер приема UART
strncpy(commandPtr, self->receiveBuffer, commandLen);
self->receiveBuffer[SERIAL_RECEIVE_BUFFER_SIZE – 1] = 0;
return OK;
}
Status Serial_SendResponse(Serial * self, char * responsePtr)
{
if (self == NULL || responsePtr == NULL)
{
return INVALID_PARAMETERS;
}
// Проверка длины строки для отправки
uint32_t responseLen = strlen(responsePtr);
if (responseLen > SERIAL_SEND_BUFFER_SIZE)
{
return OUT_OF_BOUNDS;
}
// Копируем данные в буфер отправки
strncpy(self->sendBuffer, responsePtr, responseLen);
self->sendBuffer[SERIAL_SEND_BUFFER_SIZE – 1] = 0;
return OK;
}
// ... some code
Мы написали простейшую реализацию методов приема/отправки в SerialSpy.c
с целью успешного выполнения теста. Для симуляции работы драйвера UART с помощью тестового шпиона достаточно:
- для отправки – скопировать данные в буфер в отправки с помощью
strncpy
; - для приема – проверить наличие входящих данных с помощью
strlen
и признак завершения приема команды (символ\n
). В случае завершения приема команды скопировать с помощьюstrncpy
принятые данные в буфер, переданный через параметр метода.
Далее мы запустили тест и получили положительный результат:
..
OK (2 tests, 2 ran, 5 checks, 0 ignored, 0 filtered out, 0 ms)
Обработчик команды help реализован, и тест успешно выполняется. На этом завершается фаза test-passes. Перейдем к фазе refactor.
Фаза refactor
После того как тест успешно завершен, следует выполнить рефакторинг разработанного кода. Это могут быть мелкие правки, например: изменение имен переменных, методов или оптимизация повторного использования кода. А могут быть и более серьезные: перенос части функционала из одного программного модуля в другой с целью улучшения дизайна.
Несмотря на масштабы этих правок, мы можем оценить их корректность, нажав на горячую клавишу запуска тестов. Если в процессе рефакторинга что-то «сломается», то можно откатить изменения. На данном этапе лучше запускать тесты как можно чаще, ведь это занимает всего долю секунды и помогает своевременно предотвратить внесение ошибок в код.
На этапе рефакторинга мы вынесли обработчик команд из Configurator_Handler
в отдельный static
метод HandleCommand
. Этот метод выполняет единственную операцию: парсит входящую команду и вызывает нужный обработчик полученной команды (на данном шаге реализовали только одну команду help
).
// Configurator.c
static Status HandleHelpCommand(Configurator * self)
{
if (self == NULL)
{
return INVALID_PARAMETERS;
}
Status status = Serial_SendResponse(self->serial, HELP_OUTPUT);
return status;
}
static Status HandleCommand(Configurator * self)
{
if (self == NULL)
{
return INVALID_PARAMETERS;
}
// Обработка команды help
if (strstr(self->command, helpCommand) != NULL)
{
return HandleHelpCommand(self);
}
// Обработка неизвестной команды
return UNSUPPORTED;
}
Status Configurator_Handler(Configurator * self)
{
if (self == NULL)
{
return INVALID_PARAMETERS;
}
// Получаем команду из буфера приема UART
Status status = Serial_ReceiveCommand(self->serial, self->command);
if (status != OK)
{
// При отсутствии данных UART вернется статус NO_DATA
return status;
}
// Обработка полученной команды
status = HandleCommand(self);
if(status != OK)
{
return status;
}
return status;
}
На этом у нас завершилась фаза refactor, теперь можно приступить к новой итерации fails-passes-refactor.
Для завершения обработчика Configurator_Handler
мы добавили следующие команды:
• read
– чтение флеш-памяти;
• write
– запись флеш-памяти;
• erase
– стирание флеш-памяти.
Разработка этого функционала производилась похожим образом, в этом можно убедиться, скачав готовый проект на gitlab.
Для разработки функционала вышеперечисленных команд нам также потребовалось симулировать драйвер для работы с флеш-памятью.
Тест для команды записи во флеш-память
Здесь приведен пример теста и описание применения тестового шпиона FlashSpy
для реализации логики класса Configurator
с целью успешного выполнения теста. В качестве примера мы выбрали команду write следующего формата:
write: <flash_address_in_hex> <data_to_write>\r\n
Написание теста для команды write:
TEST(Configurator, ShouldHandleWriteFlashCommand)
{
// Arrange
// Ожидаемые данные после обработки команды write
uint32_t expectedFlashData = 0x11223344;
// Устанавливаем входящую команду от ПК в буфер приема UART
char writeFlashCommand[] = "write: 0x10000 0x11223344\r\n";
LONGS_EQUAL(OK, SerialSpy_SetReceiveBuffer(serial, writeFlashCommand, sizeof(writeFlashCommand)));
// Act
Status status = Configurator_Handler(configurator);
// Assert
// Проверяем статус
LONGS_EQUAL(OK, status);
// Проверяем данные, записанные во флеш-память
uint32_t * flashPtr = NULL;
LONGS_EQUAL(OK, FlashSpy_GetFlashPtr(&flashPtr, 0x10000));
LONGS_EQUAL(expectedFlashData, *flashPtr);
}
Мы решили производить чтение и запись по 4 байта. Помним, что перед записью следует стереть флеш-память МК. В итоге мы получили следующий заголовочный файл Flash.h
:
// Flash.h
#pragma once
#include "Common.h"
#define FLASH_PAGE_COUNT 0x80
#define FLASH_PAGE_SIZE 0x400
#define FLASH_SIZE FLASH_PAGE_COUNT * FLASH_PAGE_SIZE // 128 Kb
Status Flash_Init(void);
Status Flash_DeInit(void);
Status Flash_Write(uint32_t address, uint32_t data);
Status Flash_Read(uint32_t address, uint32_t * dataPtr);
Status Flash_Erase(uint8_t pageNumber);
Для запуска тестов на локальном ПК достаточно реализовать методы из Flash.h
в FlashSpy.c
простым способом: флеш-память можно представить как массив байтов uint8_t flash[FLASH_SIZE]
, где FLASH_SIZE
равен произведению количества страниц на их размер. Поэтому для симуляции работы драйвера флеш-памяти на локальном ПК достаточно выделить массив размером FLASH_SIZE
, который будем читать с помощью метода Flash_Read
, записывать с помощью Flash_Write
и производить стирание с помощью Flash_Erase
.
Мы добавили FlashSpy.c
в проект Tests и реализовали в нем единственный шпионский метод FlashSpy_GetFlashPtr
для получения указателя на массив с заданным в address
смещением. Таким образом мы сможем проверить содержимое массива в тестах.
Также для реализации тестового шпиона добавляем упрощенную реализацию методов инициализации, деинициализации, записи, чтения и стирания флеш-памяти в файл FlashSpy.c. В каждом методе производятся несложные операции с массивом flashMemory
(размер которого равен размеру флеш-памяти в МК STM32F103C8):
Flash_Read
возвращает значение ячейки памяти размером 32 бита, которая находится по нужному смещению массиваflashMemory
.Flash_Write
устанавливает значение ячейки памяти размером 32 бита в заданном смещении массиваflashMemory
.Flash_Erase
заполняет срез массиваflashMemory
размером одной страницы памяти МК STM32F103C8 значениями 0xFF с помощьюmemset
.
Этого достаточно для написания тестов и реализации бизнес-логики в классе Configurator
.
// FlashSpy.h
#pragma once
#include "Flash.h"
Status FlashSpy_GetFlashPtr(uint32_t ** flashMemoryPtr, uint32_t address);
// FlashSpy.c
static uint8_t * flashMemory = NULL;
// Метод-шпион
Status FlashSpy_GetFlashPtr(uint32_t ** flashMemoryPtr, uint32_t address)
{
if (flashMemory == NULL)
{
return WRONG_CONDITION;
}
if (flashMemoryPtr == NULL || address >= FLASH_SIZE)
{
return INVALID_PARAMETERS;
}
*flashMemoryPtr = (uint32_t*)&flashMemory[address];
return OK;
}
// Методы для симуляции работы драйвера
Status Flash_Init(void)
{
flashMemory = (uint8_t*)malloc(FLASH_SIZE);
if (flashMemory == NULL)
{
return FAIL;
}
// Считаем, что флеш-память предварительно стерта (все биты равны 0b1)
memset(flashMemory, 0xFF, FLASH_SIZE);
return OK;
}
Status Flash_DeInit(void)
{
if (flashMemory == NULL)
{
return OK;
}
free(flashMemory);
flashMemory = NULL;
return OK;
}
Status Flash_Write(uint32_t address, uint32_t data)
{
if (flashMemory == NULL)
{
return WRONG_CONDITION;
}
if (address >= FLASH_SIZE)
{
return INVALID_PARAMETERS;
}
// Можем изменять биты: значение 0b1 на 0b0, а обратно только с помощью Flash_Erase
*(uint32_t*)(flashMemory + address) &= data;
return OK;
}
Status Flash_Read(uint32_t address, uint32_t * dataPtr)
{
if (flashMemory == NULL || dataPtr == NULL)
{
return WRONG_CONDITION;
}
if (address >= FLASH_SIZE)
{
return INVALID_PARAMETERS;
}
*dataPtr = *(uint32_t*)(flashMemory + address);
return OK;
}
Status Flash_Erase(uint8_t pageNumber)
{
if (flashMemory == NULL)
{
return WRONG_CONDITION;
}
if (pageNumber >= FLASH_PAGE_COUNT)
{
return INVALID_PARAMETERS;
}
uint32_t offset = pageNumber * FLASH_PAGE_SIZE;
memset(flashMemory + offset, 0xFF, FLASH_PAGE_SIZE);
return OK;
}
В методе Flash_Write
запись данных производится с помощью побитовой операции «И», потому что данные могут быть записаны только на предварительно стертую страницу флеш-памяти (после стирания страницы все биты принимают значение 0b1).
Далее добавили инициализацию и деинициализацию флеш-памяти в блоки кода setup()
и teardown()
в файле ConfiguratorTests.cpp.
TEST_GROUP(Configurator)
{
Configurator * configurator = NULL;
Serial * serial = NULL;
void setup()
{
serial = Serial_Create();
configurator = Configurator_Create(serial);
Flash_Init();
}
void teardown()
{
Configurator_Destroy(configurator);
Flash_DeInit();
}
};
После запуска тестов мы получили ошибку со статусом UNSUPPORTED
, т. к. в Configurator_Handler
отсутствует обработка команды write. Это была фаза test fails.
d:\exampletdd\tests\tests\configuratortests.cpp(66): error: Failure in TEST(Configurator, ShouldHandleWriteFlashCommand)
expected < 0 0x00000000>
but was <-9 0xfffffff7>
...
Errors (1 failures, 3 tests, 3 ran, 8 checks, 0 ignored, 0 filtered out, 5 ms)
Для перехода на стадию test-passes добавили обработку команды write в Configurator.c. Обработчик команды записи HandleWriteCommand:
- парсит входящую строку, которая содержит адрес и значение размером 32 бита для записи;
- проверяет адрес (не выходит ли за границы флеш-памяти);
- записывает полученное значение во флеш-память с помощью метода
Flash_Write
; - отправляет ответ на хост о статусе выполнения команды с помощью
Serial_SendResponse
.
// Configurator.c
#include "Flash.h"
// ... some code
#define LEN_MIN_ARG sizeof("0x0")
#define LEN_WRITE_COMMAND sizeof(writeCommand) + LEN_MIN_ARG + LEN_MIN_ARG
// ... some code
static const char writeCommand[] = "write:";
static const char writeResponse[] = "Written successfully\r\n>";
// ... some code
static Status HandleWriteCommand(Configurator * self)
{
if (self == NULL)
{
return INVALID_PARAMETERS;
}
// Проверка корректности длины команды
uint32_t commandLen = strlen(self->command);
if (commandLen < LEN_WRITE_COMMAND)
{
return INVALID_PARAMETERS;
}
// Парсинг адреса флеш-памяти
char * flashAddressPtr = self->command + sizeof(writeCommand);
uint32_t flashAddress = strtol(flashAddressPtr, (char**)NULL, 16);
if (flashAddress > FLASH_SIZE)
{
return INVALID_PARAMETERS;
}
// Парсинг данных для записи во флеш-память
char * dataAddressPtr = strchr(self->command + sizeof(writeCommand), ' ');
uint32_t data = strtol(dataAddressPtr, (char**)NULL, 16);
// Запись во флеш-память
Status status = Flash_Write(flashAddress, data);
if (status != OK)
{
return status;
}
// Отправка ответа на ПК по UART
status = Serial_SendResponse(self->serial, (char*)writeResponse);
return status;
}
static Status HandleCommand(Configurator * self)
{
if (self == NULL)
{
return INVALID_PARAMETERS;
}
// Команда help
if (strstr(self->command, helpCommand) != NULL)
{
return HandleHelpCommand(self);
}
// Команда write
else if (strstr(self->command, writeCommand) != NULL)
{
return HandleWriteCommand(self);
}
// Неизвестная команда
return UNSUPPORTED;
}
В итоге после запуска тестов мы получили результат test-passes.
...
OK (3 tests, 3 ran, 9 checks, 0 ignored, 0 filtered out, 0 ms)
Для завершения итерации нужно провести refactoring, как мы делали это выше. Разработка функционала для чтения и стирания флеш-памяти выполняется аналогично.
Выводы
Таким образом, мы разработали всю бизнес-логику нашего проекта. Сначала мы выделили 5 минут на составление тест-листа (за это время мы продумали код). Такой подход позволил погрузиться в суть проекта до написания первой строчки кода.
Далее согласно методологии TDD мы последовательно реализовали каждый тест и бизнес-логику для его успешного завершения. Каждый завершенный тест позволил нам оценить прогресс разработки. При написании нового функционала можно было проверять его корректность, запуская периодически готовые тесты, что помогало предотвратить внесение бага в уже существующий код.
При реализации класса Configurator
мы использовали два тестовых шпиона SerialSpy
и FlashSpy
для симуляции работы драйвера UART и драйвера флеш-памяти.
Благодаря этому у нас появилась возможность запускать тесты разработанной бизнес-логики на ПК, не загружая при этом каждый раз код в МК для проверки его корректности. Однако это привело к увеличению объема разрабатываемого кода.
Код основной логики у нас получился независимым от платформы, т. е. его можно запустить на любом МК, нужно только написать реализацию драйвера в соответствии с нашими заголовочными файлами Serial.h
и Flash.h
.
Конечно, данных тестов недостаточно, чтобы полностью проверить весь разработанный функционал. Например, для большей эффективности применения unit-тестов можно дублировать тест ShouldHandleWriteFlashCommand
и выставить граничное значение адреса. Затем можно еще раз дублировать тест и проверить, что при превышении верхней границы адреса флеш-памяти вернется ошибка. При желании каждый может скачать проект и дополнить его своими тестами.
В следующей статье мы реализуем драйверы Serial.c
и Flash.c
для STM32F103C8, а также запустим наш код, представленный в этой статье, на отладочной плате. Если тебе интересны вопросы «железной» разработки и безопасного кода, присоединяйся к нашей команде.
Ссылки
- TDD для микроконтроллеров. Часть 1: Первый полет
- TDD для микроконтроллеров. Часть 3: Запуск на железе
- Проект на GitLab
- CppUTest
- Visual Studio
- STM32CubeMX
- Atollic TrueStudio
Литература
- Test Driven Development for Embedded C, James Grenning
- STM32F103C8 Programming manual
- STM32F103C8 Reference manual
Raccoon Security – специальная команда экспертов НТЦ «Вулкан» в области практической информационной безопасности, криптографии, схемотехники, обратной разработки и создания низкоуровневого программного обеспечения.
LennyB
Я пытаюсь «печатать» в UART, используя DMA, а DMA контроллер выдаёт ошибки. Какой бы мне тест написать, чтобы всё заработало? А, нет, видимо здесь-то TDD для микроконтроллеров и заканчивается.
Benonline
В этом случае можно воспользоваться отладкой. Обычно в больших проектах времени на реализацию драйверов уходит меньше, чем на написание бизнес логики. Если у вас проект заключается в настройке периферии МК, то применение TDD не имеет смысла.
F0iL
Ничего не заканчивается.
Грамотная архитектура для встраиваемого ПО — это разделение хардварно-зависимой части и основной логики и минимизация жестких связей между ними.
Та часть, которая завязана на взаимодействие с железом (HAL) — да, она тестируется руками отладчиком или автоматизированно на стендах,
а то, что напрямую на железо не завязано (автоматы состояний, математические вычисления, парсеры коммуникационных протоколов, структуры для хранения архивов и логов и т.д.) — отлично тестируется даже без живой железки.
Да, если у вас проект — это ардуина с мигающим светодиодиком, то заморачиваться со всеми этими тестами нет смысла. А вот если у вас задача сложнее, и кроме того, продукт будет в будущем развиваться и расширяться — там это может дать очень хорошую экономию времени (еще раз, время — это деньги) и серьезно понизить вероятность просачивания ошибок конечным потребителям.
EighthMayer
Логика, ау
TDD предназначен в первую очередь для разработки той части программы, которая от платформы не зависит (а выделить эту часть из программы — достаточно сложный навык, которому надо учиться) — то есть к пересылке данных через DMA он отношения вообще не имеет. Вы-же фактически утверждаете что пинцетом неудобно гвозди забивать, а значит пинцеты бесполезные.