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
- Static Allocation with Placement New
- No Exceptions: std::expected as Deterministic Error Handling
- MISRA C++:2023 Key Rules
- Interrupt Service Routines in C++
- Fixed-Point Arithmetic
- C++23 Freestanding Additions
- Stack Usage Analysis and Worst-Case Execution Time
- Frequently Asked Questions
- Key Takeaway
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:
- Non-deterministic timing:
malloc/newexecution time depends on heap state — unpredictable in real-time systems - Fragmentation: Long-running systems with frequent alloc/free develop heap fragmentation — eventual
mallocfailure - Out-of-memory: Dynamic allocation can fail at runtime; static allocation fails at compile time (link-time)
- WCET analysis: Worst-Case Execution Time analysis is impossible when heap allocation time is unbounded
// 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 bufferStatic Allocation with Placement New
Placement new constructs an object at a pre-allocated memory location — combining static allocation with C++ object construction:
#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:
#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++:
| Rule | Category | Rationale |
|---|---|---|
| No dynamic heap allocation after init | Required | Non-deterministic, fragmentation |
No exceptions (-fno-exceptions) | Required | Non-deterministic unwind time |
| No multiple inheritance | Required | Diamond problem, layout complexity |
| No recursion without depth bound | Required | Stack overflow risk |
override on all overriding functions | Required | Prevent silent signature mismatch |
No goto | Required | Unstructured control flow |
| No raw pointer arithmetic | Advisory | Buffer overrun risk |
| Use RAII for all resources | Required | Deterministic release |
| All variables initialized at declaration | Required | Undefined behavior prevention |
No implicit conversions (use static_cast) | Advisory | Unintended narrowing |
// 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 handleInterrupt Service Routines in C++
// 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.
