TDD for microcontrollers in 5 minutes

Embedded systems are widely used in consumer electronics, industrial automation, transport infrastructure, telecommunications, medical equipment, as well as in military, aerospace engineering, etc. Although the consequences of any design error are expensive, an error in PC software or in a large corporate application is usually relatively easy to fix. And if the defect is in the embedded software (hereinafter – HPE) of the electronic control unit of the car’s brake system, this can cause a massive and costly recall of products.

The scope of embedded systems is constantly expanding, the complexity of the tasks they perform is growing. This in turn increases the risk of introducing errors during the development process, which increases the likelihood of very costly software defects.

One of the most popular methodologies for improving the quality of developed applications is Test-driven development (TDD). But is the TDD methodology effective for developing embedded systems? The answer to this question will be sought under the cut.

TDD Efficiency

A growing number of developers are of the opinion that the TDD methodology has several advantages over Test-last development (TLD). At the same time, TDD is understood as a process of iterative, continuous writing of tests and working code with mandatory refactoring phases.

TDD Iterative Development Process Diagram

The use of TDD can be distinguished following improvements when designing applications:

  • TDD allows you to concentrate on the design and understand how to make it better;
  • TDD saves developers from the fear of making changes to the code: if you make incorrect changes, it will be easy to “catch” the error by running tests;
  • well-designed tests act as a representative example of the use of a software module, which replaces the documentation of the code;
  • TDD improves code coverage with tests;
  • completed tests demonstrate development progress: each implemented test case indicates that the next functionality is completed and working;
  • the number of bugs is reduced.

It is quite simple to write tests now. Many development environments allow you to add a test to a project in a couple of mouse clicks or with the help of a keyboard shortcut, which saves a lot of time, the developer can only fill out the test body on his own. In this case, you do not need to spend time setting up the development environment or downloading additional frameworks and connecting them. For example, in Android Studio, this is pretty simple and fast process.

However, development environments for creating malware microcontrollers (hereinafter referred to as MK) are not developing so rapidly, in some of them there is no possibility to create and run tests, and also quickly get the result of their execution. In addition, the same developer can deal with different hardware platforms, i.e., conduct development using different integrated development environments (IDEs). Therefore, the following questions arise:

How to run tests for embedded, be it TLD or TDD?

Will we observe the above improvements as a result of the use of TDD in the development of embedded systems?

We will try to answer these questions in this article.

Features of development in embedded

When designing embedded systems, it is necessary to take into account the specifics of the development of malware:

  • VPO is launched on MK, which may have a limited amount of memory, its own architecture, etc.
  • VPO is performed in an environment with specific hardware support, that is, it has many libraries for interacting with various hardware modules.

Typically, a specialized IDE is used to design the malware. Typically, a developer can download from the manufacturer’s website for working with the hardware modules of a specific MK – Hardware Abstraction Level (HAL). But not every IDE provides tools for writing malware tests. In addition, using libraries or writing your own drivers for interacting with MK peripherals introduces hardware dependencies into the developed firmware code. Such a code will only work on a specific MK (or on a certain series of MK).

So, to use TDD in the design of embedded systems, you need to answer a number of questions:

  1. How to write and run tests?
  2. What to do with hardware dependencies?
  3. How to organize continuous and iterative development?

We will try to answer these questions by implementing a specific example of malware for MK using the TDD methodology. In conclusion, we give the pros and cons of using this methodology for developing embedded systems. Of course, the answers to all these questions will not be considered in the framework of one article, so we planned to publish a small series of articles.

Planned article cycle

  1. In the first part (you are reading it now) we will determine the purpose and development tools, then we will write the simplest test, run it and present the result.
  2. In the second part, we will consider the development of malware in accordance with the TDD methodology, implement the main platform-independent logic of our project, and apply methods to resolve hardware dependencies in order to test our code.
  3. In the third part, we will add a platform-specific code (driver) and run the malware on the STM32F103C8 MK, summarize the results of the entire series of articles, and list the pros and cons of using TDD when developing malware for the MK.

All project sources are posted on Gitlab.

Functionality under development

Many embedded devices can be connected to a PC to configure any parameters, i.e., such a device contains settings that can be read or written. We will give an example of the implementation of just such a functional. To connect to a PC, we will use the UART interface, and MK as flash memory. Thus, we need to implement the following functionality:

  • connecting the device to a PC via the UART interface;
  • saving parameters in non-volatile memory using a PC via the UART interface;
  • reading parameters from non-volatile memory using a PC via the UART interface.

