MODERN C++ IN EMBEDDED DEVELOPMENT: constexpr
What is constexpr?
Cppreference defines constexpr specifier as follows: “The constexpr specifier declares that it is possible to evaluate the value of the function or variable at compile time. Such variables and functions can then be used where only compile-time constant expressions are allowed (provided that appropriate function arguments are given).”
constexpr variables
A constexpr variable must be immediately initialized, and its type must be a LiteralType. Other requirements must be met, but these two are most important for starting utilizing the benefits constexpr brings.
The basic usage of constexpr variables is compile-time constants. Here is a simple example:
constexpr double pi = 3.14159265359;
If it’s not used in runtime (passing a pointer or a reference of the pi to a function), the compiler will optimize pi away. That is, it will not exist in memory as a variable.
constexpr functions
If we add a constexpr specifier to a function, it is a hint to the compiler that the function can be executed in a compile-time if provided with constant arguments. Some of the requirements for the constepxr function are: the return type must be a LiteralType, each of its parameters must be a LiteralType, and if the function is not a constructor, it needs to have precisely one return statement. Another important requirement imposed on the constexpr function is that they cannot have variables that are not initialized. Let’s take a look at a simple example of a square constexpr function:
constexpr int square(int a) {
return a*a;
}
int main() {
constexpr int ret = square(2);
return ret;
}
If the square function is executed in compile time, we should see that by examining the assembly code of the above example. Using compiler explorer, we can see that the assembly output for the above example is the following:
main:
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], 4
mov eax, 4
pop rbp
ret
You don’t need to know assembly for x86-64 architecture to realize that there is no square function in generated assembly code. Let’s remove the constexpr specifier for the above example and compare the assembly generated by the compiler.
int square(int a) {
return a*a;
}
int main() {
int ret = square(2);
return ret;
}
Below is the assembly code generated by the compiler:
square(int):
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], edi
mov eax, DWORD PTR [rbp-4]
imul eax, eax
pop rbp
ret
main:
push rbp
mov rbp, rsp
sub rsp, 16
mov edi, 2
call square(int)
mov DWORD PTR [rbp-4], eax
mov eax, DWORD PTR [rbp-4]
leave
ret
We can see that without a constexpr specifier actual square function is generated and called. Note that the optimization level in both examples is -O0. The compiler used for the above examples was GCC 11.2 for x86-64.
Macros vs. constexpr
Define macros are a classic approach to defining constants in C. It’s also common in some C++ codebases. By introducing the constexpr specifier in C++11, C++ provides a better way. Let’s explore this example:
#include <cstdio>
#define VOLTAGE 3300
#define CURRENT 1000
int main() {
const float resistance = VOLTAGE / CURRENT;
printf("resistance = %.2f\r\n", resistance);
return 0;
}
What’s the output of the example above? It’s:
resistance = 3.00
So, what happened? Both voltage and current are parsed as integer literals, and so is the result of expression voltage / current. If we’d make either voltage or current a floating-point literal using the f suffix
#define VOLTAGE 3300.f
#define CURRENT 1000.f
We’d get the expected result:
resistance = 3.30
You could argue that this is basic knowledge, but imagine a situation where you are trying to tweak a parameter, changing values of voltage and current constants, and you forget the f suffix. We can do better. Using constexpr specifier, we can have type-safe constants:
#include <cstdio>
constexpr float voltage = 3300;
constexpr float current = 1000;
int main() {
const float resistance = voltage / current;
printf("resistance = %.2f\r\n", resistance);
return 0;
}
Now the compiler is aware of the type of our constants, and there is no more need for the f suffix. The output is as expected:
resistance = 3.30
Using the constexpr specifier compiler gets insight into your variable type-ness intentions. Also, you and anyone else reading the codebase knows the type intended for the constants used.
Initialize array using constexpr functions
Generation of signals using PWM or DAC modules is a common task in embedded development. The old-school approach is to use header files with signal arrays. These header files are generated by third-party software or scripts. Using constexpr, we can create signal arrays in compile time.
Let’s take, for example, a trivial signal of square function. We want to be able to set the number of elements in signal and delta time. For the sake of simplicity, we will use int as type. The following example demonstrates the usage of constexpr for generation of square signal in compile time:
#include <array>
#include <iostream>
template <std::size_t N, int delta>
constexpr std::array<int, N> generate_array() {
std::array<int, N> arr = {0};
for (int i = 0; i < N; i++) {
arr.at(i) = (delta * i) * (delta * i);
}
return arr;
}
int main() {
auto signal = generate_array<5, 2>();
for (auto const& elem : signal) {
std::cout << elem << std::endl;
};
return 0;
}
The output of the above example is:
0
4
16
36
64
In case we need to initialize constexpr array only once and that generate function is not needed we can utilize constexpr lambda (available from C++17) as follows:
#include <array>
#include <iostream>
constexpr std::array<int, 3> signal = []() constexpr {
std::array<int, 3> arr = {0};
for (int i = 0; i < 3; i++) {
arr.at(i) = (2 * i) * (2 * i);
}
return arr;
}();
int main() {
for (auto const& elem : signal) {
std::cout << elem << std::endl;
};
return 0;
}
The output of the above example is:
0
4
16
The above examples show the power of constexpr specifier and use cases in embedded system development. The generation of custom signals in the codebase is a considerable advantage compared to generating these signals in custom scripts. It also helps with testing the business logic modules as you can write the tests in the same language and without calls to external scripts when you want to see how your module behaves with different signals.
Amar Mahmutbegović
Head of Engineering
Embedded developer with a proven history in the development of BLE embedded firmware in C and C++. Co-founder of Semblie.
S