C++17’s Useful Features for Embedded Systems

Çağlayan DÖKME
8 min readJun 15, 2022

--

Recently, our team at Meteksan Defense is migrating its development environment to newer versions of currently used tools. And, one of the steepest transitions happens on the C++ side. Shortly, we’re migrating our embedded applications from C++11 to C++17. In this article, I will be showing some features of C++17 that can also be helpful in the embedded world.

  • Note that the migration from C++11 to C++17 covers C++14 also, hence I will touch upon some aspects of it as well.
  • The full list of features can be found here. I will be referencing it frequently.

Why can’t we use all features?

C++ is a powerful language that can be utilized on both high and low-level applications. In the embedded world, we generally have resource-constrained systems. For some features we will see, I will examine the required computation power to utilize it efficiently.

A bit from the C++14

C++14 had smaller upgrades compared to the ones we saw when migrating to C++11 from C++03. Hence, there are only a few features in C++14 that you can use in an embedded system.

  • Binary Literals: If you are frequently dealing with the bitwise operations as you do on register controlling, you will love these literals. Some compilers had extensions that support such literals, but now they have a place in the actual standard.
uint8_t a = 0b110;        // == 6 
uint8_t b = 0b1111'1111; // == 255
  • Constraint relaxed constexpr: I really like the way how constant expressions work and be beneficial to code development. With C++14, the syntax you can use in a constexpr function is widened. Check out this post on StackOverflow.
    The constexpr is beneficial in the embedded world since it makes suitable calculations, computations, etc. even before the code has started. Note that an expression can only be calculated during the compile-time if all its requirements can be determined during the compilation.
constexpr int factorial(int n) {
if (n <= 1) {
return 1;
} else {
return n * factorial(n - 1);
}
}
factorial(5); // == 120 (Calculated at compile time)

The World of C++17

In contrast to C++14, the C++17 standard changed the aura of C++ much more. Don’t get scared, you will still be able to continue using whatever you were using through this time. In addition to all you had before, you will now have a more powerful syntax and libraries with C++17.

Attributes

Let’s start with the these three new attributes: [[fallthrough]], [[nodiscard]], and [[maybe_unused]]. As they are only considered at compile-time, you don’t need to worry about their efficiency at all. They exist only to enhance your code development phase.

  • [[fallthrough]]: With this attribute, you can now merge the bodies of two adjacent case branches in a switch without getting any warnings from the compiler. By using it, you tell the compiler that the prior case body is non-terminated intentionally.
switch (n) {
case 1: [[fallthrough]]
// ...
case 2:
// ...
break;
}
  • [[nodiscard]]: I’m pretty sure you forgot to check the return value of your functions at least a hundred times. With this attribute, discarding the return values will become a reason for compiler warnings.
[[nodiscard]] bool do_something() {
return is_success; // true for success, false for failure
}
do_something(); /* warning: ignoring return value of function declared with attribute 'nodiscard' */
  • [[maybe_unused]]: Are you tired of casting the unused variables to void to suppress the warnings? Then, try this attribute to get rid of that irritating warnings.
void my_callback(std::string msg, [[maybe_unused]] bool error) {
// Don't care if `msg` is an error message, just log it.
log(msg);
}

Power of Compile-Time

The power of checking things at compile-time fascinates me the most in C++. With the C++17, this ability is further enhanced with some new features. Checking things without even deploying the code is quite beneficial when you think of the cumbersome debugging process in many embedded systems. Even transferring the executables to the target and preparing the environment for the execution and testing can be harsh. With compile-time programming, some parts of that tiring procedures can be eliminated.

  • Static Assertion without message: You might think that we already had the static_assert(..) to check things at compile time. This time, the assertion mechanism works without providing an error message. This way, your codes will look more clear.
static_assert(false)
  • if constexpr: One of my favorites! By using the if constexpr, we can write codes that are instantiated depending on compile-time conditions.
