10 Handy C++20 Features for Embedded Systems

Ten C++20 features that make everyday code easier to review, harder to misuse, and more practical to maintain in real projects.

When I look at C++20 from an embedded point of view, I am not looking for a reason to rewrite a working firmware codebase. I am looking for the small pieces that make the next review calmer: the interface that is harder to call wrongly, the buffer that carries its size with it, or the constant that cannot quietly become a runtime mistake.

So I would start with the features that make intent visible in ordinary code. A configuration struct can name its fields, a buffer API can carry its size with the pointer, a command ID can be built once at compile time, and a helper can say what kind of type it accepts. None of that is flashy, but it helps in exactly the places where embedded bugs usually begin, with a small assumption that looks harmless until the code meets real hardware.

Reality check: C++20 on MCUs

Modern MCU projects can use C++, but the honest answer is still tied to the toolchain in front of you. Arm’s GNU Arm Embedded Toolchain is a GCC-based toolchain for C, C++, and assembly on Cortex-A, Cortex-M, and Cortex-R targets. GCC documents almost full C++20 support, selectable with -std=c++20 or -std=gnu++20, while noting that modules remain experimental and older GCC versions had unstable C++20 ABI details.

Other ecosystems have their own story. ESP-IDF documents C++ application support and currently defaults chip targets to a GNU C++26 mode, with caveats around exceptions, RTTI, filesystem behavior, and iostream size. IAR Embedded Workbench for Arm also lists selected C++20 capabilities. So before a feature goes into production firmware, it is worth checking the exact compiler, standard library, build flags, exception and RTTI policy, heap policy, and linker map instead of assuming that a desktop example tells the whole story.

1. Designated Initializers

Imagine adding a second UART for a debug connector late in a project. The old initializer still compiles, but the struct gained a timeout field last week, so two integers now sit in the wrong places. The code still looks ordinary, and the mistake only shows itself when the port starts dropping bytes during a long capture.

Designated initializers help because the call site names each aggregate member. Instead of asking the reader to remember which integer is baud rate, stop bits, or timeout, the code says it at the point where the hardware setup is chosen.

The boundaries are still worth respecting. C++20 designated initializers work for aggregate types, and members must appear in declaration order. You also cannot mix designated and positional initialization in the same initializer. For board configuration, sensor defaults, protocol tables, and small plain data structures, that tradeoff is usually fine. For classes that protect real invariants, constructors still belong in the design.

MCU fit: This is a strong fit when your compiler supports C++20 aggregate designated initializers. There is no runtime cost, but the C++ rules are stricter than C designated initializers, so ported vendor C examples may need a little cleanup before they compile cleanly.

2. Abbreviated Function Templates

Small generic helpers often start as things you write while trying to see what the target is doing. Today the helper prints an ADC count, tomorrow it prints a DMA flag, and next week it prints a version number. In that kind of code, the full template form can make a simple helper look more important than it is.

C++20 lets simple function templates put auto directly in the parameter list. The function still accepts different value types, but it reads more like the small utility it really is.

This style is nicest when the type is only passed through, printed, compared, or lightly inspected. Once a function has several related template parameters, overload rules, or compile-time branches, the older template <typename T> form often becomes clearer because the type has a name you can reuse.

MCU fit: Good for small helpers because it is a language feature rather than a runtime library feature, but it is still worth using with some restraint. If a helper starts accepting more types than the design really supports, move to a named concept or back to the older template form.

3. Constrained auto

A plain auto parameter is generous, and sometimes that generosity is exactly the problem. Near hardware, a register write helper should not accept a signed negative value just because the bits can be forced into place.

Concepts let you put that rule at the API boundary. The signature gives the reader the important part before they read the body: this function is for register-sized unsigned values, not for anything that happens to compile.

The useful part is not only the compiler error. The type rule becomes part of the interface, which matters at boundaries between drivers, serialization code, register wrappers, and host-side test utilities. Those are exactly the places where a broad helper can slowly become a dumping ground.

