C++ Field Manual
C++ / systems reference · rev 2026.08 · gcc · clang · aarch64

The C++ Field Manual
write it clean, build it anywhere, ship it hard.

A dense, opinionated reference for practitioners: modern language idioms, design patterns, a full CMake workflow, cross-compilation for embedded targets, production hardening, and doc generation — one page, no fluff.

Standards
C++17 · 20 · 23
Build
CMake ≥ 3.21 + Presets
Targets
x86-64 · aarch64 · Yocto
Toolchains
GCC · Clang/LLVM

▹  How to read: sections are laid out as a memory map. Each block is self-contained — jump to an offset from the sidebar, or scroll straight through.

0x00 · SECTION

Overview & baseline

Ground rules before any code. Fix your standard, your warning posture, and your definition of "done" first — everything downstream depends on it.

0.1Pick a standard and a floor

Target the newest standard your toolchain fully supports, and set it explicitly per-target — never rely on the compiler default. For most embedded/industrial work in 2026, C++20 is the sweet spot (concepts, ranges, std::span, <bit>, calendar/chrono, consteval), with C++23 where GCC 13+/Clang 17+ are available (std::expected, std::print, mdspan).

cmakeCMakeLists.txt — per-target, never global default
target_compile_features(mylib PUBLIC cxx_std_20)
set_target_properties(mylib PROPERTIES
    CXX_STANDARD_REQUIRED ON   # hard error if std unsupported
    CXX_EXTENSIONS        OFF) # -std=c++20, not gnu++20 (portable)

0.2Definition of done

A translation unit is not "done" when it compiles. It is done when it survives all of the following. Treat this as the contract the rest of this manual helps you meet:

  • Compiles clean under -Wall -Wextra -Wpedantic (see 0xC8) with warnings-as-errors in CI.
  • Passes ASan + UBSan on the host test suite; no leaks under LSan.
  • clang-tidy and clang-format clean; no suppressions without a comment justifying them.
  • Unit tests exist and run in CI on every target triple you ship.
  • Public API is documented (Doxygen, 0xE0) and the doc build has no warnings.
  • Cross-compiles reproducibly from a pinned toolchain, not "works on my machine."

0.3Mental model

C++ gives you zero-overhead abstraction and enough rope to hang the whole plant. The discipline that makes it safe is small and repeatable: own every resource with RAII, make illegal states unrepresentable with types, and let the compiler and sanitizers catch what review won't. Almost everything below is an application of those three ideas.

◆ Note

This manual assumes a hosted or Linux-class embedded target (Yocto, Debian on ARM, etc.). Deeply constrained bare-metal/MCU work (no heap, no exceptions, no RTTI, freestanding) shares the idioms but changes the trade-offs — those deltas are flagged inline.

0x08 · SECTION

Style & coding guidelines

Consistency beats personal taste. Adopt a written standard, automate it, and stop arguing in review. The C++ Core Guidelines are the reference baseline; below is the working subset that matters day to day.

1.1Naming & files

Any coherent convention works as long as it is enforced by tooling. A common, low-friction scheme:

EntityConventionExample
Types / conceptsPascalCaseSensorReading, Serializable
Functions / varssnake_caseread_frame(), retry_count
Memberstrailing _socket_, buffer_
Constants / enumsPascalCase or UPPERMaxRetries, kBaudRate
MacrosUPPER_SNAKE (avoid!)MYLIB_EXPORT
Filessnake_casemodbus_client.hpp/.cpp
Namespaceslower, shortmbus::rtu

Use header extension .hpp for C++ (distinguishes from C .h), .cpp for sources. One primary type per header where practical.

1.2Header hygiene

✓ Do
  • #pragma once (or include guards) in every header.
  • Include what you use (IWYU); don't lean on transitive includes.
  • Forward-declare in headers, #include in the .cpp.
  • Keep headers self-contained: each must compile alone.
✕ Don't
  • using namespace std; in a header — ever.
  • Define non-inline free functions in headers (ODR violations).
  • Put <iostream> in a widely-included header (static-init + bloat).
  • Expose implementation includes through your public API.
cppmodbus_client.hpp — forward-declare, minimal surface
#pragma once
#include <cstdint>
#include <span>
#include <system_error>

namespace mbus {

class Transport;               // fwd-decl: no transport header pulled in

class ModbusClient {
public:
    explicit ModbusClient(Transport& t) noexcept;

    // std::expected in C++23; error code out-param or Result<T> pre-23
    std::error_code read_holding(std::uint16_t addr,
                                 std::span<std::uint16_t> out) noexcept;
private:
    Transport& transport_;
};

} // namespace mbus

1.3const-correctness & interfaces

Mark everything const that can be. It documents intent, enables optimizations, and prevents accidental mutation. Prefer this parameter-passing table:

You wantPass asNotes
Read, cheap typeT (by value)ints, pointers, small trivially-copyable
Read, expensive typeconst T&strings, containers, big structs
Read, stringstd::string_viewno allocation, no copy; non-owning
Read, array/bufferstd::span<const T>ptr+size in one; bounds-friendly
Mutate caller's objectT&make mutation obvious at call site
Sink / take ownershipT then std::moveor T&& for a pure sink
Optional in-paramconst T*nullable; std::optional for values
▲ Pitfall

std::string_view and std::span are non-owning. Never return one that refers to a local or a temporary, and never store one past the lifetime of what it views. Dangling views are a top source of UB in modern code.

1.4auto, braces, and small rules

  • Use auto when the type is obvious from the RHS or genuinely unspeakable (iterators, lambdas). Don't use it to hide an important type from the reader.
  • Prefer brace-init {} — it bans narrowing conversions. Watch the one gotcha: std::vector<int>{3} is one element 3, not three elements.
  • Initialize every variable. Uninitialized reads are UB, not "just garbage."
  • Prefer enum class over plain enum (scoped, typed, no implicit int).
  • Prefer nullptr over NULL/0; using over typedef.
  • Keep functions short and single-purpose; if you need a comment to explain a block, extract it into a named function.
◆ Automate it

Ship a .clang-format and .clang-tidy in the repo root and wire them into CI and pre-commit. Style you don't enforce is style you don't have. Config examples live in 0xC8.

0x0C · SECTION

Types, pointers & functions

The procedural core: the types you compute with, how values are named and initialized, and the two things you pass around — pointers and functions. Everything later builds on this. Precise widths and precise initialization matter more here than in most domains, because you're mapping bytes to registers and framing them onto a wire.

2.1Fundamental types

C++ inherits C's built-in types. The rule that surprises people: the standard fixes only minimum sizes and relative ordering, not exact widths — an int is "at least 16 bits", in practice 32. For anything touching a protocol, a register, or a file format, never rely on these; use the fixed-width types in 2.2.

CategoryTypesTypical sizeNotes
Booleanbool1 byteHolds true/false; occupies a whole byte, not a bit.
Characterchar, signed/unsigned char, char8_t, wchar_t1 byte (wchar_t 2–4)Plain char's signedness is implementation-defined — pin it if it matters.
Integershort, int, long, long long (+ unsigned)≥2, ≥2, ≥4, ≥8Minimums only; long is 32-bit on Windows, 64-bit on Linux LP64.
Floating-pointfloat, double, long double4, 8, 8–16IEEE-754 binary32/binary64. Never compare with ==.
Bytestd::byte1 byteC++17 type-safe raw byte; supports bit-ops, not arithmetic.
Voidvoid"No type": non-returning functions and (historically) generic pointers.
sizeof counts bytes, a byte is CHAR_BIT bits

sizeof yields a size in bytes (i.e. chars), and a byte is CHAR_BIT bits — effectively always 8, but the standard permits more on exotic DSPs. static_assert(CHAR_BIT == 8) if your bit-twiddling depends on it.

2.2Fixed-width & sized types

Include <cstdint>. These are the default in embedded and protocol code — the width is in the name, so a struct that mirrors a wire frame means the same thing on every target.

cppthe types to reach for
#include <cstdint>
std::uint16_t reg;      // exactly 16 bits, unsigned   (a Modbus register)
std::int32_t  temp_mC;  // exactly 32 bits, signed     (milli-degrees C)
std::uint8_t  fc;       // exactly 8 bits              (a function code)

std::size_t    n;       // unsigned: sizeof, indexing, container .size()
std::ptrdiff_t d;       // signed:   result of pointer subtraction
std::uintptr_t a;       // an unsigned integer wide enough to hold a pointer
FamilyMeaning
intN_t / uintN_tExactly N bits (8/16/32/64). Simply absent if the target has no such width.
int_leastN_tSmallest type with at least N bits.
int_fastN_tFastest type with at least N bits (may be wider than N).
size_t / ptrdiff_tSize/index (unsigned) and pointer difference (signed).
intptr_t / uintptr_tInteger guaranteed to round-trip a pointer value.
Fixed width ≠ fixed byte order

A std::uint16_t has two bytes, but their order in memory is little-endian on ARM/x86 while protocols are usually big-endian ("network order"). Convert explicitly at the boundary — htons/ntohs, or C++23 std::byteswap / std::endian — never memcpy a multi-byte integer straight onto the wire.

2.3Literals, initialization & deduction

How you write a value and how you initialize a variable both carry meaning. Two habits pay off immediately: use digit separators and explicit bases for hardware constants, and prefer brace-initialization — it refuses the narrowing conversions the other forms accept silently.

cppliterals & initialization
// literals
auto a    = 1'000'000;      // ' is a digit separator (C++14)
auto mask = 0b0000'1111;    // binary literal (C++14)
auto addr = 0x40'00u;       // hex, unsigned suffix
auto hz   = 3.3e6;          // double  (3.3e6f would be float)
using namespace std::chrono_literals;
auto t    = 500ms;          // typed literal -> std::chrono::milliseconds

// initialization — prefer braces
int    x{42};               // direct-list-init; NARROWING IS AN ERROR here
int    z{};                 // value-init -> 0  (never leaves garbage)
double d = 3.9;
// int bad{d};              // won't compile: narrowing double->int is caught
int    ok = d;              // compiles, silently truncates to 3
✓ Brace-init

T v{...} is uniform and rejects narrowing at compile time; T v{} zero-initializes. Make it your default.

✗ Silent traps

T v; leaves a built-in uninitialized (garbage); T v(x) risks the most-vexing-parse; int v(3.9) truncates without a warning.

Let the compiler deduce the type with auto when it's obvious from the initializer or just noise (iterators, make_unique); spell the type out when it is the documentation (a public return type, a fixed-width field). decltype(expr) gives the type of an expression without evaluating it.

2.4String operations

std::string owns a heap buffer (with small-string optimization for short values); std::string_view is a non-owning window over existing characters. Use string for storage and mutation, string_view for read-only parameters so callers never have to allocate to call you.

cppthe operations you reach for daily
#include <string>
#include <string_view>

