We’re Officially Live β€” Lifetime Founding Membership Available for a Limited Time
Free preview

Ring Buffers

The fixed-memory FIFO behind every UART driver: head and tail indices wrapping a static array, O(1) push/pop, and the full-vs-empty trick, plus the lock-free single-producer/consumer pattern.

25 min read

In this lesson:

A ring buffer (a.k.a. circular buffer or FIFO) is the most-used data structure in embedded firmware, own this one cold.


The problem: a producer faster than the consumer

A UART receive ISR fires on every incoming byte; your main loop processes them in bursts. You need a queue that:

A ring buffer is a fixed array plus two indices, head (where the producer writes next) and tail (where the consumer reads next), that wrap around to 0 when they pass the end. The array is logically a circle.

capacity 8, after writing A,B,C and reading A:
 
 index:  0   1   2   3   4   5   6   7
        β”Œβ”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”
        β”‚ A β”‚ B β”‚ C β”‚   β”‚   β”‚   β”‚   β”‚   β”‚
        β””β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”˜
          ↑       ↑
         tail    head      (A already consumed; B,C pending)
        (read)  (write)

When head reaches the end it wraps to 0 and keeps going, the data never shifts, only the indices move.


O(1) push and pop

#define CAP 8
typedef struct {
    uint8_t buf[CAP];
    volatile uint16_t head;   // next write slot  (producer)
    volatile uint16_t tail;   // next read slot   (consumer)
} Ring;
 
bool ring_push(Ring *r, uint8_t b) {           // producer (e.g. ISR)
    uint16_t next = (r->head + 1) % CAP;
    if (next == r->tail) return false;          // full β€” drop (or overwrite)
    r->buf[r->head] = b;
    r->head = next;                             // publish AFTER writing data
    return true;
}
 
bool ring_pop(Ring *r, uint8_t *out) {          // consumer (e.g. main loop)
    if (r->tail == r->head) return false;       // empty
    *out = r->buf[r->tail];
    r->tail = (r->tail + 1) % CAP;
    return true;
}

Every operation is O(1): a couple of index updates and one byte move, no scanning, no shifting, regardless of how full the buffer is. Space is O(CAP), fixed at compile time.


The full-vs-empty ambiguity

Here's the classic trap. The buffer is empty when head == tail. But if you fill all CAP slots, head wraps right back to tail, so head == tail also looks full. The two states are indistinguishable. Two standard fixes:

1. Sacrifice one slot (used above). Declare "full" when advancing head would hit tail ((head+1) % CAP == tail). The buffer holds at most CAP-1 items, and head == tail now unambiguously means empty. Simple, no extra state, costs one slot.

2. Keep a count. Track count of items alongside head/tail. count == 0 is empty, count == CAP is full; you can use all CAP slots. Costs a counter that both sides modify, which reintroduces a shared variable that needs care under concurrency (see below).

Sacrifice-one-slot:               Count:
 full when (head+1)%CAP == tail    full when count == CAP
 uses CAP-1 slots                  uses all CAP slots
 no shared counter                 shared counter (sync needed)

For single-producer/single-consumer, the sacrifice-one-slot approach is popular precisely because it avoids a counter both sides touch.


The power-of-two trick

% (modulo) can be a slow division on MCUs without a hardware divider (e.g. Cortex-M0). If CAP is a power of two, the wrap becomes a cheap bitwise AND:

// CAP must be a power of two, e.g. 8, 16, 256
r->head = (r->head + 1) & (CAP - 1);    // & 7 instead of % 8

x % 8 and x & 7 are equal for non-negative x, but the AND is one cycle. This is why production ring buffers almost always use power-of-two capacities.


Lock-free for single producer + single consumer (SPSC)

The reason ring buffers are the ISR data structure: with exactly one producer and one consumer, you need no lock. The producer only writes head and reads tail; the consumer only writes tail and reads head. Neither modifies the other's index.

The one rule: publish the index after the data. Write buf[head], then advance head, so the consumer never sees an advanced head pointing at a slot that isn't filled yet. Mark head/tail volatile (recall the C module) so the compiler doesn't cache them across the ISR boundary. On multi-core or weakly-ordered CPUs you additionally need a memory barrier; on a single-core Cortex-M, volatile plus correct ordering is the common (if subtle) idiom.

If you have multiple producers or consumers, this breaks, then you need a critical section (the RAII guard from the C++ module) or atomic operations around the index updates.


Overflow policy: drop vs overwrite

When the buffer is full, you choose:

Decide deliberately; a silent wrong choice either loses fresh data or corrupts ordering.


Gotchas


TL;DR

This is just the start

Sign up free to track progress on this lesson, get coding problems with the Run button, and unlock the full interview Q&A.

More in Data Structures & Algorithms

Time & Space ComplexityArrays & Memory Layout
Linked Lists
Stacks & Queues
Hash Tables
Static vs Dynamic Allocation