Introduction
Critical sections and RTIC locks work well for shared state, but sometimes you need a different pattern: a producer-consumer queue where an interrupt handler produces data and the main loop consumes it. The heapless crate provides lock-free, fixed-capacity data structures designed for exactly this use case.
Key Concepts
- Lock-free: Operations complete without disabling interrupts or holding locks — they use atomic instructions instead.
- SPSC queue: Single-Producer, Single-Consumer queue — one writer and one reader can operate simultaneously without synchronization.
heapless::spsc::Queue: A fixed-capacity, lock-free ring buffer for passing data from interrupt handlers to main code.
Real World Context
A UART interrupt handler receives bytes one at a time. Rather than processing each byte in the ISR (slow) or using a critical section for every byte (high latency), you push bytes into an SPSC queue in the ISR and drain the queue in the main loop. This is the standard pattern for any streaming peripheral.
Deep Dive
SPSC Queue with heapless v0.8+
In heapless v0.8+, the queue is split into Producer and Consumer halves that can be safely held in different contexts:
rustuse heapless::spsc::Queue; // Create the queue with capacity 64 static QUEUE: Queue<u8, 64> = Queue::new(); fn main() { // Split into producer and consumer halves // SAFETY: split() must be called exactly once let (mut producer, mut consumer) = unsafe { QUEUE.split() }; // Store producer in a global for the ISR // (use Mutex<RefCell<Option<Producer>>> pattern) // ... loop { // Drain all available items while let Some(byte) = consumer.dequeue() { process_byte(byte); } cortex_m::asm::wfi(); } }
The split() method returns a Producer and Consumer pair. The producer can enqueue() from an interrupt handler while the consumer dequeue()s from the main loop — simultaneously, with no locks.
Passing the Producer to an ISR
To make the producer available in an interrupt handler, use the Mutex<RefCell<Option<>>> pattern:
rustuse cortex_m::interrupt::{free, Mutex}; use core::cell::RefCell; use heapless::spsc::{Queue, Producer}; static QUEUE: Queue<u8, 64> = Queue::new(); static TX_PRODUCER: Mutex<RefCell<Option<Producer<'static, u8, 64>>>> = Mutex::new(RefCell::new(None)); fn setup() { let (producer, consumer) = unsafe { QUEUE.split() }; free(|cs| { TX_PRODUCER.borrow(cs).replace(Some(producer)); }); // Use consumer in main loop } #[interrupt] fn USART2() { free(|cs| { if let Some(prod) = TX_PRODUCER.borrow(cs).borrow_mut().as_mut() { let byte = read_uart_byte(); prod.enqueue(byte).ok(); // Drop if queue full } }); }
The critical section here is very short — just the enqueue operation. The actual data processing happens outside with interrupts enabled.
Other heapless Types for Concurrency
Beyond SPSC queues, heapless provides additional fixed-capacity types:
rustuse heapless::{Vec, String, LinearMap, FnvIndexMap}; // Fixed-capacity vector — no heap allocation let mut buffer: Vec<u8, 256> = Vec::new(); buffer.extend_from_slice(b"sensor data").unwrap(); // Fixed-capacity hash map (FNV hashing) let mut lookup: FnvIndexMap<&str, u32, 16> = FnvIndexMap::new(); lookup.insert("temperature", 2500).unwrap();
All these types live on the stack (or in statics) and never allocate heap memory.
Common Pitfalls
- Calling
split()more than once —split()is unsafe because calling it twice creates two producers or two consumers for the same queue, breaking the SPSC guarantee. Call it exactly once. - Ignoring
enqueue()failures — When the queue is full,enqueue()returnsErr. In an ISR, you usually drop the value (.ok()), but you should increment an overflow counter to detect data loss. - Using the wrong heapless version — The SPSC queue API changed significantly between v0.7 and v0.8. Make sure your code matches the version in Cargo.toml.
Best Practices
- Size queues for worst-case burst — If your UART runs at 115200 baud and the main loop processes data every 10ms, the queue needs at least 115 bytes. Add margin for jitter.
- Use SPSC queues for ISR-to-main communication — This is the idiomatic pattern. Avoid sharing complex state between ISRs and main code.
- Monitor queue fill level — Periodically check
len()in the main loop to detect if you are falling behind. This is an early warning for throughput problems.
Summary
heapless::spsc::Queueis a lock-free SPSC ring buffer for ISR-to-main communication.- Call
split()exactly once to get aProducerandConsumerpair. - The producer can be used in an ISR, the consumer in the main loop — no locks needed during normal operation.
- Size queues for worst-case burst rates and monitor fill levels.
- All heapless types are fixed-capacity with no heap allocation.
Code Examples
use heapless::spsc::Queue;
use core::sync::atomic::{AtomicU32, Ordering};
// Fixed-capacity lock-free queue: 64 bytes, no heap
static QUEUE: Queue<u8, 64> = Queue::new();
static OVERFLOW_COUNT: AtomicU32 = AtomicU32::new(0);
// Called once during initialization
fn setup() -> heapless::spsc::Consumer<'static, u8, 64> {
// SAFETY: split() called exactly once
let (producer, consumer) = unsafe { QUEUE.split() };
// Store producer for ISR (via Mutex<RefCell<Option<>>>)
store_producer_in_global(producer);
consumer
}
// In the UART interrupt handler — enqueue received byte
fn isr_enqueue(producer: &mut heapless::spsc::Producer<'static, u8, 64>) {
let byte = read_uart_byte();
if producer.enqueue(byte).is_err() {
// Queue full — track data loss
OVERFLOW_COUNT.fetch_add(1, Ordering::Relaxed);
}
}
// In main loop — drain and process
fn process(consumer: &mut heapless::spsc::Consumer<'static, u8, 64>) {
while let Some(byte) = consumer.dequeue() {
handle_received_byte(byte);
}
}