Modern C++ Firmware: Proven Strategies for Tiny, Critical Systems (Part 3/10)

Deterministic By Construction: The Rules You Do Not Cross

Part 1 made the case that modern C++ can belong in firmware.

Part 2 argued that standard selection is an engineering decision, and that C++20 is the pragmatic baseline with C++23 features kept on a short leash.

Part 3 is the part that actually makes the whole approach work: the rules.

If you do not have hard, explicit design rules for determinism, you are not building deterministic firmware. You are building firmware that happens to behave deterministically until it does not.

The goal here is simple: establish a set of “do not cross” rules that keep timing, memory, and failure modes predictable. Then encode those rules into code and tools so they are enforced automatically.


The boundary: what must be deterministic

Before listing rules, define the boundary. Most firmware has at least three zones:

  • Hot path: periodic control loop and tight ISR work. This must be boring and predictable.
  • Warm path: initialization, mode changes, command handling, telemetry serialization. Still constrained, but not as timing-sensitive.
  • Cold path: host-only tooling, analysis, visualization, test harnesses. This can be luxurious.

Most mistakes happen when hot-path constraints leak into warm-path convenience, or warm-path dependencies leak into the hot path.

Unsolicited advice: put “hot path boundary” in writing. If you cannot point to the boundary, someone will move it by accident.


Core rules

These are the rules I consider non-negotiable for deterministic firmware on small MCUs.

No exceptions in firmware, and no dynamic allocation in the control loop

Exceptions create two problems that matter in firmware:

  • They add binary and runtime baggage (unwind tables, unexpected code paths).
  • They make control flow harder to reason about, especially under failure conditions.

Dynamic allocation creates a different set of problems:

  • Unbounded timing due to allocator behavior and fragmentation.
  • Non-deterministic failure modes (allocation fails at arbitrary times).
  • Hidden allocations via seemingly innocent APIs.

The rule is not “avoid allocation.” The rule is:

  • No exceptions anywhere in target builds.
  • No dynamic allocation in the control loop.
  • In many projects, the real rule becomes: no dynamic allocation in target code at all.

Make the failure mode explicit. Instead of “allocate and hope,” do one of these:

  • Preallocate fixed-size structures at startup.
  • Use fixed-capacity containers and report overflow deterministically.
  • Return a failure code and transition to a known fault state.

Example: fixed-capacity queue with explicit overflow behavior.

class EventQueue final {
public:
    static constexpr std::size_t kMaxEventsPerTick = 16U;

    [[nodiscard]] bool try_push(std::uint16_t event) noexcept {
        if(this->count_ >= kMaxEventsPerTick) {
            ++this->overflow_count_;
            return false;
        }
        this->events_[this->count_] = event;
        ++this->count_;
        return true;
    }

    void clear() noexcept {
        this->count_ = 0U;
    }

    [[nodiscard]] std::size_t size() const noexcept {
        return this->count_;
    }

    [[nodiscard]] std::uint32_t overflow_count() const noexcept {
        return this->overflow_count_;
    }

private:
    std::array<std::uint16_t, kMaxEventsPerTick> events_{};
    std::size_t count_{0U};
    std::uint32_t overflow_count_{0U};
};

The important part is not the container. It is the policy: overflow is counted, not ignored, and not thrown.

No virtual dispatch or std::function in hot paths

Virtual dispatch and std::function are not evil. They are just the wrong tools for hot paths on constrained targets.

  • Virtual dispatch introduces indirection and can pull in RTTI or other runtime machinery. (Note that RTTI can be removed by using -fno-rtti.)
  • std::function often implies type-erasure, and may allocate (especially when capturing lambdas), which violates the “no allocation in the loop” rule.

Hot-path substitution should be done with:

  • Concepts and templates (compile-time polymorphism).
  • Plain function pointers for actions/guards.
  • Fixed-size dispatch tables.

The rule is:

  • No virtual calls in the control loop.
  • No std::function in hot paths.
  • Keep dispatch mechanisms explicit and predictable.

If you need a callback in the loop, prefer a function pointer:

using GuardFn = bool (*)(const Snapshot&) noexcept;

struct Transition final {
    State from{};
    State to{};
    Event event{};
    GuardFn guard{nullptr};
};

If you need polymorphism across platforms, use concepts:

template <typename T>
concept HardwarePlatform = requires(T p) {
    { p.initialize() } noexcept -> std::same_as<bool>;
    { p.read_inputs() } noexcept;
    { p.write_outputs() } noexcept;
};

template <HardwarePlatform P>
void run_tick(P& platform) noexcept {
    platform.read_inputs();
    platform.write_outputs();
}

You get polymorphism without runtime dispatch.

Fixed size buffers, std::array, and std::span everywhere

Firmware is mostly buffer manipulation: sensor data, command parsing, telemetry, ring buffers, DMA queues.

The easiest way to accidentally create bugs is to pass raw pointers around and hope the size matches.

The rule is:

  • Use std::array for ownership of fixed-capacity storage.
  • Use std::span for passing views of buffers (pointer plus size together).

Example: a buffer-building API.

[[nodiscard]] bool build_status_line(std::span<char> out) noexcept {
    if(out.size() < 8U) {
        return false;
    }
    out[0] = 'O';
    out[1] = 'K';
    out[2] = '\n';
    out[3] = '\0';
    return true;
}

At the call site:

std::array<char, 64U> line{};
const bool ok = build_status_line(line);

This is the simplest, most reliable way I know to keep buffer contracts correct under pressure.

