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 bindingdomain: pure logic and state transitionsio: serialization and protocol framingapp: 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.
Update history
- — Adopted from previous gaborl.hu (Hugo) site via legacy import.