TDD для микроконтроллеров. Часть 1: Первый полет
TDD для микроконтроллеров. Часть 2: Как шпионы избавляют от зависимостей
TDD для микроконтроллеров. Часть 3: Запуск на железе

В первой части нашего цикла статей мы начали освещать тему эффективности применения методологии TDD для микроконтроллеров (далее – МК) на примере разработки прошивки для STM32. Мы выполнили следующее:
- Определили цель и инструменты разработки.
- Настроили IDE и фреймворк для написания тестов.
- Написали тест-лист для разрабатываемого функционала.
- Создали первый простой тест и запустили его.
Во второй статье мы описали процесс разработки платформонезависимой логики по методологии TDD.
В заключительной статье мы опишем, как запускали разработанный код на STM32F103C8, а также подведем итоги всего нашего исследования эффективности TDD для микроконтроллеров.
Подробности – под катом.
Цель проекта
Напомним, что цель проекта – реализация возможности напрямую работать с энергонезависимой памятью МК: считывать, записывать значения ячеек и стирать страницы флеш-памяти с помощью UART-интерфейса. Команды будут передаваться по UART-интерфейсу в виде строк с кодировкой ASCII.
Во второй части нашего цикла мы написали всю основную бизнес-логику в классе Configurator по методологии TDD. Для создания тестов мы использовали фреймворк CppUTest. Разработку тестов и основной логики мы производили на ПК (x86), потому что наша основная логика не зависит от платформы. На данном этапе весь разработанный код был завершен и покрыт тестами, для его запуска на МК оставалось написать драйверы для работы с UART и флеш-памятью. Далее мы кратко рассмотрим, как это можно сделать для STM32F103C8:
Создание проекта для STM32F103C8 в STM32CubeMX
Мы создали проект с помощью ПО STM32CubeMX. Сначала запустили ПО и нажали на кнопку «Новый проект», выбрали наш МК STM32F103C8. Далее выполнили следующие действия:
- настроили периферию во вкладке «Pinout»: активировали USART1 (пины PA9 – TX, PA10 – RX);
- настроили тактирование во вкладке Clock configuration (пример можно посмотреть тут);
- во вкладке Configuration нажали на кнопку «NVIC», в открывшемся окне NVIC Configuration активировали прерывание USART1 global interrupt;
- в меню нажали Project -> Settings... и выбрали
Toolchain / IDE = TrueStudio(все настройки можно посмотреть подробнее в репозитории проекта); - сгенерировали проект (нажали в меню Project -> Generate Code).
В результате генерации проекта с помощью STM32CubeMX будут созданы:
- драйверы для взаимодействия с выбранной периферией;
- инициализационный код для МК;
- настроенный проект для IDE TrueStudio.
Далее разработка прошивки для STM32F103C8 велась с помощью IDE TrueStudio.
Добавление бизнес-логики
Для реализации прошивки МК на данном этапе осталось:
- добавить в проект уже готовый класс Configurator;
- реализовать драйвер Serial.c;
- реализовать драйвер Flash.c.
Сначала мы добавили в проект разработанную ранее логику:
- файл Configurator.h поместили в папку проекта Include;
- файл Configurator.c – в папку Source.
В файле main.c после генерации с помощью STM32CubeMX получилось много кода и комментариев, его можно отформатировать на свой вкус.
/* Default HAL includes */
#include "main.h"
#include "stm32f1xx_hal.h"
#include "usart.h"
#include "gpio.h"
/* User includes */
#include "Common.h"
#include "Flash.h"
#include "Configurator.h"
static void InitPeripheral(void)
{
/* Reset of all peripherals, Initializes the Flash interface and the Systick. */
HAL_Init();
/* Configure the system clock */
SystemClock_Config();
/* Initialize all configured peripherals */
MX_GPIO_Init();
MX_USART1_UART_Init();
}
int main(void)
{
// Стандартная инициализация clock и периферии
InitPeripheral();
// Инициализация flash
Status status = Flash_Init();
if(status != OK)
{
Error_Handler();
}
// Инициализация UART
Serial * serial = Serial_Create();
if(serial == NULL)
{
Error_Handler();
}
// Инициализация конфигуратора
Configurator * configurator = Configurator_Create(serial);
if(serial == NULL)
{
Error_Handler();
}
while (1)
{
// Обработка команд UART
status = Configurator_Handler(configurator);
if(status != OK && status != NO_DATA && status != UNSUPPORTED)
{
Error_Handler();
}
}
}Основная логика добавлена в проект, для ее запуска осталось написать драйверы.
Написание драйверов
Заголовочные файлы Serial.h и Flash.h мы создали ранее в предыдущей статье нашего цикла, поэтому нам оставалось написать реализацию перечисленных в них методов в Serial.c и Flash.c. Проще всего скопировать уже готовые файлы SerialSpy.c и FlashSpy.c из проекта с тестами в проект прошивки STM32F103C8, переименовать их в Serial.c и Flash.c и заменить тело каждого метода. Так мы и поступили.
Для запуска на «железе» мы изменили лишь несколько строк кода в файлах Serial.c и Flash.c, структура которых уже была продумана и спроектирована заранее.
Serial-драйвер
Мы использовали функции из библиотеки HAL для работы с периферией МК. В метод Serial_SendResponse добавили вызов HAL_UART_Transmit для отправки данных по UART.
// Serial.c
#include "Serial.h"
#include <string.h>
#include "stm32f1xx_hal.h"
typedef struct SerialStruct
{
char receiveBuffer[SERIAL_RECEIVE_BUFFER_SIZE];
char sendBuffer[SERIAL_SEND_BUFFER_SIZE];
UART_HandleTypeDef * huart;
uint32_t receivePosition;
} SerialStruct;
// ... some code
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[sizeof(self->sendBuffer) - 1] = 0;
// Добавили вызов HAL_UART_Transmit
HAL_StatusTypeDef status = HAL_UART_Transmit(self->huart, (uint8_t*)self->sendBuffer, responseLen, TIMEOUT_TRANSMIT);
if(status != HAL_OK)
{
return FAIL;
}
return OK;
}Прием данных мы реализовали с помощью функции обратного вызова HAL_UART_RxCpltCallback, которую добавили в Serial.c. Она вызывается по прерыванию в случае приема данных по UART-интерфейсу. Принимать данные решили по одному символу:
- для начала процесса приема добавили вызов
HAL_UART_Receive_ITвSerial_Create; - для приема последующих данных добавили вызов
HAL_UART_Receive_ITвHAL_UART_RxCpltCallback.
В теле метода HAL_UART_RxCpltCallback каждый новый байт копируется в буфер serial->receiveBuffer, так формируется готовая команда. Признак завершения приема команды – символ новой строки \n.
Сам метод приема команды Serial_ReceiveCommand не изменился.
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if(&huart1 != huart)
{
return;
}
// Проверяем признак завершения ввода команды
bool isEndOfStringReceived = serial->receiveBuffer[serial->receivePosition] == '\n';
if(isEndOfStringReceived == true)
{
serial->receivePosition = 0;
}
// Устанавливаем индекс для копирования следующего символа в буфер
bool isNotOutOfBounds = serial->receivePosition < (sizeof(serial->receiveBuffer) - 1);
if(isNotOutOfBounds == true && isEndOfStringReceived == false)
{
serial->receivePosition++;
}
// Запускаем ожидание приема следующего байта
HAL_UART_Receive_IT(serial->huart, (uint8_t*)&serial->receiveBuffer[serial->receivePosition], 1);
}
// Этот метод остался без изменений
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;
}
// Проверяем признак завершения приема команды (символ `\n`)
bool isEndOfString = IsEndOfString(self->receiveBuffer);
if (isEndOfString == false)
{
return NO_DATA;
}
// Заполняем буфер входящей команды при завершении приема
uint32_t receivedLen = strlen(self->receiveBuffer);
strncpy(commandPtr, self->receiveBuffer, receivedLen);
return OK;
}Flash-драйвер
В соответствии с Programming manual для STM32F103C8 для обращения к области флеш-памяти нужно использовать минимальное значение адреса 0x8000000, максимальное – 0x801FFFC. Поэтому мы добавили константу FLASH_START_OFFSET, равную 0x8000000, к вычислению адреса флеш-памяти в методах чтения, записи и стирания флеш-памяти.
Далее мы дополнили наши методы вызовами функций библиотеки HAL для работы с флеш-памятью в Flash.c:
HAL_FLASH_UnlockиHAL_FLASH_Lock– в методы инициализации и деинициализации флеш-памяти соответственно;HAL_FLASH_Program– в методFlash_Writeдля записи данных во флеш-память;HAL_FLASHEx_Erase– в методFlash_Eraseдля стирания страницы флеш-памяти;- для чтения флеш-памяти с помощью
Flash_Readдостаточно изменить одну строчку кода (вызов специальных функций не потребовался).
#include "stm32f1xx_hal.h"
#include "Flash.h"
#include <string.h>
#define FLASH_START_OFFSET 0x08000000
Status Flash_Init(void)
{
HAL_StatusTypeDef halStatus = HAL_FLASH_Unlock();
if(halStatus != HAL_OK)
{
return FAIL;
}
return OK;
}
Status Flash_DeInit(void)
{
HAL_StatusTypeDef halStatus = HAL_FLASH_Lock();
if(halStatus != HAL_OK)
{
return FAIL;
}
return OK;
}
Status Flash_Write(uint32_t address, uint32_t data)
{
if (address >= FLASH_SIZE)
{
return INVALID_PARAMETERS;
}
uint32_t offset = FLASH_START_OFFSET + address;
HAL_StatusTypeDef halStatus = HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, offset, data);
if(halStatus != HAL_OK)
{
return FAIL;
}
return OK;
}
Status Flash_Read(uint32_t address, uint32_t * dataPtr)
{
if (dataPtr == NULL)
{
return INVALID_PARAMETERS;
}
if (address >= FLASH_SIZE)
{
return INVALID_PARAMETERS;
}
*dataPtr = *((volatile uint32_t*)(FLASH_START_OFFSET + address));
return OK;
}
Status Flash_Erase(uint8_t pageNumber)
{
if (pageNumber >= FLASH_PAGE_COUNT)
{
return INVALID_PARAMETERS;
}
HAL_StatusTypeDef status;
uint32_t pageError;
FLASH_EraseInitTypeDef eraseInit;
eraseInit.TypeErase = FLASH_TYPEERASE_PAGES;
eraseInit.PageAddress = pageNumber * FLASH_PAGE_SIZE + FLASH_START_OFFSET;
eraseInit.NbPages = 1;
status = HAL_FLASHEx_Erase(&eraseInit, &pageError);
if(status != HAL_OK)
{
return FAIL;
}
return OK;
}Важно помнить, что во флеш-память записывается прошивка МК. Как правило, часть страниц флеш-памяти не используется для хранения кода прошивки, поэтому там можно хранить свои пользовательские данные.
Запуск
После реализации Serial.c и Flash.c мы скомпилировали прошивку и залили ее в STM32F103C8 с помощью ST-LINK/V2.
Для проверки разработанного функционала подключили нашу отладочную плату к ПК по UART-интерфейсу с помощью преобразователя интерфейсов USB-to-UART (у нас под рукой был CNT-003B, подойдет любой аналог). Команды посылали с помощью TeraTerm, для этого в меню нажали на File -> New Connection, выбрали нужный COM-порт и нажали на ОК. Затем в меню нажали на Setup -> Serial Port, настроили параметры 115200/8-N-1 и нажали на ОК. При подаче питания на отладочную плату по UART-интерфейсу выводится сообщение Hello, User! и приглашение на ввод команды в виде символа >. Затем мы протестировали все разработанные ранее команды:
- help\r\n – для вывода списка поддерживаемых команд;
- read: <flash_address_in_hex>\r\n – для считывания значения из указанной 32-битной ячейки флеш-памяти МК;
- write: <flash_address_in_hex> <data_to_write>\r\n – для записи значения в указанную 32-битную ячейку флеш-памяти МК;
- erase: <flash_page_number_to_erase>\r\n – для стирания страницы флеш-памяти МК с заданным индексом страницы.
На скриншоте ниже представлен лог тестирования команд на отладочной плате с STM32F103C8.
Итоги
В результате у нас получился небольшой проект, в котором вся бизнес-логика была реализована в одном классе Configurator. Причем эта бизнес-логика была разработана по методологии TDD еще до того, как мы начали создавать проект прошивки для нашей отладочной платы с МК STM32F103C8. В больших проектах часто на разработку бизнес-логики отводится значительно больше времени, чем на разработку драйверов. При этом неважно, на каком именно «железе» нам нужно запускать код. Нап��имер, при необходимости запустить код на МК AVR, нужно только переписать драйверы Serial.c и Flash.c (Serial.h и Flash.h изменяться не будут).