MCU fit: Good when the compiler supports concepts. They can make template diagnostics and compile times heavier, but they do not add runtime cost by themselves. For older vendor compilers, a C++17-compatible overload or static assertion pattern may be the more practical choice.

4. Template Syntax for Generic Lambdas

Generic lambdas are useful when a small piece of logic belongs right next to the code that uses it. The downside of old auto lambda parameters is that the type rule can fade into the background. In a checksum helper, for example, accepting any object that compiles is usually too loose.

C++20 lets lambdas use explicit template parameter syntax. That gives a local helper enough shape to be reviewed properly without forcing you to move it into a separate function template.

Use this for callbacks, local transforms, small validators, and tests. When the lambda grows beyond a few lines or becomes part of a driver contract, a named function is usually easier for the next person to find, test, and review.

MCU fit: Good for local compile-time type rules and test helpers, as long as the lambdas stay small. In firmware code reviews, the moment a lambda starts carrying design meaning, it often deserves a name.

5. consteval for Immediate Functions

Protocol IDs and register masks often begin life as copied hex values. A few months later, nobody remembers whether 0x5244 meant read data, reset device, or something copied from an old branch. A compile-time builder keeps the meaning beside the value instead of leaving the reader to decode it later.

constexpr means a function can run at compile time, while consteval is stricter and says it must run at compile time. That stricter rule is useful when accepting runtime input would be a design mistake.

This is safer than repeating numeric IDs by hand, and it is cleaner than a macro because the function still has a type, a return value, and normal C++ rules. Keep consteval for values that truly belong at compile time. If a helper has a legitimate runtime use, constexpr is usually the better fit.

MCU fit: Very good for command IDs, masks, table dimensions, and register constants because the work is compile-time only. The main portability check is compiler support rather than runtime cost.

6. constinit for Safer Static Initialization

Startup problems can be frustrating because they happen before the system has much chance to explain itself. One global is ready, another one is not, and a small change in link order makes the failure move. constinit does not solve every startup problem, but it helps with a very specific one.

constinit checks that a static or thread-local variable is initialized during static initialization. It is easy to misread the name, so it is worth saying clearly: it does not make the variable read-only.

Use constinit for simple global counters, flags, default tables, and hardware-independent state that must be ready early. If the value must never change, use constexpr or const instead. The distinction is practical: constinit is about when initialization happens rather than whether the value can change later.

MCU fit: Very good for startup-sensitive code. It helps catch dynamic initialization where you expected static initialization, but it does not replace the usual work around global state, reset behavior, and linker placement.

7. std::span

Buffer APIs are one of the places where old C habits stay around for a long time. One parameter carries the pointer, another carries the count, and every caller has to keep them paired correctly. That is manageable until a parser gets reused with a shorter test vector, a DMA buffer slice, or a buffer that came from a different layer.

std::span gives you a non-owning view over contiguous data. The pointer and size travel together, and the function does not allocate or copy the buffer.

The nice part is that the function does not need to care who owns the memory. The caller owns the buffer, and the function receives a view. The lifetime rule still matters though: a std::span must not outlive the buffer it points to, and it does not solve synchronization around DMA or interrupt-owned memory.

MCU fit: Excellent when <span> is available in the standard library. For dynamic extent it is normally just a pointer and a size, which makes it a better buffer interface than raw pointer plus length while still keeping it honest as a borrowed view.

8. starts_with and ends_with

Host tools, bootloader utilities, and small command consoles often have little text classifiers scattered through them. Before C++20, even a simple prefix check could turn into manual indexing or a noisy compare() call, and the extra ceremony made the actual rule harder to see.

C++20 adds direct prefix and suffix checks to std::string and std::string_view. For simple command names, log topics, and configuration keys, the code can now say the thing you meant to check.

