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.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
struct UartConfig { int baud_rate; int data_bits; bool parity_enabled; int stop_bits; int rx_timeout_ms; }; // Bad: positional values are easy to swap when the struct grows. // UartConfig debug_old { 115200, 8, false, 1, 20 }; UartConfig debug_port { // Good: the field name carries the hardware intent. .baud_rate = 115200, .data_bits = 8, .parity_enabled = false, .stop_bits = 1, // Good: timing stays visible next to the UART assumptions. .rx_timeout_ms = 20, }; |
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.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
#include <cstdint> void debug_write_name(const char* name); void debug_write_u32(std::uint32_t value); void trace_value(const char* name, auto value) { debug_write_name(name); debug_write_u32(static_cast<std::uint32_t>(value)); } void dump_uart_status(int tx_count, bool dma_active) { // Good: the helper stays readable because the template part is not the point. trace_value("tx_count", tx_count); trace_value("dma_active", dma_active); } // OK: the old form is still clearer when the type needs a reusable name. // template <typename T> // void trace_value_old(const char* name, T value); |
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.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
#include <concepts> #include <cstdint> template <typename T> concept RegisterValue = std::unsigned_integral<T> && (sizeof(T) <= sizeof(std::uint32_t)); void write_control_register(RegisterValue auto value) { std::uint32_t raw = static_cast<std::uint32_t>(value); peripheral_write32(0x40001000u, raw); } // Bad: signed values do not belong in this register API. // write_control_register(-1); // Good: an unsigned register mask fits the hardware rule. write_control_register(0x00000080u); |
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.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
#include <concepts> #include <cstdint> #include <span> auto checksum8 = []<std::unsigned_integral T>(std::span<const T> data) { std::uint8_t sum = 0u; for (T value : data) { sum = static_cast<std::uint8_t>(sum + value); } return sum; }; std::uint8_t bytes[] { 0x10u, 0x20u, 0x30u }; // Good: the lambda keeps the unsigned byte rule local to the call site. std::uint8_t crc = checksum8(std::span<const std::uint8_t>{bytes}); // Risky: an unconstrained lambda can become a generic dumping ground. // auto checksum_any = [](auto data) { return checksum8(data); }; |
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.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
#include <cstdint> consteval std::uint16_t command_id(char high, char low) { return (static_cast<std::uint16_t>(high) << 8) | static_cast<std::uint16_t>(low); } // Good: command IDs are built once and checked at compile time. constexpr std::uint16_t CMD_READ = command_id('R', 'D'); constexpr std::uint16_t CMD_WRITE = command_id('W', 'R'); char runtime_char = 'X'; // Rejected: runtime input would defeat the compile-time-only rule. // auto id = command_id(runtime_char, 'D'); static_assert(CMD_READ == 0x5244u); |
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.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
#include <cstdint> // Good: these values are safe before dynamic startup code runs. constinit std::uint32_t boot_counter = 0u; constinit bool diagnostics_enabled = true; std::uint32_t read_default_baud_from_flash(); // Rejected: dynamic initialization does not belong in constinit storage. // constinit std::uint32_t baud = read_default_baud_from_flash(); void record_boot_event() { // Good: constinit checks init time, not immutability. ++boot_counter; } |
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.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
#include <cstdint> #include <span> #include <vector> std::uint16_t average_adc(std::span<const std::uint16_t> samples) { if (samples.empty()) { return 0u; } std::uint32_t total = 0u; for (std::uint16_t sample : samples) { total += sample; } return static_cast<std::uint16_t>(total / samples.size()); } std::uint16_t dma_buffer[] { 1010u, 1008u, 1012u, 1009u }; // Good: the array pointer and size travel together. auto avg = average_adc(dma_buffer); std::span<const std::uint16_t> saved_view; { std::vector<std::uint16_t> temporary { 1u, 2u, 3u }; // Bad: this span will outlive the temporary vector. saved_view = std::span<const std::uint16_t>{temporary}; } |
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.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
#include <string_view> enum class CommandKind { Unknown, ReadRegister, WriteRegister, DebugTrace, }; CommandKind classify_command(std::string_view cmd) { if (cmd.starts_with("REG:READ:")) { // Good: the prefix check states the command family directly. return CommandKind::ReadRegister; } if (cmd.starts_with("REG:WRITE:")) { return CommandKind::WriteRegister; } if (cmd.ends_with(":TRACE")) { return CommandKind::DebugTrace; } return CommandKind::Unknown; } |
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.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
#include <map> #include <string> std::map<std::string, int> settings { {"baud", 115200}, {"timeout_ms", 250}, {"retries", 3}, }; // OK: use find when you also need the value. if (auto baud = settings.find("baud"); baud != settings.end()) { open_serial_port(baud->second); } // Good: contains is direct when existence is all you need. if (!settings.contains("device_id")) { report_missing_setting("device_id"); } // Noisy: iterator checks add ceremony for existence-only checks. // if (settings.find("device_id") == settings.end()) { ... } |
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.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
#include <string> #include <vector> struct Channel { std::string name; bool enabled; int last_adc_counts; }; std::vector<Channel> channels { {"adc0", true, 1010}, {"adc1", false, 0}, {"temp", true, 512}, }; std::erase_if(channels, [](const Channel& ch) { // Good: the cleanup rule is the body of the predicate. return !ch.enabled; }); std::vector<int> samples { 10, 0, 12, 0, 13 }; // Good: remove the exact unwanted value directly. std::erase(samples, 0); // OK: the erase-remove idiom is still useful to recognize in older code. // channels.erase(std::remove_if(...), channels.end()); |
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.