std::string s = "modbus/tcp";
s.size();  s.empty();                    // 10, false
s += ":502";                             // append  -> "modbus/tcp:502"
auto pos  = s.find(':');                 // 10   (std::string::npos if absent)
auto host = s.substr(0, pos);            // "modbus/tcp"
s.replace(0, 6, "MODBUS");               // in-place edit
bool ok = s.starts_with("MODBUS");       // C++20  (ends_with; contains is C++23)
const char* c = s.c_str();               // NUL-terminated, for C APIs

For number↔text, prefer std::from_chars / std::to_chars (in <charconv>): no allocation, no locale, no exceptions — the right tool on an MCU or a hot parse path. Use std::stoi / std::to_string for convenience, std::stringstream only when you need its formatting flexibility.

cppconversions — fast path vs convenient path
#include <charconv>
int v{};
auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), v);  // no throw, no alloc
if (ec == std::errc{}) use(v);

std::string t = std::to_string(42);      // convenient, allocates
int n = std::stoi("123");                // convenient, THROWS on bad input

C++20 adds std::format (type-safe, positional) and C++23 std::print — prefer them to iostreams and printf: no format/argument mismatch, no manual width juggling.

cppstd::format / std::print
#include <format>                          // <print> for std::print (C++23)
std::string line = std::format("reg[{}]={:#06x}", i, value);  // "reg[3]=0x00ff"
// std::print("connected {}:{}\n", host, port);
string_view does not own

A view never extends the lifetime of what it points at. Never return a string_view of a local std::string, and never build one from a temporary — both dangle. If you must keep it, copy into a std::string. (See the pitfalls in the Appendix.)

2.5Pointers & references

A pointer holds an address; a reference is an alias for an existing object. Prefer references where you can — they can't be null and can't be reseated — and pointers where you must express "optional", "reseatable", or "points to nothing yet". Ownership is a separate question: for that use the smart pointers in Modern C++ core (section 4), never a raw owning pointer.

cppthe basics
int x = 42;
int* p = &x;        // p holds the address of x
*p = 43;            // dereference: x is now 43
p = nullptr;        // use nullptr, never NULL or literal 0

int& r = x;         // r is another name for x: no storage, cannot rebind
r = 44;             // x is now 44
DeclarationMeaning
const T* pPointer to const: can't change *p, can repoint p.
T* const pConst pointer: can change *p, can't repoint p.
const T* const pCan change neither.
const T& rReference to const: read-only alias; also binds to temporaries.

A raw array decays to a pointer to its first element in almost every expression, losing its size — the classic buffer-bug source. Prefer std::array (fixed size) or std::span (pointer + size view, C++20) so the length travels with the data.

cppcarry the size with the data
void legacy(const std::uint8_t* buf, std::size_t len);   // C-style: size passed separately
void modern(std::span<const std::uint8_t> buf);          // span carries .size() itself

std::array<std::uint8_t, 8> frame{};
modern(frame);                                           // span deduces the size
✗ Dangling is undefined behaviour

A pointer or reference to a local that has left scope, or to freed memory, is UB — not a reliable crash. Lifetime discipline (RAII, smart pointers) is how you avoid it, not vigilance.

A function pointer stores the address of a function — the basis of callbacks. In modern C++ prefer std::function (type-erased, holds any callable including lambdas with captures) at API boundaries, or a template parameter when you can afford to inline.

cppfunction pointers & std::function
std::uint16_t crc16(std::span<const std::byte>) noexcept;
using CrcFn = std::uint16_t(*)(std::span<const std::byte>);  // a function-pointer type
CrcFn fp = &crc16;

std::function<void(std::string_view)> on_msg;   // holds ANY matching callable
on_msg = [prefix](std::string_view topic){ /* capture + logic */ };

2.6Functions & lambdas

Declare functions in headers, define them in exactly one translation unit (the one-definition rule). How you pass parameters is a correctness and performance decision, not a style choice.

Pass bySignatureUse when
Valuef(T x)Small/trivially-copyable types (int, pointers, span), or you need your own copy.
const referencef(const T& x)Read-only access to something expensive to copy (std::string, big structs).
Mutable referencef(T& x)You must modify the caller's object (an "out"/"in-out" parameter).
Pointerf(T* x)The argument is genuinely optional (may be nullptr) or reseatable.
cpppassing, defaults, overloading
void poll(const Config& cfg,             // read-only, no copy
          Stats&        out,             // written back to the caller
          Logger*       log = nullptr);  // optional; default argument (binds right-to-left)

// Overloading: same name, different PARAMETER types (return type alone can't overload).
Reading parse(std::span<const std::byte>);
Reading parse(std::string_view);

Return by value and let the compiler elide the copy (RVO/NRVO); don't std::move a local on return — it can pessimize that elision. Mark functions constexpr to permit compile-time evaluation, consteval to require it, and inline to permit multiple identical definitions across TUs (it is not a "please inline" hint).

A lambda is an anonymous function object with an optional capture of surrounding variables — the everyday tool for callbacks, custom comparators, and short local helpers.

cpplambdas & captures
int base = 100;
auto add   = [](int a, int b){ return a + b; };      // no capture
auto shift = [base](int a){ return a + base; };      // capture by value (a copy)
auto tally = [&base](int a){ base += a; };           // capture by reference
auto gen   = [n = 0]() mutable { return n++; };      // init-capture + mutable state
auto anyT  = [](auto a, auto b){ return a + b; };    // generic (templated) lambda
Reference capture + storage = dangling

Capturing by reference with [&] and then storing the lambda (in a std::function, a container, or handing it to another thread) is a dangling-reference bug the instant the captured scope ends. Capture by value or by move whatever the lambda will outlive.

0x10 · SECTION

Structs, classes & OOP

C++'s object model: how you lay out plain data, how a class bundles state with the operations that keep its invariants true, and the four ideas — encapsulation, abstraction, inheritance, polymorphism — that "object-oriented" actually names. In embedded work struct layout is also an ABI and wire-format concern, so it gets real attention here.

3.1Structs & data layout

A struct is a class whose members default to public — that is the only language difference from class. Use struct for passive aggregates of data, class when the type maintains invariants behind an interface. Aggregates brace-initialize member-by-member, with designated initializers (C++20) naming fields for clarity.

cppaggregate & designated initialization
struct Reading {
    std::uint16_t reg;
    std::int32_t  value;
    bool          valid = false;      // default member initializer
};

Reading a{40001, 230, true};                      // positional aggregate init
Reading b{.reg = 40001, .value = 230,             // designated (C++20); order must match
          .valid = true};

The compiler inserts padding so each member sits on its natural alignment, so sizeof is usually larger than the sum of members — and that layout is what your ABI and any wire mapping must agree on. Query it with alignof/sizeof; control it with alignas. Ordering members large-to-small often shrinks the struct.

cpppadding is real; member order matters
struct Bad  { std::uint8_t a; std::uint32_t b; std::uint8_t c; }; // sizeof 12 (padded)
struct Good { std::uint32_t b; std::uint8_t a; std::uint8_t c; }; // sizeof 8  (reordered)

static_assert(alignof(std::uint32_t) == 4);
alignas(64) std::array<std::byte, 64> cache_line;   // over-align (e.g. avoid false sharing)
Packed structs over a wire are a trap

#pragma pack / __attribute__((packed)) removes padding so a struct matches a frame byte-for-byte — but taking the address of a misaligned member is UB on some targets and slow on others, and the layout still isn't portable across endianness or compilers. For anything leaving the process, serialize field-by-field (read/write each byte explicitly) rather than reinterpret-casting the buffer to a struct.

For on-chip register maps you can declare bit-fields, but bit ordering within the unit is implementation-defined — fine with a known compiler, not for cross-platform formats. std::bit_cast (C++20) is the defined way to reinterpret the bits of one trivially-copyable type as another, replacing UB type-punning through casts.

cppbit-fields & defined reinterpretation
struct Status { std::uint8_t ready:1, fault:1, mode:2, rsvd:4; };  // 1 byte, on-chip use
float f = std::bit_cast<float>(raw_u32);   // defined bit reinterpretation (no aliasing UB)

3.2Classes: anatomy & lifecycle

A class bundles data (members) with the functions that operate on it, and controls access so callers can only reach it through an interface that keeps its invariants true. That access control — public / private / protected — is the whole point of using a class over a struct.

cppan annotated class
class TempSensor {
public:                                     // the interface
    explicit TempSensor(std::uint8_t addr)  // 'explicit' blocks implicit conversion
        : addr_{addr} {}                     // member initializer list (preferred)

    ~TempSensor() { disconnect(); }          // destructor: runs at end of scope (RAII)

    [[nodiscard]] std::int32_t read() const; // 'const': promises not to modify *this
    void set_offset(std::int32_t o) { offset_ = o; }

    static constexpr std::uint8_t kMaxAddr = 247;  // one value shared by all instances

private:                                     // the implementation — callers can't touch
    std::uint8_t addr_;
    std::int32_t offset_ = 0;
    void disconnect();
};
ConceptWhat it is
Constructor / destructorSet up / tear down; the destructor drives RAII (releases in reverse order of acquisition).
Member init list : x_{...}Initializes members directly; assigning in the body constructs then overwrites.
const member functionCallable on const objects; promises not to mutate observable state.
static memberOne instance shared by the whole class, not one per object.
explicitStops a one-argument constructor from being used as a silent implicit conversion.
thisPointer to the current object inside a member function.

The compiler can generate the default constructor, destructor, copy/move constructors, and copy/move assignment. If you declare any of the destructor or copy/move operations, obey the Rule of 0/3/5 (see Modern C++ core, section 4): declare none and let the compiler manage everything, or declare them all consistently.

3.3The four pillars of OOP

"Object-oriented" is shorthand for four ideas. C++ gives you the mechanisms; it does not require you to use them for every problem.

PillarThe ideaC++ mechanism
EncapsulationBundle state with its operations; hide the internals behind an interface.private members + public methods; accessors only where they earn it.
AbstractionExpose what a type does, not how.Header/interface separation; pure-virtual interfaces; PIMPL (§9).
InheritanceA derived type reuses and extends a base type.class D : public B { … };
PolymorphismOne interface, many behaviours chosen at run time.virtual functions dispatched through a base pointer/reference.
Prefer composition over inheritance

Inheritance is the tightest coupling C++ offers and the easiest to overuse. Reach for it when you have a true "is-a" relationship and need runtime polymorphism; otherwise hold the other type as a member (composition), or use the compile-time alternatives — templates, CRTP, std::variant, type erasure — in Design patterns (§9).

3.4Inheritance & polymorphism

Runtime polymorphism works through virtual functions: a call through a base reference or pointer dispatches to the derived override, resolved at run time via the vtable. An abstract base with pure-virtual methods (= 0) defines an interface with no implementation.

cppinterface + concrete implementations
class Sensor {                              // abstract interface
public:
    virtual ~Sensor() = default;            // MUST be virtual (or protected) — see warning
    virtual std::int32_t read()       = 0;  // pure virtual: no body, class is abstract
    virtual const char*  name() const = 0;
};

