Writing no_std Rust firmware requires different habits than server Rust. Memory is constrained, timing is strict, and panic strategy must be deliberate. This guide covers practical patterns that scale from prototypes to maintainable firmware.

1. Crate architecture

Keep modules separate by responsibility:

  • hal_adapter: hardware binding
  • domain: pure logic and state transitions
  • io: serialization and protocol framing
  • app: orchestration loop

The domain layer should compile without hardware dependencies. This makes logic testable on host.

2. Allocation policy

Avoid dynamic allocation in hot paths. Prefer:

  • fixed-size buffers
  • static ring buffers
  • compile-time capacity types

If heap is required, isolate it and monitor high-water marks.

3. Panic and fault behavior

Default panic behavior is rarely appropriate for deployment. Choose and document strategy:

  • panic -> log minimal info -> reset
  • panic -> enter safe state and blink fault code

The choice depends on safety profile of the device.

4. Time and scheduling

Use monotonic timers and explicit deadlines. Busy waiting is acceptable only when measured and bounded. Cooperative scheduling with short tasks keeps latency predictable.

5. Error design

Typed errors are valuable even in no_std. Keep enums compact and map transport errors clearly.

#[derive(Debug, Clone, Copy)]
pub enum SensorError {
    Timeout,
    Crc,
    NotReady,
}

Avoid string-heavy error paths in constrained targets.

6. Peripheral ownership model

Use ownership to prevent unsafe shared access:

  • one owner per peripheral when possible
  • controlled split APIs when sharing is necessary
  • critical sections only around minimal operations

Race bugs in embedded systems are expensive to detect and reproduce.

7. Diagnostics in constrained environments

Even with limited bandwidth, include lightweight diagnostics:

  • reset reason code
  • error counters in RAM or persistent storage
  • compact event log ring buffer

A few bytes of diagnostics can save days of blind debugging.

8. Host-side testing

You can test more than expected without hardware:

  • domain logic unit tests
  • parser round-trip tests
  • property tests for edge input cases

Reserve hardware-in-loop for timing and peripheral integration validation.

A minimal no_std skeleton

Before any of the above matters, the crate has to actually build without std. The skeleton I start from declares no_std/no_main, provides a panic handler, and uses a Cortex-M runtime entry point:

#![no_std]
#![no_main]

use cortex_m_rt::entry;
use panic_halt as _; // panic = halt; swap for panic-reset in the field

use heapless::Vec; // fixed-capacity, no allocator required

#[entry]
fn main() -> ! {
    // Capacity is fixed at compile time; pushes past 16 return Err, never allocate.
    let mut samples: Vec<u16, 16> = Vec::new();
    let _ = samples.push(0);

    loop {
        // cooperative control loop goes here
    }
}

heapless gives me Vec, String, and queues with capacity baked into the type, so I get familiar collections with zero heap. The panic handler is not optional in no_std — the program will not link without one. panic-halt is fine on the bench; on deployed hardware I switch to a handler that logs a fault code and resets, matching the panic strategy from section 3.

Final note

no_std Rust is not about removing features. It is about designing explicit constraints into architecture. When ownership, memory, and error paths are intentional, firmware quality increases sharply.

Sources

  • Imported from the previous version of the site (gaborl.hu). Original publication around 2024-10-15.