IDEAS INC.Electronics · Embedded
Product Engineering
← All articlesEmbedded · 1 October 2026 · 10 min read · IDEAS Engineering

Rust vs C/C++ for constrained embedded systems: the practical trade-offs

Rust promises memory safety without a garbage collector. On a microcontroller with 64 KB of RAM, does that beat decades of C and C++? A practical comparison for firmware teams.

Rust has gained real traction in embedded software because it offers something the traditional embedded languages cannot: memory safety enforced by the compiler, with no garbage collector.

But a microcontroller is not a server. A typical controller has tens or hundreds of kilobytes of RAM, a few hundred kilobytes of Flash, no operating system, hard real-time deadlines, and peripherals driven directly through registers. So the useful question is not "is Rust better than C?" but:

Why embedded is a different problem

On a desktop, a few extra kilobytes are irrelevant. On a Cortex-M0+ with 16 KB of RAM they can decide whether the design fits. Memory is part of the architecture, and firmware engineers routinely reason about:

  • Stack depth per task and per interrupt, static RAM, and whether a heap exists at all
  • Flash budget, including bootloader and space for over-the-air updates
  • DMA buffers, alignment and cache coherency
  • Interrupt latency and worst-case execution time (WCET)
  • Peripheral registers, read-modify-write races and memory-mapped I/O

Any language comparison has to be made against these constraints, not against general-purpose programming benchmarks.

C: still the baseline

C's dominance is not about elegance. It is that the entire embedded ecosystem speaks C: startup code, CMSIS, vendor HALs and SDKs, RTOS ports, middleware, debuggers, static analysers and decades of example code.

It has a negligible runtime, maps directly onto hardware, and gives the developer complete control over where everything lives:

c
#define GPIOA_ODR (*(volatile uint32_t *)0x40020014u)

static uint8_t rx_buffer[256];   /* fixed size, static RAM, no heap */

void led_on(void)
{
    /* read-modify-write on a hardware register */
    GPIOA_ODR |= (1u << 5);
}

That freedom is also C's weakness. Nothing stops a wrong address, a missing volatile, or an index past the end of a buffer:

c
uint8_t buffer[10];
/* compiles; silently corrupts whatever lives next to buffer */
buffer[20] = 5;

Buffer overflows, use-after-free, dangling pointers, uninitialised variables, integer overflow and data races between interrupts and the main loop are all expressible in C, and many of them only appear under rare timing or input conditions in the field. Coding standards such as MISRA C, static analysis and code review exist precisely to compensate.

C++: more structure, same control

C++ keeps C's low-level access and adds tools for organising larger firmware: classes, templates, RAII, namespaces, constexpr and stronger types. Used well, these are zero-cost — they compile down to the same machine code as hand-written C.

cpp
template <typename T>
constexpr T square(T x) { return x * x; }

static_assert(square(12) == 144);   // evaluated entirely at compile time

C++ also calls existing C drivers directly, so a project can keep the vendor HAL and build a cleaner application architecture on top of it.

The cost is language size. Exceptions, RTTI, unconstrained heap use, deep inheritance and template-heavy code can quietly add Flash, RAM and non-determinism. Embedded C++ works best as a deliberately restricted subset:

  • Build with -fno-exceptions -fno-rtti (and usually -fno-threadsafe-statics)
  • No heap after initialisation; use std::array, fixed-capacity containers (e.g. the ETL library) and static objects
  • Keep virtual dispatch for real polymorphism only, and review template instantiations for code size
  • Adopt a guideline such as MISRA C++:2023 or AUTOSAR C++14 where safety matters

Rust: safety checked by the compiler

Rust's ownership and borrowing rules let the compiler prove that a large class of memory errors cannot happen in safe code — before the firmware ever runs. Compare two function signatures:

c
/* Who owns data? How long is it? Is it still valid? */
void process(uint8_t *data);
rust
// a borrowed slice: carries its length, cannot outlive its owner
fn process(data: &[u8]) { }

The Rust slice carries its length, so out-of-range indexing is caught (a bounds check that panics) instead of silently corrupting memory. And the borrow checker guarantees the data is valid for the duration of the call.

No garbage collector — and no heap required

Embedded Rust runs in #![no_std] mode: no operating system, no standard library and no garbage collector. Allocation is opt-in. Note that the familiar vec![0u8; 256] needs a heap allocator, which a bare-metal target does not have unless you add one. Most embedded Rust avoids it entirely:

rust
#![no_std]
#![no_main]

use heapless::Vec;               // fixed-capacity collections, no allocator
use static_cell::StaticCell;

static RX_BUF: StaticCell<[u8; 256]> = StaticCell::new();

fn init() {
    // static RAM, handed out exactly once
    let rx: &'static mut [u8; 256] = RX_BUF.init([0; 256]);

    // capacity fixed at compile time
    let mut frame: Vec<u8, 64> = Vec::new();

    // returns an error when full, never reallocates
    frame.push(0x7E).ok();
}