Hardware Platform Selection

To implement our project, we chose a debug board with MK STM32F103C8, because MK STM32 is one of the most popular at the moment, and a debug board is inexpensive and easy to purchase.

As non-volatile memory in the selected MK flash memory can be used. However, it should be remembered that the malware code is also stored in flash memory, which is divided into pages. The number and size of pages varies depending on the MK line (described in detail in Programming manual)
Before writing to flash memory, make sure that the page has been erased beforehand.

Development tools

To create the tests and the main logic of the project, we chose the IDE to our taste and color, because first of all we developed platform-independent code that can be compiled and run on the local PC. For the development of malware, most often either “pure” C or C ++ is used, therefore, to write tests for malware, you need to use the appropriate testing framework. As a result, we selected the following tools for writing tests and platform-independent business logic:

  1. As an IDE – Visual studio, because we like its appearance, the convenience of debugging and refactoring code. This IDE is also suitable for writing code in pure C.
  2. Cpputest – An easy to configure and learn unit testing framework that can be used to write any unit tests in C / C ++.

Creating and customizing a project in Visual Studio

In order to write tests and code of our business logic, first of all we created a new solution in Visual Studio, we added the first project to it Visual c ++ with the name of the Tests project and the type “Windows console application.” This project contains only test code and additional program modules for testing (for example, spies, mocks, stubs, etc.).

Set up the Tests project

  1. We go in Properties -> C / C ++ -> General -> Additional Include Directories and add the lines:
    – $ (CPP_U_TEST) include
    – $ (SolutionDir) .. Firmware Project Include (path to the header files of the tested code)
  2. We go in Properties -> Linker -> Input and add the lines:
    – $ (CPP_U_TEST) lib cpputestd.lib
    – $ (SolutionDir) Debug ProductionCodeLib.lib

    Where $ (CPP_U_TEST) – Windows environment variable that contains the path to the folder cpputest (see screenshot).

    Add a file to the project Tests.cpp with content:

#include "CppUTest/CommandLineTestRunner.h"
int main(int argc, char** argv)
{
    return RUN_ALL_TESTS(argc, argv);
}

Then, in the same solution, they created a second project named ProductionCodeLib, the type is a static library Visual c ++. We will add a business logic code to this project, which we plan to run on hardware, that is, code compiled into the firmware file for STM32F103C8.

Set up the ProductionCodeLib project

Add the paths to the header files used to create the malware:

  • go to Properties -> C / C ++ -> General -> Additional Include Directories and add the line $ (SolutionDir) .. Firmware Project Include

After setting up for the first time, we started the project by clicking on the “Run” button, and saw a report that not a single test was performed:

OK (0 tests, 0 ran, 0 checks, 0 ignored, 0 filtered out, 0 ms)

On this setup is completed, you can start iterative development.

VPO development according to TDD methodology

We decided to use “pure” C, while trying to maintain the application of the basic principles of OOP. This approach is usually called pseudo-OOP, because “pure” C does not support classes. In accordance with the goal of our project, we created a class Configurator, which implemented the following logic:

  • processing PC commands via the UART interface;
  • read / write to flash memory;
  • erasing a flash page.

Of course, first of all we created the list of tests for the future class. To do this, they took a notebook and pen (keyboard and text editor) and described in simple words what logic we needed. Such a process for our module took about 5 minutes. Below is test list for class Configurator

Test list

1. Upon receipt of the command read data placed at the specified address on the flash memory is returned.
2. Upon receipt of the command write data is written to the specified address in flash memory.
3. Upon receipt of the command erase erases the page with the specified number.
4. Upon receipt of the command help a list of supported commands is displayed.
5. Upon receipt неизвестной команды an error message is returned.

For simplicity and clarity, we decided to use the string format of commands in ASCII encoding.

CppUTest Basics and First Test

To implement the tests created a file ConfiguratorTests.cpp in project Testswhich was then gradually filled with new tests in accordance with the TDD methodology.

To write tests using CppUTest is used simple structure.

Test writing structure for CppUTest:

TEST_GROUP(TestGroupName)
{
    void setup()
    {
    }

    void teardown()
    {
    }
};

TEST(TestGroupName, TestName)
{
}

Where:

  • TEST_GROUP – a block of code that may contain methods setup () and teardown ()as well as other helper methods or variables;
  • setup () is a function called before starting each test;
  • teardown () – function called at the end of each test;
  • TEST – a code block in which one test is implemented; there can be many such tests;
  • TestGroupName – the name of the test group usually coincides with the name of the class for which the tests are intended;
  • TestName is the name of the test.

