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


В первой части нашего цикла статей мы начали освещать тему эффективности применения методологии TDD для микроконтроллеров (далее – МК) на примере разработки прошивки для STM32. Мы выполнили следующее:


  1. Определили цель и инструменты разработки.
  2. Настроили IDE и фреймворк для написания тестов.
  3. Написали тест-лист для разрабатываемого функционала.
  4. Создали первый простой тест и запустили его.

Во второй статье мы описали процесс разработки платформонезависимой логики по методологии 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 получилось много кода и комментариев, его можно отформатировать на свой вкус.


Переписываем main.c
/* 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
// 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 не изменился.


Пишем прием данных в Serial.c
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 достаточно изменить одну строчку кода (вызов специальных функций не потребовался).

Дописываем реализацию чтения, записи и стирания флеш-памяти во Flash.c
#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.


При реализации нашего небольшого проекта они (не все) были учтены в некоторой степени. Проект можно скачать здесь.




Raccoon Security – специальная команда экспертов НТЦ «Вулкан» в области практической информационной безопасности, криптографии, схемотехники, обратной разработки и создания низкоуровневого программного обеспечения.