The result is the same deterministic memory model C engineers rely on — fixed buffers, static allocation, no fragmentation — but with the compiler checking that each buffer has exactly one mutable owner.

Where Rust helps most: interrupts and shared state

Data races between an interrupt handler and the main loop are one of the hardest classes of firmware bug. In C, this compiles without complaint:

c
volatile uint32_t pulses;

void TIM2_IRQHandler(void) { pulses++; }

void main_loop(void)
{
    /* read-modify-write: an interrupt in between loses a count */
    pulses = pulses + 10;
}

In safe Rust, a static that is shared with an interrupt cannot be mutated without some form of synchronisation, so the equivalent code has to make the critical section explicit:

rust
use core::cell::RefCell;
use critical_section::Mutex;

static PULSES: Mutex<RefCell<u32>> = Mutex::new(RefCell::new(0));

fn add_pulses(n: u32) {
    // interrupts are masked for the duration of this closure
    critical_section::with(|cs| *PULSES.borrow_ref_mut(cs) += n);
}

Frameworks such as RTIC and Embassy go further, assigning resources to tasks and interrupt priorities so the compiler can prove access is race-free with minimal locking.

unsafe, hardware access and the ecosystem

Rust does not remove unsafe operations — embedded code must write registers, set up DMA and call C. What it does is make the boundary explicit and searchable. Register access is usually through a Peripheral Access Crate (PAC) generated from the vendor's SVD file, with a HAL on top:

rust
// singleton: there is only ever one owner of the peripherals
let dp = pac::Peripherals::take().unwrap();

// typed register fields instead of magic masks
// (exact method names depend on the PAC and its generator version)
dp.GPIOA.odr().modify(|_, w| w.odr5().set_bit());

A good architecture keeps unsafe in a thin layer and builds everything else in safe code:

text
Application (safe Rust)
        │
Drivers / embedded-hal traits (safe Rust)
        │
HAL + PAC  ← unsafe is contained here
        │
Registers / vendor C SDK

The practical limitation is the ecosystem, which is mature for some MCU families and thin for others:

  • Rust compiles through LLVM. ARM Cortex-M and RISC-V are first-class targets, and Espressif maintains official Rust support for its chips. AVR is experimental, and many 8-bit and proprietary cores (8051, PIC, RL78 and similar) have no Rust target at all.
  • Support for STM32, nRF and RP2040/RP2350 is largely community-maintained (stm32-rs, nrf-rs, rp-rs, Embassy) and often very good. For a brand-new or niche part, the vendor C SDK will usually arrive first.
  • Tooling has improved a lot: embedded-hal 1.0 standardises driver interfaces, probe-rs handles flashing and debugging, and defmt provides compact logging.
  • For safety-certified work, Ferrocene provides a Rust toolchain qualified for ISO 26262 (ASIL D) and IEC 61508 (SIL 4).

So the question is rarely "can Rust drive this hardware?" — it usually can — but "how mature is Rust support for this MCU, its peripherals and our RTOS or stack?"

Performance, code size and real-time behaviour

Rust, C and C++ all compile to optimised native code via mature backends (LLVM, GCC). For CPU-bound work, well-written code in any of them performs comparably; the compiler, optimisation level and algorithm matter more than the language label. Two Rust-specific costs are worth knowing:

  • Bounds checks on indexing. Usually negligible, and often removed by the optimiser when iterators or pre-checked lengths are used.
  • Panic and formatting machinery. Pulling in core::fmt can add several kilobytes; a minimal panic handler (halt or reset) and defmt instead of format!-style logging keep this small.

Claims like "Rust firmware is always bigger" or "always smaller" are both wrong. Size depends on optimisation settings, generics, libraries, panic strategy and architecture. Measure the real binary, with comparable settings:

toml
# Cargo.toml — typical size-oriented release profile
[profile.release]
opt-level = "z"      # optimise for size ("s" is a good alternative)
lto = true
codegen-units = 1
panic = "abort"
debug = true         # debug info stays in the ELF, not in Flash
sh
cargo size --release -- -A        # Rust (cargo-binutils)
arm-none-eabi-size firmware.elf   # C/C++: text = Flash, data + bss = RAM

Real-time behaviour is the same story. Rust has no garbage-collector pauses, but determinism still comes from the architecture: interrupt priorities, bounded critical sections, no blocking calls or allocation in time-critical paths, and measured worst-case execution time. Memory safety does not imply real-time safety — in any language.

Developer productivity and the learning curve

Rust moves effort from the debugger to the compiler. A C developer can declare uint8_t *ptr; and keep going; a Rust developer must satisfy ownership, borrowing, lifetimes and traits before the code compiles. The compiler will sometimes reject code that is actually correct, simply because it cannot prove it.

