C++Systems Engineering

Embedded C++ & Safety-Critical Engineering: MISRA, Freestanding, Placement New & std::expected

TT
TopicTrick Team
Embedded C++ & Safety-Critical Engineering: MISRA, Freestanding, Placement New & std::expected

Embedded C++ & Safety-Critical Engineering: MISRA, Freestanding, Placement New & std::expected


Table of Contents


Hosted vs Freestanding C++


Why Dynamic Allocation is Forbidden in Safety-Critical Code

In automotive (ISO 26262), aerospace (DO-178C), and medical (IEC 62304) standards, dynamic memory allocation after system initialization is typically prohibited because:

  1. Non-deterministic timing: malloc/new execution time depends on heap state — unpredictable in real-time systems
  2. Fragmentation: Long-running systems with frequent alloc/free develop heap fragmentation — eventual malloc failure
  3. Out-of-memory: Dynamic allocation can fail at runtime; static allocation fails at compile time (link-time)
  4. WCET analysis: Worst-Case Execution Time analysis is impossible when heap allocation time is unbounded
cpp
// FORBIDDEN after initialization in safety-critical code:
auto data = std::make_unique<SensorData>(); // Heap allocation!
std::vector<int> v;
v.push_back(1); // Heap reallocation!
std::string s = "hello"; // Heap allocation!

// ALLOWED: static storage (known at compile time):
static SensorData data;           // Single global instance
std::array<int, 100> buffer;      // Fixed-size stack array
std::string_view sv = "hello";    // No allocation — view of literal
std::span<int> view(buffer);      // No allocation — view of buffer

Static Allocation with Placement New

Placement new constructs an object at a pre-allocated memory location — combining static allocation with C++ object construction:

cpp
#include <new>
#include <cstddef>

// Method 1: alignas + char array (classic approach)
alignas(NetworkPacket) char packet_storage[sizeof(NetworkPacket)];
NetworkPacket* pkt = nullptr;

void init_system() {
    // Construct NetworkPacket at startup in static storage:
    pkt = new (packet_storage) NetworkPacket(); // Placement new!
    // No heap allocation — uses packet_storage
}

// Method 2: Pre-allocated object pool:
template<typename T, size_t N>
class StaticPool {
    alignas(T) char storage_[N * sizeof(T)];
    bool  used_[N] = {};
    
public:
    T* allocate() {
        for (size_t i = 0; i < N; i++) {
            if (!used_[i]) {
                used_[i] = true;
                return new (storage_ + i * sizeof(T)) T(); // Placement new
            }
        }
        return nullptr; // Pool exhausted
    }
    
    void deallocate(T* p) {
        // Find index and call destructor explicitly:
        size_t idx = (reinterpret_cast<char*>(p) - storage_) / sizeof(T);
        p->~T(); // MUST call destructor manually for placement new objects!
        used_[idx] = false;
    }
};

static StaticPool<SensorReading, 32> sensor_pool; // 32 pre-allocated sensors

void handle_sensor_event() {
    SensorReading* sr = sensor_pool.allocate(); // No heap!
    if (sr) {
        sr->timestamp = get_tick_count();
        sr->value     = read_adc();
        process_reading(sr);
        sensor_pool.deallocate(sr); // Explicit destructor + return to pool
    }
}

No Exceptions: std::expected as Deterministic Error Handling

The -fno-exceptions compiler flag disables C++ exception support entirely — no throw, no try/catch. Use std::expected<T, E> (C++23) for structured error propagation:

cpp
#include <expected>
#include <cstdint>

enum class SensorError { Timeout, Overrange, CalibInvalid, I2CBusError };
enum class MotorError  { OverCurrent, EncoderFault, PositionLimit };

// Error-returning function (no exceptions):
std::expected<float, SensorError> read_temperature(uint8_t sensor_id) {
    if (!i2c_select(sensor_id)) return std::unexpected(SensorError::I2CBusError);
    
    uint16_t raw = i2c_read_u16();
    if (raw == 0xFFFF) return std::unexpected(SensorError::Timeout);
    
    float temp = (raw / 65535.0f) * 200.0f - 40.0f; // Convert to Celsius
    if (temp < -40.0f || temp > 125.0f)
        return std::unexpected(SensorError::Overrange);
    
    return temp; // OK — contains temperature
}

// Composing multiple fallible operations:
std::expected<void, SensorError> run_calibration() {
    auto t1 = read_temperature(SENSOR_A);
    if (!t1) return std::unexpected(t1.error());
    
    auto t2 = read_temperature(SENSOR_B);
    if (!t2) return std::unexpected(t2.error());
    
    calibrate(t1.value(), t2.value());
    return {}; // Success
}