Также увеличился объем написанного кода за счет применения тестовых шпионов SerialSpy.c и FlashSpy.c, которые используются для тестов и не включены в компиляцию прошивки для МК. Благодаря этому у нас появилась возможность запускать тесты разработанной логики на ПК (х86), не загружая при этом каждый раз код в МК для проверки его корректности. При необходимости можно производить отладку платформонезависимой логики на ПК: не нужно каждый раз прошивать МК, ждать инициализации всех подсистем, переводить систему в определенное состояние и т. д. При отладке сложных систем это может существенно сэкономить время. Конечно, есть особенности компилятора и аппаратной среды, которые сложно учесть в тестах. Однако в платформонезависимой логике они встречаются не часто. Аппаратные особенности, как правило, учитываются при реализации драйверов.
На этапе разработки драйверов отладку следует производить на конкретном МК. На данном этапе у нас уже была готова основная логика для STM32F103C8, покрытая тестами. Поэтому в случае появления бага мы бы смогли его локализировать, исключив из поиска проверенный с помощью тестов код.
Дополнение или изменение кода для покрытой тестами системы происходит намного проще, при этом багам будет сложнее проникнуть в такую систему, т. к. в большинстве таких случаев в процессе изменения логики тесты будут возвращать ошибку. Например, если через год или два после сдачи проекта понадобилось дополнить его новым функционалом и использовать для других целей. В обычной ситуации пришлось бы вспоминать, как работает весь разработанный код и каковы особенности архитектуры системы, чтобы не внести новый баг при изменении кода. А в случае с TDD можно открыть тесты и посмотреть, как именно работает тот или иной код. При внесении некорректных изменений можно сразу увидеть ошибки в процессе запуска тестов. Уменьшение вероятности появления багов означает увеличение надежности системы.
Теперь можно вспомнить о том, что говорилось под заголовком «Эффективность TDD» первой статьи цикла и отметить для себя, есть ли указанные улучшения при разработке в embedded. На примере нашего проекта мы показали, что указанные улучшения есть. Конечно, TDD – это не «серебряная пуля», но это действенный инструмент, который можно эффективно применять в embedded. Если тебе интересны вопросы «железной» разработки и безопасного кода, присоединяйся к нашей команде.
P.S.
Для разработки тестов использовался паттерн 3А (act, arrange, assert), который позволил структурировать тесты и делать их максимально простыми и читаемыми.
В статье совсем немного внимания уделено фазе refactoring, она особенно эффективна в больших проектах. На этой фазе тест уже завершается успешно, поэтому можно изменять код и улучшить дизайн или читабельность. В этом плане TDD очень хорошо сочетается с применением принципов:
• ООП (или псевдо-ООП, если используется «чистый» С);
• SOLID;
• KISS;
• DRY;
• YAGNI.
При реализации нашего небольшого проекта они (не все) были учтены в некоторой степени. Проект можно скачать здесь.
Ссылки
- TDD для микроконтроллеров за 5 минут. Часть 1: Первый полет
- TDD для микроконтроллеров за 5 минут. Часть 2: Как шпионы избавляют от зависимостей
- Проект на GitLab
- CppUTest
- Visual Studio
- STM32CubeMX
- Atollic TrueStudio
Литература
- Test Driven Development for Embedded C, James Grenning
- STM32F103C8 Programming manual
- STM32F103C8 Reference manual
Raccoon Security – специальная команда экспертов НТЦ «Вулкан» в области практической информационной безопасности, криптографии, схемотехники, обратной разработки и создания низкоуровневого программного обеспечения.
