Embedded

Ring buffers for ISR-to-task data

By CourseTron Team ·  7 min read ·  Updated Oct 2026 💬 𝕏 in f
Table of Contents
    ? Print / Save as PDF

    In short: A single-producer/single-consumer ring buffer lets an ISR hand data to a background task with no locks — the producer owns the head, the consumer owns the tail, and a careful index scheme plus a memory barrier keeps the two cores honest without ever disabling interrupts.

    When an interrupt service routine (ISR) captures a byte from a UART, a sample from an ADC, or a timestamp from a timer-capture unit, it has to get that data to slower application code without stalling the next interrupt. Blocking inside an ISR — grabbing a mutex, calling malloc, or spinning on a flag — is the classic way to wreck real-time behaviour. The lock-free single-producer/single-consumer (SPSC) ring buffer is the standard answer, and it shows up in nearly every serious firmware codebase, from FreeRTOS stream buffers to the Linux kernel's kfifo.

    Why a ring buffer and not a queue with a lock

    A ring buffer (circular buffer) is a fixed array treated as if its ends were joined. Two indices walk around it: a head where the producer writes, and a tail where the consumer reads. Because the storage is pre-allocated and the indices only ever advance, there is no dynamic memory, no fragmentation, and — crucially — no shared mutable bookkeeping that both sides modify.

    That last property is what buys us lock-freedom. If the ISR is the only writer of head and the task is the only writer of tail, neither needs to lock the other out. The ISR never waits; it either has room and writes, or it detects a full buffer and drops/overwrites. This matters because disabling interrupts to protect a shared queue inflates your worst-case interrupt latency — every critical section you add to the consumer becomes jitter the ISR must tolerate.

    Ring buffer: 8 slots, head and tail01234567tail (task reads)head (ISR writes)count = 3head − tailoccupied (unread)freeCourseTron · coursetron.com
    Head advances on each ISR write; tail advances as the task drains. The arc between them is the occupied region.

    The index arithmetic

    Keep the capacity a power of two, say 256, and use index & (SIZE-1) to wrap instead of a modulo. On a Cortex-M0 without a hardware divider, a % can cost dozens of cycles; a bit-mask is one. The classic emptiness/fullness test uses free-running indices that are allowed to wrap the whole integer:

    • Empty when head == tail.
    • Full when (head - tail) == SIZE.
    • Count is simply head - tail (unsigned subtraction wraps correctly).

    Keeping head and tail as monotonically increasing counters and masking only on array access sidesteps the ambiguous "head == tail could mean full or empty" problem that plagues the naive one-index-wraps-at-SIZE design. You spend one wasted slot, or an extra counter — the free-running scheme avoids both.

    Free-running indices, SIZE = 8 (example)Emptyhead == tailPartialhead − tail = 3Fullhead − tail == SIZEFilled cell = unread item. Indices keep counting; mask with SIZE-1 only on array access.CourseTron · coursetron.com
    Empty, partially filled and full states of an 8-entry ring using free-running head and tail counters.

    On a UART running at 921,600 baud, a byte arrives roughly every 11 µs. A 256-byte ring buys the consumer ~2.8 ms of slack before overrun — plenty for an RTOS task that wakes on a 1 ms tick.

    Memory ordering: the part people get wrong

    Lock-free does not mean barrier-free. On a Cortex-M4, the compiler and the write-buffer can reorder a store to the data slot past the store that advances head. If the consumer sees the new head but the data hasn't landed, it reads garbage. The producer must publish data first, then head, with a release barrier between:

    • ISR: write buf[head & mask] = x; → __DMB() (or a C11 atomic_store_explicit(…, memory_order_release)) → advance head.
    • Task: read tail, acquire-load head, then read the slot.

    Declaring the indices volatile is necessary but not sufficient on multi-core or write-buffered parts — volatile stops the compiler reordering but not the hardware. Use acquire/release on anything SMP (a dual-core STM32H7 or an i.MX with an M-core alongside an A-core). Tools like the Synopsys VC Formal memory-consistency apps and Siemens Questa are routinely used to prove these producer/consumer orderings in IP verification; Arm's own litmus tests document the exact reorderings a DMB prevents.

    One byte: ISR to ring to taskISRring buffertaskowns headdecouples bothowns tailOrange dot = the captured byte; the ISR never waits for the task.CourseTron · coursetron.com
    A captured byte moves producer → ring → consumer. The ring decouples the fast ISR from the slower task.

    Handling overrun: drop, overwrite, or back-pressure

    Eventually the consumer falls behind — a long flash write, a higher-priority task hogging the CPU. You must decide, at design time, what the ISR does when the ring is full:

    PolicyISR behaviour on fullUse when
    Drop-newestDiscard the incoming item, bump an overrun counterLog/telemetry streams where latest loss is acceptable
    Overwrite-oldestAdvance tail too, keep newest N samplesSensor rings where only recent data matters (IMU, scope)
    Back-pressureAssert flow control (RTS/CTS), slow the sourceUART/USB where the peer can pause

    Whatever you pick, count the drops. A silent overrun is the bug that takes three days to find. Expose the counter over your debug shell so field units can report it. Never try to grow the buffer from inside the ISR.

    Overwrite-oldest in an SPSC ring is subtle: now the ISR writes tail too, breaking the single-writer invariant. Either disable just that one interrupt briefly, or move the overwrite decision to the consumer — don't pretend it's still lock-free.

    A worked example: UART receive

    A DMA-less UART RX ISR fires per byte. It reads DR, stores to buf[head & 0xFF], issues a barrier, and increments head. Total: about 15–20 instructions, deterministic, no branches that can block. The application task calls a ring_pop() that loops while head != tail, copying bytes into a line assembler. Because the ISR never spins and the task never masks interrupts, the UART can run flat-out while the task is preempted for milliseconds. This exact pattern underlies the receive path of most vendor HALs — and when you move to DMA, the ring simply becomes the DMA destination with the hardware advancing an implicit head.

    If you are building production sensor or comms firmware and want the hands-on labs, our team also offers custom embedded and VLSI design services at Avecas alongside the course material. For a deeper look at how ISR timing feeds into throughput budgets, see our companion note on DMA versus interrupt-driven transfer.

    Want to write ISR-safe data paths with confidence? The CourseTron Embedded Systems & RTOS track walks through ring buffers, memory barriers, and lock-free queues with real Cortex-M targets and a logic analyzer.

    FAQ

    Do I need a mutex around a ring buffer?

    Not for the single-producer/single-consumer case. One ISR writing and one task reading need only correct index ownership and a memory barrier. The moment you have two producers or two consumers, you need a lock or a proper lock-free MPMC structure.

    Is volatile enough to make it correct?

    volatile prevents the compiler from caching or reordering the index in registers, but it does not emit hardware memory barriers. On write-buffered or multi-core parts you also need __DMB() or C11 atomics with acquire/release ordering.

    Why keep the size a power of two?

    So you can wrap indices with a single AND mask instead of a modulo. On MCUs without a hardware divider (Cortex-M0/M0+), modulo is a costly software routine, while the mask is one cycle.

    What happens on overrun?

    You choose a policy up front: drop the newest item, overwrite the oldest, or apply back-pressure to the source. Always increment a counter so overruns are observable rather than silent.

    Advertisement