class Modbus : public Sensor {
public:
    std::int32_t read() override;           // 'override' = the compiler checks the signature
    const char*  name() const override { return "modbus"; }
};

void log_all(std::span<Sensor* const> sensors) {
    for (Sensor* s : sensors)               // one interface...
        record(s->name(), s->read());       // ...dispatched to each concrete type
}
The virtual-destructor rule

If you ever delete a derived object through a base pointer, the base's destructor must be virtual — otherwise only the base sub-object is destroyed and you leak (or worse). Any class with a virtual function should have a virtual destructor. Turn on -Wnon-virtual-dtor. (Also in the Appendix pitfalls.)

KeywordEffect
virtualEnables runtime dispatch for this function.
overrideDeclares intent to override a base virtual; a signature mismatch becomes a compile error. Always use it.
finalForbids further overriding (on a method) or subclassing (on a class); can also help the compiler devirtualize.
= 0 (pure)No implementation here; the class becomes abstract and can't be instantiated directly.

Virtual dispatch costs an indirection and blocks inlining — negligible for a per-reading sensor call, but measurable in a tight inner loop. When the set of types is known at compile time, the compile-time tools in §9 (templates, CRTP, std::variant) give you polymorphism with no runtime overhead.

0x18 · SECTION

Modern C++ core

The load-bearing idioms. Get ownership and value semantics right and whole categories of bug — leaks, double-frees, dangling, use-after-move — stop being possible.

4.1RAII — the one rule

Every resource (memory, file, socket, lock, GPIO handle, DMA channel) is owned by an object whose destructor releases it. Acquisition is initialization; cleanup is automatic and exception-safe. If you write a raw new/delete, fopen/fclose, or malloc/free pair in application code, you are doing it wrong — wrap it.

cppraii_fd.hpp — own a POSIX fd, never leak it
#include <unistd.h>
#include <utility>

class UniqueFd {
public:
    UniqueFd() = default;
    explicit UniqueFd(int fd) noexcept : fd_(fd) {}

    ~UniqueFd() { if (fd_ >= 0) ::close(fd_); }

    // move-only: fds are not copyable
    UniqueFd(UniqueFd&& o) noexcept : fd_(std::exchange(o.fd_, -1)) {}
    UniqueFd& operator=(UniqueFd&& o) noexcept {
        if (this != &o) { reset(); fd_ = std::exchange(o.fd_, -1); }
        return *this;
    }
    UniqueFd(const UniqueFd&)            = delete;
    UniqueFd& operator=(const UniqueFd&) = delete;

    int  get() const noexcept { return fd_; }
    int  release() noexcept   { return std::exchange(fd_, -1); }
    void reset() noexcept     { if (fd_ >= 0) ::close(fd_); fd_ = -1; }
    explicit operator bool() const noexcept { return fd_ >= 0; }
private:
    int fd_ = -1;
};

4.2Smart pointers — pick by ownership

TypeMeaningUse when
T / T&value / borrow, no ownershipthe default — reach for this first
unique_ptr<T>sole ownership, move-onlyheap object with one owner, PIMPL, factories
shared_ptr<T>shared ownership, refcountedonly when lifetime is genuinely shared/unclear
weak_ptr<T>non-owning observer of sharedbreak cycles; caches; observer back-refs
raw T*non-owning, nullable borrowoptional param / observer; never owns
▲ Pitfall

shared_ptr is not a default — it has atomic refcount overhead and hides ownership. Two shared_ptrs pointing at each other leak forever; break the cycle with weak_ptr. Reach for unique_ptr first and only promote to shared when you can name the second owner.

cppsmart_pointers.cpp
#include <memory>

auto sensor = std::make_unique<Sensor>(config);   // never `new`
auto shared = std::make_shared<Bus>();            // one alloc for ctrl+obj

// custom deleter for C resources without a wrapper type:
using FilePtr = std::unique_ptr<FILE, decltype([](FILE* f){ if(f) std::fclose(f); })>;
FilePtr fp{ std::fopen("log.bin", "rb") };

// weak_ptr to observe without extending lifetime
std::weak_ptr<Bus> watcher = shared;
if (auto b = watcher.lock()) { b->poll(); }        // safe: nullptr if gone

Always construct with std::make_unique/std::make_shared, never bare new. make_shared does a single allocation for the object and control block. Pass smart pointers by value only when transferring ownership; otherwise pass the underlying T&/T* — a function that just uses an object shouldn't take const shared_ptr&.

4.3Rule of 0 / 3 / 5

Rule of Zero is the goal: design classes so the compiler-generated special members are correct, by holding members that already manage themselves (containers, smart pointers, RAII wrappers). Then you write no destructor, no copy, no move — and can't get them wrong.

If you must write one of {destructor, copy ctor, copy assign, move ctor, move assign} because you manage a raw resource, you almost certainly must consider all five (Rule of Five). Writing a destructor suppresses implicit moves, silently degrading to copies — an easy performance and correctness trap.

✓ Rule of Zero
class Frame {
  std::vector<std::byte> data_;
  std::string          topic_;
  // nothing else — all 5
  // special members are correct
};
▲ If you manage raw
// declare ALL five, or = default
// / = delete each explicitly.
~T(); T(const T&); T& operator=(const T&);
T(T&&) noexcept;
T& operator=(T&&) noexcept;

4.4Move semantics & value semantics

Moving transfers guts (pointers, handles) instead of copying them — O(1) instead of O(n). Return big objects by value and trust the compiler: guaranteed copy elision (C++17) and NRVO mean return v; costs nothing. Do not write return std::move(local); — it disables NRVO and is slower.

cppmove.cpp — the rules that actually matter
std::vector<int> build() {
    std::vector<int> v(1000);
    return v;                    // elided/NRVO — do NOT std::move here
}

void sink(std::string s);        // takes by value
std::string name = "modbus";
sink(std::move(name));           // move into the sink; `name` now valid-but-unspecified

// mark your own move ops noexcept — containers rely on it to move (not copy)
// on reallocation. A throwing move silently degrades vector growth to copies.
Buffer(Buffer&&) noexcept;
✕ After move

A moved-from object is in a valid but unspecified state. You may destroy it or assign to it; do not read its value and assume anything. Never std::move a variable you use again.

4.5constexpr — push work to compile time

Mark functions and data constexpr when they can run at compile time; use consteval (C++20) when they must. This moves lookup tables, CRC tables, protocol constants, and validation off the runtime path entirely — valuable on constrained targets.

cppconstexpr_crc.cpp
constexpr std::uint16_t crc16(std::span<const std::byte> d) noexcept {
    std::uint16_t crc = 0xFFFF;
    for (auto b : d) {
        crc ^= std::to_integer<std::uint16_t>(b);
        for (int i = 0; i < 8; ++i)
            crc = (crc & 1) ? (crc >> 1) ^ 0xA001 : crc >> 1;
    }
    return crc;
}
// usable at compile time AND runtime:
static_assert(crc16(/*...*/) == 0x4B37);
0x30 · SECTION

Vocabulary types

The standard library ships the types that make interfaces honest: "maybe a value," "one of these," "a view," "a result." Use them instead of sentinels, out-params, and raw pointers — the signature then tells the truth.

5.1optional / variant / expected

TypeModelsReplaces
optional<T>a value or nothingsentinel values (-1, nullptr, empty)
variant<A,B,C>exactly one of a fixed settagged unions, type punning
expected<T,E> (C++23)a value or an errorerror-code out-params
anyany single type (type-erased)void* — rarely the right tool
cppvocabulary.cpp
#include <optional>
#include <variant>
#include <expected>   // C++23

std::optional<Config> parse(std::string_view s);   // may fail to produce a value
if (auto cfg = parse(text)) { use(*cfg); }

// variant + visitor: exhaustive, no dynamic_cast
using Msg = std::variant<Ndata, Ddata, Nbirth>;
std::visit([](auto&& m){ handle(m); }, msg);         // compile-checked dispatch

// expected: value-or-error without exceptions (great for embedded/no-except builds)
std::expected<Frame, std::error_code> recv();
auto r = recv();
if (!r) return std::unexpected(r.error());
process(*r);
◆ Pre-C++23

No std::expected yet? Use tl::expected (header-only, same API) or a small hand-rolled Result<T,E>. It's the cleanest error model for codebases that build with -fno-exceptions.

5.2string_view & span — non-owning views

std::string_view is a (ptr, len) over character data; std::span<T> is a (ptr, len) over any contiguous range. Take them as parameters to accept any backing storage — std::string, char[], std::vector, C arrays — with zero copies and zero allocations.

cppviews.cpp — one signature, every buffer
// accepts vector, array, C-array, part of a buffer — no copy:
std::uint16_t checksum(std::span<const std::byte> bytes);

std::array<std::byte, 64> buf;
checksum(buf);                         // whole
checksum(std::span{buf}.first(8));     // just the header

// string_view: no allocation to inspect text
bool starts_with_topic(std::string_view s) { return s.starts_with("spBv1.0/"); }
▲ Lifetime

Views borrow — they never own or extend lifetime. std::string_view sv = get_string(); where get_string() returns by value is an immediate dangle. Bind the owner first, or take ownership with std::string.

5.3Concepts — constrain your templates (C++20)

Concepts replace SFINAE and give readable compiler errors at the call site instead of 200 lines of template spew. Constrain template parameters so misuse fails fast and clearly.

cppconcepts.cpp
#include <concepts>

template <class T>
concept Serializable = requires(const T& t, std::vector<std::byte>& out) {
    { t.serialize(out) } -> std::same_as<void>;
    { T::type_id }       -> std::convertible_to<std::uint16_t>;
};

template <Serializable T>                 // clear constraint at declaration
void publish(const T& msg) { /* ... */ }

// constrain numeric algorithms too:
auto clamp_gain(std::floating_point auto x) { return std::min(x, 1.0); }

5.4Ranges & chrono

Ranges (C++20) let you compose lazy, allocation-free pipelines that read top-to-bottom instead of nesting iterator pairs. <chrono> gives you type-safe time — durations that won't let you add seconds to bytes.

cppranges_chrono.cpp
#include <ranges>
#include <chrono>
namespace views = std::views;
using namespace std::chrono_literals;

// lazy pipeline: only-online sensors, their ids, first 8 — no temp vectors
auto ids = sensors
         | views::filter([](auto& s){ return s.online; })
         | views::transform(&Sensor::id)
         | views::take(8);

// type-safe time: the compiler stops unit mistakes
std::chrono::milliseconds window = 500ms;
auto deadline = std::chrono::steady_clock::now() + window;
if (std::chrono::steady_clock::now() > deadline) timeout();
0x40 · SECTION

Serialization & JSON

Turning structured data into bytes and back — the everyday job at the edge of an IoT system, where MQTT payloads, config files, and HTTP bodies are usually JSON. Which library to reach for depends mostly on whether you're in a tight C/embedded context or full application C++.

6.1Choosing a library

