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; };

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.

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.
};

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.

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::function GET_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.

OO

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:

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:
Next: