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 – специальная команда экспертов НТЦ «Вулкан» в области практической информационной безопасности, криптографии, схемотехники, обратной разработки и создания низкоуровневого программного обеспечения.