LibraryLangProfile
cJSONCTiny, ubiquitous, C-linkable. Manual memory management. Ideal for MCUs/RTOS and mixed C/C++ codebases.
nlohmann/jsonC++11Header-only, extremely ergonomic — JSON feels native. Heavier compile/runtime; the default on the application side.
RapidJSONC++Very fast, low-allocation, SAX + DOM APIs. More manual; reach for it when throughput dominates.
GlazeC++23Compile-time reflection, struct↔JSON at near-zero overhead. Newest; excellent for perf-critical paths.

On the constrained aarch64/RTOS end of a fleet, cJSON keeps the footprint small and links cleanly against C. On the gateway or application side, nlohmann/json's ergonomics usually win. The two examples below cover both ends.

6.2cJSON — parse & build

cJSON's model is simple: parse a string into a tree of cJSON nodes, walk it with typed accessors, and you own the tree — every cJSON_Parse/cJSON_Create* is balanced by exactly one cJSON_Delete on the root. In C++ that pairing is precisely what a unique_ptr with a custom deleter expresses.

cppparse.cpp — an MQTT telemetry payload, RAII-wrapped
#include <cJSON.h>

// RAII wrapper: the tree is freed automatically, even on early return or throw.
struct CjsonDel { void operator()(cJSON* p) const noexcept { cJSON_Delete(p); } };
using CjsonPtr = std::unique_ptr<cJSON, CjsonDel>;

std::optional<Reading> parse_payload(std::string_view json) {
    CjsonPtr root{cJSON_ParseWithLength(json.data(), json.size())};
    if (!root) return std::nullopt;                       // malformed JSON

    const cJSON* reg = cJSON_GetObjectItemCaseSensitive(root.get(), "reg");
    const cJSON* val = cJSON_GetObjectItemCaseSensitive(root.get(), "value");
    if (!cJSON_IsNumber(reg) || !cJSON_IsNumber(val))     // ALWAYS type-check first
        return std::nullopt;

    return Reading{ .reg   = static_cast<std::uint16_t>(reg->valueint),
                    .value = val->valueint,
                    .valid = true };
}
cppbuild.cpp — construct & serialize
CjsonPtr doc{cJSON_CreateObject()};
cJSON_AddStringToObject(doc.get(), "topic", "spBv1.0/plant/DDATA/edge/chiller");
cJSON_AddNumberToObject(doc.get(), "reg",   40001);
cJSON_AddBoolToObject  (doc.get(), "ok",    cJSON_True);

char* out = cJSON_PrintUnformatted(doc.get());  // compact; cJSON_Print for pretty output
std::string payload = out;                      // copy into owned storage
cJSON_free(out);                                // free cJSON's buffer (NOT cJSON_Delete)
cJSON's two memory rules

Two things leak if you forget them: the parsed/created tree (free the root once with cJSON_Delete — the wrapper above does this for you), and the string returned by cJSON_Print* (free with cJSON_free). Items added via cJSON_Add*ToObject become owned by their parent and are freed with it — never delete them yourself.

6.3nlohmann/json — the ergonomic C++ path

When you're in C++ and not fighting for every kilobyte, nlohmann/json makes JSON feel like a native type and can serialize your own structs with a single macro. Parse without exceptions at untrusted boundaries so bad input can't crash the process.

cppparse defensively; map straight into a struct
#include <nlohmann/json.hpp>
using json = nlohmann::json;

json j = json::parse(payload, nullptr, /*allow_exceptions=*/false);
if (j.is_discarded()) return std::nullopt;        // parse failed, no throw
int reg = j.value("reg", 0);                       // typed get with a default
if (!j.contains("value")) return std::nullopt;

// struct <-> json with one macro:
struct Reading { std::uint16_t reg; std::int32_t value; bool valid; };
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(Reading, reg, value, valid)

Reading r = j.get<Reading>();                      // parse directly into your type
std::string s = json(r).dump();                    // ...and serialize back to text
Never trust a payload

Whichever library you use: parse without exceptions (or catch them), check every field's type and range before use, and bound the input size — a device on an open MQTT topic will eventually receive garbage or a hostile message. Validate at the boundary, then hand a typed struct to the rest of the program — the same discipline as config validation in Production readiness (§12).

0x48 · SECTION

Error handling

Choose one primary strategy per layer and apply it consistently. The failure of most codebases isn't the mechanism — it's mixing three of them incoherently and swallowing errors.

7.1Which mechanism, when

MechanismUse forCost / caveat
Exceptionstruly exceptional, unrecoverable-locally errors; ctors that can failzero cost on happy path; unbounded latency on throw — often banned in hard-real-time / MCU
expected<T,E> / error_codeexpected failures (I/O, parse, timeout, protocol NAK)explicit; forces handling; ideal with -fno-exceptions
optional<T>"not found" where the reason doesn't matterno error detail carried
assert / contractsprogrammer bugs & invariants that must never be falsecompiled out in release unless you keep them
Terminate / abortcorrupted state you cannot safely continue fromlast resort; log first if you can
◆ Rule of thumb

Expected errors → return them (expected/error_code). Broken invariants / bugs → assert or throw. A failed sensor read is expected; a null this is a bug. Don't use exceptions for ordinary control flow, and don't use error codes for "the universe is on fire."

7.2Exception safety guarantees

Every function offers one of these. Aim for strong or basic; document which. RAII is what makes basic-or-better essentially free.

  • No-throw — cannot fail. Mark noexcept (destructors, swap, move ops, deallocation).
  • Strong — commit-or-rollback: on failure, state is unchanged (the "copy-and-swap" idiom).
  • Basic — no leaks, invariants intact, but state may have changed.
  • None — avoid; a throw here may leak or corrupt.
cppexceptions.cpp — noexcept where it counts, error_code where expected
// mark true no-throw ops noexcept: enables optimizations & container moves
void swap(Buffer& a, Buffer& b) noexcept;
~Session() noexcept;                       // destructors must not throw — ever

// expected failure: return it, force the caller to look
std::error_code connect(std::string_view host, std::uint16_t port) noexcept;
if (auto ec = connect(h, p)) {
    log.warn("connect failed: {}", ec.message());
    return ec;                              // propagate, don't swallow
}

// custom error category for your domain codes
enum class ModbusError { Timeout = 1, CrcMismatch, IllegalFunction };
std::error_code make_error_code(ModbusError);   // + register a category
✕ Never

Throw from a destructor (calls std::terminate during stack unwinding). Write empty catch(...){} that swallows silently. Catch by value (slicing) — always catch (const std::exception&). Use exceptions to return normal results.

7.3Assertions & invariants

Encode preconditions and invariants explicitly. Keep a lightweight always-on check for safety-critical invariants even in release — a controlled abort beats silent corruption of a chiller-plant controller.

cppassert.cpp
#include <cassert>
void write_reg(std::uint16_t addr, std::uint16_t val) {
    assert(addr < kRegCount && "register address out of range"); // dev-time bug catch

    // always-on invariant for safety-critical paths (survives NDEBUG):
    if (!bus_.healthy()) [[unlikely]] { log_fatal("bus fault"); std::abort(); }
    // ...
}
// C++23: std::unreachable() for genuinely impossible branches (UB if reached)
0x58 · SECTION

Concurrency

A data race is undefined behaviour — not "a wrong value," but license for the compiler to do anything. The whole game is: share nothing, or share only through a synchronization primitive.

8.1Threads & the golden rule

Prefer std::jthread (C++20): it joins automatically on destruction and carries a cooperative stop_token, so you can't accidentally leak a thread or forget to signal shutdown. The golden rule: any variable touched by two threads where at least one writes must be protected — by a mutex or by being atomic.

cppjthread.cpp — auto-join + cooperative cancellation
#include <thread>
#include <stop_token>

std::jthread poller([](std::stop_token st){
    while (!st.stop_requested()) {
        poll_bus();
        std::this_thread::sleep_for(100ms);
    }
});
// ... on scope exit: poller.request_stop() is called AND it joins. No leak.

8.2Mutexes & locks

Never lock/unlock by hand — use RAII lock guards so an exception can't leave a mutex held. Lock multiple mutexes with std::scoped_lock (deadlock-free ordering). Use unique_lock only when you need to unlock early or hand it to a condition variable.

cpplocks.cpp
#include <mutex>
#include <condition_variable>
#include <queue>

template <class T>
class Mailbox {
public:
    void push(T v) {
        { std::scoped_lock lk(m_); q_.push(std::move(v)); }
        cv_.notify_one();
    }
    T pop() {                                   // blocking
        std::unique_lock lk(m_);
        cv_.wait(lk, [&]{ return !q_.empty(); });// predicate guards spurious wakeups
        T v = std::move(q_.front()); q_.pop();
        return v;
    }
private:
    std::mutex m_;
    std::condition_variable cv_;
    std::queue<T> q_;
};
▲ Deadlock discipline

Always acquire multiple locks in the same global order, or use std::scoped_lock(a, b) which does it for you. Keep critical sections tiny — never call user callbacks, I/O, or blocking work while holding a lock.

8.3Atomics & memory order

For single values shared across threads, std::atomic avoids a mutex. Default to seq_cst (the safe, intuitive ordering) and only weaken to acquire/release or relaxed once you can prove it — and have measured that it matters. Getting relaxed ordering wrong produces bugs that appear only on weakly-ordered hardware like aarch64, never on your x86 dev box.

cppatomics.cpp
#include <atomic>
std::atomic<bool> running{true};       // simple flag: default seq_cst is fine
running.store(false);                  // signal stop from another thread

std::atomic<std::uint64_t> frames{0};
frames.fetch_add(1, std::memory_order_relaxed);  // counter: relaxed is provably ok

// producer publishes data, then a ready flag with release;
// consumer reads flag with acquire -> sees the data. Textbook handoff:
data = build();
ready.store(true, std::memory_order_release);
// -- other thread --
if (ready.load(std::memory_order_acquire)) use(data);

8.4Higher-level & embedded notes

  • std::async/std::future for one-shot background results; but you usually want a real thread pool for steady work (see libraries below).
  • Prefer message-passing (queues, actors) over shared mutable state — it scales and it's testable. This is the model behind sharded actor designs on Tokio-style runtimes; the C++ equivalent is a pool of workers each owning its state, fed by lock-free or mutex-guarded queues.
  • Real-time / embedded: the standard threading API gives no priority or affinity control. Drop to pthread_setschedparam, pthread_setaffinity_np, and SCHED_FIFO for deterministic latency. Avoid heap allocation and unbounded blocking on the real-time path.
  • Libraries worth reaching for: a bounded MPMC queue (e.g. moodycamel), a lightweight thread pool, or an executor. Don't hand-roll lock-free structures unless you have a very good reason and a stress test.
◆ Test it under a race detector

Build your test suite with ThreadSanitizer (-fsanitize=thread). It catches data races that are invisible in normal runs and only bite in the field, weeks later, on the customer's dual-core ARM box. See 0xC8.

0x70 · SECTION