These helpers are best for straightforward string classification: command names, log topics, configuration keys, file names, and simple protocol text. If the input is really a protocol grammar, use a real parser or a deliberate state machine rather than stretching prefix checks too far.

MCU fit: Good for embedded Linux, ESP-IDF-class systems, bootloader tools, command shells, and host utilities. On tiny bare-metal targets, std::string_view is often fine, but dynamic std::string use should follow the project’s allocation policy.

9. contains for Associative Containers

Configuration loaders and PC-side test utilities often need to answer a plain question: is this key present? Before C++20, that usually meant an iterator comparison. It was correct, but it made a simple existence check look busier than it needed to be.

C++20 adds contains() to ordered and unordered associative containers, so the code can ask directly whether a key exists.

Use contains() when existence is all you need. Use find() when you also need the iterator or when you want to avoid a second lookup before reading the value.

MCU fit: Usually better for host tools, embedded Linux, configuration loaders, and larger RTOS applications than for tiny bare-metal firmware. The member function is fine, but std::map and std::unordered_map bring memory and library choices that may not belong on a small MCU.

10. std::erase and std::erase_if

Cleanup code is common enough that it should not make the reader stop and mentally expand an idiom. Removing disabled channels, zero samples, stale requests, or empty names is a maintenance task, and the code should look like one.

C++20 adds std::erase and std::erase_if for common removal operations. The old erase-remove idiom is still worth recognizing when you read older code, but the common case can now be written more directly.

For a vector, std::erase_if still moves elements internally, so removal is not free. What you gain is clarity and a smaller chance of writing the old pattern incorrectly. If removal performance dominates the design, choose the container and algorithm deliberately.

MCU fit: Good when the container itself is already acceptable. For fixed-size firmware data, a project-owned static array or ring buffer may still be the better choice, while host-side tools and larger embedded applications often benefit from the clearer cleanup code.

A Quick Comparison

These features do not all matter in the same kind of project. A small MCU firmware codebase may get the most value from initialization checks, buffer views, and compile-time IDs. A host-side production test tool may benefit more from string helpers, maps, cleanup code, and constrained utilities.

Feature Practical benefit Embedded fit
Designated initializers Clear hardware and config setup Strong MCU fit, if compiler supports C++20 rules
Abbreviated templates Less noise for small generic helpers Good, but keep APIs reviewable
Constrained auto Type rules visible at the API Good with modern GCC, Clang, or compatible vendor compiler
Template lambdas Local generic callbacks and helpers Good for local helpers and tests
consteval Compile-time-only IDs and masks Strong MCU fit
constinit Safer static initialization Strong MCU fit for startup-sensitive state
std::span Buffer view with pointer and size Excellent if <span> is available
starts_with and ends_with Cleaner string classification Good for command shells, host tools, and larger embedded systems
contains Direct key existence checks Mostly host tools, embedded Linux, or larger RTOS projects
std::erase_if Simpler cleanup code Good when dynamic containers are already acceptable

Practical Takeaways

C++20 does not need a big migration plan before it becomes useful. Start with the pieces that make a local decision easier to read and make the wrong use harder to express.

For MCU firmware, that usually means designated initializers, std::span, consteval, constinit, constrained auto, and template lambdas where they clarify local rules without pulling in runtime machinery. Treat strings, maps, vectors, exceptions, RTTI, iostreams, and filesystem features as project decisions rather than automatic defaults. Used well, C++20 should make the rule visible in the code and leave less room for the kind of assumption that passes review and then fails on hardware.

Saeid Yazdani working at an electronics workbench
Saeid Yazdani

Embedded Systems Engineer with 15+ years of professional experience developing firmware, electronics, measurement systems, and hardware-software solutions. I have been programming for more than two decades and write about Embedded C/C++, STM32, AURIX, PCB design, debugging, and practical engineering lessons from real-world projects.

Articles: 40

Leave a Reply

Your email address will not be published. Required fields are marked *