C++ On Embedded Systems
One fear about using C++ on an embedded system is a decrease in performance (in terms of memory and processing speed). As with most complex issues, the answer really is — “it depends”. Also, the concept on what an embedded system various between people and companies. You can use most C++ features on high-level embedded devices that are running a full-blown OS such as Linux. What this page refers to is “deeply” embedded systems — systems which are running “bare metal” (no OS) or with an RTOS on a microcontroller. Memory is normally very constrained, typical flash memory sizes are between 16-256kB, and RAM between 2-64kB. The CPU clock speed might be anywhere from 8 to 250MHz, with typically a single core.
I believe you can carefully select a subset of the C++ language which provides most of the benefits of OO-based design, but does not incur any significant performance hits for the target embedded system.
This page aims to cover many of C++‘s features and weigh-in on their suitability in deeply embedded systems.
C++ Features That Should Be Used
This is a list of all the C++ features that you SHOULD USE in most embedded firmware:
- Classes
- RAII
- Templates (no overhead, can be thought of as a more powerful version of a macro). However, incorrect/careless use of templates can cause a huge increase in code size. Also be careful of overusing them, too much templating makes code very unreadable.
- Function overloading and default parameters - Makes it easy to extend functions without massive refactors! And provides more flexibility to an API.
- Enum classes, typesafe typedefs
- Operator overloading (when done sensibly!)
- References (they are just safer pointers that can’t be null!)
- Namespaces (no overhead)
- Inheritance
auto
- Smart pointers (
std::unique_ptr
,std::shared_ptr
) std::array
- Embedded Template Library (ETL)
These are explained in more detail in the below sub-sections.
Classes
If you’ve ever done a C-based embedded project and had multiple instances of an peripheral to control (e.g. a UART), you’ve probably realized it’s inefficient and hard to maintain the code if you just copy all your Uart_Write(char * bytes, ...)
and Uart_Read()
functions and call them Uart2_Write()
, Uart2_Read()
e.t.c. You then probably thought, hey, I’ll just have one copy of all the functions, but for all of them as the first parameter, pass in a Uart struct
which contains all the configuration and state data for a particular UART.
Now your functions are looking something like Uart_Write(Uart* uart, char* bytes, ...)
. Well guess what, this is the basic idea of a class in C++. But proper classes provide a more readable and maintainable way to do this. So there is no reason not to use C++ classes in embedded firmware, and there is no performance penalty, at least when classes are used in this basic sense.
Polymorphism
Hand-in-hand with classes and inheritance is the concept of polymorphism. Polymorphism is when different child objects can be treated as an object of a common parent type. I have found this most useful in embedded firmware for writing unit and integration tests.
You can define abstract base classes which define the interface for your microcontroller peripherals. Then the real and mock implementations of this can inherit from this and provide the desired functionality. The real class would talk to the actual hardware (either directly to registers or via a HAL/driver). The mock class would just keep track of function calls and store passes in data for verify during testing.
This is called runtime polymorphism, and may have a small overhead due to the virtual table pointer. However, this is rarely an issue, and if it is, you can always use compile time polymorphism instead (or switch out the .cpp
files based on what you are compiling for). More on this below.
RAII
RAII is a programming technique which stands for “Resource Acquisition Is Initialization”. Basically, it means you should acquire resources in the constructor of a class (e.g. allocate memory, lock a mutex, open a network connection) and release them in the destructor. This ensures that the resources are always released when the object goes out of scope.
This may not sound like a big deal, but it’s actually a very powerful technique which should be utilized to it’s fullest extent, especially in embedded firmware. RAII will influence how you design classes and how you structure your code. RAII is enabled in C++ by the fact that all classes have a constructor and destructor which are guaranteed to be called when the object goes in and out of scope. This is not the case in C, in where the closest you can get is to make sure to call init()
and deinit()
functions.
A classic example of RAII in the embedded world is to use RAII to make sure mutex locks are always released once the code locking the mutex is finished. To do this, you would create a MutexLock
class which would lock the mutex in the constructor and unlock it in the destructor. You would then create a lock()
method on the Mutex
class which returns a MutexLock
object. See the below C++ pseudo-code for an example:
class MutexLock {public: MutexLock(Mutex& mutex) : m_mutex(mutex) { m_mutex.lock(); } ~MutexLock() { m_mutex.unlock(); }};
class Mutex {public: MutexLock lock() { return MutexLock(*this); }private: void unlock() { // ... }};
// Then in your code you can do the following:void someFunction() { Mutex mutex; MutexLock mutex.lock(); // ...} // Mutex is automatically unlocked here
This completely gets rid of the need to manually unlock the mutex in the code. Your function may have many exit points, and you no longer have to remember to unlock the mutex at each one. Also, if you do happen to allow exceptions, the mutex will be unlocked automatically when the exception is thrown.
See the Mutex and MutexLockGuard class in my ZephyrCppToolkit library for a real world example of this.
Although your real world firmware application may have little concern about cleaning up resources in it’s destructor (it normally is designed to just run forever), cleaning up suddenly becomes an important consideration if you are writing unit or integration tests for your firmware. Unit/integration tests are typically a single executable that runs many test functions. Each one of these functions will want to create a small part (unit test) or all of the application (integration test), test something, and clean up leaving no altered global state. This is where RAII comes in really handy. I design my firmware so that the root level App
class has a destructor which cleans up all of it’s contained objects. These child objects make sure they clean up their own resources, and also make sure to clean up their child objects and so on. This can mean the destructors do things like send Exit
events to any threads and close any connections. I normally don’t have to worry about delete
, since I try to either embed objects directly in other objects, or use smart pointers to manager the memory.
Designed this way, I can just let the App
object go out of scope at the end of the test function, and all the resources will be cleaned up correctly.
Templates
Templates allow you to create slightly different versions of classes that use different types at compile time.
Be careful not to overuse templates. When used too heavily they can make code very hard to read. Just to show how far you can take templating and metaprogrmming, someone wrote an entire compile-time ray tracer. Just because you can, doesn’t mean you should 😊.
Enums Classes
Enums in C can be dangerous. A user of a library function can pass any old integer value as a parameter into a function which takes an enum. The compiler will not complain, however at runtime the provided value could be completely out-of-range and cause unexpected bugs.
Also, all enums share the same scope, so you have to be careful to avoid name collisions! For example, if you want to have two IDLE
enums for different state machines, you are forced to change the names, e.g. GPS_IDLE
and MOTOR_IDLE
.
C++ improves things with the concept of the enum class
. This is a more strongly typed enum, and the compiler will error if you try and convert between this type and an integer without explicitly doing a cast. The enum values are also scoped to the name of the enum, preventing naming collisions with other enums in your project. This combined with namespaces is a very powerful combination.
The example below shows how C-style enums can be implicitly cast to integers (which can be dangerous), whilst an enum class
won’t.
// C-style enumsenum EStates { OFF, IDLE, RUNNING };enum EColors { RED, BLUE, GREEN };
// C++-style enum classesenum class ECStates { OFF, IDLE, RUNNING };enum class ECColors { RED, BLUE, GREEN };
int main() { EStates myEState; myEState = IDLE; if (myEState == GREEN) { // Bad idea, but compiles // ... }
ECStates myECState = ECStates::IDLE; if (myECState == ECColors::GREEN) { // Bad idea, but yay, won't compile! // ... }}
The below example shows how C-style enums can cause scoping/naming issues.
enum GpsState { OFF, IDLE, SCANNING};
enum MotorState { OFF, // Because of the global scope, this is going to collide with the OFF in GpsState! RUNNING};
But if we use enum class
we avoid the naming collision:
enum class GpsState { OFF, IDLE, SCANNING};
enum class MotorState { OFF, // This is fine, MotorState::OFF is not going to collide with GpsState::OFF RUNNING};
Namespaces
C++ namespaces are a great feature to use to help you organise your code. They allow you to organize your code into logical scoped groups and prevent naming collisions. This is especially important when writing libraries, as from the library writers perspective, they have no control over the naming of everything else in the projects it will be used in, and from the library users perspective, they really don’t want to be diving into library code to change things to fix naming collisions if at all possible.
namespace my_namespace {// All my stuff goes here}
std::array
std::array
is a wrapper around a C-style array that provides more safety around it’s usage. Unlike in C, it does not decay to a pointer to the element type automatically1.
If you use the []
operator to access the elements, it behaves the same as in C, with no bounds checking:
std::array<uint8_t, 3> myArray;myArray[3] = 10; // Woops, no bounds checking!
However, if you use .at()
instead, you get bounds checking:
std::array<uint8_t, 3> myArray;myArray.at(3) = 10; // Bounds checking, this will throw an exception
std::out_of_range
is thrown if the index is out of bounds.
You also get a useful .fill()
function, to fill all the elements with a single value:
// Set the array to all 0'smyArray.fill(0);
Another useful feature if that you can copy an entire array by just using the =
operator:
std::array<uint8_t, 3> myArray1 = {1, 2, 3};std::array<uint8_t, 3> myArray2;myArray2 = myArray1;
Note this only works with arrays of the same type AND same size.
```c++std::array<uint8_t, 4> myArray3;myArray3 = myArray2; // Compiler error! Different sizes
constexpr and Compile-Time Computation
constexpr
is a keyword which allows you to define a constant value at compile time. This is useful for things like array sizes, or other values which are known at compile time.
constexpr
should be preferred over #define
for things like array sizes and other constants, as it is typed and scoped, rather the brute force text replacement nature of #define
.
// Don't do this#define ARRAY_SIZE 10
// Do thisstatic constexpr size_t arraySize = 10;
constexpr
can also be used to perform compile-time computations.
Range Based For Loops
The traditional for(int x = 0; x < arrayLength; x++)
loop is a pain to write, and is error prone if you get the range wrong (the most common mistake being to accidentally use x <= arrayLength
instead of x < arrayLength
which gives out-of-bounds access, woops!).
C++11 introduced the range based for loop which looks normally like this: for (auto& value : container)
. It is a more elegant way to iterate over something that is “iterable” (has a begin()
and end()
method). You should use this instead of the traditional for
loop every time you are iterating over every element in a container and do not need to know the index.
std::array<uint8_t, 3> myArray = {1, 2, 3};
// Range based for loopfor (auto& value : myArray) { // ...}
The most common type to use is auto&
, which will automatically be a reference to whatever type the container holds.
C++ Features That Could Be Used, But Only After Careful Consideration
new/delete
- Virtual Methods
- RTTI
- Exceptions
std::string
std::vector
- Boost
new/delete
new
and delete
is C++‘s answer to C’s malloc()
and free()
functions. They are improved versions of malloc()
and free()
.
My general rule for embedded firmware is, unless otherwise banned from using dynamic memory allocation due to a specific coding standard (e.g. MISRA), to allow the use of dynamic memory allocation, but only during initialization. My opinion is that this gives the best of both worlds. The ability to use dynamic memory allocation allows your firmware to be flexible to initialization variables and configuration. Only allowing it an initialization prevents the “memory segmentation” issues that can occur when objects are continually created and deleted from the heap at runtime.
I make sure to avoid memory issues by doing the following:
- Preferring to embed a object directly as a variable in another class rather than the alternative of storing a pointer to the object, and allocating it with
new
in the constructor. This means I don’t have to worry about any accidental null pointers, or forgetting to free the memory. However some times it is not possible to construct the child object in the initializer list of the parent objects constructor, and in that case I fall back to using a pointer. - Try to wrap all raw pointers in either a
std::unique_ptr
orstd::shared_ptr
to ensure the memory is automatically freed when the objects go out of scope.
Exceptions
Exceptions are powerful, they allow errors to propagate up a stack until you care about catching them, without having to add any code at any of the layers which don’t care about the exception. However, they are a problem for most deeply-embedded firmware projects, because:
- Stack unwinding is processor intensive.
- Correct, exception handling code can be hard to read (vs. return codes). See Raymond Chen’s article Cleaner, more elegant, and harder to recognize.
With exceptions not allowed in embedded firmware, error handling is typically done by returning error codes from functions and checking them every time the function is called. This may seem like a lot of hassle at first, but is a very common paradigm and forces you to think about the “exceptional” scenarios and how to handle them. Many more modern languages such as Go and Rust encourage the “return error code, check error code, continue” process over exceptions anyway.
When exceptions are disabled, one issue to think about is how to handle errors in constructors. See the Handling Errors In Constructors section below on that.
The GCC compiler supports the -fno-exceptions
flag which disables exceptions.
std::string
I would generally recommend against using std::string
in embedded firmware, due to it’s use of dynamic memory allocation at runtime (when using the default allocator). The embedded template library (ETL) provides a better suited etl::string
which uses a template parameter to determine the size of the string buffer on the stack at compile time, side stepping the need for dynamic memory allocation.
In embedded C++, std::string_view
can be used as a safer wrapper around raw NTBS (null-terminated byte strings) without using dynamic memory allocation at runtime.
Handling Errors In Constructors
One issue when writing classes in firmware is the difficulty in signalling back to the caller when an error occurs in a C++ class constructor. The standard function approach of returning a error number does not work as you are not allowed a return value from C++ constructors. When developing C++ applications running on a full-blown OS, the standard way to deal with it is to throw an exception. In an embedded application where you are not allowed to use exceptions, this is obviously not an option. For example, consider the following example class:
#include <cstdint>
class TempSensor {public: TempSensor(uint8_t i2cAddress) { // Validate I2C address if (i2cAddress > 127) { // Argh, error! What do we do in firmware when we can't throw an exception? } this->i2cAddress = i2cAddress; }private: uint8_t i2cAddress;};
int main() { TempSensor tempSensor(10); // We don't know if this is ok or not!}
You want to check in the constructor if the caller has provided a valid I2C address (one with the range 0-127). There are a few ways you can signal this fault back to the caller, and we’ll discuss these now.
Move All Non-Trivial Code To Init()
One popular way of getting around this problem is to move all non-trivial might-cause-an-error code out of the constructor and into an Init()
function.
#include <cstdint>#include <cstdio>
class TempSensor {public: TempSensor() { // Nothing here now };
uint8_t Init(uint8_t i2cAddress) { // Validate I2C address if (i2cAddress > 127) { return 1; } this->i2cAddress = i2cAddress; return 0; };private: uint8_t i2cAddress;};
int main() { TempSensor tempSensor; uint8_t status;
status = tempSensor.Init(10); // OK, returns 0 status = tempSensor.Init(200); // Error, returns 1}
Using an Init()
function also opens up the possibility of the caller being able to re-initialize the class without having to delete this object and construct a new one. This may or may not be beneficial for a particular application!
One downside to this approach is that the caller has to remember to call Init()
before they can use any of the classes other functions. Also, adding the m_isInitialized
check is more code that you have to add to every member function.
Pass In Status Pointer To Constructor
Another way to solve the problem is to pass into the constructor a pointer to a status variable so the constructor can assign to it, similar to how you would pass in extra “outputs” to a normal function if the function had more than one output.
#include <cstdint>#include <cstdio>
class TempSensor {public: TempSensor(uint8_t i2cAddress, uint8_t& status) { // Validate I2C address if (i2cAddress > 127) { status = 1; return; } m_i2cAddress = i2cAddress; status = 0; }private: uint8_t m_i2cAddress;};
int main() { uint8_t status; TempSensor tempSensor(200, status); if (status != 0) { printf("Error initializing temp. sensor. status=%u\n", status); }}
Hopefully the caller remembers to check the status variable after constructing the object, and only uses the object if the constructor succeeded.
Provide Second Function Or Member Variable To Check If Constructor Worked
The final way we will solve the constructor error problem is to save the constructor success state to the class, and then provide the caller with another function to get that state:
#include <cstdint>#include <cstdio>
class TempSensor {public: TempSensor(uint8_t i2cAddress) { // Validate I2C address if (i2cAddress > 127) { // Returning here will mean m_initSuccessful will stay false return; } m_i2cAddress = i2cAddress; m_initSuccessful = true; }
bool initSuccessful() { return m_initSuccessful; }private: bool m_initSuccessful = false; uint8_t m_i2cAddress;};
int main() { TempSensor tempSensor(10); if(!tempSensor.initSuccessful()) { printf("Initialization of temp. sensor failed."); }}
The boolean m_initSuccessful
could be changed to a uint8_t
if you want to provide the caller with more detailed information as to why the constructor failed. This is very similar to the “pass a status in” approach above.
C++ And Interrupts
There is a few things to be aware of when using C++ for embedded firmware projects that use interrupts and interrupt service routines (ISRs):
C++ Mangling The ISR Name
C++ mangles function names (adds arbitrary characters to the name) — If your firmware environment relies on exact function names for ISRs, then you cannot use a C++ function. The work around for this is to wrap the ISR function with extern "C"
. The following example is using the STM32 standard peripherals firmware library:
// In a .cpp file somewhere...extern "C" { void SPI1_IRQHandler(void) { // ISR handler code goes here }}
If you didn’t wrap SPI1_IRQHandler(void)
in extern "C"
, it’s compiled name would be something like AbXllGSPI1_IRQHandler(void)
. This would not get picked up by the linker, and because in the startup code there is a default (fall-back) IRQ function defined with __weak
, you wouldn’t even get a error saying the function is missing!
You won’t have this problem if the platform you use provides a function to “register” ISRs, as you’ll be passing the mangled name to the register function anyway:
int main(void) { // In your firmware app somewhere... register_isr(&SPI1_IRQHandler);}
ISRs Calling Class Functions
ISRs typically have a strict function type signature — no input variables and no return type. Thus there is no way to pass through as function inputs instances of C++ classes to access and call class member functions. For this reason you generally have to define file scoped class instances that the ISR has access to (or a static member function, or just call a C-style function). See the C++ Callbacks page for more info.
C++ Standard Libraries For Embedded Devices
uClibc++ is a C++ standard library designed specifically for microcontrollers. It even has exception support!
Check out The Standard Template Library (STL) For AVR With C++ Streams if you want to get a library for using things like string
and iostream
with AVR microcontrollers.
The Embedded Template Library (ETL)
The Embedded Template Library (ETL) is an popular open-source, MIT licensed C++ library which is designed to complement (and in cases replace) parts of the C++ STL (Standard Template Library) to provide a standard library which is useful in embedded environments2. One of the main problems with the STL from an embedded perspective is the use of dynamic memory allocation built into the use of the STL containers (e.g. std::vector
and std::string
). Dynamic memory allocation is used in these containers to provide powerful and flexible containers that work well when running on a full-blown OS, but the indeterministic nature and fragmentation of dynamic memory allocation usually makes these STL containers unsuitable for embedded systems.
The ETL provides a set of alternative containers that are statically allocated, and thus do not use dynamic memory allocation.
ETL is popular in the embedded community and as of September 2024 it has 2.1k stars on GitHub3. CMake is used to build the library2.
Installation
One way of adding ETL to your project is to either copy the repo or add it as a submodule. Once you have done that, you can use CMake’s add_subdirectory()
. The example below shows a CMakeLists.txt
configuration assuming you have added the ETL repo as a submodule to lib/etl
and you have your source code under src/
:
cmake_minimum_required(VERSION 3.22)project(my_project)
add_subdirectory(lib/etl)add_executable(my_project src/main.cpp)target_link_libraries(my_project PRIVATE etl::etl)
My favourite way to add ETL is to use CMake’s FetchContent
, which means you don’t even have to download the ETL repo manually! The example below shows how to do this:
Include(FetchContent)
FetchContent_Declare( etl GIT_REPOSITORY https://github.com/ETLCPP/etl GIT_TAG <targetVersion>)
FetchContent_MakeAvailable(etl)
add_executable(my_project src/main.cpp)target_link_libraries(my_project PRIVATE etl::etl)
Containers
ETL provides a number of containers (e.g. a vector, string, array) that are similar to the C++ STL containers, except they are backed by statically allocated memory and no dynamic allocation occurs.
Arrays
ETL provides a etl::array
which offers a array which has to have a fixed size known at compile time. Below is an example of a etl::array
:
#include <etl/array.h>
int main() { etl::array<uint8_t, 3> myArray; // Declare an array of uint8_t which as 3 elements}
etl::array
provides two main ways of randomly accessing the elements of the array, the []
operator and the .at()
method. The []
operator is faster, but does not check if the index is out of bounds. The .at()
method is slower, but does check if the index is out of bounds and will either assert or throw an exception (depending on how you have configured ETL) if it is.
myArray[0] = 10; // Use the [] operator to access the element. Faster, but no bounds checking!myArray.at(0) = 10; // Use the .at() method to access the element. Slower, but safer!
I would recommend you use .at()
by default, and only resort to []
if you have to for performance reasons. Another say way to access elements (but not in a random way) is to use iterators, which are explained below.
You can loop over the array elements by using iterators, which further protects you from accidentally accessing an element outside the array bounds.
for (auto it = myArray.begin(); it != myArray.end(); ++it){ // Do something with the element. "it" is a pointer to the element *it = 10;}
You can call .size()
to get the number of elements in the array.
You can use .fill(value)
to set all the elements of the array to a single value.
myArray.fill(10);
Vectors
Below is an example if the etl::vector
container and how you would use it:
#include <iostream>#include <inttypes.h>
#include <etl/vector.h>
void passMeAVector(etl::ivector<uint32_t>& vec) { // Do something with the vector vec.push_back(13); vec.push_back(14);}
int main() { etl::vector<uint32_t, 6> vec; passMeAVector(vec); printf("Num. of things in vec is %lu, vec[0] is %u, capacity is %lu\n", vec.size(), vec[0], vec.capacity()); return 0;}
Notice how both the type (uint32_t
) and the size of the vector (6) are specified in the template arguments when creating the vector. This allows space for 6 elements to be allocated on the stack (inside main()
at that point in time). This in comparison with std::vector
in which only the type is specified, and the buffer to hold the element is dynamically allocated (and resized!) on the heap at runtime.
One clever thing ETL does it allow you to accept function inputs of the etl::vector
type without having to also provide the size. This would be a pain, as it would mean a different version of the function for every different sized vector. ETL does this by defining a base class called etl::ivector
which only required the type as a template parameter. You can pass a etl::vector
to a function that accepts a etl::ivector
and it will work fine.
Strings
ETL provides fixed capacity string classes via etl::string
and etl::string_ext
. Unlike std::string
, etl::string
does not perform any dynamic memory allocation. The downside is that you need to either provide the max. size as a template parameter (so that etl::string
can allocate a buffer of that size in it’s object at compile time) or provide your own already allocated buffer.
ETL strings are provided via the etl/string.h
header file.
etl::string
is a simple string class which contains it’s own fixed size array. To create a etl::string
you need to specify the size of the array as a template parameter, as shown below:
#include <etl/string.h>
int main() { etl::string<10> myString;}
You also have the ability to provide your own buffer to create a string, using etl::string_ext
:
char buffer[10 + 1]; // +1 for the null terminatoretl::string_ext<10> myString(buffer, etl::size(buffer));
ETL strings are safe from overflow and other issues typically associated with raw C-style strings (char *
). ETL strings have an internal flag which records the truncation state. If a string is constructed, assigned or modified in any way that the text contained would have exceeded it’s capacity then the truncation flag is set. The truncation flag is also propagated to other strings if it is built up from the originally truncated string, even if the resulting string is not truncated. This is useful behaviour that let’s you check if a string is valid or not after many operations. It is similar to how the special float value NaN
propagates to all other floats involved in a calculation with it.
You can format things like integers and floats into a string using the etl::to_string
function, provided by the <etl/to_string.h>
header file.
etl::string<20> text = "Number is: ";etl::to_string(5, text, true); // Make sure to append as to not overwrite "Number is: "// String is now "Number is: 5"
Sometimes you will have to pass a ETL string to a function that expects a char *
. Just like with std::string
, you can use the .c_str()
method to get a char *
from an ETL string.
String Views
ETL provides string views, which are low-cost views into specific parts of a string.
The following example creates a string view from a string with a different start and end position:
etl::string<10> greeting("Hello World");etl::string_view view(greeting.begin() + 2, greeting.size() - 4);
Configuration
You can configure ETL by adding a file called etl_profile.h
into your project. It is automatically included by the ETL library if it exists (platform.h
is the file which pulls it in).
ETL uses a trick I hadn’t seen before to only include the file if it exists, via a __has_include
macro as shown below:
//*************************************// Include the user's profile definition.#if !defined(ETL_NO_PROFILE_HEADER) && defined(__has_include) #if !__has_include("etl_profile.h") #define ETL_NO_PROFILE_HEADER #endif#endif
#if !defined(ETL_NO_PROFILE_HEADER) #include "etl_profile.h"#endif
Typically you will want to define this file and at a minimum either add ETL_LOG_ERRORS
or ETL_THROW_EXCEPTIONS
(if you allow the use of exceptions on your target platform). Adding ETL_VERBOSE_ERRORS
is also useful as it adds more information to the error messages such as the file name (but it does use up more flash).
For example, your etl_profile.h
file might look like this:
#define ETL_LOG_ERRORS#define ETL_VERBOSE_ERRORS
See https://www.etlcpp.com/macros.html for a full lists of the macros you can define in etl_profile.h
.
Common Pitfalls
While I would recommend using C++ over C for embedded firmware, there are some things to be aware of.
Accidental Dynamic Memory Allocation
Because more things are hidden behind constructors, templates, operator overloads and polymorphism, it is easy to accidentally call something which will allocate dynamic memory when you don’t want it to.
Way to avoid this is to stay clear of the std
containers unless you know exactly what they do (use the ETL containers instead, they are designed to never call new
).
Template Bloat
Given the power of templates to generate code “on the fly”, it can be easy to overuse them and suddenly consume far too much flash space with template generated code. Make sure that you are using templates where it makes sense to, and you have carefully considered the alternatives. Sometimes, rather than using templates to make your library class portable to any type, just pass things around as void *
instead.
Virtual Function Overhead
Virtual functions make things like testing with mock implementations of peripherals easy. It is worth noting that virtual functions can come with a performance penalty due to the extra layer of indirection (the virtual table pointer). However, this is a rarely an issue, and compilers are pretty good at optimizing virtual functions away in many cases. If you are concerned, profile the code and see if virtual functions are a bottleneck you cannot live with.
If they are problematic, then you have a few options:
- Use compile time polymorphism using templates rather than runtime polymorphism using virtual functions.
- Use the C style trick of having a shared header file but switching out the
.cpp
implementation file based on what you are compiling for (this only works when you are happy switching out every implementation for a different one, e.g. real implementations vs. mock implementations). You can switch the.cpp
files either by including the correct one in your build system (e.g. a CMake target), or using preprocessor macros and#if
statements.
More Resources
The document “Technical Performance on C++ Performance” is a good read if you are really interested in the advantages/disadvantages of using C++ on an embedded platform4.
The Embedded C++ Homepage is sort of a hub for embedded C++ programming. They define a sub-set of the full C++ language for use on embedded devices such as microcontrollers.
Footnotes
-
cppreference.com. std::array. Retrieved 2024-03-15, from https://en.cppreference.com/w/cpp/container/array. ↩
-
Embedded Template Library (ETL). Embedded Template Library [homepage]. Retrieved 2024-09-03, from https://www.etlcpp.com/. ↩ ↩2
-
Embedded Template Library (ETL). Embedded Template Library [GitHub repo]. Retrieved 2024-09-03, from https://github.com/ETLCPP/etl. ↩
-
ISO/IEC (2006, Feb 15). ISO/IEC TR 18015:2006(E) - Technical Report on C++ Performance. Retrieved 2022-09-29, from https://www.open-std.org/jtc1/sc22/wg21/docs/TR18015.pdf. ↩