A Lock-Free SPSC Ring Buffer for ISR-to-Main-Loop Data in Embedded C
An architecture note on a lock-free single-producer/single-consumer ring buffer for moving data from an ISR to the main loop in embedded C: minimal design, trust boundaries, overflow policy, checks, and failure modes.
26 Nov 2025, 05:33 UTC

The problem: getting data out of an ISR without blocking
An interrupt service routine that receives bytes from a UART, samples from an ADC, or events from a GPIO must hand that data to the main loop. The ISR cannot wait: blocking on a mutex, allocating memory, or looping until the consumer catches up would stretch interrupt latency and can destabilize the whole system. The useful takeaway: on a single-core microcontroller with exactly one producer (the ISR) and one consumer (the main loop), a fixed-size ring buffer with one writer per index needs no locks at all — but only if you get the index handling, sizing, and overflow policy right.
Requirements
- The ISR must never block, never allocate, and finish in bounded time.
- The main loop consumes data whenever it gets to it, possibly much later than it arrived.
- Data ordering must be preserved (FIFO).
- Capacity is fixed at build time; RAM is scarce.
- Behavior on overflow must be explicit, not accidental.
The smallest suitable design
Use a power-of-two byte array plus two indices: head (written only by the ISR) and tail (written only by the main loop). Because each index has exactly one writer, there is no read-modify-write race and no mutex is required. Power-of-two sizing turns wrap-around into a bitmask, which is cheaper and keeps ISR latency predictable on small MCUs without a fast divider.
#include <stdint.h>
#include <stdbool.h>
#define RB_SIZE 64u /* must be a power of two */
#define RB_MASK (RB_SIZE - 1u)
static volatile uint8_t rb_buf[RB_SIZE];
static volatile uint32_t rb_head; /* written by ISR only */
static volatile uint32_t rb_tail; /* written by main loop only */
static volatile uint32_t rb_dropped;
/* Called from ISR context only. */
void rb_push(uint8_t b)
{
uint32_t head = rb_head;
uint32_t next = (head + 1u) & RB_MASK;
if (next == rb_tail) { /* full: drop new data */
rb_dropped++;
return;
}
rb_buf[head] = b;
rb_head = next; /* publish after data write */
}
/* Called from main-loop context only. */
bool rb_pop(uint8_t *out)
{
uint32_t tail = rb_tail;
if (tail == rb_head) /* empty */
return false;
*out = rb_buf[tail];
rb_tail = (tail + 1u) & RB_MASK;
return true;
}
Design points worth noting:
- One slot is kept empty. Full is detected as "next head equals tail"; empty is "head equals tail". This avoids a shared count variable, which would have two writers and reintroduce a race.
- Indices never wrap down to zero by comparison tricks. They are masked on every increment, so all comparisons stay simple equality checks.
- The data byte is written before the head index is updated. On a single-core MCU with naturally aligned word-sized stores this ordering is enough; the consumer never sees the new head before the byte is in place.
Trust and data boundaries
The boundary between ISR and main-loop context is where this design lives or dies. The volatile qualifiers on the indices and buffer are not decoration: without them the compiler may cache rb_head in a register inside the consumer's loop and never observe the ISR's updates. volatile forces each access to memory.
Two further constraints keep this safe:
- Index width must be atomically accessible. Use the MCU's natural word size (e.g. 32-bit on an ARM Cortex-M, 8/16-bit on small AVR/PIC parts). If an index ever requires two instructions to load, the ISR can interrupt the consumer mid-load and the consumer reads a torn value. On 8-bit targets, either use 8-bit indices or snapshot the ISR-owned index with interrupts briefly masked.
- This is single-core only.
volatiledoes not insert hardware memory barriers. On multi-core parts, or when the producer is a DMA engine rather than code, you need the architecture's barrier instructions or atomic operations, and a different analysis.
Overflow policy is a decision, not a bug
The example above drops new data when full and counts the drops. That is usually right for control and command streams, where stale queued data is more valuable than fresh data, and silent loss of either is worse than a counted loss. The alternative — overwriting the oldest byte by advancing tail in the ISR — suits continuous sampling streams where freshness matters more than completeness. Whichever you pick, make it explicit and instrumented; an uncounted overflow is indistinguishable from correct operation until something downstream misbehaves.
Operational checks
- Drop counter. Expose
rb_droppedover your debug interface or a trace log. Any non-zero value in testing means the buffer is undersized or the consumer is too slow. - High-water mark. In the consumer, record the maximum occupancy ever seen (
(head - tail) & RB_MASK). If it approachesRB_SIZE - 1, you have no margin for bursts. - Latency check on hardware. Toggle a GPIO on ISR entry and another when the consumer pops the byte, then measure with a logic analyzer under worst-case load. This verifies end-to-end latency stays bounded — something a host test cannot show.
- Host-based unit test. The structure compiles fine on a PC. Drive it with a simulated ISR that bursts faster than the consumer drains, and assert on drop counts and data ordering.
- Disassembly inspection. Check the generated code for head/tail accesses at your production optimization level: loads and stores should be single instructions, with no register caching across the context boundary. Latent bugs here often appear only at
-O2.
Failure modes
- Torn index reads on targets where the index type is wider than the atomic load width — symptoms are rare, intermittent corruption that vanishes under a debugger.
- Missing
volatilecausing the consumer to spin forever on a stale cached head, typically only at higher optimization. - Unbounded burst exceeding capacity between consumer runs, surfacing as a climbing drop counter.
- Accidental second writer — a later maintainer adds a second call site for
rb_pushfrom non-ISR context, silently breaking the single-writer invariant. Document the context rules in the header.
When this design is the wrong one
Change the design when any of these become true: the target is multi-core (add memory barriers or use the vendor's atomic primitives); the producer is DMA (cache coherency and barriers enter the picture); there are multiple producers or consumers (you need critical sections or a genuine lock-free MPMC queue, which is a much harder structure); or interrupt latency from any masking fallback is unacceptable. If you find yourself disabling interrupts around every access in the main loop, that is a valid fallback but a signal to re-examine whether the simple structure still fits.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.