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

C++ in Embedded & IoT

C++ for embedded systems: freestanding C++, MISRA-C++, RTOS integration (FreeRTOS, Zephyr), ISR patterns, DMA with RAII, bare-metal patterns, deterministic timing, and zero-overhead abstractions.

C++ on microcontrollers

Modern C++ (C++14/17/20) is fully usable on bare-metal microcontrollers — ARM Cortex-M, RISC-V, AVR, ESP32. The key insight: most of C++ has zero overhead. Templates, constexpr, RAII, and inline functions generate identical machine code to their C equivalents when optimized. The overhead comes from specific features: exceptions, RTTI, dynamic allocation.


Freestanding C++

A freestanding implementation provides only a minimal subset of the standard library — no dynamic memory, no filesystem, no threads, no I/O streams. Most embedded compilers target freestanding mode.

cpp
// Available in freestanding C++
#include <cstdint>      // uint8_t, uint32_t, ...
#include <cstddef>      // size_t, ptrdiff_t, nullptr_t
#include <type_traits>  // all type trait templates
#include <limits>       // std::numeric_limits
#include <bit>          // std::bit_cast, popcount (C++20)
#include <array>        // std::array (C++23 made more of this freestanding)
#include <span>         // std::span (C++23 freestanding)

// NOT available without hosted stdlib:
// #include <vector>        // dynamic memory
// #include <string>        // dynamic memory
// #include <iostream>      // file I/O, streams
// #include <thread>        // OS threads

Disable exceptions and RTTI

cmake
# CMakeLists.txt for embedded
target_compile_options(firmware PRIVATE
    -fno-exceptions     # disables exception handling code
    -fno-rtti           # disables typeid and dynamic_cast
    -fno-threadsafe-statics  # no mutex for static local init
    -Os                 # optimize for size
)

Zero-overhead abstractions for hardware

Register abstraction with constexpr

cpp
// Model a peripheral register with compile-time address
template<std::uintptr_t Address, typename T = uint32_t>
struct Register {
    static volatile T& ref() {
        return *reinterpret_cast<volatile T*>(Address);
    }
    static T read() { return ref(); }
    static void write(T val) { ref() = val; }
    static void set(T mask) { ref() |= mask; }
    static void clear(T mask) { ref() &= ~mask; }
};

// STM32F4 GPIOA ODR (Output Data Register)
using GPIOA_ODR = Register<0x40020014>;

GPIOA_ODR::set(1 << 5);    // set pin PA5
GPIOA_ODR::clear(1 << 5);  // clear pin PA5
// Generates: LDR/ORR/STR — same as hand-written C

GPIO abstraction without virtual dispatch

cpp
template<uint32_t Port, uint8_t Pin>
struct GPIO {
    static constexpr uint32_t mask = 1u << Pin;

    static void set() noexcept {
        *reinterpret_cast<volatile uint32_t*>(Port + 0x14) = mask;
    }
    static void clear() noexcept {
        *reinterpret_cast<volatile uint32_t*>(Port + 0x18) = mask;
    }
    static bool read() noexcept {
        return (*reinterpret_cast<volatile uint32_t*>(Port + 0x10) & mask) != 0;
    }
};

// Usage — all calls inlined, zero overhead
using LED = GPIO<0x40020000, 5>;  // GPIOA Pin 5
LED::set();
LED::clear();

RAII on bare metal

RAII works perfectly without the heap:

cpp
// Interrupt guard — disable interrupts for a critical section
class CriticalSection {
public:
    CriticalSection() noexcept : saved_primask_(__get_PRIMASK()) {
        __disable_irq();
    }
    ~CriticalSection() noexcept {
        if (!saved_primask_) __enable_irq();
    }
    CriticalSection(const CriticalSection&) = delete;
private:
    uint32_t saved_primask_;
};

void update_shared_state() {
    CriticalSection cs;  // interrupts disabled here
    shared_data_.value = compute();
    // cs destructor re-enables interrupts automatically
}  // even if exception (if enabled) or early return

Static memory — no heap required

cpp
// Static buffer instead of std::vector
template<typename T, std::size_t N>
class StaticVector {
    alignas(T) std::byte storage_[sizeof(T) * N];
    std::size_t size_ = 0;
public:
    bool push_back(const T& val) noexcept {
        if (size_ >= N) return false;
        new(storage_ + size_ * sizeof(T)) T{val};
        ++size_;
        return true;
    }
    T& operator[](std::size_t i) noexcept {
        return *std::launder(reinterpret_cast<T*>(storage_ + i * sizeof(T)));
    }
    std::size_t size() const noexcept { return size_; }
    std::size_t capacity() const noexcept { return N; }
};

StaticVector<SensorReading, 64> readings;
readings.push_back({timestamp, value});

RTOS integration

FreeRTOS with C++ tasks

cpp
#include "FreeRTOS.h"
#include "task.h"

// Wrap FreeRTOS task in a C++ class
class SensorTask {
    TaskHandle_t handle_{nullptr};
    StaticTask_t task_buffer_;
    StackType_t  stack_[256];

public:
    void start(UBaseType_t priority) {
        handle_ = xTaskCreateStatic(
            &SensorTask::task_func,
            "Sensor",
            256,
            this,
            priority,
            stack_,
            &task_buffer_
        );
    }