// C++23 monadic operations — chain without explicit if:
void start() {
    auto result = read_temperature(SENSOR_A)
        .and_then([](float t) -> std::expected<float, SensorError> {
            return t > 100.0f ? std::unexpected(SensorError::Overrange) : t;
        })
        .transform([](float t) { return t * 1.8f + 32.0f; }) // Celsius to Fahrenheit
        .or_else([](SensorError e) -> std::expected<float, SensorError> {
            log_error(e);
            return 0.0f; // Default on error
        });
}

MISRA C++:2023 Key Rules

MISRA C++:2023 (Motor Industry Software Reliability Association) updated for modern C++:

RuleCategoryRationale
No dynamic heap allocation after initRequiredNon-deterministic, fragmentation
No exceptions (-fno-exceptions)RequiredNon-deterministic unwind time
No multiple inheritanceRequiredDiamond problem, layout complexity
No recursion without depth boundRequiredStack overflow risk
override on all overriding functionsRequiredPrevent silent signature mismatch
No gotoRequiredUnstructured control flow
No raw pointer arithmeticAdvisoryBuffer overrun risk
Use RAII for all resourcesRequiredDeterministic release
All variables initialized at declarationRequiredUndefined behavior prevention
No implicit conversions (use static_cast)AdvisoryUnintended narrowing
cpp
// MISRA-compliant error handling pattern:
[[nodiscard]] bool initialize_sensor(SensorConfig& config) noexcept {
    // All paths must return a value
    if (!hardware_present()) return false;
    config.calibration_offset = read_eeprom_config();
    return true;
}

// MISRA: Always check [[nodiscard]] return values
bool ok = initialize_sensor(config);
if (!ok) { handle_init_failure(); return; } // Must handle

Interrupt Service Routines in C++

cpp
// ISR in C++ — restrictions:
// - No heap allocation
// - No exceptions
// - No blocking operations
// - Minimal stack usage
// - Shared state must be volatile or atomic

#include <atomic>

// Shared flag between ISR and main loop:
volatile uint32_t tick_count = 0; // volatile for ISR-accessed globals

// Or prefer atomic for read-modify-write:
std::atomic<uint32_t> event_flags{0};
constexpr uint32_t SENSOR_READY_BIT = (1u << 0);
constexpr uint32_t UART_RX_BIT      = (1u << 1);

// ISR (platform-specific attribute):
extern "C" __attribute__((interrupt)) void TIM2_IRQHandler() {
    ++tick_count; // OK: volatile increment (32-bit aligned, atomic on ARM)
    event_flags.fetch_or(SENSOR_READY_BIT, std::memory_order_relaxed);
    // Clear interrupt flag (platform-specific register write):
    TIM2->SR &= ~TIM_SR_UIF;
}

// Main loop reads events:
void main_loop() {
    while (true) {
        auto flags = event_flags.exchange(0, std::memory_order_acquire);
        if (flags & SENSOR_READY_BIT) { process_sensor(); }
        if (flags & UART_RX_BIT)      { process_uart();   }
        // Enter low-power sleep if no work
        __WFI(); // Wait for interrupt (ARM Cortex-M instruction)
    }
}

Frequently Asked Questions

Is C++ better than C for embedded systems? Yes, when used correctly. C++ templates provide zero-cost abstractions over what C achieves with macros. RAII guarantees register/peripheral cleanup even under error paths. std::array replaces unsafe C arrays. std::expected replaces error-code sprawl. The risk is misusing hosted-only features (std::vector, std::string, exceptions) — MISRA and static analysis tools catch this.

Can I use the STL in embedded code? Selectively. The following are safe in embedded (no dynamic allocation): std::array, std::string_view, std::span, std::optional, std::expected, std::pair/tuple (with trivial types), std::variant, type traits, constexpr algorithms. The following require a heap and are typically forbidden: std::vector, std::string, std::map, std::list, std::function.

What is WCET analysis and how does it affect C++? Worst-Case Execution Time (WCET) analysis determines the maximum time any code path can take — essential for real-time systems where tasks must complete within deadlines. Dynamic dispatch (virtual functions), heap allocation, and exception unwind defeat WCET because their execution time is data-dependent. CRTP (static polymorphism), static allocation, and std::expected are WCET-friendly.


Key Takeaway

Embedded C++ is not "less C++"; it's C++ with explicit constraints turned into engineering discipline. The constraints — no heap after init, no exceptions, deterministic timing — force you to think about resource usage at every level. The reward: code that runs correctly in a car's brake controller for 15 years, a pacemaker's pulse generator for 10 years, or a satellite's attitude control system indefinitely. Mastering these constraints makes you a better engineer for all C++ contexts.

Read next: Enterprise Deployment: CI/CD & Final Wrap-up →


Part of the C++ Mastery Course — 30 modules from modern C++ basics to expert systems engineering.