Teams typically need a few months to become productive. The pay-off is that whole categories of bugs — dangling pointers, double frees, unsynchronised shared state — stop reaching integration testing and the field.

Side-by-side comparison

AreaCC++Rust
MCU / vendor SDK supportUniversalUniversalStrong on Cortex-M, RISC-V, ESP32; varies elsewhere
Memory-error protectionWeak (relies on discipline, MISRA, analysis)Moderate (RAII, types; still unchecked)Strong in safe code
Data-race protectionNot checked by compilerNot checked by compiler (atomics available)Checked at compile time, in safe code
No-heap / static designExcellentExcellentExcellent (heapless, StaticCell)
Runtime overheadMinimalMinimal with a restricted subsetMinimal; watch panic/fmt code
AbstractionLimitedExcellentExcellent
Learning curveLow–mediumHighHigh
Legacy C integrationNativeNativeGood via FFI
Safety-certified toolchainsMatureMatureAvailable (Ferrocene)
Ecosystem maturityVery highVery highGrowing quickly

Choosing for your product

The MCU and the product constraints usually decide. Two contrasting cases:

System ASystem B
Hardware8/16-bit or small Cortex-M0+, 32 KB RAM, 128 KB FlashCortex-M33/M7 or RISC-V, 512 KB RAM, 2 MB Flash
SoftwareBare metal, small control loop, vendor C SDKRTOS or async, networking, several protocols, OTA updates
TeamOne or two firmware engineersSeveral developers, long product life
Likely fitC (or minimal C++)C++ subset or Rust — Rust especially for parsers, protocols and concurrency

Choose C when the part is very small or has no Rust target, the vendor SDK is the product, the codebase and team are C-centric, or schedule matters more than language-level safety.

Choose C++ when the firmware is large enough that architecture dominates — motor control, communications, diagnostics, fault management and configuration as separate components — and the team can hold to a disciplined subset.

Choose Rust when memory safety and concurrency correctness are expensive to get wrong, the device parses untrusted or complex input (radio stacks, network protocols, file formats), several developers will maintain the code for years, and Rust support for the MCU is solid.

A hybrid is often the best answer

It does not have to be all or nothing. Rust and C link together through a C ABI, so a product can keep its proven vendor HAL in C and write new application components — or a single high-risk module such as a protocol parser — in Rust:

rust
// Rust module callable from existing C firmware
#[no_mangle]   // Rust 2024 edition: #[unsafe(no_mangle)]
pub extern "C" fn frame_crc16(data: *const u8, len: usize) -> u16 {
    // SAFETY: the C caller guarantees data points to len valid bytes
    let bytes = unsafe { core::slice::from_raw_parts(data, len) };
    crc16(bytes)   // everything from here on is safe Rust
}

This lets a team adopt Rust incrementally, measure the real cost in Flash, RAM and development time on their own hardware, and expand only where it pays off.

Decision checklist

  • Does the MCU have a stable Rust target, PAC and HAL — and do our RTOS, stacks and middleware have Rust support or a clean C interface?
  • What are our Flash and RAM margins after the bootloader and OTA reserve?
  • Which bugs have actually cost us most in the field: memory corruption and races, or logic and timing?
  • Does the team have time to absorb a new language, or is there a C++ subset we would follow consistently?
  • Is a certified toolchain required (ISO 26262, IEC 61508, IEC 62304), and is it available for our target?
  • Can we pilot one module, measure it, and decide from data?

Conclusion

There is no universal winner. C remains hard to replace thanks to its ecosystem, vendor support and predictable resource use. C++ is a strong middle ground once firmware architecture becomes the main challenge. Rust offers something genuinely new for embedded work: low-level control with many memory and concurrency errors moved from field failures to compile errors.

Its costs are real too: a steep learning curve, uneven MCU support, and the continuing need for unsafe and C at the hardware boundary. For many existing products the answer will stay C or C++. For new, complex, connected devices, Rust deserves serious evaluation — not as a replacement for the traditional languages, but as a different, and often better, trade-off between control and safety.

Working on something similar?

Talk to the IDEAS team about your hardware or firmware.

Start a conversation ↗
MORE ARTICLES
Power Electronics · 28 October 2026

Shoot-through and the VGS bump: understanding and calculating them in a half-bridge

Why both MOSFETs in a half-bridge can end up on at once, how a fast switch node lifts the gate of the 'off' device, and how to calculate dead time and the VGS bump before the first board is built.

Hardware · 18 September 2026

Design for EMC from the first schematic

Most EMC failures are decided long before the test lab. A few habits at schematic and layout stage save weeks of re-spins.

IoT · 27 August 2026

Choosing a wireless link for battery-powered devices

BLE, Zigbee, Wi-Fi, NB-IoT or LoRa? The right choice starts with how much data you send, how often, and who owns the network.