Modern C++ (17/20/23) for Embedded Systems
How C++17, C++20 and C++23 make embedded software safer and clearer at little to no runtime cost — applied with discipline for constrained targets.
In short: Modern C++ (C++17, C++20 and C++23) makes embedded software safer, clearer and more correct — and thanks to zero-cost abstractions it does so at little to no runtime cost. The condition is discipline: keep exceptions, RTTI and dynamic allocation under control, keep an eye on code size, and know precisely which standard-library subset your target toolchain actually provides.
For years, C++ carried a reputation in embedded circles as “C with classes, plus overhead you cannot afford on a microcontroller.” That reputation is outdated. The language revisions of the past decade were built around the principle of zero-cost abstractions: you pay only for what you use, and a well-written abstraction compiles down to the same machine code you would have written by hand. For a constrained target that is exactly the right bargain — more guarantees at compile time, the same instructions at run time. The sections below go through what each standard adds, where the concrete embedded benefit lies, and which caveats keep you out of trouble on an MCU or a bare-metal board.
Why modern C++ fits constrained targets
Three properties make the language a natural fit for embedded work. The first is zero-cost abstractions. A std::array, a strongly typed enum or a small wrapper class carries no runtime penalty over the equivalent C construct once the optimizer has run; the abstraction exists only in the source, not in the binary. You gain readability and type safety for free.
The second is RAII (Resource Acquisition Is Initialization). A resource — a file descriptor, a DMA channel, a mutex lock, a GPIO handle — is tied to the lifetime of an object. Its destructor releases it deterministically at the end of the enclosing scope, on every path including early returns. There is no manual goto cleanup, no forgotten free, no leak that only surfaces after days of uptime. On a long-running device that determinism is worth a great deal.
The third is stronger compile-time type safety. Every bug the compiler catches is a bug that never reaches the field — where reproducing it may mean a service technician on site. Modern C++ pushes more checking into the compiler: strong types instead of a bare int, enum class instead of loose constants, concepts instead of untyped templates. This shift matters most precisely where debugging is hardest. It is the backbone of our C++ development work.
C++17: the solid foundation
C++17 is the baseline that virtually every current cross-toolchain supports, which makes it the safe default for new embedded projects. Several features earn their place immediately:
std::optional<T>models “a value or nothing” without a sentinel value or a magic-1. A sensor read that may fail returnsstd::optional<Reading>instead of overloading the return type.std::string_viewis a non-owning view onto character data — pass strings around without copying and without allocating.std::variant<...>is a type-safe tagged union, useful for a state machine or a protocol message whose payload depends on its type, with no virtual dispatch.if constexprselects a branch at compile time and discards the other entirely. Hardware-variant code stops needing SFINAE gymnastics or tag dispatch.- Structured bindings decompose a pair or small struct into named variables (
auto [status, value] = read();), which reads far better than.first/.second. - More
constexprmoves lookup tables and derived constants into compile-time evaluation, so they land in flash rather than being computed at startup.
C++20: concepts, ranges and std::span
C++20 is a larger step, and toolchain support is now broad enough to rely on for most Linux-class targets. Three additions stand out for embedded.
Concepts put named requirements on template parameters. Instead of a wall of substitution-failure noise, a violated constraint yields a message that names the actual requirement — “this type is not a Sensor” rather than fifty lines of internal template instantiation. Interfaces become self-documenting and errors become readable.
std::span<T> is arguably the single most useful embedded addition: a non-owning view over a contiguous buffer, carrying both pointer and length. The classic (uint8_t* buf, size_t len) pair — the source of countless overruns — collapses into one object the callee cannot read past. Ownership stays with the caller.
consteval and constinit sharpen compile-time work. consteval marks a function that must run at compile time; constinit guarantees a variable is constant-initialized, eliminating the static-initialization-order problem and any runtime init cost — the data goes straight into .rodata. ranges and designated initializers round the release out, though ranges deserves a note: on tightly constrained targets, keep an eye on the code size and compile time that heavy view pipelines can introduce.
C++23: std::expected and more compile-time work
C++23 is the newest tier, and here availability genuinely depends on your compiler version — check what your cross-toolchain ships before designing around it.
Its headline feature for embedded is std::expected<T, E>: a return type that holds either a value or an error, explicitly and without exceptions. On targets where exceptions are disabled (see below), it replaces bare error codes with something the type system obliges you to inspect. std::mdspan brings a multi-dimensional, non-owning view — natural for image tiles, sensor matrices or DMA framebuffers — again without owning the memory. C++23 also makes still more of the standard library constexpr, extending how much computation you can pull into the build.
Example: ownership and error handling without exceptions
The following combines three of these ideas: an RAII wrapper that cannot leak its descriptor, a std::span that cannot be read out of bounds, and std::expected instead of a raw error code.
#include <cerrno>
#include <cstddef>
#include <cstdint>
#include <expected>
#include <span>
#include <system_error>
#include <unistd.h>
// RAII: the destructor closes the fd on every exit path – no leak,
// no manual cleanup, deterministic release at end of scope.
class FileDescriptor {
public:
explicit FileDescriptor(int fd) noexcept : fd_{fd} {}
~FileDescriptor() { if (fd_ >= 0) ::close(fd_); }
FileDescriptor(FileDescriptor&& o) noexcept : fd_{o.fd_} { o.fd_ = -1; }
FileDescriptor(const FileDescriptor&) = delete;
FileDescriptor& operator=(const FileDescriptor&) = delete;
int get() const noexcept { return fd_; }
private:
int fd_{-1};
};
// std::span passes (pointer, length) as one object the callee
// cannot overrun; std::expected returns a value OR an error code,
// with no exception thrown.
std::expected<std::size_t, std::errc>
read_frame(const FileDescriptor& dev, std::span<std::uint8_t> buffer) {
const ssize_t n = ::read(dev.get(), buffer.data(), buffer.size());
if (n < 0)
return std::unexpected(static_cast<std::errc>(errno));
return static_cast<std::size_t>(n);
}
The caller inspects the result explicitly — there is no hidden control flow and no way to ignore the error path by accident:
std::array<std::uint8_t, 256> buf;
if (auto r = read_frame(dev, buf))
process(buf.data(), *r);
else
log_error(r.error());
Discipline on the target: what to keep under control
Modern C++ is not a licence to switch off engineering judgement. Several defaults that are harmless on a desktop need deliberate handling on constrained targets:
- Exceptions and RTTI are frequently disabled (
-fno-exceptions -fno-rtti) on MCUs and in hard-real-time code, both to save code size and to keep control flow predictable. This is exactly why value-based error handling such asstd::expectedmatters. - Dynamic allocation must be controlled or avoided in hard-real-time and MCU contexts — a heap that fragments or an allocation that blocks is a latent failure. Prefer fixed-size containers, arenas and stack buffers.
- Code size deserves active measurement. Templates instantiate per type, and features like
<format>or heavyrangespipelines can pull in more than a small ROM budget allows. Measure, do not assume. - Standard-library support is a subset on bare-metal and MCU targets. A freestanding implementation gives you the language and a core of headers, but not necessarily
std::vectororstd::expected. Know your target before you design around a feature.
The table below summarizes the trade for the most relevant features:
| Feature | Embedded benefit | Caveat |
|---|---|---|
| RAII | Deterministic, leak-free resource release on every path | Requires disciplined ownership design |
std::optional (C++17) | Explicit “no value” state without a sentinel | Not a substitute for errors carrying context |
std::span (C++20) | Safe non-owning buffer view, no pointer+length pair | No lifetime guarantee — the buffer must outlive the view |
| Concepts (C++20) | Readable template errors, self-documenting interfaces | Compile-time only, no runtime effect |
constexpr / consteval | Moves work to build time, into flash instead of RAM | Raises compile time; not everything is constexpr-able |
std::expected (C++23) | Error handling without exceptions, value-or-error explicit | Needs a C++23 toolchain and library |
Toolchains: Qt and the Yocto SDK
Two everyday building blocks already put modern C++ within reach. Qt is itself a modern C++ framework — Qt 6 requires C++17 and its APIs increasingly assume it — so building a Qt/QML interface means writing modern C++ on both sides of the QML boundary. And the cross-compilers that ship in a Yocto SDK are current GCC and Clang toolchains, so the same C++20 or C++23 features you use on the desktop are available for the target — which is one more reason the compiler and standard choice belongs in the Embedded Linux and BSP work rather than being an afterthought.
Conclusion
Modern C++ is not a fashion; it is a set of tools that make embedded software measurably safer and clearer while respecting the runtime budget — when applied with the discipline the target demands. The art lies in choosing the right subset for a given board and toolchain, and in knowing which defaults to switch off. We share exactly this judgement in our Modern C++ trainings and in day-to-day project work. If you are weighing how to modernize an embedded codebase without overrunning your constraints, get in touch.
Frequently asked questions
- Does modern C++ add runtime overhead on embedded targets?
- As a rule, no. Zero-cost abstractions like std::span, std::optional or RAII wrappers compile down to the same instructions as hand-written C once the optimizer runs. Overhead comes from features you must control deliberately — exceptions, RTTI and dynamic allocation — not from the abstractions themselves.
- Can I use std::expected on a microcontroller?
- Only if your toolchain's standard library provides it — std::expected is a C++23 feature. On a freestanding or MCU target you may have the language but not the full library. Check what your cross-toolchain ships; where it is missing, a small expected-like type or an error-code convention fills the gap.
- Which C++ standard should a new embedded project target?
- C++17 is the safe default — nearly every current cross-toolchain supports it fully. C++20 (concepts, std::span, constinit) is available for most Linux-class targets. C++23 depends on the compiler version, so treat its features as opt-in per target.
bitshift dynamics