Design patterns

Patterns are vocabulary, not goals. In modern C++ many classic patterns collapse into a language feature (a lambda, a variant, a template). Learn the intent; reach for the lightest tool that expresses it.

GroupPatternModern C++ realization
CreationalFactory / Builder / Singletonfree function returning unique_ptr; fluent builder; Meyers singleton (sparingly)
StructuralPIMPL / Adapter / Decorator / Facade / Proxyunique_ptr Impl; wrapper class; composition; type erasure
BehavioralStrategy / Observer / Command / State / Visitorstd::function; signal/callback list; lambda/functor; variant+visit
C++ idiomsRAII / CRTP / Type erasure / Policythe ones you'll actually use daily — below

9.1Creational — factory & builder

Prefer a free function returning unique_ptr to a heavyweight factory class. It hides the concrete type, returns an owning handle, and validates before construction.

cppfactory_builder.cpp
// Factory: return the interface, hide the implementation
std::unique_ptr<Transport> make_transport(const Uri& uri) {
    if (uri.scheme == "tcp")    return std::make_unique<TcpTransport>(uri);
    if (uri.scheme == "serial") return std::make_unique<SerialTransport>(uri);
    throw std::invalid_argument("unknown scheme: " + uri.scheme);
}

// Builder: readable construction of a many-optioned object
class BrokerConfig {
public:
    BrokerConfig& host(std::string h)      && { host_ = std::move(h); return *this; }
    BrokerConfig& port(std::uint16_t p)    && { port_ = p;            return *this; }
    BrokerConfig& keepalive(std::chrono::seconds s) && { ka_ = s;    return *this; }
    Broker build() && { /* validate + construct */ return Broker{std::move(*this)}; }
private:
    std::string host_{"localhost"}; std::uint16_t port_{1883};
    std::chrono::seconds ka_{60};
};
auto b = BrokerConfig{}.host("emqx.local").port(8883).keepalive(30s).build();
✕ On Singleton

Singletons are global mutable state in a costume: they wreck testability, hide dependencies, and cause static-init-order fiasco. If you truly need one process-wide object, use a Meyers singleton (static T& instance(){ static T t; return t; } — thread-safe since C++11) and inject it as a parameter everywhere else. Better: just pass the dependency in.

9.2PIMPL — compilation firewall

The pointer-to-implementation idiom hides private members behind an opaque pointer. It breaks compile-time coupling (change internals without recompiling clients) and gives you a stable ABI for shared libraries — essential when you ship a .so to the field and want to patch it without rebuilding every consumer.

cppsession.hpp / session.cpp — ABI-stable public header
// ---- session.hpp : no implementation details leak, no heavy includes ----
#pragma once
#include <memory>
#include <string_view>
class Session {
public:
    Session();
    ~Session();                              // defined in .cpp (Impl is complete there)
    Session(Session&&) noexcept;
    Session& operator=(Session&&) noexcept;
    void send(std::string_view topic, std::span<const std::byte> payload);
private:
    struct Impl;                             // opaque
    std::unique_ptr<Impl> p_;
};

// ---- session.cpp : all the real dependencies live here ----
struct Session::Impl { /* sockets, buffers, tls ctx ... */ };
Session::Session() : p_(std::make_unique<Impl>()) {}
Session::~Session() = default;               // MUST be here, not in header
Session::Session(Session&&) noexcept = default;
Session& Session::operator=(Session&&) noexcept = default;

9.3Strategy & Observer — behaviour as data

Runtime-swappable behaviour is just a std::function (Strategy). Fan-out notification is a list of callbacks (Observer) — no inheritance hierarchy required.

cppstrategy_observer.cpp
// Strategy: inject the algorithm, no subclassing
class Retrier {
public:
    using Backoff = std::function<std::chrono::milliseconds(int attempt)>;
    explicit Retrier(Backoff b) : backoff_(std::move(b)) {}
private:
    Backoff backoff_;
};
Retrier exp{ [](int n){ return std::chrono::milliseconds(100 << n); } };

// Observer: subscribers are callbacks; RAII token auto-unsubscribes
class Signal {
public:
    using Slot = std::function<void(const Event&)>;
    [[nodiscard]] std::size_t connect(Slot s) {
        auto id = next_++; slots_.emplace(id, std::move(s)); return id;
    }
    void disconnect(std::size_t id) { slots_.erase(id); }
    void emit(const Event& e) const { for (auto& [_, s] : slots_) s(e); }
private:
    std::unordered_map<std::size_t, Slot> slots_; std::size_t next_{0};
};

9.4Visitor — via std::variant

The classic double-dispatch Visitor becomes a closed variant plus std::visit. It's exhaustive (the compiler errors if you miss a case with the overload set), needs no virtual functions, and keeps message types as plain values — a great fit for protocol message handling.

cppvisitor.cpp — the overload idiom
template <class... Ts> struct overload : Ts... { using Ts::operator()...; };
template <class... Ts> overload(Ts...) -> overload<Ts...>;   // CTAD guide

using SpMessage = std::variant<NBirth, NData, NDeath, DBirth, DData>;

void dispatch(const SpMessage& m) {
    std::visit(overload{
        [](const NBirth& b){ register_node(b); },
        [](const NData&  d){ apply_metrics(d); },
        [](const NDeath& ){ mark_offline();   },
        [](const auto&   ){ /* DBirth/DData default */ },
    }, m);
}

9.5CRTP — static polymorphism

The Curiously Recurring Template Pattern gives you interface reuse and virtual-like dispatch with zero runtime cost — the call is resolved at compile time, no vtable, no indirection. Ideal on hot paths and constrained targets where you want polymorphic design without the virtual-call overhead.

cppcrtp.cpp
template <class Derived>
class Codec {                                 // reusable behaviour, no vtable
public:
    std::vector<std::byte> encode(const Frame& f) const {
        return self().encode_impl(f);         // dispatched at compile time
    }
private:
    const Derived& self() const { return static_cast<const Derived&>(*this); }
};

class SparkplugCodec : public Codec<SparkplugCodec> {
    friend class Codec<SparkplugCodec>;
    std::vector<std::byte> encode_impl(const Frame&) const { /* ... */ return {}; }
};
◆ Static vs dynamic

Use virtual functions when the set of types is open/plugin-like and dispatch happens across an ABI boundary. Use CRTP when the types are known at compile time and you want inlining. Use C++20 concepts + templates when you just need "any type that does X" with no base class at all.

9.6Type erasure — value semantics for polymorphism

Type erasure (how std::function and std::any work) lets you store any type satisfying an interface as a regular copyable value — no base class, no raw pointers, no manual lifetime. It's the modern answer to "I want a heterogeneous container of things that all draw()."

cpptype_erasure.cpp — hold any Sensor-like value
class AnySensor {
    struct Concept {                          // internal interface
        virtual ~Concept() = default;
        virtual double read() const = 0;
        virtual std::unique_ptr<Concept> clone() const = 0;
    };
    template <class T>
    struct Model : Concept {                   // adapts any concrete T
        T obj;
        explicit Model(T o) : obj(std::move(o)) {}
        double read() const override { return obj.read(); }
        std::unique_ptr<Concept> clone() const override {
            return std::make_unique<Model>(*this);
        }
    };
    std::unique_ptr<Concept> self_;
public:
    template <class T>                          // T needs .read(); no inheritance!
    AnySensor(T x) : self_(std::make_unique<Model<T>>(std::move(x))) {}
    AnySensor(const AnySensor& o) : self_(o.self_->clone()) {}
    double read() const { return self_->read(); }
};

std::vector<AnySensor> sensors;               // heterogeneous, value-semantic, copyable
sensors.emplace_back(ModbusTemp{});
sensors.emplace_back(OpcUaFlow{});           // unrelated types, same container

9.7RAII scope guard — cleanup as a value

When there's no natural wrapper type, a scope guard runs cleanup on scope exit — the ad-hoc RAII you reach for around C APIs and transactions.

cppscope_guard.cpp
template <class F>
struct ScopeGuard {
    F f; bool active = true;
    ~ScopeGuard() { if (active) f(); }
    void dismiss() noexcept { active = false; }
};
template <class F> ScopeGuard(F) -> ScopeGuard<F>;

void txn(Db& db) {
    db.begin();
    ScopeGuard rollback{[&]{ db.rollback(); }};   // runs if we throw / early-return
    do_work(db);
    db.commit();
    rollback.dismiss();                            // success: skip rollback
}
// (Prefer std::experimental::scope_exit / a vetted library if available.)
0x90 · SECTION

CMake build system

"Modern CMake" means one thing: everything is a target, and you describe targets, not the toolchain. You never touch CMAKE_CXX_FLAGS globally or scatter include_directories(). You set properties on targets and let requirements propagate.

10.1The target model — PUBLIC / PRIVATE / INTERFACE

Every dependency you attach to a target has a propagation scope. This is the single most important concept in CMake:

KeywordApplies to the targetPropagates to consumers
PRIVATEyesno — implementation detail
PUBLICyesyes — part of your API
INTERFACEnoyes — header-only / consumer-only

Example: if your header #includes a dependency, it's PUBLIC; if only your .cpp uses it, it's PRIVATE. Getting this right means consumers automatically get exactly the include dirs, defines, and flags they need — and none they don't.

10.2Project layout

textcanonical layout
myproj/
├─ CMakeLists.txt            # top-level: project(), options, add_subdirectory
├─ CMakePresets.json         # named configure/build/test presets
├─ cmake/
│  └─ toolchains/aarch64-linux-gnu.cmake
├─ include/myproj/           # PUBLIC headers (installed)
│  └─ client.hpp
├─ src/                      # implementation + private headers
│  ├─ CMakeLists.txt
│  └─ client.cpp
├─ tests/
│  ├─ CMakeLists.txt
│  └─ client_test.cpp
├─ apps/                     # executables
└─ third_party/ (or use FetchContent)

10.3A real, modern CMakeLists

cmakeCMakeLists.txt — top level
cmake_minimum_required(VERSION 3.21)          # presets v3, good defaults
project(myproj VERSION 1.4.0 LANGUAGES CXX)

# Guard: no in-source builds
if(PROJECT_SOURCE_DIR STREQUAL PROJECT_BINARY_DIR)
  message(FATAL_ERROR "In-source builds are banned. Use a build/ dir.")
endif()

# Defaults only when we are the top project (don't dictate to parents)
if(PROJECT_IS_TOP_LEVEL)
  set(CMAKE_CXX_STANDARD 20)
  set(CMAKE_CXX_STANDARD_REQUIRED ON)
  set(CMAKE_CXX_EXTENSIONS OFF)
  set(CMAKE_EXPORT_COMPILE_COMMANDS ON)       # for clang-tidy / IDEs / LSP
  set_property(GLOBAL PROPERTY USE_FOLDERS ON)
endif()

option(MYPROJ_BUILD_TESTS "Build tests" ${PROJECT_IS_TOP_LEVEL})
option(MYPROJ_WARNINGS_AS_ERRORS "Treat warnings as errors" OFF)

