
Modern C++ for Deterministic Firmware, Part 4
Time and Scheduling Without Footguns
Part 3 laid down the rules you do not cross: no exceptions in target builds, no dynamic allocation in the control loop, no virtual dispatch or std::function in hot paths, and disciplined buffer handling.
Part 4 is about the other half of determinism: time.
Most firmware failures that feel “random” are really time failures:
- Someone mixed milliseconds and ticks.
- A timeout constant was copied from another subsystem with different units.
- A periodic task slipped under load and nobody noticed until field conditions changed.
- Wraparound happened and the code compared timestamps like it was 1999.
This post focuses on how to represent time and scheduling in modern C++ so timing behavior becomes explicit, reviewable, and enforceable.
The boundary: what must be time-safe
You can only make timing predictable if you are clear about what timing means in your system.
Most embedded systems have two time domains:
- Monotonic time: tick counters, hardware timers, systick. This is what you use to measure durations and deadlines.
- Wall-clock time: RTC, GPS time, network time. This is for timestamps and logs, not for control scheduling.
The rule is:
- Use monotonic time for anything that drives control behavior.
- Treat wall-clock time as data, never as scheduling truth.
Unsolicited advice: if a control loop uses RTC time to schedule itself, assume it will fail eventually.
Core rules
Time bugs are almost always unit bugs, wraparound bugs, or implicit scheduling bugs. The rules below target those failure modes.
No “naked integer time” in interfaces
A function that takes int timeout is a defect waiting to happen. Is that ticks, milliseconds, microseconds, or “whatever the author meant”?
The rule is:
- All time quantities in interfaces use std::chrono durations or time points.
- If you must interact with hardware tick counters, convert at the boundary and keep the rest of the system in chrono types.
Example: type-safe durations.
using namespace std::chrono_literals;
class Watchdog final {
public:
void arm(std::chrono::milliseconds timeout) noexcept {
this->timeout_ = timeout;
}
[[nodiscard]] bool expired(std::chrono::milliseconds elapsed) const noexcept {
return elapsed >= this->timeout_;
}
private:
std::chrono::milliseconds timeout_{0ms};
};At the call site:
watchdog.arm(250ms);
There is no ambiguity. You cannot pass “250” and hope everyone remembers the unit.
Keep time conversions at the boundary
MCUs often provide time in ticks. That is fine. The bug is letting “ticks” become a unit used across the codebase.
The rule is:
- Hardware timers and tick counters stay in the platform layer.
- Core logic consumes chrono durations and time points.
Example: converting a tick delta to a duration once.
class PlatformClock final {
public:
static constexpr std::uint32_t kTickHz = 1'000U;
[[nodiscard]] std::uint32_t now_ticks() const noexcept;
[[nodiscard]] std::chrono::milliseconds ticks_to_ms(std::uint32_t ticks) const noexcept {
return std::chrono::milliseconds{static_cast<std::int64_t>(ticks)};
}
};If your tick rate is not 1 kHz, do the conversion explicitly and document it. Do not sprinkle “/ 32” across your code.
Handle wraparound deliberately
If you use a 32-bit tick counter, it will wrap. The only question is whether it wraps during your product’s operating life.
The rule is:
- Assume wraparound is possible.
- Use unsigned arithmetic and compute elapsed time using subtraction, not comparison.
Example pattern:
[[nodiscard]] std::uint32_t ticks_elapsed(std::uint32_t start, std::uint32_t end) noexcept {
return static_cast<std::uint32_t>(end - start);
}That works across wraparound for unsigned integers.
Unsolicited advice: do not implement “if(end >= start)” logic unless you are absolutely certain wraparound cannot happen within a mission profile. Most teams are wrong about that.
Make the scheduler explicit
A control loop that “just runs” usually becomes a control loop that drifts.
The rule is:
- Express the schedule as data: period, next deadline, slip tracking.
- Decide what happens on slip: skip work, run late, or degrade functionality.
A simple fixed-rate scheduler skeleton:
using namespace std::chrono_literals;
class FixedRateScheduler final {
public:
explicit FixedRateScheduler(std::chrono::milliseconds period) noexcept
: period_(period) {}
void reset(std::chrono::milliseconds now) noexcept {
this->next_ = now + this->period_;
this->slip_count_ = 0U;
}
[[nodiscard]] bool should_run(std::chrono::milliseconds now) noexcept {
if(now < this->next_) {
return false;
}
if(now - this->next_ > this->period_) {
++this->slip_count_;
}
this->next_ += this->period_;
return true;
}
[[nodiscard]] std::uint32_t slip_count() const noexcept {
return this->slip_count_;
}
private:
std::chrono::milliseconds period_{0ms};
std::chrono::milliseconds next_{0ms};
std::uint32_t slip_count_{0U};
};This makes drift measurable. You can choose how to react to slip, and you can test that reaction.
Encoding the rules into code
As with Part 3, the point is to make the safe path the default path.
Use std::chrono in signatures, not comments
If you see code like this:
void set_timeout(std::uint32_t timeout_ms);
that is a red flag! The unit is in a comment disguised as a suffix. It will be violated eventually.
Prefer:
void set_timeout(std::chrono::milliseconds timeout) noexcept;
This also makes your tests cleaner, because you can write 500ms and be done.
Centralize timing constants
Timing constants should not be scattered across the codebase.
The rule is:
- Define timing constants in one place, as chrono durations.
- Give them names that capture intent, not implementation.
Example:
namespace timing {
using namespace std::chrono_literals;
inline constexpr auto kControlPeriod = 10ms; // 100 Hz
inline constexpr auto kPostTimeout = 5s;
inline constexpr auto kStartTimeout = 10s;
}If you later change tick rates or scheduling architecture, you are not hunting down magic numbers.
Distinguish time points from durations
Durations represent how long. Time points represent when.
The rule is:
- Do not use raw integers for either.
- Do not mix them in the same variable.
If your platform gives you ticks, convert to a duration for elapsed time, and keep absolute “now” as a time point type in the platform layer.
If you do not have a full std::chrono::steady_clock on target, you can still keep the conceptual distinction in your own types.
Tooling support: make timing errors fail in CI
Time bugs are cheap to create and expensive to debug on hardware. The pipeline should stop them early.
Compiler warnings as errors for conversion issues
Enable warnings that catch implicit conversions and narrowing, then treat them as errors. Time code is full of “it compiles” bugs.
Unsolicited advice: if you allow implicit conversions from floating-point to integer in timing code, you are asking for drift.
clang-tidy checks that reinforce chrono usage
Use clang-tidy rules to flag:
- Functions that accept raw integers for timeouts or delays.
- Manual arithmetic on tick counters outside of the platform boundary.
- Implicit unit conversions and suspicious casts.
You will not catch everything with generic checks, but you can catch the common footguns.
Host-first tests for scheduling behavior
Scheduling is logic. Logic is testable.
Write host tests for:
- Deadline computation.
- Slip behavior under simulated load.
- Timeout transitions and state behavior when time advances.
- Wraparound behavior for tick deltas.
This is exactly the kind of thing that is easy to simulate on the host and painful to diagnose on target.
A minimal timing checklist
If you want to adopt time discipline immediately, start with this:
- All public interfaces use std::chrono durations and time points.
- Ticks exist only in the platform layer, and conversions happen there.
- Wraparound is handled by design, not by hope.
- Schedulers are explicit, not implicit loops with ad hoc delays.
- Drift and slip are measured and reported, even if only as counters.
- CI enforces basic rules and host tests cover timing logic.
Make time boring on purpose
Time and scheduling are where “deterministic” dies quietly. Modern C++ gives you better types and better tools so unit mistakes and drift become less likely.
The theme of this series is not “use modern features.” The theme is “use modern features to make unsafe behavior harder.”
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.