C-style arrays are a reliability trap in firmware. They decay to pointers at the call site, they do not carry size information, and they make it easy to create APIs where the buffer length is an informal convention instead of a contract. That is exactly how you end up with off-by-one writes and “works in the lab” corruption bugs.

The rule is simple:

  • Own fixed-size storage with std::array.
  • Pass buffers as std::span.
  • Prefer .at() when indexing std::array unless you have measured and justified operator[] in a truly hot path.

Using .at() is not about hand-holding. It is about making out-of-bounds access deterministic and visible during development and testing. If you ever index past the end, you want it to be loud and immediate in your host tests, sanitizers, and debug builds, not a silent memory stomp that surfaces weeks later as “random behavior.”

Example:

std::array samples{};

// Prefer .at() for safety and debuggability.
samples.at(0U) = read_adc();
samples.at(1U) = read_adc();


Unsolicited advice: bake this into your coding standard and code review muscle memory. Most firmware buffer bugs are not clever. They are boring indexing mistakes that slip through because the code does not make the contract explicit.


Encoding the rules into code

Rules that exist only in a document will be broken. Encode them into code style, APIs, and compile-time constraints.

this-> for explicit member access

(Plenty of people will argue with this…but it’s my blog, and I get to give the advice here.) Using this-> everywhere for member access is not about aesthetics. It is about reducing ambiguity and preventing shadowing mistakes, especially in safety-critical code.

It gives you:

  • Visual separation of member state vs local variables.
  • Fewer accidental self-assignments.
  • Easier navigation in unfamiliar codebases.

Example:

class Controller final {
public:
    void set_target_rpm(std::uint16_t rpm) noexcept {
        this->target_rpm = rpm;
    }

    void update(std::uint16_t measured_rpm) noexcept {
        this->error = static_cast<int>(this->target_rpm) - static_cast<int>(measured_rpm);
    }

private:
    std::uint16_t target_rpm{0U};
    int error{0};
};

If you have a coding standard, this is a good rule to include because it prevents a class of subtle, boring bugs.

[[nodiscard]] on important returns

Deterministic firmware should be explicit about when failure is possible and what happens next.

Mark important returns [[nodiscard]] so ignoring them becomes a compiler-visible event.

class Platform final {
public:
    [[nodiscard]] bool initialize() noexcept;
    [[nodiscard]] bool is_healthy() const noexcept;
};

This nudges the codebase toward explicit failure handling, which is where deterministic behavior comes from.

Clear naming conventions

Naming conventions are not cosmetic in firmware. They are a low-cost way to reduce cognitive load.

Examples that work well:

  • get/set for accessors
  • is/has/can for boolean queries
  • try prefix for operations that may fail without side effects (try_push, try_parse)
  • *_count and *_limit for size-related values

This makes control flow and failure modes easier to scan during reviews.


Tooling support: enforce rules automatically

If your rules are serious, your toolchain should enforce them. Otherwise, they will erode.

Compiler flags to disable exceptions and RTTI

Target builds should hard-disable the features you do not allow.

Typical flags:

  • Disable exceptions: -fno-exceptions
  • Disable RTTI: -fno-rtti
  • Treat warnings as errors: -Werror

Treat “a warning in firmware” as a defect. People ignore warnings until they are forced not to.

Also, separate host and target builds. Host builds can keep RTTI and exceptions if you want them for tests, but your target build should be strict and consistent.

clang-tidy and static analysis to catch violations

You want automated checks for the behaviors you banned:

  • Forbidden headers and APIs in target code (iostreams, in hot path modules).
  • Use of new/delete or malloc/free in target code.
  • Virtual methods in hot-path classes.
  • Missing [[nodiscard]] usage for designated APIs.
  • Unsafe buffer APIs where span should be used.

Some of this can be done with clang-tidy checks. Some of it is better done with simple pattern checks that are tailored to your codebase.

My opinion: do not over-index on generic “one-size static analysis.” Firmware needs targeted rules that match the way your code is structured.


A minimal “do not cross” checklist

If you want a short list to adopt immediately:

  • Target builds: exceptions off, RTTI off, warnings are errors.
  • Hot path: no allocation, no virtual dispatch, no std::function.
  • Buffers: own with std::array, pass with std::span.
  • Error handling: explicit returns with [[nodiscard]].
  • Code clarity: this-> for member access, consistent naming for failure and boolean queries.
  • CI: a job that fails when any of the above rules are violated.

The rest of the series will build on these rules, not replace them. In the next post, I will take the most important “positive” pattern enabled by these constraints: compile-time state machines and transition tables that are reviewable, testable, and boring at runtime.


The Complete “Modern C++ Firmware” Series:


Need professional firmware development help? Engage with Polyrhythm!


Discover more from John Farrier

Subscribe to get the latest posts sent to your email.

2 thoughts on “Modern C++ Firmware: Proven Strategies for Tiny, Critical Systems (Part 3/10)

  1. Forcing the use of at() while having exceptions being disabled seems to be counterintuitive. The purpose of at() is to check the boundaries and throw if out of bound.

    Also, I can not see any argument for using a function pointer over a virtual function. Disabling RTTI does not mean, that objects won’t have virtual functions anymore. And using compile time polymorphism can not replace runtime polymorphism (if that is required).

    Anyway: Wonderful series of articles. 🙂

  2. “this-> for explicit member access”
    Finally, someone else who advocates this!

Leave a Reply

Discover more from John Farrier

Subscribe now to keep reading and get access to the full archive.

Continue reading