C++ architectural preferences
2026-03-02 09:30 by Ian
I've done firmware architecture for at least four companies (depending on how you count it). This is a running log of my observations and experiences building embedded code bases to solve common business problems in an engineering org.
It becomes progressively more expensive to find bugs as more of the software lifecycle is traversed before finding them. That is, it is always cheaper to find a bug at buildtime, rather than during unit testing.
The most expensive way to find a bug is to be informed of it by a customer.
Therefore, any bugs that can be systematically prevented by failing a build are ipso facto preferable to a bug that passes a build. This will be a theme in feature selection.
I have a related style post that may also be germane.
Features I encourage
Function overloading
Function overloading is the reason to use C++, even if you ignore everything else about the language.
Preprocessor macros are the devil. Their use should be relegated to as few duties as possible, and never used to supply actual code to the compiler. Most of the reasons people use macros in C (where they are no less acceptable) is because C does not support function overloading, and the preprocessor (being typeless) does. Which lets people commit this flavor of atrocity...
uintu_t x = 15; int32_t y = -847503; uint32_t who_knows = max(x, y);
But we don't need to live that way.
In C++ we can do this...
/* Return the maximum of two values. */
inline double strict_max(double a, double b) { return (a > b) ? a : b; };
inline float strict_max(float a, float b) { return (a > b) ? a : b; };
inline uint64_t strict_max(uint64_t a, uint64_t b) { return (a > b) ? a : b; };
inline uint32_t strict_max(uint32_t a, uint32_t b) { return (a > b) ? a : b; };
inline uint16_t strict_max(uint16_t a, uint16_t b) { return (a > b) ? a : b; };
inline uint8_t strict_max(uint8_t a, uint8_t b) { return (a > b) ? a : b; };
inline int64_t strict_max(int64_t a, int64_t b) { return (a > b) ? a : b; };
inline int32_t strict_max(int32_t a, int32_t b) { return (a > b) ? a : b; };
inline int16_t strict_max(int16_t a, int16_t b) { return (a > b) ? a : b; };
inline int8_t strict_max(int8_t a, int8_t b) { return (a > b) ? a : b; };
- PRO: Legibility++; Brevity++;
- PRO: Allows the deprecation of macros like min() and max(), which are not type-safe.
- CON: Mangles names for translation units linked as C++ (for real functions).
Strict pointer type checking
We will get strict pointer type enforcement by default, just by naming the file .cpp and passing it through g++. If your code was already good, getting this benefit will incur no extra effort whatsoever.
uint8* buf = malloc(42); // Suddenly becomes a build-breaking error, and will need to be uint8* buf = (uint8*) malloc(42); // changed to specifically cast the type.
- PRO: Type-punning is never permitted without explicit type declaration. Immediate reliability benefits.
- CON: For already existing codebases, someone needs to go clean up all the sloppy allocations. It usually takes only a few man-hours to do this unless bugs are discovered in the process.
There are usually bugs discovered in the process.
enum class
C++ allows you to define an underlying type for enums. Embedded devs would care because it allows them to confidently shape enum values to correspond to real constants in their designs...
/* These enum values indicate addresses. */
enum class MCP23x17RegID : uint8_t {
IODIR = 0x00,
IPOL = 0x02,
GPINTEN = 0x04,
DEFVAL = 0x06,
INTCON = 0x08,
IOCON = 0x0A, // Technically an 8-bit register, but is replicated at the next address.
GPPU = 0x0C,
INTF = 0x0E,
INTCAP = 0x10,
GPIO = 0x12,
OLAT = 0x14,
INVALID = 0xFF // End-of-enum. There are 11 registers.
};
- PRO: Forces code to either stay within a static namespace, or cast explicitly. Big buff to reliability. Finds hidden bugs. No runtime cost.
- PRO: Allows for integer width to be specified, and thus safely and easily castable to practical values.
- CON: Makes enums slightly more verbose. GPPU becomes MCP23x17RegID::GPPU, for instance.
- CON: Will break builds until all uses of the enum are disciplined. See "Pros" above.
static constexpr
Being able to say this in a header file...
static constexpr uint16_t MAXIMUM_REFS = 64000;
...is far cleaner and easier than any of the options C would force upon you. You can either use the preprocessor (as most do), and accept the compiler's replication of the constant value wherever used, or you can...
extern const uint16_t MAXIMUM_REFS;
...which is ugly for a separate set of reasons. constexpr allows you to get the definition and declaration for constants in a header file, without the burden of choosing a static storage space off in some obscure corner of your codebase. Why it didn't always work this way is beyond my capability to imagine. I see no CONS to this, and it is the reason to use C++14.
Templates
Code is replicated by the compiler for each type, thus simplifying logic that would otherwise need to account for differences in (size) or (algebra) for two given types. (IE, vector multiplication [and float multiplication, for that matter]) is not commutative. Allows such issues to be handled by mages practiced at those crafts, and isolates everyone else from needing to care.
If type-punning of pointers is in use anywhere in the codebase, the upside to templates usually dwarfs the worst of their possible downsides. Type-punning is a leading cause of RedBull overdose.
- PRO: Implements logic without regard for types. Don't repeat yourself.
- PRO: Reduces complexity of the binary, vis-a-vis writing the same logic twice or type-punning.
- CON: Teams can get carried away (or write sloppy templates), and triple the sizes of their builds in an afternoon.
- CON: Careless or redundant typing can increase build size for no beneficial decrease in complexity. If the C programmers point at your build size and say "this is why I type-pun", they might be right. Flash is cheap, but build sizes still have consequences.
Lambdas
In my mind, lambdas are the reason to use C++17. If I don't know much about a team's preferences, but am pressed to field a recommendation, I will usually start at C++17, simply for the sake of being able to offer the team decent lambda support as a design option. Guys who write that way aren't subject to a certain class of concurrency bugs, and they are easily able to re-balance their stack/heap use with trivial changes to their code. So I don't even want them to have to ask for it.
Lambdas give all the important properties of a real function, without the namespace burden and clutter that would come with something flavored like this...
typedef void (*FxnPointer)(void* ptr);
void some_reusable_named_function(void* ptr) { ... }
FxnPointer my_instance_fxn = some_reusable_named_function;
my_instance_fxn(nullptr);
Functional values which revolve around scope-control and/or clarity would rather....
std::functionGET_FXN = [](void* ptr) { ... }; GET_FXN(nullptr);
It is just a function like any other, and can be relocated as you would otherwise expect. But by their nature, such functions are nameless. Without even a name to mangle, the compiler autogenerates something obtuse. Check your .map file and weep.
- PRO: Functional styles and patterns are easy to read, and possibly the tightest means of scoping code available in any computer language. Their economy and purity is worthy of study, even if their application is impractical for a given purpose. As a design-test, I sometimes ask myself: "What would Clojure do?"
- CON: If you think finding name-mangled functions in the linker's output is burdensome, lambdas will drive you bonkers. May be a bad idea in cases where you want precise linktime control over how and where a function is placed in the final binary.
- CON: Tends to subvert object-oriented patterns unless C++17 or higher is supported by the compiler.
OO
- PRO: Elegant use of OO conceals complexity. See "CON", below.
- CON: Elegant use of OO conceals complexity. See "PRO", above.
- CON: Tends to subvert functional patterns. Hidden complexity conceals state in a way that can be very hard to debug.
Anything written below can be construed as a pro or a con, depending on your values. The choice to use OO (or not) has wide-ranging consequences (both "good" and "bad"). Product reliability, engineer spin-up time/depth, company profitability, and even the kinds of engineers that choose to work on the software team. If we chose, we could use C++ without any OO whatsoever (what you may have heard described as "C-flavored C++", or simply "C+".) Some of the fastest and tightest software in the world is written this way, and it scales down to a coin cell fairly well, if done correctly.
Basically everyone who has written a non-trivial program in the past 20 years understands OO. Even if they don't know much C++. The design practice leverages brain architecture that already works reliably in everyone. It is difficult to overstate the value of this. But it is the difference between having a new engineer making his own RoI in four weeks versus six months (or a year).
This makes it easier to learn and reason about, but also easier to mis-apply or take for granted.
As a finished piece of clockwork, OO will introduce runtime overhead in three ways:
- Build size since toolchain-provided features might have code associated with them for things such as allocators and any data types you lean against. The same bloat you'd see from using a 3rd-party library.
- Build size from vtables. Although small, they represent a per-call data access to memory that was probably linked as "read-only", and therefore might be stored in serial ROM.
- Execution time due to the point above. This is at the root of why many embedded engineers have a sour taste for C++ performance generally on embedded (especially ROMless parts). But a C program of comparable size could easily suffer the same drag from veneer functions. In either case, mitigating it demands level-30 magecraft be exercised in the linker scripts. ESP-IDF handles this automatically, IIRC. Vtable data should always be placed in IRAM, and usually isn't but a few KB.
Isolation of concerns makes it much easier to easy to think about contracts, and it becomes far easier for stateful programs (such as hardware drivers). Once a C program gets complicated enough, most programmers write their own OO to manage state rather than use the OO provided by C++. IE, they do things like this (from X11's code base)...
typedef struct {
Pixmap background_pixmap; /* background, None, or ParentRelative */
unsigned long background_pixel; /* background pixel */
Pixmap border_pixmap; /* border of the window or CopyFromParent */
unsigned long border_pixel; /* border pixel value */
int bit_gravity; /* one of bit gravity values */
int win_gravity; /* one of the window gravity values */
int backing_store; /* NotUseful, WhenMapped, Always */
unsigned long backing_planes; /* planes to be preserved if possible */
unsigned long backing_pixel; /* value to use in restoring planes */
Bool save_under; /* should bits under be saved? (popups) */
long event_mask; /* set of events that should be saved */
long do_not_propagate_mask; /* set of events that should not propagate */
Bool override_redirect; /* boolean value for override_redirect */
Colormap colormap; /* color map to be associated with window */
Cursor cursor; /* cursor to be displayed (or None) */
} XSetWindowAttributes;
...which is then composed into other structs (inheritance), and passed around explicitly to functions that operate on it (implied this pointer in C++).
Unless we are striving to provide a pure-C API (as is X11's case), we might increase reliability/reasonability by renaming the file to .cpp, and adding private and protected designators. That benefit will be to the extent that we are defining large numbers of structs that have state-tracking members.
Such problems as (concurrency, structure-packing, memory management, etc) are suddenly better-bounded with language-enforced OO. If you have an enforceable contract, you can write tests.
And if you can have tests, you can have automated enforcement (the CI pipeline).
And at the end of it all, you can truly say: "I don't make the same mistakes twice."
Features I discourage, but accept with good reasons
Standard library
The standard C++ library can easilly overtake your program in terms of build complexity and runtime. Certain pieces of the C++ stdlib are a given (operator new/delete and the allocators that go with them, init routines, and a handful of small classes and data structures, but even using string carries the potential to create breeding grounds for nightmare bugs. So it should be measured and understood when stdlib features are #included.
Exceptions
With a modest amount of platform effort, the exceptions feature in C++ can be tied to hardware exception handling, thus allowing try, catch, and throw to do the expected things for common hardware-supported exceptions with trivial risk and overhead. Division by zero is a common use case. There isn't much downside to enabling that tie-in, other than to possibly encourage a pattern that is of questionable value and sparse use to begin with.
Sometimes, you have a library that needs it. [shrug]
Reflection
If I see GCC invoked with "--no-rtti", I immediately think it is embedded C++. RunTime Type Information is required for all use of native C++ reflection, as far as I am aware. Its primary runtime cost (and why I discourage it for embedded) is binary size. Sometimes several hundred kilobytes, depending on the program.
If you are using C++ for its rich type expression and control, you likely have many types that will contribute to the resting flash load of RTTI, and are probably only present to support a design choice that (like exceptions) is questionable in an embedded context. However, I have seen reports that C++23 now supports reflection for enums with zero runtime overhead. I have not verified this, but if true, should be usable without RTTI. Any use of reflection that does not depend on RTTI is probably fine.
Multiple inheritance
Like reflection, multiple inheritance is one of those questions that draws a clear line between "can you" versus "should you". Done carefully, I've seen many cases of MI used in a manner that saves time, effort, and is actually worth the complexity. But every metaphor breaks down eventually. And the same benefits of neural re-use that OO allows, also exposes OO designs to the same kinds of mistakes and sloppy logic that we normally exhibit for sets and categories.
Over-generalizing some into all or none, "single-implies != double implies", etc...
Fortunately, out-of-control MI is also fairly easy to refactor unless you've allowed it evolve unaddressed for a long time. So there usually isn't any harm in doing it for leaf-classes to try out an idea.
Features I blacklist
Type auto
Use of type auto hurts understanding of a program so badly that I blacklist it from all of my code bases, despite its negligible-to-nothing runtimes costs. It invites bugs, laziness, and conceals the ontology of the very data that the program is meant to handle.
Some people whose skill I respect allow type auto for standard library iterators. My attitude about this is generally: "If your type ontology is ugly, you ought to be confronted with that fact whenever you use that code, and why are you using the STL, anyway?"
Previous: Notes on using ChatGPT for software work
Next: Vibe-coder rant