This article is for embedded software developers with a solid working knowledge of C or C++, but who struggle with large and complex projects.
If you learn to develop embedded code, e.g. using the Arduino IDE, you find plenty of small example programs. It is helpful to get things started quickly, but as soon as your project begins to grow, help related to software design is rare.
In contrast, when you learn software development for desktop applications, project structures and software design is an integral part of the learning process.
In this short article, I will give you a simple guide on how you can build a modular structure for your firmware. This will keep your code clean and maintainable for large and complex projects.
Refactor your CodeIf you start a new project, you can already prepare the structures as described. For this article, I will assume that you already have a working firmware but need to improve the code quality.
Improving your code in an iterative process is called refactoring. Testing is an integral part of this process: after each small change, you need to test whether the software is still working as expected.
In desktop application development, there are unit tests to ensure the integrity of smaller modules. I found it difficult to apply unit tests to embedded code, if not for small independent functions or modules. Therefore, you must use a simple run-time test of your software to ensure it is still functioning properly.
Refactoring only changes the code, but not the functionality. Even if you change names, move code around, or change implementations, the function of your code stays exactly the same. It is important that you either change or extend functionality or do refactoring, but you should never do both at the same time (or in the same commit).
Use a Version Control SystemChanging your code without version history is a bad idea. If you do not already manage your code in a version control system, now is the time to start using one!
If you have never used a version control system before, use GIT and read one of the many tutorials on how to use it. There are graphical user interfaces for any operating system, so you do not need to work on the console. It does not matter how you manage your code, but rather that you implement a version control system.
After each small successful change, you should commit a new version. If you run into difficulty at a later stage, you can easily analyse every applied change to the code and go back to the last working version.
Demo SetupIf you like to follow along using the real demo setup, you will first need an Arduino Uno, three LEDs with matching resistors and two pushbuttons. The example code expects a circuit as shown in the next illustration.
The example code we start with is something I unfortunately see quite often. Please open a second browser window with the code at the following URL:
https://github.com/LuckyResistor/guide-modular-firmware/blob/master/fade_demo_01/fade_demo_01.ino
I cannot use a highly complex firmware for this article, and your source code may be in a different state. Nevertheless, this example code contains most of the elements I would like to discuss.
Because of the length of the code, I will simply link to the full examples. The code snippets in the article should have the correct line numbers, so you can easily find the locations.
Read the Example CodeTry to read the example code and determine its purpose before testing it. Here, there is no documentation, no functions and, therefore, no structure that would provide any hints.
Reading and understanding the code will likely take some time because of the unnamed literals and nested control statements.
Compile and UploadIf you set up the hardware for the demo, compile and upload it to the board. Otherwise, you can just follow along. There is no need to actually have a working circuit.
The firmware here is working just fine: there is an animated pattern on the three LEDs, and you can easily use the two buttons to switch between four different animations.
Here lies the problem with embedded development: if the device seems to work as expected, nobody asks if the code is written modular and in good quality.
What is a Module?In the context of desktop software development, UML typically defines the terminology used to talk about design. When using UML, we would talk about a component with interfaces. I often use the term module in a similar sense.
In firmware, a module is code which has an interface and encapsulates its functionality:
- In C, a module is a compiling unit with a header, implementation file and several functions that build the interface.
- In C++, it can be a compiling unit with a header and implementation file…
- … and several functions in a namespace that build the interface.
- …with a class declaration as interface.
It is important to understand that the module encapsulates its functionality in a way that creates a level of abstraction. There is always the user of the module, and this user only has access to the interface of the module.
A module may depend on the interfaces of other modules, but never depends on code of the module’s user.
This principle is called interface-based programming, which is a vitally important design concept.
The Current State of the ExampleHere you can see the current state of the firmware. There is only one module, and that module contains everything. To be fair, we are using a function (delay
) from the Arduino environment, which would be in a second module.
At this point, we should start refactoring the existing code and building new modules. This is an iterative process:
1. Encapsulate a piece of functionality:
- Move from low-level to high-level (bottom-up).
- Refactoring should never change the functionality.
2. Test the revised code.
3. Commit the change.
4. Repeat.
Encapsulate the Hardware AccessThe lowest level in our code contains the parts directly accessing registers, as shown in the setup()
function:
void setup() {
// Initialize Timer
TCCR2A = 0;
TCCR2B = 1;
OCR2A = 0;
OCR2B = 0;
TIMSK2 = _BV(TOIE2);
// Ports
DDRB |= 0b00111000;
DDRB &= ~0b00000110;
PORTB &= ~0b00111000;
PORTB |= 0b00000110;
}
There are two major parts accessing the hardware:
- Control the LED brightness using software PWM.
- Detect button presses.
We want to start with the first part in order to control the brightness of the device’s individual LEDs. Not the animation, just the lowest level of functionality.
Before I write any implementation or apply changes, I should think about the interface, which is required for the functionality.
Display.hpp
#pragma once
#include <Arduino.h>
/// The display module to control the attached LEDs
///
namespace Display {
/// The color of the LED
///
enum class Color : uint8_t {
Orange = 0,
Green = 1,
Red = 2
};
/// Initialize the display module.
///
void initialize();
/// Get the maximum level.
///
/// @return The maximum level value.
///
uint8_t getMaximumLevel();
/// Get the level of the given LED.
///
/// @param color The color of the LED to retrieve.
///
uint8_t getLevel(Color color);
/// Set the level of the given LED.
///
/// @param color The color of the LED to change.
/// @param level The level for the LED.
///
void setLevel(Color color, uint8_t level);
}
It is important to keep the changes as small and simple as possible. With every step, modifications to the software get easier. It is better to go through many small iterations rather than trying to create the perfect interface all at once.
As you can see, the interface is relatively straightforward. Next, I write the implementation for the interface and move the affected portions of the code to the implementation file.
With the implementation complete, I now change the existing code in the main module to use the new interface.
Please open the following URL with the changed project:
https://github.com/LuckyResistor/guide-modular-firmware/tree/master/fade_demo_02
As you can see, I did not optimise the implementation of the Display
module. I tried to move everything with few changes as possible, and improving the code is another step.
For the main module, the result is mixed. While it reduced the amount of low-level register access and made changing the LED levels readable, the code became larger and the repetitive parts are notable.
Using modules will give you this kind of insights. You read all the Display::setLevel
calls and already begin to think about how you can reduce and simplify them.
Before starting to clean up, we create the second low-level module to handle the input from the buttons.
As before, I first design the interface for the input module. We need to be able to poll the buttons and execute code when a button is pressed. These use cases are covered in the new interface.
Buttons.hpp
#pragma once
#include <Arduino.h>
/// A module to handle button presses.
///
namespace Buttons {
/// The callback function.
///
typedef void (*Function)();
/// The button.
///
enum Button : uint8_t {
NoButton = 0,
Plus = 1,
Minus = 2
};
/// Initialize the buttons module.
///
void initialize();
/// Set a callback if the given button is pressed.
///
void setCallback(Button button, Function fn);
/// Poll the button states.
///
void poll();
}
Next, I write the implementation and move any suitable code from the main module to the new Button
module. In this case, I had to add some new functions to the main module for the button callbacks. I also moved the state initialisation code into its own function to keep it operating properly with the callback functions.
Please open the following URL with the changed project:
https://github.com/LuckyResistor/guide-modular-firmware/tree/master/fade_demo_03
As you can see, the implementation here is simple and crude. The focus is on creating interfaces and modules, not on optimising the existing code. This is one of the next tasks: continue to create modules until clean-up and optimisation are required to proceed.
The Updated Module GraphThe new module graph now looks like this:
After encapsulating the lowest level of code, we move up one level and encapsulate the poorly implemented state machine of the animation module.
As I did the previous two times, I first design the interface for the animation module. Because it is a simple state machine, it only requires an initialise
and progress
method. The animation mode can be changed using the setMode
and getMode
methods.
Animation.hpp
#pragma once
/// The module to animate the LEDs
///
namespace Animation {
/// The animation mode.
///
enum class Mode {
Fade,
RollRight,
RollLeft,
Blink
};
/// Initialize the animation module.
///
void initialize();
/// Set the current animation mode.
///
void setMode(Mode mode);
/// Get the current animation mode.
///
Mode getMode();
/// Progress the current animation.
///
void progress();
}
Next, I create the implementation and start to adapt the code in the main module for the new interface.
I now realise, while adapting the code in the main
module, setMode
and getMode
are not the right choices for the current implementation. Therefore, I add the method cycleMode
to the interface before I continue with the changes.
Please open the following URL with the changed project:
https://github.com/LuckyResistor/guide-modular-firmware/tree/master/fade_demo_04
The Final Module GraphThe final module graph looks like this:
As soon the messy code is safely split up into modules, you can start cleaning everything up. You can now focus on one module and optimise the implementation without touching the interface. You can also consider interface changes that can simplify the implementation of user code.
Just remember to change only one part at a time. Thoroughly test it and commit it before you move on to the next problem area.
The current main module is already in good shape. It is short and easy to read and understand.
fade_demo.ino
#include "Display.hpp"
#include "Buttons.hpp"
#include "Animation.hpp"
void onPlusPressed();
void onMinusPressed();
void setup() {
Display::initialize();
Buttons::initialize();
Buttons::setCallback(Buttons::Plus, &onPlusPressed);
Buttons::setCallback(Buttons::Minus, &onMinusPressed);
Animation::initialize();
}
void loop() {
Animation::progress();
Buttons::poll();
delay(50);
}
void onPlusPressed()
{
Animation::cycleMode(Animation::Next);
}
void onMinusPressed()
{
Animation::cycleMode(Animation::Previous);
}
Initial Clean-Up on the “Display” ModuleThe Display
module is now short enough to analyse it for problems. After a quick glance, we can identify an obvious one:
volatile uint8_t gOrangeLevel = 0x0c;
volatile uint8_t gGreenLevel = 0x12;
volatile uint8_t gRedLevel = 0x18;
Using three independent variables for the LEDs will generate repetitive code that differs only by the used names and masks, as shown in the following part:
if (gFadeCounter < gOrangeLevel) {
mask |= 0b100000;
}
if (gFadeCounter < gGreenLevel) {
mask |= 0b010000;
}
if (gFadeCounter < gRedLevel) {
mask |= 0b001000;
}
There are two methods you can utilise to solve this problem: using functions that reference the variables, or using variable arrays for the levels and bit-masks. For this situation, I decided to use the latter method.
Please open the following URL to view the changed project:
https://github.com/LuckyResistor/guide-modular-firmware/tree/master/fade_demo_05
Clean up the “Button” ModuleThe Button
module has a similar problem as the Display
module. I have to convert the variables into an array to remove the repetitive code.
The workflow remains the same for all clean-up work:
- Change a small part of the code.
- Compile and resolve any errors.
- Test whether the device is still working as expected.
- Create a new commit.
- Repeat.
Now open the following URL with the changed project:
https://github.com/LuckyResistor/guide-modular-firmware/tree/master/fade_demo_06
Working on the “Animation” ModuleThe remaining module is the Animation
module. Its state machine is implemented in a static way, and adding a new animation is no simple task.
There are several designs that can improve the situation and each solution has its pros and cons. Because the animation is the main task of this device and we have plenty of flash and memory left, I decided to implement the different modes using a class hierarchy.
I simply converted the existing state machine into a class hierarchy without changing the actual code. Please open the following URL with the changed project:
https://github.com/LuckyResistor/guide-modular-firmware/tree/master/fade_demo_07
Using classes with the virtual
methods add ~800 bytes to the firmware. If size is a concern, you have several alternatives:
- Split the state machine into small functions, call the function from
switch
statements.
- Create separate modules for each animation mode and call them from
switch
statements.
- Use an array of function pointers to call the module functions.
For this example, I implemented all animation modes in the same AnimationMode.hpp
and AnimationMode.cpp
files. Note that this is not optimal and should be changed at a later time by moving each class into its own compile unit.
Now that everything is properly structured, we have one final problem with repetitive calls to the display methods. This is primarily because the animations actually address the LEDs not by its colours, but by its positions. Extending the Display
interface could simplify the animation code.
Please open the following URL with the changed project:
https://github.com/LuckyResistor/guide-modular-firmware/tree/master/fade_demo_08
This change not only simplified the code, but also reduced the firmware size by 100 bytes.
The final version of the firmware presented here is not perfect yet, but it is now in a maintainable state and additional changes and extensions can be easily made. Also, if there is a problem, you can easily find it in a relatively short time.
ConclusionWriting modular and extensible firmware is simple, and improving existing code can be done in little time. To summarise:
- Move from low-level to high-level (bottom-up).
- Refactoring should never change the functionality.
- Only make small changes.
- Test and commit after each change.
- Repeat.
Read the more articles like this on my website. They are completely free, with no ads.
Comments