add_subdirectory(src)
add_subdirectory(apps)

if(MYPROJ_BUILD_TESTS)
  enable_testing()
  add_subdirectory(tests)
endif()
cmakesrc/CMakeLists.txt — the library target
add_library(myproj client.cpp)
add_library(myproj::myproj ALIAS myproj)       # namespaced alias for consumers

target_include_directories(myproj
  PUBLIC
    $<BUILD_INTERFACE:${PROJECT_SOURCE_DIR}/include>
    $<INSTALL_INTERFACE:include>                # correct path after install
  PRIVATE
    ${CMAKE_CURRENT_SOURCE_DIR})               # private headers

target_compile_features(myproj PUBLIC cxx_std_20)

# Warnings: attach per-target, generator-expression per compiler
target_compile_options(myproj PRIVATE
  $<$<CXX_COMPILER_ID:GNU,Clang>:-Wall -Wextra -Wpedantic -Wshadow -Wconversion>
  $<$<BOOL:${MYPROJ_WARNINGS_AS_ERRORS}>:-Werror>
  $<$<CXX_COMPILER_ID:MSVC>:/W4>)

target_link_libraries(myproj PUBLIC fmt::fmt PRIVATE Threads::Threads)

10.4Dependencies — three good options

ApproachBest forHow
find_packagesystem / SDK-provided libs (Yocto, distro)relies on installed Config.cmake
FetchContentpinned source deps, reproducible, no infradownloads & builds at configure time
vcpkg / Conanlarge dep trees, binary caching, teamsmanifest + toolchain integration
cmakedeps.cmake — FetchContent with a pinned tag
include(FetchContent)
FetchContent_Declare(fmt
  GIT_REPOSITORY https://github.com/fmtlib/fmt.git
  GIT_TAG        10.2.1                        # pin! never a moving branch
  GIT_SHALLOW    TRUE)
FetchContent_MakeAvailable(fmt)

# System/SDK dependency (found via toolchain sysroot when cross-compiling):
find_package(Threads REQUIRED)
find_package(OpenSSL 3 REQUIRED)               # target: OpenSSL::SSL

10.5Presets — kill the flag soup

CMakePresets.json replaces the pile of shell scripts everyone wraps CMake in. Configure, build, and test presets live in version control, so cmake --preset debug means the same thing on every machine and in CI.

jsonCMakePresets.json
{
  "version": 6,
  "configurePresets": [
    {
      "name": "base", "hidden": true, "generator": "Ninja",
      "binaryDir": "${sourceDir}/build/${presetName}",
      "cacheVariables": { "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" }
    },
    {
      "name": "debug", "inherits": "base",
      "cacheVariables": {
        "CMAKE_BUILD_TYPE": "Debug",
        "CMAKE_CXX_FLAGS": "-fsanitize=address,undefined -fno-omit-frame-pointer"
      }
    },
    {
      "name": "release", "inherits": "base",
      "cacheVariables": { "CMAKE_BUILD_TYPE": "RelWithDebInfo",
                          "MYPROJ_WARNINGS_AS_ERRORS": "ON" }
    },
    {
      "name": "aarch64", "inherits": "release",
      "toolchainFile": "${sourceDir}/cmake/toolchains/aarch64-linux-gnu.cmake"
    }
  ],
  "buildPresets": [
    { "name": "debug",   "configurePreset": "debug" },
    { "name": "release", "configurePreset": "release" },
    { "name": "aarch64", "configurePreset": "aarch64" }
  ],
  "testPresets": [
    { "name": "debug", "configurePreset": "debug",
      "output": { "outputOnFailure": true } }
  ]
}
configure cmake --preset debug build cmake --build --preset debug test ctest --preset debug cross cmake --preset aarch64 && cmake --build --preset aarch64

10.6Install & export (be a good library)

If anyone else consumes your library, ship an install + export set so they can just find_package(myproj) and link myproj::myproj. This is also how your cross-compiled artifacts land in a sysroot cleanly.

cmakeinstall.cmake
include(GNUInstallDirs)
include(CMakePackageConfigHelpers)

install(TARGETS myproj EXPORT myprojTargets
        ARCHIVE  DESTINATION ${CMAKE_INSTALL_LIBDIR}
        LIBRARY  DESTINATION ${CMAKE_INSTALL_LIBDIR}
        RUNTIME  DESTINATION ${CMAKE_INSTALL_BINDIR})
install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})

install(EXPORT myprojTargets
        NAMESPACE myproj:: FILE myprojTargets.cmake
        DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/myproj)

write_basic_package_version_file(myprojConfigVersion.cmake
        VERSION ${PROJECT_VERSION} COMPATIBILITY SameMajorVersion)
# + configure_package_config_file(...) -> myprojConfig.cmake, then install it.
◆ Build-type note

Single-config generators (Ninja/Make) need CMAKE_BUILD_TYPE set — an empty build type means no optimization and no debug info. Prefer RelWithDebInfo for anything you'll profile or debug in the field; it optimizes and keeps symbols.

0xB0 · SECTION

Cross compilation

Building for a target that isn't your host. The whole thing hinges on two artifacts: a toolchain file (which compiler, which architecture) and a sysroot (the target's headers and libraries). Get those right and CMake handles the rest.

11.1Anatomy of a toolchain file

A toolchain file is read before your project and tells CMake it's cross-compiling. The FIND_ROOT_PATH modes are the part people get wrong: you want to find programs on the host but libraries and headers only in the target sysroot — otherwise you'll link the host's libssl into an ARM binary.

cmakecmake/toolchains/aarch64-linux-gnu.cmake
# --- What we're building for ---
set(CMAKE_SYSTEM_NAME       Linux)
set(CMAKE_SYSTEM_PROCESSOR  aarch64)

# --- The cross toolchain (adjust prefix to your GCC/Clang) ---
set(cross  aarch64-linux-gnu)
set(CMAKE_C_COMPILER   ${cross}-gcc)
set(CMAKE_CXX_COMPILER ${cross}-g++)
set(CMAKE_AR           ${cross}-ar)
set(CMAKE_STRIP        ${cross}-strip)

# --- The target sysroot: headers + libs for the device ---
set(CMAKE_SYSROOT      /opt/sysroots/aarch64)
set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})

# --- Search rules: programs on host, everything else in sysroot ---
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)   # use host's cmake/pkg-config
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)    # link target libs only
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)    # target headers only
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)

# --- Optional: target-specific tuning ---
set(CMAKE_C_FLAGS_INIT   "-mcpu=cortex-a72")
set(CMAKE_CXX_FLAGS_INIT "-mcpu=cortex-a72")

Invoke it via a preset (10.5) or directly:

bashconfigure & build for aarch64
cmake -B build/arm -G Ninja \
      -DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/aarch64-linux-gnu.cmake \
      -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build/arm -j

file build/arm/apps/myapp   # -> ELF 64-bit LSB ... ARM aarch64  (verify!)

11.2The four things that break cross builds

SymptomCauseFix
Links host libs into ARM binaryFIND_ROOT_PATH_MODE_LIBRARY not ONLYset the four MODE vars above
Runs host tool that can't execute target binarybuild tries to run what it builtguard with QEMU or precompute at configure
try_run / feature tests failcan't execute target code on hostset the cache result vars, or use QEMU binfmt
Wrong ABI (hard vs soft float, wrong SIMD)flag mismatch vs the devicematch -mcpu/-mfpu to the SoC exactly
▲ rustls-style lesson applies here too

The single most common cross-build headache is a dependency that shells out to the host's OpenSSL or expects host headers. Prefer dependencies that either come from the sysroot or vendor their own crypto. When a lib insists on the system OpenSSL, point find_package(OpenSSL) at the sysroot copy via CMAKE_FIND_ROOT_PATH — don't let it fall back to the host.

11.3Yocto SDK — let the SDK write the toolchain

If your target is a Yocto image, generate an SDK (bitbake <image> -c populate_sdk) and use its environment. It already contains a matched sysroot, cross toolchain, and an OEToolchainConfig.cmake — you don't hand-write the toolchain file at all.

bashyocto SDK workflow
# 1. Install the SDK produced by populate_sdk
./poky-glibc-x86_64-...-aarch64-toolchain-4.0.sh -d /opt/sdk

# 2. Source its environment: sets CC, CXX, CROSS_COMPILE, OECORE_* , and
#    exports the correct sysroot + CMAKE_TOOLCHAIN_FILE for you
. /opt/sdk/environment-setup-cortexa72-poky-linux

# 3. Now CMake picks up the OE toolchain automatically
cmake -B build/yocto -G Ninja -DCMAKE_BUILD_TYPE=Release \
      -DCMAKE_TOOLCHAIN_FILE="$OECORE_NATIVE_SYSROOT/usr/share/cmake/OEToolchainConfig.cmake"
cmake --build build/yocto -j

For a package that ships inside the image, write a Yocto recipe using the cmake class — bitbake then drives the same install/export you set up in 10.6:

bashmyproj_1.4.0.bb — recipe sketch
SUMMARY = "myproj IoT edge client"
LICENSE = "MIT"
LIC_FILES_CHKSUM = "file://LICENSE;md5=..."
SRC_URI = "git://example.com/myproj.git;branch=main;protocol=https"
SRCREV = "${AUTOREV}"
S = "${WORKDIR}/git"
inherit cmake
EXTRA_OECMAKE = "-DMYPROJ_BUILD_TESTS=OFF"

11.4Reproducible builds in Docker

Pin the toolchain in a container so every developer and CI runner cross-compiles from bit-identical inputs — no "works on my machine," no host contamination.