    void run() {
        TickType_t last_wake = xTaskGetTickCount();
        for (;;) {
            vTaskDelayUntil(&last_wake, pdMS_TO_TICKS(10));
            read_sensor();
        }
    }

private:
    static void task_func(void* param) {
        static_cast<SensorTask*>(param)->run();
    }

    void read_sensor() {
        // ADC read, filter, enqueue
    }
};

// Static allocation — no heap needed for the task itself
SensorTask sensor_task;

Thread-safe queue between tasks

cpp
#include "queue.h"

template<typename T, std::size_t N>
class RTOSQueue {
    QueueHandle_t handle_;
    StaticQueue_t queue_def_;
    uint8_t       storage_[N * sizeof(T)];

public:
    RTOSQueue() {
        handle_ = xQueueCreateStatic(N, sizeof(T), storage_, &queue_def_);
    }

    bool send(const T& item, TickType_t timeout = 0) noexcept {
        return xQueueSend(handle_, &item, timeout) == pdTRUE;
    }

    bool receive(T& item, TickType_t timeout = portMAX_DELAY) noexcept {
        return xQueueReceive(handle_, &item, timeout) == pdTRUE;
    }
};

RTOSQueue<SensorReading, 16> sensor_queue;

MISRA-C++ guidelines

MISRA-C++ (2023 edition) is a subset of C++ for safety-critical systems (automotive, aerospace, medical). Key rules:

RuleRationale
No dynamic memory allocation after initHeap fragmentation, non-deterministic timing
No recursionStack depth unpredictable
No exceptionsOverhead, non-determinism
No RTTICode size, overhead
Limited use of templatesComplexity, code bloat
All paths must return a valueUndefined behavior if not
No implicit integral conversionsWidth-dependent bugs
cpp
// MISRA compliant pattern
// Explicit types, no implicit conversion
uint32_t read_adc() noexcept;  // noexcept — no exceptions

// Explicit cast where widening occurs
uint32_t raw = read_adc();
uint64_t accumulated = static_cast<uint64_t>(raw) + prev_sum;

// No VLAs, no alloca — use fixed-size arrays
void process(uint8_t data[32], uint8_t len) noexcept;
// Better with span (C++20 / MISRA 2023):
void process(std::span<const uint8_t, 32> data) noexcept;

Linker script essentials

/* Typical ARM Cortex-M linker script */
MEMORY {
    FLASH  (rx)  : ORIGIN = 0x08000000, LENGTH = 256K
    SRAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 64K
}

SECTIONS {
    .text : { *(.text*) *(.rodata*) } > FLASH
    .data : {
        _data_start = .;
        *(.data*)
        _data_end = .;
    } > SRAM AT > FLASH         /* stored in FLASH, runs from SRAM */
    .bss  : {
        _bss_start = .;
        *(.bss*)
        *(COMMON)
        _bss_end = .;
    } > SRAM
    _stack_top = ORIGIN(SRAM) + LENGTH(SRAM);
}

C++ startup must call global constructors (.init_array) before main():

cpp
// startup.cpp — called before main
extern "C" void __libc_init_array();  // calls global constructors

extern "C" void Reset_Handler() {
    // 1. Copy .data from FLASH to SRAM
    // 2. Zero .bss
    // 3. Call global constructors
    __libc_init_array();
    // 4. Call main
    main();
    // 5. Loop forever (never return from main on embedded)
    for (;;) {}
}

Size optimization

bash
# Optimize for size
-Os                     # size-optimized (similar to -O2 but avoids size-increasing opts)
-Oz                     # (Clang) even more aggressive size reduction

# Strip debug symbols for release
-s                      # strip all symbols
objcopy --strip-debug firmware.elf firmware_stripped.elf

# Check binary size breakdown
size firmware.elf       # text/data/bss breakdown
nm --size-sort firmware.elf | tail -20  # largest symbols

# Link-time optimization — removes dead code across TUs
-flto

Interrupt service routines (ISR) in C++

ISRs run asynchronously and share data with normal code. The key rules: shared variables must be volatile, communication must use an ISR-safe mechanism (ring buffer or std::atomic), and ISRs must never call code that allocates or blocks.

cpp
#include <atomic>
#include <cstdint>

// ISR-safe ring buffer — single producer (ISR), single consumer (main loop)
template<typename T, std::size_t N>
class ISRQueue {
    static_assert((N & (N - 1)) == 0, "N must be power of 2");

    T data_[N];
    std::atomic<std::size_t> head_{0};  // written by ISR
    std::size_t tail_{0};               // read/written by main loop only

public:
    // Called from ISR — must be noexcept, no blocking
    bool push_from_isr(const T& item) noexcept {
        std::size_t h = head_.load(std::memory_order_relaxed);
        std::size_t next = (h + 1) & (N - 1);
        if (next == tail_) return false;  // full — drop sample
        data_[h] = item;
        head_.store(next, std::memory_order_release);
        return true;
    }

