The rules I build by
DEV Community

The rules I build by

The rules I build by

New here? Start with the overview . Before the rules, a debt. These are not mine alone; they are the labor of a whole career, and every one of them was shaped by the engineers I have had the honor to work beside. Most of what I hold to here was taught to me, one code review and one hard-won bug and one patient explanation at a time. That is really the method, more than any single rule below: let your thinking evolve with every project and every review, keep what survives contact with the work, and let the rest go. They have changed as the standards and the tools changed, and they will keep changing. I set them down not as a decree but as what I have learned so far; if they are any good the credit is shared, and if they are still improving, that is rather the point. Special thanks to Brian, Jim, Erik, Patty, Dan, Glenn, Ken, and the many others I have worked beside over the years, who formulated and crafted these ideas with me.

In the overview I said the whole modem has to run on a microcontroller, not just a workstation, and that one constraint shaped every line of it. This installment is that constraint, spelled out: the rules I hold myself to, and why I think they make the code both safer and faster than the C it is so often assumed to be slower than. These aren't modem rules, or even embedded rules; I hold every project to them, and that consistency is the point. Apply the same discipline everywhere and two things compound. You get better at writing it right the first time, so the gap between "it works" and "it's right" keeps shrinking. And the reach-back gets cheap: that genuinely useful function you wrote eight months ago drops into the new project with far less rework, because it already speaks the same types, returns errors the same way, and owns nothing it shouldn't. Discipline you practice everywhere is discipline that pays you back.

Here is the shape of it before the detail, so you know where we are headed. One distinction sits under everything: style , which only ever costs you a confused maintainer, versus functional discipline, which is what actually keeps the program from failing. The embedded target then forces exactly three carve-outs from the language, and everything that remains organizes into four habits:

  • Memory and mutation, locked down. No heap on the hot path, a bounded and knowable stack, const by default.
  • One type, top to bottom. A single numeric scalar, almost no casts, and physical quantities that carry their units in their types.
  • Checked, not hoped. Errors are values you cannot ignore, one way to fail everywhere, and as much proof as possible moved to compile time.
  • Less you have to trust. No macros computing values, no shared mutable state in the core, the algorithms portable and the platform kept at the edges.

The claim underneath all of it, the one I want to earn over the rest of this piece, is that this is not the slow, careful C++ people brace for. Done this way it is both safer and faster than the C it is so often assumed to trail.

Embedded-first, or nothing

Here's the constraint that shaped everything: this core must cross-compile to embedded microcontrollers, Cortex-M33 and M7-class parts, not just run on a workstation. If you've ever watched a single stray heap allocation blow a real-time audio deadline on a small ARM part, you already know why there is no new anywhere in my signal path. That one mandate rules out a mountain of the bloat that quietly accretes in DSP code, and it turns "good style" from a preference into a spec.

The rules

Before any specific rule, one distinction that matters more than any single one of them: there are two kinds of "coding standard," and they fail in completely different ways.

The first is style, which is about human readability and nothing else: what you name a variable, a class, a function, a member; tabs or spaces; where a line wraps. A formatter like astyle enforces most of it, and a short style guide covers the rest. Get style wrong and nothing breaks; you've just left visual noise for whoever maintains the code next, which in mission-critical work is far more often than you write it. It matters, but it matters to people, not to the compiler.

The second is functional, and it's where the C++ Core Guidelines earn their keep: disciplined casting, strict types, honest error handling, DRY, const used liberally (const data, const pointers, or better still a reference), buffers that carry their length with their type (std::array, not a bare pointer and a separate size you hope still matches), scopes kept small, every object initialized before it's used, one name declared per line and only once you have a value for it. Get one of these wrong and the program can actually fail: a silent narrowing, an uninitialized read, a dangling reference, a buffer that lost track of its length. I follow almost all of them.

Almost, because the embedded target forces exactly three carve-outs, and all three are functional:

  • No exceptions
  • No RTTI
  • No dynamic memory allocation on the signal path

Everything else in the guidelines stands. So the build runs under -fno-exceptions -fno-rtti, warnings-as-errors, and in practice the functional rules look like this:

Memory and mutation, locked down

  • No heap on the hot path. Every buffer is a fixed std::array sized by a compile-time constant, its type and its length traveling together. The receiver allocates nothing while running; pools are prepared before the stream starts, and real-time callbacks never allocate, never block, never touch I/O.
  • Bounded, known stack; no unbounded recursion. With the heap gone, the stack is the last place memory can surprise you, so recursion on the signal path is out and the depth stays bounded and knowable instead of a runtime unknown. I build with -fstack-usage, and every function in the core reports a fixed, compile-time frame; there are no variable-length arrays and no alloca, so nothing can grow the stack while the program runs. On a part with no MMU, a stack that can grow without limit is just a slower way to corrupt memory; I would rather know the worst case before the part ships than discover it in the field.
  • const by default, references over pointers. Data is const unless it has a reason to change, and where a raw pointer would do I reach for a reference instead. Fewer things can move, so fewer things can go wrong.
static constexpr std::size_t maximum_taps = 32U;
std::array<IQSample, maximum_taps> mCoefficients {}; // fixed size, zero-init, never allocates

