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
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.
Before the comparisons, a quick "why bother":
The trade-off? A steeper learning curve upfront. That's what this article is for.
This is where Rust diverges most dramatically from both C and Python.
| Rust | C | Python | |
|---|---|---|---|
| Model | Ownership + borrowing | Manual (malloc/free) | Garbage collection (reference counting + GC) |
| Heap allocation | Explicit (Box, Vec) | malloc() / calloc() | Implicit (everything is heap-allocated) |
| Stack allocation | Default for local variables | Default for local variables | N/A (CPython uses a private heap) |
| Deallocation | Automatic when owner goes out of scope | Manual — you must call free() | Automatic via reference counting |
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
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
}
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.
| Rust | C | Python | |
|---|---|---|---|
| Mechanism | Result<T, E> type | Return codes + errno | Exceptions (try/except) |
| Can you ignore errors? | No — compiler warns on unused Result | Yes — easy to forget checking | Yes — uncaught exceptions crash |
| Propagation | ? operator | Manual if chains | Automatic stack unwinding |
fn read_sensor() -> Result<u16, SensorError> {
let raw = i2c.read(SENSOR_ADDR, &mut buf)?; // `?` propagates error
Ok(parse_value(raw))
}
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;
}
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.
| Rust | C | Python | |
|---|---|---|---|
| Syntax | match expression | switch statement | match statement (3.10+) |
| Returns a value? | Yes — it's an expression | No — it's a statement | No |
| Exhaustive? | Yes — compiler error if you miss a case | No | No |
| Destructuring? | Yes — deep structural matching | No | Yes (structural) |
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
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
}
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.
| Rust | C | Python | |
|---|---|---|---|
| Abstraction | Traits | Function pointers / vtables | Duck typing / Protocols |
| Checked at | Compile time | Runtime (or never) | Runtime |
| Zero-cost? | Yes (static dispatch) | Yes (but unsafe) | No (dynamic lookup) |
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);
}
}
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);
}
}
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.
| Rust | C | Python | |
|---|---|---|---|
| Safety model | Safe by default, unsafe blocks for exceptions | Everything is unsafe | Safe (CPython handles memory) |
| Hardware access | Requires unsafe or HAL wrappers | Direct pointer access | ctypes / FFI |
| Boundary | Explicit and auditable | None — entire codebase is "unsafe" | At the C extension boundary |
// 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);
// No safety boundary — you just do it
uint32_t status = *(volatile uint32_t *)STATUS_REG;
// hope you got the address right!
# 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.
| Rust (Embassy) | C (RTOS) | Python (asyncio) | |
|---|---|---|---|
| Model | async/await (cooperative) | Threads + mutexes (preemptive) | async/await (cooperative) |
| Data sharing | Compile-time checked (Send/Sync) | Manual locking (hope you don't forget) | GIL makes it "safe" (but slow) |
| Stack usage | Single stack (state machines) | One stack per task | Single stack (event loop) |
#[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;
}
}
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));
}
}
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.
| Concept | Rust | C | Python |
|---|---|---|---|
| Null | Option<T> | NULL pointer | None |
| Error | Result<T, E> | Return code + errno | Exception |
| Interface | trait | Function pointer table | Protocol / ABC |
| Generic | fn foo<T: Trait>() | void * + macros | Duck typing |
| Immutable by default | Yes (let) | No (const is opt-in) | No (by convention) |
| String type | &str / String | char * / char[] | str |
| Array with length | [T; N] / &[T] | Pointer + separate length | list |
| Print debug | {:?} (Debug trait) | Manual printf | repr() |
| Package manager | Cargo | None (CMake, Make, ...) | pip / poetry |
| Build system | Cargo | Make / CMake / Ninja | setuptools / hatch |
The hardest part of learning Rust isn't the syntax — it's unlearning habits from C and Python:
malloc/free. Let ownership handle it.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.
The very first attempt to implement 4DX in Bravelab.io
A real-life example of implementing the Four Disciplines of Execution framework in a software development company. Learn what worked, what didn't, and how to get started with 4DX.
Mirroring a Wearable OLED Display to a Projector for Live Demos
How to stream a tiny OLED screen from an embedded device to a laptop and projector in real-time using UART and Python. A practical guide for hardware demos and presentations.