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
// 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 allocationCRTP Hardware Abstraction Layer
Zero-cost polymorphism — no vtable, full inlining:
// 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
// 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
#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
// 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 timeStack Allocation Patterns
// 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
// 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
# 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.
// 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:
/* 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.
// 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| Region | Typical use | DMA-capable | Zero wait-state |
|---|---|---|---|
| SRAM | General heap/stack/data | Yes | No |
| CCM RAM | Stack, fast data (STM32) | No | Yes |
| DTCM | Fast data (H7/F7) | No | Yes |
| ITCM | Fast code (H7/F7) | No | Yes |
| Backup SRAM | Retention across reset | MCU-specific | No |
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.
#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.
// 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.
#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.
// 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);
}| Mode | CPU | Clocks | RAM | Wakeup latency | Typical current |
|---|---|---|---|---|---|
| Run | On | All on | On | N/A | 10–50 mA |
| Sleep | Off | Peripherals on | On | ~1 µs | 1–5 mA |
| Stop | Off | Most off | On | ~10 µs | 50–500 µA |
| Standby | Off | All off | Off (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.
// 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.
# 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: 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.
| Method | Requires | Data rate | Intrusive? |
|---|---|---|---|
| JTAG/SWD + GDB | Probe + OpenOCD | N/A (halting) | Yes (halts CPU) |
| SWO/ITM trace | SWD probe with SWO | ~1–6 Mbps | No |
| Semihosting | SWD probe | ~10–100 KB/s | Yes (halts on syscall) |
| UART printf | UART + serial adapter | Baud-limited | No |
| RTT (Segger) | J-Link probe | ~1 MB/s | No |