Each test should work independently of any other tests. Therefore, before each test run, you should create an object and delete it at the end of the test. So, for our class Configurator in the simplest case in setup() an instance is created, and in teardown() deleted. To make sure that the object is successfully created, we added a simple test to check the value of the pointer. If the object was not created for some reason, then the pointer will be equal to NULL. Called the test ShouldNotBeNull.

Implementation of the ShouldNotBeNull test:

// ConfiguratorTests.cpp
TEST_GROUP(Configurator)
{
    Configurator * configurator = NULL;
    void setup()
    {
        configurator = Configurator_Create();
    }
    void teardown()
    {
        Configurator_Destroy(configurator);
    }
};

TEST(Configurator, ShouldNotBeNull)
{
    CHECK_TRUE(configurator);
}

The first test was ready, but still returned compilation errors, because at this stage the methods were not implemented Configurator_Create and Configurator_Destroy. To successfully complete the test, it remained only to write these two methods. And only at this step we wrote the first lines with the implementation of the HPE functionality in the project ProductionCodeLib. To do this, created a header file Configurator.h and file Configurator.cwhich contains the implementation of the business logic. IN Configurator.h added prototypes of the two methods listed. And to the file Configurator.c stubs were first added, i.e. left the body of each method empty. This was necessary in order to compile the project and run the tests.

Implementing stubs for the ShouldNotBeNull test:

// Configurator.h
typedef struct ConfiguratorStruct Configurator;
Configurator * Configurator_Create(void);

void Configurator_Destroy(Configurator * self);

// Configurator.c
#include "Configurator.h"

typedef struct ConfiguratorStruct
{
    char command[32];
} ConfiguratorStruct;

Configurator * Configurator_Create(void)
{
    return NULL;
}

void Configurator_Destroy(Configurator * self)
{
}

In accordance with the TDD methodology, you should make sure that the test starts, but ends with an error (because the body of the method Configurator_Create at the moment it was empty). We try to run and get the status of the test failedas expected. This means that we have successfully completed the phase. test-fails.

Error output to the screen when starting the test:

d:\exampletdd\tests\tests\configuratortests.cpp(27): error: Failure in TEST(Configurator, ShouldNotBeNull)
CHECK_TRUE(configurator) failed
.
Errors (1 failures, 1 tests, 1 ran, 1 checks, 0 ignored, 0 filtered out, 2 ms)

To go to the next phase test passes it was necessary to fill the body of the constructor. We added to the method Configurator_Create memory allocation and returning a pointer to an object, this is enough for a successful test ShouldNotBeNull. It was also necessary to free the allocated memory at the end of the test, so they filled the body of the destructor Configurator_Destroy.

Eventually Configurator_Create and Configurator_Destroy look like this:

Configurator * Configurator_Create(void)
{
    Configurator * self = (Configurator*)calloc(1, sizeof(ConfiguratorStruct));
    return self;
}

void Configurator_Destroy(Configurator * self)
{
    if (self == NULL)
    {
        return;
    }
    free(self);
    self = NULL;
}

As a result, we launched the test and got a positive result:

.
OK (1 tests, 1 ran, 1 checks, 0 ignored, 0 filtered out, 0 ms)

This means that the test-passes phase has completed. This is followed by the refactoring phase, in which, as a rule, the design, readability of the code is improved, etc. In our case, there is still very little code, so we just replaced the “magic” number 32 to constant using #define (can be used enum or const instead define)

We remove the antipattern (“magic” number) with #define:

// Configurator.h

#define SERIAL_RECEIVE_BUFFER_SIZE 32

// Configurator.c
typedef struct ConfiguratorStruct
{
    char command[SERIAL_RECEIVE_BUFFER_SIZE];
} ConfiguratorStruct;

Total

To summarize the intermediate results of the experience described in this article:

  1. We determined the goals of the project, chose the hardware platform and design tools.
  2. Prepared a PC for local development of tests and malware code without the use of specialized IDEs for a specific MK.
  3. They created the simplest test, to start which it is enough to click on the “Run” button (or the corresponding hot key) to instantly get the result of the execution on the screen.

In the next article, we will write the entire platform-independent logic of our project on the TDD methodology in accordance with the test list developed above. If you are interested in hardware development issues and safe code, join our team. So to be continued …

Additional Information

References

Literature

Raccoon Security is a special team of experts at the Volcano Scientific and Technical Center in the field of practical information security, cryptography, circuitry, reverse engineering and the creation of low-level software.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *