
Choosing C++20 Today, C++23 on a Short Leash
Introduction: Choosing a Standard Is an Engineering Decision
In Part 1, I argued that modern C++ can belong on small microcontrollers without sacrificing determinism, but only if you treat “modern C++” as a disciplined subset, enforced by tooling and CI. Part 2 is where that discipline starts to become concrete: picking a language standard.
A lot of teams choose a C++ standard for the wrong reasons. The two most common failure modes look like this:
- “Use the latest because it’s the latest.”
This is how you end up with a half-supported standard library, surprising compiler bugs, and developers quietly working around toolchain gaps. In embedded, the cost is not annoyance. It is schedule risk, test risk, and sometimes safety risk. - “Use the oldest because it feels safe.”
This is how you give up the features that actually help you write safer firmware with less effort: compile-time validation, clearer interfaces, safer buffer handling, and better diagnostics. You do not automatically get determinism by choosing an older standard. You just get fewer tools to enforce it.
The standard choice should be driven by constraints, not taste. The only question that matters is:
Given our timing and memory budgets, our lifecycle, and our toolchain reality, which C++ standard reduces risk and keeps the codebase maintainable for the next decade?
That framing naturally leads to a rule of thumb I like for firmware: use the most recent standard minus one. C++23 exists, so choose C++20 as the baseline. You still get a very modern compiler and optimizer, you get the features that materially improve firmware quality, and you land in a part of the toolchain ecosystem that is far more mature.
C++23 is not off-limits. It is just not the default. Treat it as an opt-in set of features that you adopt only when you can prove your toolchain supports them and your process can enforce them.
The rest of this post will make that decision process explicit: the constraints you should write down, why choosing C++20 is the pragmatic default today, where C++23 adds value, and how to keep “standard selection” from turning into a fashion argument.
The Real Constraints That Should Drive the Decision for Choosing C++20
Determinism and WCET
In firmware, the “correct” result at the wrong time is a bug. That’s where Worst Case Execution Time (WCET) comes in.
Your standard choice matters only insofar as it helps or harms your ability to produce code with:
- Bounded execution time in the control loop
- Bounded interrupt service work
- Predictable failure modes when something goes wrong
The WCET story usually breaks when teams allow unbounded behavior into hot paths. Language features are not the root cause. The root cause is the lack of explicit rules. Typical offenders:
- Dynamic allocation in periodic loops (fragmentation, allocator path variability)
- Implicit heavy work hidden behind APIs (formatting, logging, I/O)
- Virtual dispatch in hot paths (less common, but it still shows up)
- Exceptions (unpredictable control flow, binary bloat, hard-to-analyze paths)
If your project needs WCET analysis, you should start by defining what code is allowed to exist inside the timing-critical boundary. Do not rely on “developer discipline” as your only control.
RAM and Flash Budgets
Flash and SRAM budgets are not guidelines. They are hard constraints that will fail late if you do not enforce them early.
A practical way to think about memory impact:
- Flash growth comes from:
- Linking in large libraries (formatting, streams, locales)
- Exception tables and unwind info
- Generic runtime mechanisms you do not need on target
- SRAM growth comes from:
- Accidental large globals, large stacks, and unbounded buffers
- Data structures that silently allocate or grow
- Debug-only features that accidentally ship in release builds
Advice: treat “fits in memory” as a CI gate, not as a one-time milestone. If you can regress flash by 6 KB in a PR, you will.
Lifecycle and Certification Pressure
Embedded firmware often has a longer lifespan than the people who wrote it. Standards decisions are sticky because:
- You will be rebuilding old releases years later.
- You will be porting to new silicon under schedule pressure.
- You may need to justify toolchain choices to auditors, customers, or internal safety boards.
A newer standard can add real value, but it can also add risk if your toolchain support is partial or inconsistent. In safety-adjacent work, “works on my machine” is not a build strategy.
Toolchain Churn and Vendor Lag
This is the constraint most desktop developers underestimate.
- The C++ standard is one timeline.
- Embedded compiler releases, vendor SDKs, and qualified toolchains are a different timeline.
- Standard library support lags language support, and embedded is often behind desktop.
The key question is not “does the standard exist.” The question is:
- Can we build this reliably across our supported environments, for years, with predictable behavior?
If you have multiple toolchains (vendor GCC, Clang for analysis, a certified compiler), you should assume your least capable toolchain dictates the baseline.
Why Choosing C++20 Is the Current Pragmatic Default
The “Most Recent Standard Minus 1” Rule of Thumb
A practical firmware rule: default to the most recent standard minus one.
- C++23 exists, so choose C++20 as your baseline.
- This usually lands you in the “broad support, fewer sharp edges” window.
- You still run a modern compiler and optimizer, just with fewer library gaps and fewer surprises.
Important nuance: “minus one” is about the language mode (-std=c++20), not the compiler version. You can and should use newer compiler releases in CI for better diagnostics and codegen.
The Minimum Feature Set That Changes the Game
C++20 contains several features that directly improve firmware quality without requiring runtime cost:
- Concepts: compile-time interface contracts, great for hardware abstractions without vtables.
- Improved constexpr and constinit: push validation and initialization to compile time.
- std::span: safer buffer interfaces that still compile to pointer plus size.
- std::array: fixed-capacity containers that make “no allocation” practical.
- Designated initializers: make large tables readable and reviewable.
- [[nodiscard]]: a simple way to make “check the return value” enforceable.
- std::chrono: type-safe time units, fewer “was that milliseconds or ticks” bugs.
Opinion: if a team is going to use C++ in firmware, but they refuse span, chrono, and constexpr, they should seriously consider staying in C. Half-modern C++ is where complexity grows and benefits vanish.
Benefits Specifically for Firmware
Choosing C++20 tends to reduce risk in ways that matter on embedded targets:
- Fewer buffer and unit bugs due to stronger types (span and chrono).
- Cleaner hardware abstraction without runtime polymorphism (concepts).
- Less runtime initialization and fewer “boot-time surprises” (constinit, constexpr tables).
- Better code review ergonomics because intent is explicit (designated initializers, nodiscard).
These benefits compound in long-lifecycle projects.
Toolchain Reality Check
Before you choose C++20, validate you can support it in the environments that matter:
- Your shipping target compiler and standard library (the real constraint).
- A modern Clang toolchain for analysis and host testing (usually easy).
- Your debug tools: map files, size tools, linkers, and LTO behavior.
- Your CI environment: containers, reproducibility, and pinned versions.
Unsolicited advice: pin toolchain versions in CI, and treat upgrades like any other engineering change. “Floating latest” is a good way to spend a weekend chasing a linker regression.
What You Can Comfortably Ban
The standard is not the policy. Your coding rules are.
A typical deterministic firmware policy that pairs well with choosing C++20:
- Disable exceptions and RTTI in target builds.
- Ban dynamic allocation in hot paths (often ban it entirely in target code).
- Ban iostreams and locale-heavy formatting on target.
- Avoid std::function and type-erasure in hot paths.
- Avoid coroutines and modules on target (toolchain maturity and determinism story).
Concrete enforcement matters. For example, compile flags in the target toolchain are non-negotiable, not “recommended.”
Where Choosing C++23 Fits, and Where It Does Not
Useful C++23 Features for Firmware
C++23 has features that can improve firmware, but they should be adopted selectively.
std::expected
Good fit for initialization, configuration loading, and health checks where you want explicit errors without exceptions.
- Use it at boundaries: startup, device init, driver bring-up, command parsing.
- Keep the control loop on a simple, noexcept path.
Stronger constexpr and library constexpr expansions
Useful for deeper compile-time validation, precomputed tables, and static configuration checks.
Attributes like [[assume]] (and branch hints where available)
Can help the optimizer in hot paths when you have hard invariants. Use sparingly and only when you can defend the assumption with tests and profiling.
Want to learn more about hot paths? Branch Prediction: The Definitive Guide.
std::mdspan
Potentially useful when you have multi-dimensional sensor buffers or calibration tables and you want explicit layout. It can reduce indexing bugs and clarify intent.
Risks and Limitations
C++23 is where embedded teams get burned if they treat “available” as “safe.”
Risks to plan for:
- Partial support in embedded standard libraries even when the compiler accepts
-std=c++23. - Vendor toolchains that lag, or implement features inconsistently.
- Audit and qualification headaches if you rely on newer library behavior that is not widely deployed.
- Engineers using C++23 features that quietly pull in heavier dependencies (formatting and I/O are common traps).
My default stance: C++23 is not a baseline for firmware today unless you control the full toolchain story and can keep it stable for years.
A Sensible Adoption Strategy
Adopt C++23 like you adopt a new hardware revision: deliberately, with gates.
A practical strategy:
Baseline: Choose C++20 for target code.
Allow C++23 in host-only tooling first.
Tools, visualizers, log parsers, and test harnesses are the safest place to benefit early.
Gate any C++23 feature behind build-time checks.
Use feature-test macros and CI jobs to prove it is supported in your target toolchain.
- Adopt only where it reduces risk or complexity.
- std::expected replacing ad hoc status codes is a good trade.
- Dropping [[assume]] everywhere is not.
Example pattern (conceptual, not project-specific):
- Target library builds with
-std=c++20. - Host tools can use
-std=c++23. - If you want std::expected on target, require a CI job that builds the target toolchain and checks for the feature-test macro, then enable it behind a config flag.
The key is to make “we use C++23 here” a deliberate, reviewed decision, not something that creeps in because one developer had a newer compiler installed.
Writing Constraints Down Before You Pick a Standard
If you do not write your constraints down, you do not have constraints. You have vibes. Vibes do not survive staffing changes, schedule pressure, or the third release after the prototype.
The language standard is part of a broader contract: what is allowed on target, what must be deterministic, and what must be provable. Write it down early, then enforce it continuously.
What to capture in a Language ADR
A good language decision record (ADR) is short, specific, and testable. It should include:
- Target class: MCU family, clock rate, flash and SRAM budgets.
- Timing model: tick rate, ISR budget expectations, what is considered “hot path”.
- Lifecycle and constraints: safety level, audit or certification expectations (if applicable), expected support horizon.
- Toolchains: shipping compiler and linker, minimum versions, and what is used in CI for analysis.
- Baseline standard: for example, C++20 for target.
- Allowed language features: span, array, chrono, constexpr usage, designated initializers, concepts.
- Forbidden features: exceptions, RTTI, allocation in loop, iostreams, type erasure in hot paths, etc.
- Dependency rules: what libraries are allowed on target, what is host-only, and how segregation is enforced.
- Deviation process: how someone requests an exception, and what evidence is required.
Unsolicited advice: do not write a novel. If you cannot summarize the firmware rules on one page, you will not enforce them.
Constraints that actually help
Here are examples that are concrete enough to enforce:
- Target builds compile with exceptions and RTTI disabled.
- No dynamic allocation in the control loop. Preferably none in target code at all.
- Hot path functions are noexcept.
- std::span is the default for buffer parameters.
- std::chrono is the default for time quantities. No raw integers for durations.
- Host-only tooling may use dynamic allocation and heavier libraries, but target code may not depend on them.
- All warnings are treated as errors on target builds.
- Flash and SRAM budgets are enforced in CI per build variant.
Avoid soft language like “should not” unless you also define what happens when someone does it anyway.
How GitLab pipelines enforce the document
CI is where rules stop being aspirational. Your pipeline should fail when the contract is violated.
Examples of enforcement patterns:
- A build job that rejects target compilation unless the expected flags are present.
- A static analysis job that flags allocation, exceptions, or forbidden headers in target code.
- A size-check job that fails if flash or SRAM exceeds your budgets.
- A reproducible container image for the embedded toolchain, pinned to specific versions.
If you want discipline, you need automation. Otherwise you are betting that every engineer will remember every rule on every commit forever.
Migration Stories: C or Legacy C++ to Modern C++20 Without Drama
Most teams do not get to start greenfield. You have legacy firmware, vendor code, and a toolchain that is older than your newest hire.
The goal is not “upgrade to modern C++.” The goal is “reduce risk while keeping determinism.”
Start with safer types, not flashy features
The first migration wins should eliminate common bug classes:
- Replace pointer plus size pairs with std::span.
- Replace ad hoc fixed arrays with std::array.
- Replace naked integer time units with std::chrono.
- Replace informal “status codes” with a consistent result pattern, even before you adopt std::expected.
These changes are boring. That is why they work.
Tighten constraints incrementally
A realistic progression:
- Enable C++20 in host builds first, run tests there.
- Introduce a “core” library that is platform-agnostic and testable on host.
- Begin enforcing no exceptions and no RTTI in new code first.
- Move allocations to startup paths, then enforce “no allocation in loop” with checks.
- Segregate host tools from target code early, before dependencies become entangled.
Unsolicited advice: if you try to modernize and refactor architecture at the same time, you will stall. Do one axis at a time.
When C++23 is justified
C++23 should enter the firmware codebase only when it reduces risk or removes complexity.
Good examples:
- Using std::expected at system boundaries where explicit error reporting is valuable and you are replacing a messy homegrown status scheme.
- Using improved constexpr support to move validation to compile time and eliminate runtime checks.
- Using std::mdspan when you have real multi-dimensional data and layout clarity prevents indexing bugs.
Bad examples:
- Using C++23 features because someone likes the aesthetics.
- Adding attributes like [[assume]] everywhere without profile data and strong tests.
- Treating
-std=c++23as a blanket upgrade when your embedded standard library support is incomplete.
My opinion: C++23 is best introduced first in host-only tooling. That gets you the benefits quickly without risking your target determinism story.
Short, Opinionated Recommendations
This is where I stop being polite.
For new safety-relevant firmware projects
- Default to C++20 for target code.
- Use the “most recent standard minus one” rule unless you have a strong reason not to.
- Disable exceptions and RTTI on target from day one.
- Decide what “hot path” means and enforce no allocation and no surprises there.
- Invest in CI early. If you delay the pipeline, you will pay later with regressions that are harder to unwind.
If someone wants to ship firmware without size checks, without sanitizers on host builds, and without consistent rules for allocation, they are choosing chaos.
For existing C or legacy C++ firmware
- Do not jump straight to “C++23 everywhere.”
- Start with std::span, std::array, and std::chrono. They remove bug classes immediately.
- Build a host-testable core and a strict target boundary.
- Add CI gates before you allow new features to spread.
- Keep vendor SDK code isolated. Do not let it leak into your core logic.
Unsolicited advice: if your build system cannot produce a host executable for tests, fix that before arguing about language standards.
For teams debating standards based on aesthetics
- Stop. Write down the constraints, the toolchains, and the lifecycle.
- Choose the standard that reduces risk under those constraints.
- Record the decision and the rationale.
- Revisit only when toolchain support and project requirements materially change.
Firmware is not a social media trend. The standard is part of your long-term support strategy.
Standard Choice as a Contract, Not a Preference
The argument for choosing C++20 as the baseline is not that C++23 is bad. The argument is that firmware success depends on predictability and long-lived maintainability, and C++20 currently sits in the sweet spot where:
- The language features are powerful enough to materially improve firmware quality.
- Toolchain support is broad enough to avoid constant workarounds.
- The ecosystem of analysis tools and CI workflows is mature.
Treat C++23 as an opt-in set of features. Use it deliberately, and only when your toolchain and process can support it. Keep the baseline boring. Boring is good in firmware.
In the next post, I will get concrete about the rules that make this work: no exceptions, no allocation in the loop, what is allowed in hot paths, and how to enforce those constraints so they do not erode over time.
Related Articles:
The Complete “Modern C++ Firmware” Series:
- Modern C++ Firmware: Proven Strategies for Tiny, Critical Systems (Part 1/10)
- The Case for Modern C++ on Tiny, Safety Critical Targets
- Modern C++ Firmware: Proven Strategies for Tiny, Critical Systems (Part 2/10)
- Choosing C++20 Today, C++23 on a Short Leash
- Modern C++ Firmware: Proven Strategies for Tiny, Critical Systems (Part 3/10)
- Deterministic By Construction: The Rules You Do Not Cross
- Modern C++ Firmware: Proven Strategies for Tiny, Critical Systems (Part 4/10)
- Time and Scheduling Without Footguns
- Modern C++ Firmware: Proven Strategies for Tiny, Critical Systems (Part 5/10)
- Concepts for Hardware Platforms, Not Vtables
- Modern C++ Firmware: Proven Strategies for Tiny, Critical Systems (Part 6/10)
- No Allocation in the Loop: Memory Rules That Survive CI
- Modern C++ Firmware: Proven Strategies for Tiny, Critical Systems (Part 7/10)
- Test the Firmware Without the Board: Host First Strategy
- Modern C++ Firmware: Proven Strategies for Tiny, Critical Systems (Part 8/10)
- Python and ASCII Protocols for Hardware in the Loop
- Modern C++ Firmware: Proven Strategies for Tiny, Critical Systems (Part 9/10)
- Observability Belongs on the PC, Not in the Production Binary
- Modern C++ Firmware: Proven Strategies for Tiny, Critical Systems (Part 10/10)
- GitLab Pipeline Blueprint and a Migration Checklist
Need professional firmware development help? Engage with Polyrhythm!
Discover more from John Farrier
Subscribe to get the latest posts sent to your email.
I am thoroughly enjoying this series, looking forward to reading the next parts! I have only just started using C++ for my embedded projects and a lot of what you are writing here is resonating with me.
I have been through the whole “containerization and building and testing on a host with sanitizers and tests” cycle with C. C++ makes it all so much smoother. I’m really enjoying the transition but still have a lot to learn, and I’m sure your posts are going to be something I’ll be coming back to often. Thank you!
Looks like page naming rule was unified, but part 2 and part 3 still have old urls:
https://johnfarrier.com/modern-cpp-firmware-part-02-choosing-cpp20/
https://johnfarrier.com/modern-c-firmware-proven-strategies-for-tiny-critical-systems-part-3-10/
Thanks! The team responsible for maintaining my website have been sacked.