template<typename T>
auto length(const T& value) noexcept {
if constexpr (std::integral<T>::value) { // is number
return value;
else
return value.length();
}
int main() noexcept {
int a = 5;
std::string b = "foo";
std::cout << length(a) << ' ' << length(b) << '\n'; // Prints "5 3"
}

Prior to C++17, the above code needs to have two different functions for the string and integer inputs like below.

int length(const int& value) noexcept {
return value;
}
std::size_t length(const std::string& value) noexcept {
return value.length();
}
  • constexpr lambda: If you also like using the lambda expressions in your codes, you will love this feature. Lambdas can also be invoked at compile-time by declaring them as constexpr.
auto identity = [](int n) constexpr { return n; };
static_assert(identity(123) == 123);

Syntactic Sugar

In C++17, there are some features that help you to write your codes in more beautiful ways. Even though their existence doesn’t affect the runtime performance dramatically, you will like using them.

  • Fold Expressions: If you had a chance to use the variadic templates to elaborate a recursive algorithm with a variable amount of inputs or iterations, then you might face the issue of having to implement a terminator for that variadic template function. For example, the code below is written in C++11 and it accumulates the given numbers.
int sum() { return 0; }template<typename ...Args>
int sum(const int& arg, Args... args) {
return arg + sum(args...);
}

This code wouldn’t compile if we didn’t implement the terminator that doesn’t take any inputs. Thanks to the fold expressions, you don’t have to implement a terminator anymore and your code will look way better than the old one. See below.

template<typename ...Args> 
int sum(Args&&... args) {
return (args + ...);
}
  • Nested Namespace: I don’t know how the committee of C++ didn’t think of this before. No need to explain actually, see the difference between the nested namespace definitions below in C++11 and C++17 respectively.
// C++11
namespace A {
namespace B {
namespace C {
int i;
}
}
}
// C++17
namespace A::B::C {
int i;
}
  • Enhanced Conditional Statements: Wouldn’t it be more powerful if all conditional statements have the initialization section like the for statement has? With C++17, we now have the initialization part in conditional statements also.
    This is one of the most powerful features I’ve seen so far since the variables that you create before entering a sequence of if-else statements or a switch-case will no more crowd in your local variable set.
if(int i = 4; i % 2 == 0  )
cout << i << " is even number" << endl;
switch(int i = rand() % 100; i) {
default:
cout << "i = " << i << endl;
break;
}
  • Inline Variables: Prior to C++17, we had to instantiate the in-class static variables in the source file. With the inline variables, you can merge the declaration and the initial assignment inside the class definition as below.
struct BabaMrb {
static const int value = 10;
static inline std::string className = "Hello Class";
}

Miscellaneous

There are numerous other features in C++17 that I couldn’t classify easily. We will cover them in this section.

  • Guaranteed Copy Elision: Copy elision, i.e. return value optimization, is an optimization implemented by most compilers to prevent extra copies in certain situations. As of C++17, copy elision is guaranteed when an object is returned directly.
    In some situations, even a single copy operation affects the performance of a system, e.g. systems with strict real-time requirements. In such cases, it’s better to make certain that you avoid copying in order not to deteriorate the system performance.
struct C {
C() { std::cout << "Default constructor" << std::endl; }
C(const C&) { std::cout << "Copy constructor" << std::endl; }
};

C f() {
return C(); // Definitely performs copy elision
}
C g() {
C c;
return c; // May perform copy elision
}

int main() {
C obj = f(); //Copy constructor isn't called
}
  • Shared Mutex: Shortly, with the shared mutex, you can read the object in demand without locking it and you will lock it before writing as you did before. With that feature, read-only access operations will be faster as they will be able to occur simultaneously. (Images)
Classic Mutex Implementation (Lock before read/write)
Classic Mutex Behavior (Lock before reading/writing)
Shared Mutex Behavior (Lock before writing only)
  • Hardware Interference Size: This new library feature helps you to determine the L1 cache line size during compilation. With this feature, you will be able to align your structures, buffers, etc. according to the L1 cache line size.
    For me, this would be helpful when I was implementing a low-level, bare-metal DMA driver for an ARM Cortex-A9 core with C++11 where I had to manage the coherency between the cached and main-memory manually. If you would like to know further, please take a look at this post of mine.
    Although this feature is quite powerful, it isn’t implemented in any versions of GCC until version 12. So, it is highly possible that your current compiler doesn’t even support it. Check out the codes below to have a better understanding. You may need this feature one day in the future.
#ifdef __cpp_lib_hardware_interference_size // Undefined prior to C++17
using std::hardware_constructive_interference_size;
using std::hardware_destructive_interference_size;
#else
// 64 bytes on x86-64 │ L1_CACHE_BYTES │ L1_CACHE_SHIFT │ __cacheline_aligned │ ...
constexpr std::size_t hardware_constructive_interference_size = 64;
constexpr std::size_t hardware_destructive_interference_size = 64;
#endif
struct alignas(hardware_constructive_interference_size) OneCacheLiner { // occupies one cache line
std::atomic_uint64_t x{};
std::atomic_uint64_t y{};
};

Conclusion

As opposed to C++14, C++17 came with many new features. Some of those features are beneficial in the world of embedded systems and some of them are not. I inspected the ones that I liked the most by directly utilizing them in my current designs.
The computation power range of embedded devices varies considerably between different products. Some of the features that I chose might not be applicable in your system due to several reasons such as CPU performance, lack of compiler support, verification necessity, etc. Migration to C++17 might cost you a severe amount of time and effort. It’s better to know whether you really require the migration or not.

This post is open to comments and criticism. Let me know if there are any other features that you liked and can be utilized in embedded systems.

--

--