Blog
Feb 15, 2026 - 12 MIN READ
Learning Embedded Rust: A C/Python Developer's Rosetta Stone

Learning Embedded Rust: A C/Python Developer's Rosetta Stone

A practical guide for C and Python developers transitioning to embedded Rust. Side-by-side comparisons of ownership, error handling, pattern matching, and more — mapped to concepts you already know.

Mariusz Smenzyk

Mariusz Smenzyk

AI Developer ✨ MusicTech ✨ SportTech

You know C. You know Python. Now you're staring at Rust code full of &mut, Result<T, E>, and lifetime annotations — and wondering if you accidentally opened a math textbook.

I've been there. While building BeatBuddy — a wearable swim tempo device — I transitioned from C (nRF52832) to Rust (nRF52840, ESP32). This article is the "Rosetta Stone" I wish I had: side-by-side comparisons of everyday patterns across all three languages, focused on embedded and systems programming.

Why Rust for Embedded?

Before the comparisons, a quick "why bother":

  • Memory safety without a garbage collector — no more use-after-free bugs at 3 AM
  • Zero-cost abstractions — iterators, generics, and traits compile down to the same code you'd write by hand in C
  • Cargo ecosystem — dependency management that actually works (looking at you, C)
  • Fearless concurrency — the compiler catches data races at compile time

The trade-off? A steeper learning curve upfront. That's what this article is for.


1. Memory Management — The Big One

This is where Rust diverges most dramatically from both C and Python.

RustCPython
ModelOwnership + borrowingManual (malloc/free)Garbage collection (reference counting + GC)
Heap allocationExplicit (Box, Vec)malloc() / calloc()Implicit (everything is heap-allocated)
Stack allocationDefault for local variablesDefault for local variablesN/A (CPython uses a private heap)
DeallocationAutomatic when owner goes out of scopeManual — you must call free()Automatic via reference counting

Rust

fn process_data() {
    let buffer = vec![0u8; 256]; // heap-allocated, owned by `buffer`
    do_something(&buffer);       // borrowed — no ownership transfer
}   // `buffer` dropped here automatically, memory freed

C

void process_data() {
    uint8_t *buffer = malloc(256);
    if (!buffer) return;          // must check for NULL!
    do_something(buffer, 256);
    free(buffer);                 // forget this = memory leak
}

Python

def process_data():
    buffer = bytearray(256)      # heap-allocated, GC-managed
    do_something(buffer)          # passed by reference
    # no cleanup needed — GC handles it

Key insight: Rust gives you C-level control with Python-level convenience. The compiler enforces the rules that C trusts you to follow manually.


2. Error Handling

RustCPython
MechanismResult<T, E> typeReturn codes + errnoExceptions (try/except)
Can you ignore errors?No — compiler warns on unused ResultYes — easy to forget checkingYes — uncaught exceptions crash
Propagation? operatorManual if chainsAutomatic stack unwinding

Rust

fn read_sensor() -> Result<u16, SensorError> {
    let raw = i2c.read(SENSOR_ADDR, &mut buf)?;  // `?` propagates error
    Ok(parse_value(raw))
}

C

int read_sensor(uint16_t *value) {
    uint8_t buf[2];
    int err = i2c_read(SENSOR_ADDR, buf, sizeof(buf));
    if (err < 0) return err;    // manual propagation
    *value = parse_value(buf);
    return 0;
}

Python

def read_sensor() -> int:
    try:
        raw = i2c.read(SENSOR_ADDR, 2)
        return parse_value(raw)
    except IOError as e:
        raise SensorError(f"Read failed: {e}")

Key insight: Rust's ? operator gives you the explicitness of C's return codes with the ergonomics of Python's exceptions — but with compile-time enforcement.


3. Pattern Matching

RustCPython
Syntaxmatch expressionswitch statementmatch statement (3.10+)
Returns a value?Yes — it's an expressionNo — it's a statementNo
Exhaustive?Yes — compiler error if you miss a caseNoNo
Destructuring?Yes — deep structural matchingNoYes (structural)

Rust

enum SensorState {
    Ready(u16),
    Calibrating { progress: u8 },
    Error(SensorError),
}

let message = match state {
    SensorState::Ready(value) => format!("HR: {} bpm", value),
    SensorState::Calibrating { progress } => format!("Cal: {}%", progress),
    SensorState::Error(e) => format!("Err: {:?}", e),
};  // compiler ensures ALL variants are covered

C

switch (state.type) {
    case SENSOR_READY:
        sprintf(msg, "HR: %d bpm", state.value);
        break;                   // forget this = fall-through bug
    case SENSOR_CALIBRATING:
        sprintf(msg, "Cal: %d%%", state.progress);
        break;
    case SENSOR_ERROR:
        sprintf(msg, "Err: %d", state.error);
        break;
    // no compiler warning if you forget a case
}

Python

match state:
    case SensorState(status="ready", value=v):
        message = f"HR: {v} bpm"
    case SensorState(status="calibrating", progress=p):
        message = f"Cal: {p}%"
    case SensorState(status="error", error=e):
        message = f"Err: {e}"
    # no enforcement if you miss a case

Key insight: Rust's match is what C's switch dreams of being — it returns values, destructures data, and the compiler won't let you forget a case.


4. Traits vs Function Pointers vs Duck Typing

