Skip to content
C++
Domain Track
Difficulty 3/5

Embedded Systems C++ Patterns

C++ for embedded systems — no heap, no exceptions, constexpr configuration, CRTP hardware abstraction, fixed-size buffers, and interrupt-safe code.

TL;DR

Embedded C++ avoids heap allocation, exceptions, and RTTI. Use constexpr for configuration, fixed-size containers, CRTP for zero-cost hardware abstraction, and volatile/atomics for registers and ISRs.


No Heap — Fixed-Size Containers

cpp
// Replace std::vector with fixed-capacity alternative
template<typename T, size_t Capacity>
class StaticVector {
    alignas(T) std::byte storage_[sizeof(T) * Capacity];
    size_t size_ = 0;

public:
    template<typename... Args>
    bool push_back(Args&&... args) {
        if (size_ >= Capacity) return false;
        ::new(&storage_[size_ * sizeof(T)]) T(std::forward<Args>(args)...);
        ++size_;
        return true;
    }

    T& operator[](size_t i) { return reinterpret_cast<T*>(storage_)[i]; }
    size_t size() const { return size_; }
    bool full()   const { return size_ == Capacity; }
    bool empty()  const { return size_ == 0; }
    static constexpr size_t capacity() { return Capacity; }

    ~StaticVector() {
        for (size_t i = 0; i < size_; ++i)
            reinterpret_cast<T*>(storage_)[i].~T();
    }
};

StaticVector<int, 16> buf;
buf.push_back(42);  // no heap allocation

CRTP Hardware Abstraction Layer

Zero-cost polymorphism — no vtable, full inlining:

cpp
// Generic GPIO interface via CRTP
template<typename Derived>
class GPIO {
public:
    void set_high()  { static_cast<Derived*>(this)->set_pin(true); }
    void set_low()   { static_cast<Derived*>(this)->set_pin(false); }
    bool read()      { return static_cast<Derived*>(this)->read_pin(); }

    void toggle() {
        if (read()) set_low();
        else        set_high();
    }
};

// STM32 concrete implementation
struct STM32_PA5 : GPIO<STM32_PA5> {
    void set_pin(bool high) {
        if (high) GPIOA->BSRR = (1 << 5);
        else      GPIOA->BRR  = (1 << 5);
    }
    bool read_pin() {
        return (GPIOA->IDR >> 5) & 1;
    }
};

// Completely inlined at compile time
STM32_PA5 led;
led.set_high();
led.toggle();

Register Access with volatile

cpp
// Map hardware registers to typed pointers
struct UART_Regs {
    volatile uint32_t DR;    // data register
    volatile uint32_t SR;    // status register
    volatile uint32_t BRR;   // baud rate register
    volatile uint32_t CR1;   // control register 1
};

constexpr auto* UART1 = reinterpret_cast<UART_Regs*>(0x40011000);

// Write register
UART1->CR1 |= (1 << 3);   // enable TX

// Read register (prevents optimization away)
while (!(UART1->SR & (1 << 7))) {}  // wait for TX empty

UART1->DR = 'A';  // send character

// Bit-field register model (type-safe)
struct ControlReg {
    uint32_t enable   : 1;
    uint32_t mode     : 2;
    uint32_t reserved : 29;
};
volatile ControlReg& ctrl = *reinterpret_cast<volatile ControlReg*>(0x40000000);
ctrl.enable = 1;
ctrl.mode   = 2;

Interrupt-Safe Code

cpp
#include <atomic>

// Use std::atomic for ISR-shared variables
std::atomic<uint32_t> tick_count{0};

// Interrupt Service Routine (ISR)
extern "C" void SysTick_Handler() {
    tick_count.fetch_add(1, std::memory_order_relaxed);
}

// Main thread reads atomically
uint32_t get_ticks() {
    return tick_count.load(std::memory_order_relaxed);
}

// For complex data: disable interrupts around critical section
void critical_update() {
    __disable_irq();   // ARM: CPSID i
    // ... update shared data ...
    __enable_irq();    // ARM: CPSIE i
}

