C++ for Embedded Programming: constexpr
Is C++ good for embedded programming? Don’t ask me! What does “good” even mean? However, as I start to dip my toes into embedded I had two thoughts:
- It appears to me that C is still the dominant language in embedded systems
- In theory the C++ principles of “zero-cost abstraction” and “don’t pay for what you don’t use” should be especially useful in embedded systems
I’m just a hobbyist at embedded and I don’t intend to tell the pros what to do. However, some C++ features have made my life easier in my own embedded projects. I wanted to share a few specific examples. The first one is using constexpr
.
What is constexpr?
In modern C++, the constexpr
keyword indicates that a function may be evaluated at compile time. For example, in C++98, you might have something like:
int area(int width, int height) {
return width * height;
}
const int size = area(16, 9);
Then the area
function is included in your binary, and it will get called at program start (before main
). In C++11 and later, you can write:
constexpr int area(int width, int height) {
return width * height;
}
constexpr int size = area(16, 9);
Then the compiler will (probably) compute size
at compile time and optimize out the whole area
function entirely.
In this case it’s not such a big deal, but imagine if the area
function were more expensive to compute, or if you really needed to keep the size of your binary down, etc.
If you want your mind blown by the kinds of stuff you can do at compile-time, check out Sprout.
A practical example: USB descriptors
Recently I’ve been working on my first from-scratch USB device, which required dipping into the USB protocol.
The USB protocol allows a USB device to provide descriptors, which tell the host computer information about what the device is and what it’s for. For example, one USB descriptor is supposed to contain a string representing the name of the device. This is how when you plug in a USB device, your computer knows it’s a “Logitech Mouse” or whatever.
A USB string descriptor packet looks like this:
- size of packet in bytes (1 byte)
- string packet type indicator (1 byte)
- string encoded in UTF-16 (variable)
So, to make a USB device, you need to program the microcontroller to return the device name in the correct format when requested. Lemon squeezy, right?
First attempt: transform descriptors on demand
The most straightforward solution may be something like that:
uint8_t descriptor[MAX_SIZE];
uint8_t const* get_descriptor() {
pack_descriptor("MyDevice", descriptor_buffer, MAX_SIZE);
return descriptor;
}
The device name, "MyDevice"
, is a regular string literal; when the host asks for it, we pack it into the correct format and send the packet.
That’s perfectly fine – this is what TinyUSB does, for example. But it’s not totally satisfying.
Memory is scarce on embedded systems: the MCU I’m currently working with has 4 KB of RAM (yes, that’s a K). In this case you need memory for the original "MyDevice"
literal, plus separate memory for the descriptor buffer – roughly doubling the memory required for your descriptor strings.
The TinyUSB example linked above re-uses the same buffer for every string descriptor, which saves memory, but requires you to have confidence that the USB stack won’t ever try to collect two descriptor strings at once.
Second attempt: manually prepare descriptor packets
Another solution is to prepare the descriptor packets by hand in advance:
const uint8_t descriptor[] = {
12, 3, 0, 'h', 0, 'e', 0, 'l', 0, 'l', 0, 'o',
};
This is memory-efficient, and it works fine, but it’s hard to read. And it’s error-prone; are you sure you got the packet size correct? Are the unicode characters supposed to be big-endian or little-endian?
Third attempt: pack the descriptor at compile time
The beauty of constexpr
is that you can pack the descriptor at compile time. For example:
template<size_t N>
struct USBDescriptor {
constexpr USBDescriptor(char16_t const (&s)[N]) :
size(2 * N),
type(USB_STRING_TYPE) {
for (size_t i = 0; i < N - 1; ++i) {
chars[i] = s[i];
}
}
uint8_t size;
uint8_t type;
char16_t chars[N - 1];
};
constexpr USBDescriptor name(u"hello");
uint8_t const* get_descriptor() {
return reinterpret_cast<uint8_t const*>(name);
}
The type of the literal u"hello"
is const char16_t[6]
(5 for the characters plus one for the null terminator), so the compiler can deduce the value of N
that makes the template match.
Amazingly, modern compilers will evaluate the constructor at compile time. The spec does not require compilers to evaluate every constexpr at compile time, but I tested this with clang 13 and gcc-arm 10, and they both precompute it. This means the original null-terminated "hello"
literal won’t appear in your binary; instead, the prepacked USB descriptor will be in there, occupying exactly the number of bytes you need with no waste.
What’s more, you can add compile-time error checking. Since the packet length is only a single byte, and two bytes are occupied by the header, the longest possible descriptor string is 126 characters.
const size_t MAX_LENGTH = 126;
template<size_t N>
struct USBDescriptor {
constexpr USBDescriptor(char16_t const (&s)[N]) :
size(2 * N),
type(USB_STRING_TYPE) {
static_assert(
N <= MAX_LENGTH,
"descriptor is too long"
);
for (size_t i = 0; i < N - 1; ++i) {
chars[i] = s[i];
}
}
uint8_t size;
uint8_t type;
char16_t chars[N - 1];
};
constexpr Descriptor baddesc(
u"extremely long USB descriptor; it's just totally "
u"unreasonable to have a USB descriptor this long; "
u"ideally, the compiler would stop me"
);
When you try to compile that, it fails with a (relatively) helpful error like static_assert failed due to requirement '134UL < MAX_LENGTH' "descriptor is too long"
The constexpr
trick shaved nearly 100 bytes off my firmware. Now that wasn’t strictly necessary for my device; I had a little RAM to spare either way. But with more ambitious firmware, you might brush up against some tight RAM or flash constraints, and it’s nice to have tricks like this available.