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