dockerfileDockerfile.cross — aarch64 build environment
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
      cmake ninja-build git ca-certificates pkg-config \
      g++-aarch64-linux-gnu \
    && rm -rf /var/lib/apt/lists/*
# (mount or COPY your target sysroot to /opt/sysroots/aarch64)
WORKDIR /src
ENTRYPOINT ["cmake", "--preset", "aarch64"]
bashbuild via container
docker build -f Dockerfile.cross -t myproj-cross .
docker run --rm -v "$PWD:/src" -v /opt/sysroots:/opt/sysroots myproj-cross
docker run --rm -v "$PWD:/src" myproj-cross cmake --build --preset aarch64

11.5Testing what you cross-built

You can't run an aarch64 binary on x86 directly, but you have options — register QEMU user-mode with binfmt and CTest runs your target tests transparently:

bashrun target tests under QEMU
# one-time: enable binfmt_misc handlers for foreign binaries
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes

# now target ELF "just runs":
qemu-aarch64 -L /opt/sysroots/aarch64 build/arm/tests/client_test

# or tell CMake to wrap all test invocations:
#   set(CMAKE_CROSSCOMPILING_EMULATOR qemu-aarch64;-L;/opt/sysroots/aarch64)
# then: ctest --preset ...   runs on-host via emulation
◆ Emulation ≠ hardware

QEMU catches most logic bugs cheaply, but it will not reproduce real timing, weak-memory-ordering races (8.3), driver quirks, or float edge cases. Keep a smoke-test stage that deploys to a real device (or a board farm) before release.

0xC8 · SECTION

Production readiness

The gap between "it works" and "it ships": aggressive warnings, sanitizer-verified tests, static analysis, real logging, and CI that runs all of it on every target. This is where a controller earns the right to run unattended in a plant room.

12.1Compiler flags — the working set

Two postures. Warnings catch bugs at compile time (dev + CI). Hardening makes the shipped binary resist exploitation and fail loudly instead of silently corrupting.

bashwarnings — turn these on everywhere (GCC/Clang)
-Wall -Wextra -Wpedantic        # baseline; non-negotiable
-Wshadow                        # variable shadowing (subtle bugs)
-Wconversion -Wsign-conversion  # implicit narrowing / signedness
-Wcast-qual -Wold-style-cast    # dangerous / C-style casts
-Wnon-virtual-dtor              # base class w/ virtuals but non-virtual dtor
-Wdouble-promotion              # float silently promoted (matters on MCUs)
-Wformat=2 -Wnull-dereference -Wimplicit-fallthrough
-Werror                         # in CI: make warnings fatal
bashhardening — for release binaries
-D_FORTIFY_SOURCE=2             # runtime buffer-overflow checks (needs -O1+)
-fstack-protector-strong       # stack canaries
-fstack-clash-protection
-fPIE -pie                     # ASLR for executables
-Wl,-z,relro,-z,now            # full RELRO: GOT read-only after load
-Wl,-z,noexecstack             # non-executable stack
-fcf-protection=full           # CET/branch-protection (x86); use
                               # -mbranch-protection=standard on aarch64

12.2Sanitizers — your best bug finders

Build your test binaries with sanitizers and run the suite under them in CI. They find real, latent UB that no amount of review will. They're mutually exclusive in some combos and add overhead, so run them as separate CI jobs, not in the shipped binary.

SanitizerFlagCatches
Address-fsanitize=addressheap/stack overflow, use-after-free, leaks (+LSan)
Undefined-fsanitize=undefinedsigned overflow, bad shifts, null deref, misalignment
Thread-fsanitize=threaddata races, lock-order issues (8.x)
Memory-fsanitize=memoryreads of uninitialized memory (Clang only)
bashtypical: ASan + UBSan together for the test suite
cmake -B build/asan -G Ninja -DCMAKE_BUILD_TYPE=Debug \
  -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -g"
cmake --build build/asan && ctest --test-dir build/asan --output-on-failure
# ASAN_OPTIONS=detect_leaks=1:halt_on_error=1  UBSAN_OPTIONS=print_stacktrace=1

12.3Static analysis & formatting

Wire clang-tidy into the build via CMAKE_EXPORT_COMPILE_COMMANDS. Commit the config files so the whole team — and CI — applies identical rules.

.clang-format
BasedOnStyle: Google
ColumnLimit: 100
IndentWidth: 4
PointerAlignment: Left
AllowShortFunctionsOnASingleLine: Empty
SortIncludes: CaseSensitive
.clang-tidy
Checks: >
  bugprone-*, cppcoreguidelines-*,
  performance-*, modernize-*,
  readability-*, -modernize-use-trailing-return-type
WarningsAsErrors: 'bugprone-*'
HeaderFilterRegex: 'include/myproj/.*'
bashrun analysis over the compile DB
run-clang-tidy -p build/debug            # uses compile_commands.json
cppcheck --enable=warning,performance,portability --std=c++20 \
         --project=build/debug/compile_commands.json
# In CMake, lint during build:
#   set(CMAKE_CXX_CLANG_TIDY clang-tidy;--warnings-as-errors=bugprone-*)

12.4Testing — CTest + a framework

Use GoogleTest or Catch2; register with CTest so ctest and presets drive everything. Aim to test behaviour and edge cases, not implementation details.

cmaketests/CMakeLists.txt — GoogleTest via FetchContent + auto-discovery
include(FetchContent)
FetchContent_Declare(googletest
  GIT_REPOSITORY https://github.com/google/googletest.git
  GIT_TAG v1.15.2)
FetchContent_MakeAvailable(googletest)

add_executable(client_test client_test.cpp)
target_link_libraries(client_test PRIVATE myproj::myproj GTest::gtest_main)

include(GoogleTest)
gtest_discover_tests(client_test)          # each TEST() becomes a ctest case
cppclient_test.cpp
#include <gtest/gtest.h>
#include "myproj/client.hpp"

TEST(Crc16, KnownVector) {
    std::array<std::byte, 2> d{std::byte{0x01}, std::byte{0x04}};
    EXPECT_EQ(mbus::crc16(d), 0x0A61);
}
TEST(Client, RejectsBadScheme) {
    EXPECT_THROW(make_transport(Uri{"gopher", "x"}), std::invalid_argument);
}

12.5CI matrix

Run the full gate on every push, across compilers and — critically — your target triples. A green x86 build tells you nothing about the aarch64 device.

yaml.github/workflows/ci.yml
name: ci
on: [push, pull_request]
jobs:
  build:
    strategy:
      matrix:
        preset: [debug, release]        # debug carries ASan+UBSan
        cxx:    [g++, clang++]
    runs-on: ubuntu-latest
    env: { CXX: "${{ matrix.cxx }}" }
    steps:
      - uses: actions/checkout@v4
      - run: sudo apt-get update && sudo apt-get install -y ninja-build clang-tidy
      - run: cmake --preset ${{ matrix.preset }} -DMYPROJ_WARNINGS_AS_ERRORS=ON
      - run: cmake --build --preset ${{ matrix.preset }}
      - run: ctest --preset ${{ matrix.preset }} --output-on-failure
  cross-aarch64:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: sudo apt-get update && sudo apt-get install -y g++-aarch64-linux-gnu ninja-build qemu-user-static
      - run: cmake --preset aarch64
      - run: cmake --build --preset aarch64

12.6Logging, config & versioning

  • Logging: use a real library — spdlog (fast, async, rotating sinks) or the C++23 std::print/<format>. Log with levels and structured fields; never std::cout << your way through a field deployment. Make the level runtime-configurable.
  • Config: keep it out of code. TOML/YAML/env, validated once at startup into a typed struct (so the rest of the program sees only valid values). Fail fast and loudly on bad config.
  • Versioning: stamp git describe + build type into the binary via a generated header (configure_file), and print it on --version. When a device misbehaves in the field, the first question is always "which build is on it?"
  • Observability: expose health/metrics (a counter of frames, reconnects, errors) — the same discipline as your MQTT/Sparkplug pipelines, applied to the process itself.
cpplogging.cpp — spdlog, levelled & structured
#include <spdlog/spdlog.h>
spdlog::set_level(spdlog::level::from_str(cfg.log_level));   // runtime-controlled
spdlog::info("connected broker={} port={} tls={}", host, port, use_tls);
spdlog::warn("reconnect attempt={} backoff_ms={}", n, backoff.count());
if (ec) spdlog::error("publish failed topic={} err={}", topic, ec.message());
✓ Ship checklist lives in 0xF0

Everything in this section rolls up into the pre-release checklist in the Appendix. If a box isn't ticked, it isn't ready.

0xE0 · SECTION

Documentation generation

Documentation that lives next to the code, is generated on every build, and fails CI when it drifts. The goal is a browsable API reference plus prose for the parts that a signature can't express — ownership, threading, units, and lifetime. If the doc build emits warnings, treat them like compiler warnings: fix them.

13.1Document the contract, not the obvious

A good comment explains what the type system can't: who owns the pointer, which thread may call this, what units the int is in, what invariants hold. Restating the signature in English is noise. Write for the caller who has your header but not your source.

✗ Restates the signature
/// Sets the timeout.
/// @param ms the timeout
void set_timeout(int ms);
✓ States units, range, effect
/// Timeout for a single poll cycle, in **milliseconds**.
/// Clamped to [10, 60000]. 0 disables polling. Not thread-safe:
/// call only from the owning reactor thread.
void set_timeout(int ms);

13.2Doxygen comment style

Doxygen is the de-facto standard for C++ API docs. Use the /** … */ or /// forms with @-commands. Document every public entity; keep private helpers commented for maintainers but out of the generated API surface. The commands below cover ~95% of real usage.

cppmodbus_client.hpp — documented public interface
#include <cstdint>
#include <expected>
#include <span>

namespace fieldbus {

/// @brief Synchronous Modbus/TCP client for a single device.
///
/// One instance owns one TCP connection and is **not** thread-safe;
/// serialize access or give each thread its own client. Register
/// addresses are zero-based; the wire protocol's 1-based offset is
/// applied internally.
///
/// @note Reconnection is the caller's responsibility — a dropped
///       socket surfaces as @ref Error::transport on the next call.
/// @see  https://modbus.org/specs.php
class ModbusClient {
public:
  /// Error categories returned by every operation.
  enum class Error { timeout, crc, exception_response, transport };

  /// @brief Read a block of holding registers (function code 0x03).
  ///
  /// @param unit   Modbus unit/slave id (1–247).
  /// @param addr   Zero-based starting register address.
  /// @param count  Number of 16-bit registers to read (1–125).
  /// @return The register values on success, or an Error on failure.
  ///
  /// @pre  @p count is in [1, 125]; larger reads must be split.
  /// @post On success, the returned span has exactly @p count elements.
  ///
  /// @code
  ///   auto r = client.read_holding(unit: 1, addr: 0, count: 8);
  ///   if (r) use(*r);
  ///   else   log_error(r.error());
  /// @endcode
  [[nodiscard]] std::expected<std::vector<std::uint16_t>, Error>
  read_holding(std::uint8_t unit, std::uint16_t addr, std::uint16_t count);

  /// @brief Write a single holding register (function code 0x06).
  /// @param unit  Unit id (1–247).  @param addr  Zero-based address.
  /// @param value 16-bit value to write.
  /// @retval Error::exception_response  Device rejected the write.
  std::expected<void, Error>
  write_register(std::uint8_t unit, std::uint16_t addr, std::uint16_t value);
};

/// @brief CRC-16 (Modbus polynomial 0xA001) over a byte range.
/// @tparam Bytes  A contiguous range of @c std::byte.
/// @param  data   The bytes to checksum (header + payload, no CRC).
/// @return The 16-bit CRC in little-endian wire order.
template <class Bytes>
[[nodiscard]] std::uint16_t crc16(std::span<const std::byte> data) noexcept;

} // namespace fieldbus
CommandUse it for
@briefOne-line summary (shows in the member list). Keep it to a sentence.
@param / @param[in,out]Each parameter. Direction hint documents whether it's mutated.
@return / @retvalThe return value; @retval ties a specific value to a meaning.
@tparamTemplate parameters and the concepts/constraints they must satisfy.
@pre / @postPreconditions the caller guarantees; postconditions you guarantee.
@throws / @exceptionWhat is thrown and when. Omit for noexcept functions.
@note / @warningThreading, lifetime, and footguns the signature can't show.
@code … @endcodeA compilable usage example. The single most valuable thing in any doc.
@ref / @seeCross-links to related entities or external specs.