// Or RAII interrupt lock
struct CriticalSection {
    CriticalSection()  { __disable_irq(); }
    ~CriticalSection() { __enable_irq();  }
};

{
    CriticalSection cs;
    shared_data.update();
}

constexpr Configuration

cpp
// All configuration at compile time — no runtime overhead
struct HardwareConfig {
    static constexpr uint32_t CPU_FREQ_HZ    = 168'000'000;
    static constexpr uint32_t UART_BAUD      = 115'200;
    static constexpr uint32_t FLASH_BASE     = 0x08000000;
    static constexpr uint32_t SRAM_SIZE      = 192 * 1024;

    static constexpr uint32_t uart_brr() {
        return CPU_FREQ_HZ / UART_BAUD;
    }
};

// Checked at compile time
static_assert(HardwareConfig::SRAM_SIZE >= 64 * 1024,
    "Need at least 64KB SRAM");

// Constexpr lookup tables (no flash penalty from runtime init)
constexpr std::array<uint8_t, 256> build_crc_table() {
    std::array<uint8_t, 256> tbl{};
    for (int i = 0; i < 256; ++i) {
        uint8_t crc = i;
        for (int j = 0; j < 8; ++j)
            crc = crc & 1 ? (crc >> 1) ^ 0x8C : crc >> 1;
        tbl[i] = crc;
    }
    return tbl;
}
constexpr auto crc_table = build_crc_table();  // in flash, computed at compile time

Stack Allocation Patterns

cpp
// Object pool for ISR-safe allocation
template<typename T, size_t N>
class ISR_Pool {
    T objects_[N];
    std::atomic<uint32_t> bitmask_{0};  // one bit per object

public:
    T* acquire() {
        uint32_t mask = bitmask_.load(std::memory_order_relaxed);
        while (true) {
            int free_idx = __builtin_ctz(~mask);  // find first free bit
            if (free_idx >= N) return nullptr;
            uint32_t new_mask = mask | (1u << free_idx);
            if (bitmask_.compare_exchange_weak(mask, new_mask,
                    std::memory_order_acquire))
                return &objects_[free_idx];
        }
    }

    void release(T* p) {
        int idx = p - objects_;
        bitmask_.fetch_and(~(1u << idx), std::memory_order_release);
    }
};

Linker Script Integration

cpp
// Symbols from linker script
extern "C" {
    extern uint32_t _sidata;   // init data source (flash)
    extern uint32_t _sdata;    // data section start (RAM)
    extern uint32_t _edata;    // data section end
    extern uint32_t _sbss;     // bss start
    extern uint32_t _ebss;     // bss end
}

// C++ startup (called before main)
void system_init() {
    // Copy initialized data from flash to RAM
    uint32_t* src = &_sidata;
    for (uint32_t* dst = &_sdata; dst < &_edata; ++dst)
        *dst = *src++;

    // Zero BSS
    for (uint32_t* p = &_sbss; p < &_ebss; ++p)
        *p = 0;

    // Call C++ global constructors
    // (typically handled by _init/_fini or __libc_init_array)
}

Compile Flags for Embedded

cmake
# Bare-metal ARM Cortex-M4 example
target_compile_options(firmware PRIVATE
    -mcpu=cortex-m4
    -mthumb
    -mfpu=fpv4-sp-d16
    -mfloat-abi=hard
    -Os           # optimize for size
    -fno-exceptions
    -fno-rtti
    -ffunction-sections
    -fdata-sections
    -ffreestanding
)

target_link_options(firmware PRIVATE
    -T linker.ld
    -Wl,--gc-sections    # remove unused code
    -Wl,-Map=output.map  # generate map file
    -nostdlib
    -specs=nosys.specs
)

Startup Code and Linker Scripts

The vector table, .data copy, and .bss zero-fill happen before main() — understanding this lets you control every byte of flash and RAM. A minimal Cortex-M startup written in C++ registers the reset handler and the vector table in a dedicated flash section, then delegates to the system init routine.