One type, top to bottom

  • One numeric type, chosen at build time. The whole modem is parameterized on a single Real scalar: float by default, which already carries far more precision than the data ever has (the datasheet note below), with a seam already cut for a future fixed-point Q15/Q31 backed by the M33's CORDIC. Verification is by bit-exact golden vectors and an independent reference decoder, not by a higher-precision build; the point of one type is that nothing silently promotes or narrows between the layers. One type all the way up and down, almost no casts.
  • Unify a call stack on one type and functions stop converting a size to call a helper that converts it back. Casts are where bugs hide (a silent narrowing, a signed/unsigned flip) and where the compiler stops being able to help you, and they are rarely free: an int-to-float conversion is a VCVT, a stray promotion to double is a software-emulated call on a part with no hardware double, and a few of those per sample, multiplied across a real-time block, is how a DSP loop quietly eats its cycle budget and misses its deadline. The only sanctioned cast is at the boundary of an API you don't own: the codec's int16 buffer, a published C/DMA interface.
  • Strong types, not bare numbers. A physical quantity carries its unit in its type: a Frequency, an Angle, a Decibel, a SampleRate, a Baud, a BitRate. You cannot build one or read it back without naming a unit, so a hertz cannot be mistaken for a kilohertz and a symbol rate cannot be passed where a bit rate belongs. The decibel types go a step further and know their own algebra: a gain adds to a power level, the difference of two power levels is a ratio, and adding two absolute power levels does not compile at all. It is the same instinct as the single Real type, taken up a level; make the wrong thing impossible to say, and let the compiler enforce the physics. And it is free at runtime: the wrappers fold away, so on the microcontroller a Frequency costs exactly what the bare number underneath it would.
using Real = float; // the one scalar the whole modem is built on
Frequency carrier { 1800, Hz }; // the unit lives in the type
Power eirp = transmit + antenna; // dBW + dBi -> Power; but Power + Power will not compile

Checked, not hoped

  • Errors are values, not surprises. With exceptions off, a fallible operation returns a [[nodiscard]] std::expected<T, Status>, C++23's standard "a value, or the reason there isn't one," and exactly the shape the Core Guidelines' no-exceptions guidance describes (E.25 through E.28): you must handle it; you can't forget it, and you can't be ambushed by a throw three layers down. I mark the error type itself [[nodiscard]], too, so even a bare status returned by value fails the build when it is dropped; forgetting to check an error is not a mistake you can make by accident, it is a mistake the compiler refuses to let you commit.

One wrinkle earns its own sentence, because it is exactly where the no-exceptions rule and std::expected meet. With exceptions off, calling .value() on an error does not throw; it aborts, with no diagnostic and no Status left to inspect. So the fallible type is a thin Result<T> wrapped over std::expected<T, Status>: the storage is the standard type, but value() routes a missing value through an installable handler that can record the failure before it traps. It is one seam kept on purpose, for the one diagnostic the bare abort would have swallowed; everything else is just std::expected. In code the wrapper is small, and the whole design lives in one method:

// A fallible T: std::expected<T, Status> underneath (standard, constexpr, trivially
// copyable when T is), wrapped for one reason. With exceptions off, std::expected's
// own value() on an error aborts, with no diagnostic and no Status to inspect.
template <typename T>
class [[nodiscard]] Result {
public:
    constexpr Result(T value) noexcept : mResult{ std::move(value) } {}
    constexpr Result(Status e) noexcept : mResult{ std::unexpected(std::move(e)) } {}
    // `return Status{...}` just works

    [[nodiscard]] constexpr explicit operator bool () const noexcept {
        return mResult.has_value();
    }

    // Safe on success too, unlike std::expected::error(): a good result reports an ok Status.
    [[nodiscard]] constexpr Status error () const noexcept {
        return mResult.has_value() ? Status::success() : mResult.error();
    }

    // The one seam: a missing value routes through an installable handler that can record the
    // failure before it traps, instead of the blind abort. (The const& and && overloads mirror this.)
    [[nodiscard]] constexpr T& value() & noexcept {
        if (!mResult) { critical_error(mResult.error()); }
        return *mResult;
    }

private:
    std::expected<T, Status> mResult;
};

So where a signature above hands back std::expected<T, Status>, the shipped type is this Result<T>: the same value-or-error contract, drop-in with the standard type, plus the one trap the bare abort would have swallowed. The implicit Result(Status) constructor is what lets a function just return Status{...} on failure and return value; on success, with no ceremony at either end.

  • One way to fail, everywhere. The single-type rule has an error-handling twin. If a function can fail it returns the one fallible type, a Result<T> (or a Status when there is no value to hand back), and nothing else; never a bare bool that secretly means "did it work," never a sentinel -1 or SIZE_MAX, never a naked error code whose meaning the caller has to memorize. A bool gets exactly one job, a question the caller asks (is_locked(), empty()), never an answer about whether the last call survived. One channel means nobody learns a new failure convention per function: the way you check the channel estimate is the way you check the demodulator is the way you check the file open, a failure three layers down propagates up in the same shape, and the test is always if (!result) with the reason riding along. It is also a library's proper manners; the core originates failures and passes them up, it almost never inspects them, because deciding what an error means is the caller's job. That is why the error-code checks in this project cluster in the application that consumes the modem, not in the modem itself.

Compute at compile time, check at compile time

Anything knowable before the program runs, a table, a size, a bit of geometry, is constexpr; the invariants that must hold, a buffer sized for the worst case, a type that must stay trivially copyable, a rate that divides evenly, are static_asserted, so a wrong assumption fails the build instead of the mission. There are well over a hundred of these compile-time checks across the core, and every one of them costs nothing at runtime.

[[nodiscard]] std::expected<ChannelEstimate, Status> estimate_flat_channel (...) noexcept;
static_assert ( sizeof(Instance) <= 4096U ); // a wrong assumption fails the build, not the mission

Less you have to trust

  • No macros for values or logic. A constant is a constexpr, a set of states is an enum class, a small helper is an inline function or a template. A #define is text substitution with no type and no scope, and it bites where the debugger cannot follow it: the double evaluation, the silent narrowing, the name that collides with something three headers away. In the whole core the only macros are a platform export shim and a couple of build-time fe
Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.