13.3Doxyfile — the settings that matter

Generate a template with doxygen -g, then set these. Everything else can stay default. The important habits: turn warnings into a gate, point INPUT at your public headers, and (if you want call graphs) enable Graphviz dot.

iniDoxyfile — essential overrides
PROJECT_NAME           = "fieldbus"
PROJECT_NUMBER         = $(GIT_DESCRIBE)     # inject version from CMake
OUTPUT_DIRECTORY       = build/doc
INPUT                  = include README.md
FILE_PATTERNS          = *.hpp *.h *.md
RECURSIVE              = YES
USE_MDFILE_AS_MAINPAGE = README.md           # landing page from your README

EXTRACT_ALL            = NO                  # only document commented entities
EXTRACT_PRIVATE        = NO                  # keep internals out of the API ref
EXTRACT_STATIC         = YES
JAVADOC_AUTOBRIEF      = YES                 # first sentence = @brief

GENERATE_HTML          = YES
GENERATE_LATEX         = NO
GENERATE_XML           = YES                 # required if you feed Sphinx/Breathe
GENERATE_TREEVIEW      = YES                 # sidebar navigation

HAVE_DOT               = YES                 # needs Graphviz installed
CALL_GRAPH             = YES
CLASS_GRAPH            = YES

WARNINGS               = YES
WARN_IF_UNDOCUMENTED   = YES
WARN_IF_DOC_ERROR      = YES
WARN_AS_ERROR          = FAIL_ON_WARNINGS    # doc drift breaks the build

13.4Wire it into CMake

Modern CMake ships a FindDoxygen module with doxygen_add_docs() — it writes the Doxyfile for you from DOXYGEN_* variables, so there's no stale config file to maintain by hand. Make the target part of the build (or a CI job) so docs are never an afterthought.

cmakedocs/CMakeLists.txt
find_package(Doxygen REQUIRED dot)          # 'dot' component = Graphviz graphs

# Each var maps to a Doxyfile setting: DOXYGEN_<NAME> -> <NAME>.
set(DOXYGEN_EXTRACT_PRIVATE      NO)
set(DOXYGEN_GENERATE_TREEVIEW    YES)
set(DOXYGEN_GENERATE_XML         YES)       # for a later Sphinx/Breathe pass
set(DOXYGEN_WARN_AS_ERROR        FAIL_ON_WARNINGS)
set(DOXYGEN_USE_MDFILE_AS_MAINPAGE "${PROJECT_SOURCE_DIR}/README.md")
set(DOXYGEN_PROJECT_NUMBER       "${PROJECT_VERSION}")

doxygen_add_docs(docs                       # target name: `cmake --build . -t docs`
  "${PROJECT_SOURCE_DIR}/include"
  "${PROJECT_SOURCE_DIR}/README.md"
  ALL                                       # build docs as part of the default target
  COMMENT "Generating API documentation with Doxygen")
Gate it in CI

Run cmake --build build --target docs as its own CI step. With WARN_AS_ERROR = FAIL_ON_WARNINGS, an undocumented new public method or a @param that no longer matches the signature fails the pipeline — the same discipline you apply to -Werror.

13.5Richer docs: Sphinx + Breathe

Doxygen's HTML is fine for a pure API reference. When you want narrative documentation — tutorials, architecture pages, protocol write-ups — alongside the API, the common pipeline is Doxygen → XML → Breathe → Sphinx. Doxygen extracts the API into XML; Breathe is the bridge that pulls that XML into Sphinx; Sphinx renders everything (with the modern Read the Docs or Furo theme) and hosts your prose in reStructuredText or Markdown.

bashtoolchain
pip install sphinx breathe furo             # Sphinx + the Doxygen bridge + theme
# 1) Doxygen with GENERATE_XML = YES  ->  build/doxygen/xml
# 2) Sphinx consumes that XML via Breathe  ->  build/sphinx/html
pythondocs/conf.py — Sphinx + Breathe wiring
project = "fieldbus"
extensions = ["breathe", "myst_parser"]     # myst_parser = Markdown in Sphinx
html_theme = "furo"

# Point Breathe at Doxygen's XML output.
breathe_projects = {"fieldbus": "../build/doxygen/xml"}
breathe_default_project = "fieldbus"
breathe_default_members = ("members", "undoc-members")
bashpull an API entity into a prose page (index.rst)
.. doxygenclass:: fieldbus::ModbusClient
   :members:

.. doxygenfunction:: fieldbus::crc16
✓ Rule of thumb

Reach for plain Doxygen when you need an API reference and nothing more — it's one tool and one build step. Add Sphinx + Breathe when the docs are a product in their own right: versioned guides, hosted on Read the Docs, with tutorials that outweigh the reference. Don't stand up the heavier pipeline before you have prose to justify it.

0xF0 · SECTION

Appendix & checklists

The one-glance references: a consolidated flag table, the classic C++ traps and their fixes, and a pre-release gate that rolls up everything earlier in the manual. Print this section; ignore the rest until you need it.

14.1Compiler & linker flags — quick reference

The working sets by intent. Warnings and sanitizers are for building and testing; the hardened/size/LTO rows are for what actually leaves the building. Flags are GCC/Clang; MSVC equivalents differ.

GoalFlagsWhere
Everyday warnings-Wall -Wextra -Wpedantic -Wshadow -Wconversion -Wsign-conversionevery build
Fatal warnings-WerrorCI only
Debug + find bugs-g -O0 -fno-omit-frame-pointer -fsanitize=address,undefinedtests / CI
Release-O2 -DNDEBUGshipped binary
Hardened release-D_FORTIFY_SOURCE=2 -fstack-protector-strong -fPIE -pie -Wl,-z,relro,-z,now -Wl,-z,noexecstackshipped binary
Size (flash-constrained)-Os -ffunction-sections -fdata-sections -Wl,--gc-sectionsMCU / small rootfs
Link-time optimization-flto=auto (compile and link)release, if link time allows
aarch64 branch protection-mbranch-protection=standardaarch64 release
Reproducible builds-ffile-prefix-map=$PWD=. -Wl,--build-idrelease / packaging
Sanitizers never ship

-fsanitize=address/undefined/thread are diagnostic builds only. They add overhead and change the binary; ship the hardened -O2 -DNDEBUG build and run the sanitized build in CI.

14.2Gotchas & pitfalls

The traps that survive code review and bite in the field. Each line is the mistake, then the fix.

  • Dangling string_view / span. They don't own — returning one that refers to a local, or binding to a temporary, is a use-after-free. Fix: ensure the backing storage outlives the view; never return a view of a local.
  • Iterator / reference invalidation. push_back can reallocate and invalidate every iterator, pointer, and reference into a vector. Fix: reserve() up front, or re-acquire iterators after modifying.
  • Integer promotion & signed/unsigned mix. uint8_t a,b; a*b is done in int; comparing signed to size_t flips negatives to huge values. Fix: -Wconversion -Wsign-conversion, cast deliberately, use std::ssize() / std::cmp_less.
  • Object slicing. Assigning or passing a derived object by value to a base drops the derived part. Fix: pass polymorphic types by reference or pointer, never by value.
  • Most vexing parse. Widget w(); declares a function, not an object. Fix: brace-init — Widget w{};.
  • Static initialization order fiasco. Order of non-local statics across translation units is undefined. Fix: the Meyers singleton — a function-local static initialized on first use.
  • Throwing from a destructor. If a second exception is in flight, the program calls std::terminate. Fix: destructors are implicitly noexcept; swallow/log, never throw.
  • Uninitialized members. Built-in members left out of the initializer list hold garbage. Fix: default member initializers (int n_ = 0;) and let the compiler warn.
  • Forgetting virtual destructor. Deleting a derived object through a base pointer without one is UB. Fix: base classes with virtuals get a virtual (or protected non-virtual) destructor — -Wnon-virtual-dtor.
  • Capturing by reference in a lambda that outlives the scope. [&] stored in a std::function or posted to another thread dangles. Fix: capture by value/move what the lambda outlives.
  • std::move that doesn't move. Moving a const object silently copies; using a variable after moving from it is a logic bug. Fix: don't const what you intend to move; treat moved-from as empty.
  • Narrowing in braces is an error — elsewhere it's silent. Relying on implicit double→int truncation. Fix: brace-init catches it at compile time; prefer {}.
  • Comparing floats for equality. a == b on computed doubles rarely holds. Fix: compare within a tolerance appropriate to the magnitude.
  • UB from data races. Two threads touching one non-atomic variable, one writing, is UB — not "usually fine". Fix: std::atomic, a mutex, or don't share; verify with TSan (0x58).

14.3Pre-release ship checklist

The gate. Nothing goes to a device with an unticked box. This rolls up Production readiness (0xC8) and the sections it references.

Build & correctness
  • ☐ Compiles clean with -Wall -Wextra -Wpedantic -Werror on every target.
  • ☐ Release build is -O2 -DNDEBUG; no assert relied on for control flow.
  • ☐ No warnings from clang-tidy / cppcheck in CI.
  • ☐ Formatted with committed .clang-format; no drift.
Test & verify
  • ☐ Unit tests pass under ctest on host and under QEMU for the target arch.
  • ☐ Test suite is green under ASan + UBSan; TSan for anything threaded.
  • ☐ Coverage on the critical paths (protocol parsing, error handling) is real, not nominal.
  • ☐ Fuzzed the wire/parser boundary if it faces untrusted input.
Cross & packaging
  • ☐ Built with the target toolchain file / Yocto SDK, not the host compiler.
  • ☐ Hardening flags on: FORTIFY, stack protector, RELRO, PIE, noexecstack.
  • ☐ Runtime deps satisfied on target (ldd / correct sysroot glibc); no host leakage.
  • ☐ Binary stamped with git describe + build type; --version prints it.
Runtime & docs
  • ☐ Logging at sane levels, runtime-configurable; no cout debugging left in.
  • ☐ Config validated at startup into a typed struct; fails fast on bad input.
  • ☐ Health/metrics exposed (frames, reconnects, errors).
  • ☐ Public API documented; Doxygen build has zero warnings.

14.4One-line reminders

The whole manual, compressed to what should be reflex:

Own every resource with RAII Make illegal states unrepresentable unique_ptr first, shared only when proven Return by value; don't std::move the return Views borrow — never dangle them Every shared write needs a lock or atomic Prefer std::expected for expected failure Describe targets, not the toolchain Pin your dependencies Let sanitizers find what review won't If it's not in CI, it's not enforced Document the contract, not the signature
Keep going

This manual is a starting point, not scripture. When your toolchain, target, or the standard disagrees with a line here, the toolchain wins — verify against your compiler and the Core Guidelines before you ship. The offsets in the sidebar are there so you can jump straight back to the part you need.