RustCPython
AbstractionTraitsFunction pointers / vtablesDuck typing / Protocols
Checked atCompile timeRuntime (or never)Runtime
Zero-cost?Yes (static dispatch)Yes (but unsafe)No (dynamic lookup)

Rust

trait Sensor {
    fn read(&mut self) -> Result<u16, SensorError>;
    fn name(&self) -> &str;
}

impl Sensor for Max30100 {
    fn read(&mut self) -> Result<u16, SensorError> { /* ... */ }
    fn name(&self) -> &str { "MAX30100" }
}

fn log_reading(sensor: &mut impl Sensor) {  // static dispatch
    if let Ok(val) = sensor.read() {
        println!("{}: {}", sensor.name(), val);
    }
}

C

typedef struct {
    int (*read)(void *ctx, uint16_t *value);
    const char *(*name)(void *ctx);
} sensor_vtable_t;

void log_reading(const sensor_vtable_t *vtable, void *ctx) {
    uint16_t val;
    if (vtable->read(ctx, &val) == 0) {
        printf("%s: %d\n", vtable->name(ctx), val);
    }
}

Python

class Max30100:
    def read(self) -> int: ...
    def name(self) -> str: return "MAX30100"

def log_reading(sensor):  # duck typing — any object with read() and name()
    try:
        val = sensor.read()
        print(f"{sensor.name()}: {val}")
    except Exception:
        pass

Key insight: Rust traits give you C's performance (zero-cost static dispatch) with Python's expressiveness (clean interfaces) — plus compile-time verification that your types actually implement what they claim.


5. Unsafe Code

RustCPython
Safety modelSafe by default, unsafe blocks for exceptionsEverything is unsafeSafe (CPython handles memory)
Hardware accessRequires unsafe or HAL wrappersDirect pointer accessctypes / FFI
BoundaryExplicit and auditableNone — entire codebase is "unsafe"At the C extension boundary

Rust

// Safe wrapper around unsafe hardware access
fn read_register(addr: u32) -> u32 {
    unsafe {
        core::ptr::read_volatile(addr as *const u32)
    }
}

// Application code stays safe
let status = read_register(STATUS_REG);

C

// No safety boundary — you just do it
uint32_t status = *(volatile uint32_t *)STATUS_REG;
// hope you got the address right!

Python

# Usually not applicable — you'd use a library
import ctypes
status = ctypes.c_uint32.from_address(STATUS_REG).value

Key insight: In C, everything is unsafe — you just don't notice. Rust makes you mark the dangerous parts explicitly, so the rest of your code has compiler-backed safety guarantees. It's not more work — it's the same work, made visible.


6. Concurrency — Async in Embedded

Rust (Embassy)C (RTOS)Python (asyncio)
Modelasync/await (cooperative)Threads + mutexes (preemptive)async/await (cooperative)
Data sharingCompile-time checked (Send/Sync)Manual locking (hope you don't forget)GIL makes it "safe" (but slow)
Stack usageSingle stack (state machines)One stack per taskSingle stack (event loop)

Rust (Embassy)

#[embassy_executor::task]
async fn sensor_task(mut sensor: Max30100) {
    loop {
        let hr = sensor.read().await;  // yields to other tasks
        Timer::after(Duration::from_millis(100)).await;
    }
}

C (FreeRTOS)

void sensor_task(void *params) {
    max30100_t *sensor = (max30100_t *)params;
    while (1) {
        uint16_t hr;
        max30100_read(sensor, &hr);    // blocks this thread
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

Python (asyncio)

async def sensor_task(sensor):
    while True:
        hr = await sensor.read()       # yields to event loop
        await asyncio.sleep(0.1)

Key insight: Rust's Embassy framework gives you Python-like async syntax on a microcontroller with no heap allocation and no RTOS overhead. Each await point compiles into a state machine — zero runtime cost.


Quick Reference Cheat Sheet

ConceptRustCPython
NullOption<T>NULL pointerNone
ErrorResult<T, E>Return code + errnoException
InterfacetraitFunction pointer tableProtocol / ABC
Genericfn foo<T: Trait>()void * + macrosDuck typing
Immutable by defaultYes (let)No (const is opt-in)No (by convention)
String type&str / Stringchar * / char[]str
Array with length[T; N] / &[T]Pointer + separate lengthlist
Print debug{:?} (Debug trait)Manual printfrepr()
Package managerCargoNone (CMake, Make, ...)pip / poetry
Build systemCargoMake / CMake / Ninjasetuptools / hatch

Final Thoughts

The hardest part of learning Rust isn't the syntax — it's unlearning habits from C and Python:

  • From C: Stop thinking about malloc/free. Let ownership handle it.
  • From Python: Stop assuming everything is dynamic. Embrace the type system — it's your ally, not your enemy.
  • From both: Stop fighting the borrow checker. When it complains, it's usually pointing out a real bug you'd ship in C or a performance issue you'd ignore in Python.

The learning curve is real, but it's front-loaded. Once you internalize ownership and borrowing, Rust becomes the most productive systems language I've used. You get C's performance, Python's expressiveness, and safety guarantees neither can offer.

Welcome to the Rust side. Your microcontrollers will thank you.


This article was written while building BeatBuddy, a wearable swim tempo device. The project transitioned from C (nRF52832) to Rust (nRF52840 + ESP32), giving me a front-row seat to this language comparison.