cpp
// startup.cpp — bare-metal Cortex-M reset sequence
extern "C" {
    extern uint32_t _sidata;  // LMA of .data in flash
    extern uint32_t _sdata;   // VMA start of .data in RAM
    extern uint32_t _edata;   // VMA end of .data in RAM
    extern uint32_t _sbss;    // start of .bss
    extern uint32_t _ebss;    // end of .bss

    // Provided by linker or crt0
    void __libc_init_array();  // call C++ global constructors

    int main();

    void Reset_Handler() {
        // 1. Copy .data from flash to RAM
        uint32_t* src = &_sidata;
        for (uint32_t* dst = &_sdata; dst < &_edata; )
            *dst++ = *src++;

        // 2. Zero-fill .bss
        for (uint32_t* p = &_sbss; p < &_ebss; )
            *p++ = 0;

        // 3. Call C++ constructors (global/static objects)
        __libc_init_array();

        // 4. Enter application
        main();

        // 5. Infinite loop — should never reach here
        for (;;) {}
    }
}

// Vector table placed in .isr_vector section (mapped to 0x08000000)
__attribute__((section(".isr_vector"), used))
const uintptr_t vector_table[] = {
    0x20020000,                                 // initial stack pointer
    reinterpret_cast<uintptr_t>(Reset_Handler), // reset
    0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0  // other exception slots
};

Matching linker script excerpt:

cpp
/* linker.ld */
MEMORY {
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 512K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

SECTIONS {
    .isr_vector : { KEEP(*(.isr_vector)) } >FLASH

    .text : {
        *(.text*)
        *(.rodata*)
        _sidata = .;        /* LMA for .data */
    } >FLASH

    .data : AT(_sidata) {
        _sdata = .;
        *(.data*)
        _edata = .;
    } >RAM

    .bss : {
        _sbss = .;
        *(.bss*)
        *(COMMON)
        _ebss = .;
    } >RAM

    _stack_top = ORIGIN(RAM) + LENGTH(RAM);
}

Placing Variables in Specific Memory Regions

Linker script AT and __attribute__((section(...))) let you pin variables to TCM, CCM, or external SDRAM without runtime cost. This is critical when certain peripherals need DMA-accessible buffers or when latency-sensitive code must execute from tightly-coupled memory.

cpp
// Place a DMA buffer in CCM RAM (not accessible by DMA on some MCUs —
// adjust section to match your memory map)
__attribute__((section(".ccmram")))
uint8_t dma_rx_buffer[1024];

// Place a lookup table in DTCM for fast CPU access
__attribute__((section(".dtcm")))
constexpr uint16_t sine_table[256] = { /* ... */ };

// Place a function in ITCM (execute from tightly-coupled memory)
__attribute__((section(".itcm"), noinline))
void fast_isr_routine() {
    // runs from ITCM — zero wait-state fetch
}

// Linker script additions for the above:
// .ccmram (NOLOAD) : { *(.ccmram) } >CCMRAM AT>FLASH
// .dtcm   (NOLOAD) : { *(.dtcm)   } >DTCM
// .itcm           : { *(.itcm)    } >ITCM AT>FLASH
RegionTypical useDMA-capableZero wait-state
SRAMGeneral heap/stack/dataYesNo
CCM RAMStack, fast data (STM32)NoYes
DTCMFast data (H7/F7)NoYes
ITCMFast code (H7/F7)NoYes
Backup SRAMRetention across resetMCU-specificNo

ISR Design in C++

ISRs must be declared extern "C" so the linker finds the mangled-free symbol, and all data shared with the ISR must be volatile or protected by std::atomic. On ARM Cortex-M the compiler does not automatically insert memory barriers around volatile accesses, so use __DSB()/__ISB() when ordering matters.

cpp
#include <atomic>
#include <cstdint>
#include <cmsis_gcc.h>   // __DSB, __ISB, __NVIC_* helpers

// Shared ring-buffer between ISR and main
struct RingBuf {
    static constexpr size_t SIZE = 64;
    volatile uint8_t  data[SIZE];
    std::atomic<size_t> head{0}, tail{0};
};

RingBuf uart_rx;

// ISR — no heap, no blocking, no virtual calls
extern "C" void USART1_IRQHandler() {
    uint8_t byte = USART1->DR;                       // read clears RXNE flag
    size_t next = (uart_rx.head.load(std::memory_order_relaxed) + 1) % RingBuf::SIZE;
    if (next != uart_rx.tail.load(std::memory_order_acquire)) {
        uart_rx.data[uart_rx.head.load(std::memory_order_relaxed)] = byte;
        uart_rx.head.store(next, std::memory_order_release);
    }
}

// Configure NVIC priority and enable
void uart_irq_init() {
    NVIC_SetPriority(USART1_IRQn, 5);   // lower number = higher priority
    NVIC_EnableIRQ(USART1_IRQn);
    __DSB();   // ensure NVIC write is visible before returning
    __ISB();   // flush pipeline
}

// Main thread reads without disabling interrupts
std::optional<uint8_t> uart_read() {
    size_t t = uart_rx.tail.load(std::memory_order_relaxed);
    if (t == uart_rx.head.load(std::memory_order_acquire))
        return std::nullopt;
    uint8_t b = uart_rx.data[t];
    uart_rx.tail.store((t + 1) % RingBuf::SIZE, std::memory_order_release);
    return b;
}

HAL Abstraction Without Virtual Dispatch

Policy-based design (templates) provides a hardware abstraction layer with zero runtime overhead — the compiler sees through the indirection entirely. Each peripheral driver is parameterized on its hardware backend; swapping targets means changing a type alias, not a vtable.

cpp
// Policy concept (C++20)
template<typename T>
concept UartPolicy = requires(T t, uint8_t b) {
    { t.send_byte(b) } -> std::same_as<void>;
    { t.recv_byte()  } -> std::same_as<std::optional<uint8_t>>;
    { t.tx_ready()   } -> std::same_as<bool>;
};

// Generic driver built on the policy
template<UartPolicy Hw>
class UartDriver {
    Hw hw_;
    StaticVector<uint8_t, 128> tx_buf_;

public:
    explicit UartDriver(Hw hw = {}) : hw_{hw} {}

    void write(std::string_view s) {
        for (char c : s) {
            while (!hw_.tx_ready()) {}   // spin — replace with OS yield
            hw_.send_byte(static_cast<uint8_t>(c));
        }
    }

    void println(std::string_view s) {
        write(s);
        hw_.send_byte('\r');
        hw_.send_byte('\n');
    }
};

// STM32 hardware back-end
struct Stm32Uart1 {
    void send_byte(uint8_t b) {
        while (!(USART1->SR & USART_SR_TXE)) {}
        USART1->DR = b;
    }
    std::optional<uint8_t> recv_byte() {
        if (!(USART1->SR & USART_SR_RXNE)) return std::nullopt;
        return static_cast<uint8_t>(USART1->DR);
    }
    bool tx_ready() { return USART1->SR & USART_SR_TXE; }
};

// Fully inlined — no function pointer overhead
UartDriver<Stm32Uart1> uart;
uart.println("Hello from bare-metal C++");

FreeRTOS Integration

FreeRTOS C++ integration wraps tasks, queues, and mutexes in RAII types so resources are released automatically even when tasks are deleted. Avoid new/delete inside tasks; use StaticTask_t and stack buffers for deterministic memory.

cpp
#include <FreeRTOS.h>
#include <task.h>
#include <queue.h>
#include <semphr.h>

// RAII task wrapper — statically allocated
template<size_t StackWords = 256>
class Task {
    StaticTask_t tcb_;
    StackType_t  stack_[StackWords];
    TaskHandle_t handle_ = nullptr;

public:
    using Fn = void(*)(void*);

    bool create(const char* name, UBaseType_t prio, Fn fn, void* arg = nullptr) {
        handle_ = xTaskCreateStatic(fn, name, StackWords, arg, prio,
                                     stack_, &tcb_);
        return handle_ != nullptr;
    }

    ~Task() { if (handle_) vTaskDelete(handle_); }
};

// RAII mutex
class Mutex {
    StaticSemaphore_t buf_;
    SemaphoreHandle_t h_;
public:
    Mutex() : h_{xSemaphoreCreateMutexStatic(&buf_)} {}
    ~Mutex() { vSemaphoreDelete(h_); }

    bool lock(TickType_t timeout = portMAX_DELAY) {
        return xSemaphoreTake(h_, timeout) == pdTRUE;
    }
    void unlock() { xSemaphoreGive(h_); }
};

// RAII queue
template<typename T, size_t Depth>
class Queue {
    StaticQueue_t buf_;
    uint8_t       storage_[Depth * sizeof(T)];
    QueueHandle_t h_;
public:
    Queue() : h_{xQueueCreateStatic(Depth, sizeof(T), storage_, &buf_)} {}
    ~Queue() { vQueueDelete(h_); }

    bool send(const T& item, TickType_t timeout = 0) {
        return xQueueSend(h_, &item, timeout) == pdTRUE;
    }
    bool recv(T& item, TickType_t timeout = portMAX_DELAY) {
        return xQueueReceive(h_, &item, timeout) == pdTRUE;
    }
};

// Example: producer/consumer via queue
struct SensorReading { uint16_t adc_value; uint32_t timestamp; };

Queue<SensorReading, 8> sensor_queue;

void sensor_task(void*) {
    for (;;) {
        SensorReading r{read_adc(), xTaskGetTickCount()};
        sensor_queue.send(r);
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

void process_task(void*) {
    SensorReading r{};
    for (;;) {
        if (sensor_queue.recv(r))
            process(r);
    }
}

Power Management

Sleep modes gate peripheral clocks and halt the CPU, cutting active current from tens of milliamps to single microamps — enabling years of battery life. On ARM Cortex-M the WFI/WFE instructions enter sleep; the MCU vendor controls which peripherals remain powered.

cpp
// ARM Cortex-M low-power entry
inline void enter_sleep() {
    __DSB();          // complete pending memory accesses
    __WFI();          // Wait For Interrupt — CPU halts until any enabled IRQ
}

inline void enter_deep_sleep() {
    SCB->SCR |= SCB_SCR_SLEEPDEEP_Msk;  // request deep-sleep on next WFI
    __DSB();
    __WFI();
    SCB->SCR &= ~SCB_SCR_SLEEPDEEP_Msk; // clear flag after wakeup
}

// Gate peripheral clocks when not in use (STM32 example)
inline void disable_unused_peripherals() {
    RCC->AHB1ENR &= ~RCC_AHB1ENR_GPIOBEN;  // disable GPIOB clock
    RCC->APB1ENR &= ~RCC_APB1ENR_TIM2EN;   // disable TIM2 clock
}

// Tickless idle hook (FreeRTOS)
extern "C" void vApplicationIdleHook() {
    // Entered when all tasks are blocked
    enter_sleep();
}

// Wakeup source configuration — RTC alarm
void configure_rtc_wakeup(uint32_t seconds) {
    // Vendor-specific: enable RTC, set alarm, configure EXTI line
    RTC->CR  |= RTC_CR_ALRAE;          // enable alarm A
    EXTI->IMR |= EXTI_IMR_MR17;        // unmask RTC alarm EXTI line
    EXTI->RTSR |= EXTI_RTSR_TR17;      // rising-edge trigger
    NVIC_EnableIRQ(RTC_Alarm_IRQn);
}
ModeCPUClocksRAMWakeup latencyTypical current
RunOnAll onOnN/A10–50 mA
SleepOffPeripherals onOn~1 µs1–5 mA
StopOffMost offOn~10 µs50–500 µA
StandbyOffAll offOff (backup only)~50 µs + boot<5 µA

Bootloader Considerations

A first-stage bootloader lives at the MCU's default flash origin and jumps to the application after validating a CRC or signature — dual-bank flash allows atomic firmware swaps. The bootloader must set the vector table offset register (VTOR) before jumping so the application's ISRs are resolved correctly.

cpp
// Bootloader jump to application (ARM Cortex-M)
constexpr uint32_t APP_FLASH_BASE = 0x08010000;  // app starts after 64 KB bootloader

void jump_to_application() {
    // Validate CRC stored at known offset
    const uint32_t* app_crc_ptr =
        reinterpret_cast<const uint32_t*>(APP_FLASH_BASE + 0x200);
    if (!verify_crc32(APP_FLASH_BASE, APP_SIZE, *app_crc_ptr))
        return;  // stay in bootloader / enter DFU mode

    // Disable all IRQs and clear pending flags
    for (int i = 0; i < 8; ++i) {
        NVIC->ICER[i] = 0xFFFFFFFF;
        NVIC->ICPR[i] = 0xFFFFFFFF;
    }

    // Relocate vector table to application
    SCB->VTOR = APP_FLASH_BASE;
    __DSB();

    // Load application stack pointer and reset handler
    uint32_t app_sp  = *reinterpret_cast<const uint32_t*>(APP_FLASH_BASE);
    uint32_t app_pc  = *reinterpret_cast<const uint32_t*>(APP_FLASH_BASE + 4);

    // Set MSP and jump
    __set_MSP(app_sp);
    reinterpret_cast<void(*)()>(app_pc)();
}

// Dual-bank swap flag in backup registers
void request_firmware_update() {
    RTC->BKP0R = 0xDEADBEEF;  // magic word checked by bootloader on next boot
    NVIC_SystemReset();
}

// CRC-32 validator (uses hardware CRC unit on STM32)
bool verify_crc32(uint32_t base, uint32_t len, uint32_t expected) {
    RCC->AHB1ENR |= RCC_AHB1ENR_CRCEN;
    CRC->CR = CRC_CR_RESET;
    const uint32_t* p = reinterpret_cast<const uint32_t*>(base);
    for (uint32_t i = 0; i < len / 4; ++i)
        CRC->DR = p[i];
    return CRC->DR == expected;
}

Debugging Embedded Systems

JTAG/SWD gives full control — instruction-level stepping, memory inspection, and live variable watch — without modifying firmware or needing a serial port. OpenOCD bridges a USB debug probe to a GDB remote stub; gdb-multiarch connects to that stub from the host.

bash
# Install toolchain and debug tools (Debian/Ubuntu)
sudo apt install gcc-arm-none-eabi gdb-multiarch openocd

# Start OpenOCD with an ST-Link probe targeting STM32F4
openocd -f interface/stlink.cfg -f target/stm32f4x.cfg

# In a second terminal — connect gdb-multiarch
gdb-multiarch build/firmware.elf
(gdb) target remote :3333          # connect to OpenOCD GDB server
(gdb) monitor reset halt           # reset and halt before main
(gdb) load                         # flash the ELF
(gdb) break main                   # set breakpoint
(gdb) continue
(gdb) print tick_count             # inspect variable
(gdb) x/16xw 0x20000000           # dump 16 words from SRAM start
(gdb) set var led_state = 1        # modify variable live
cmake
# CMake: generate ELF with debug info and semihosting
target_compile_options(firmware PRIVATE -g3 -Og)
target_link_options(firmware PRIVATE
    -specs=rdimon.specs    # semihosting: printf → host terminal via SWD
    -lrdimon
)

Semihosting routes printf output through the debug probe without a physical UART — useful early in bring-up when peripherals are not yet configured.

MethodRequiresData rateIntrusive?
JTAG/SWD + GDBProbe + OpenOCDN/A (halting)Yes (halts CPU)
SWO/ITM traceSWD probe with SWO~1–6 MbpsNo
SemihostingSWD probe~10–100 KB/sYes (halts on syscall)
UART printfUART + serial adapterBaud-limitedNo
RTT (Segger)J-Link probe~1 MB/sNo