    // Called from main loop / task
    bool pop(T& item) noexcept {
        if (tail_ == head_.load(std::memory_order_acquire))
            return false;  // empty
        item = data_[tail_];
        tail_ = (tail_ + 1) & (N - 1);
        return true;
    }
};

ISRQueue<uint16_t, 64> adc_samples;

// ISR — runs on hardware interrupt, cannot be a member function
extern "C" void ADC_IRQHandler() {
    uint16_t raw = ADC1->DR;          // read hardware register
    adc_samples.push_from_isr(raw);   // enqueue for main loop
}

// Main loop
void process_loop() {
    uint16_t sample;
    while (true) {
        while (adc_samples.pop(sample))
            filter_and_process(sample);
        __WFI();   // sleep until next interrupt
    }
}

Mark ISR functions extern "C" — the CPU's vector table holds raw function pointers and the linker expects C linkage. Never call any function from an ISR that might allocate memory, acquire a mutex, or call back into the RTOS scheduler.


DMA with RAII

DMA (Direct Memory Access) transfers data between peripherals and memory without CPU involvement. RAII ensures the DMA handle is always stopped on scope exit, even under early returns.

cpp
#include <cstddef>
#include <cstdint>

// Minimal STM32-style DMA channel wrapper
class DmaTransfer {
    uint32_t channel_;
    bool     active_{false};

public:
    explicit DmaTransfer(uint32_t channel, volatile void* src,
                         volatile void* dst, std::size_t len) noexcept
        : channel_(channel)
    {
        // Configure DMA channel registers (platform-specific)
        DMA1_Channel[channel].CMAR = reinterpret_cast<uint32_t>(dst);
        DMA1_Channel[channel].CPAR = reinterpret_cast<uint32_t>(src);
        DMA1_Channel[channel].CNDTR = len;
        DMA1_Channel[channel].CCR |= DMA_CCR_EN;   // enable
        active_ = true;
    }

    bool complete() const noexcept {
        return DMA1->ISR & (DMA_ISR_TCIF1 << (channel_ * 4));
    }

    ~DmaTransfer() noexcept {
        if (active_)
            DMA1_Channel[channel_].CCR &= ~DMA_CCR_EN;  // disable
    }

    DmaTransfer(const DmaTransfer&) = delete;
    DmaTransfer& operator=(const DmaTransfer&) = delete;
};

// Usage — DMA transfer automatically disabled on scope exit
void read_spi_block(uint8_t* buf, std::size_t len) {
    DmaTransfer xfer(DMA_CHANNEL_SPI_RX, &SPI1->DR, buf, len);
    while (!xfer.complete()) __NOP();
    // xfer destructor disables DMA channel
}

Zephyr RTOS C++ patterns

Zephyr is a modern open-source RTOS with native C++ support and a west build system based on CMake. Unlike FreeRTOS, Zephyr provides C++ kernel wrappers in <zephyr/kernel.hpp>.

cpp
#include <zephyr/kernel.hpp>

// Zephyr thread as a C++ class
class UartTask {
    k_thread thread_;
    K_THREAD_STACK_MEMBER(stack_, 1024);

public:
    void start() {
        k_thread_create(&thread_, stack_, K_THREAD_STACK_SIZEOF(stack_),
            run, this, nullptr, nullptr,
            K_PRIO_PREEMPT(5), 0, K_NO_WAIT);
    }

private:
    static void run(void* self, void*, void*) {
        static_cast<UartTask*>(self)->loop();
    }

    void loop() {
        while (true) {
            // Blocking read from UART ring buffer
            uint8_t byte;
            uart_poll_in(DEVICE_DT_GET(DT_NODELABEL(uart0)), &byte);
            handle_byte(byte);
        }
    }
    void handle_byte(uint8_t b);
};

// Message queue between tasks — Zephyr's type-safe wrapper
k_msgq sensor_queue;
K_MSGQ_DEFINE(sensor_queue, sizeof(SensorReading), 8, 4);

void producer_task(void*, void*, void*) {
    SensorReading r{read_adc(), k_uptime_get_32()};
    k_msgq_put(&sensor_queue, &r, K_NO_WAIT);
}

void consumer_task(void*, void*, void*) {
    SensorReading r;
    while (k_msgq_get(&sensor_queue, &r, K_FOREVER) == 0)
        process(r);
}

Zephyr enables CONFIG_CPP=y and CONFIG_EXCEPTIONS=y/n in prj.conf. For safety-critical builds use CONFIG_EXCEPTIONS=n and rely on error codes / std::expected.


Toolchains

TargetToolchain
ARM Cortex-M (STM32, NXP)arm-none-eabi-gcc / arm-none-eabi-g++
RISC-Vriscv32-unknown-elf-g++
AVR (Arduino)avr-g++
ESP32 (Xtensa)ESP-IDF with xtensa-esp32-elf-g++
ESP32-C3 (RISC-V)ESP-IDF with riscv32-esp-elf-g++
Nordic nRF5xarm-none-eabi-g++ + nRF SDK / Zephyr