Why ring buffers are everywhere
A ring buffer (also called a circular buffer) solves a specific problem that comes up constantly in embedded firmware: decoupling a producer running at one rate from a consumer running at a different rate, without dynamic memory allocation. The UART receive ISR fires at baud rate. The application processes elements in the main loop. The ring buffer sits between them, absorbing the difference.
The concept is simple. The implementation has several non-obvious correctness requirements that tutorials frequently skip. I hit all three of the bugs described below while building the Phase 1 UART driver, which is why this post exists.
The naive implementation and its bug
Most tutorials implement a ring buffer roughly like this:
#define RING_BUF_SIZE 64
typedef struct {
uint8_t buf[RING_BUF_SIZE];
uint8_t head; /* write index */
uint8_t tail; /* read index */
} RingBuffer_t;
bool RingBuf_Push(RingBuffer_t *rb, uint8_t element) {
uint8_t next_head = (rb->head + 1) % RING_BUF_SIZE;
if (next_head == rb->tail) return false; /* full */
rb->buf[rb->head] = element;
rb->head = next_head;
return true;
}
bool RingBuf_Pop(RingBuffer_t *rb, uint8_t *element) {
if (rb->head == rb->tail) return false; /* empty */
*element = rb->buf[rb->tail];
rb->tail = (rb->tail + 1) % RING_BUF_SIZE;
return true;
}
This works. But it wastes one slot. When head == tail the buffer is empty.
To distinguish full from empty, the implementation reserves one slot: the buffer can hold
RING_BUF_SIZE - 1 elements, not RING_BUF_SIZE. A 64-element array holds
63 elements. For a UART receive buffer this probably doesn't matter. For a
resource-constrained system where you sized the buffer carefully, it does.
When head and tail are the same index, you cannot tell whether the buffer is empty (nothing written yet) or full (written all the way around to where tail is). The naive solution is to sacrifice one slot so that full is defined as "head is one behind tail" rather than "head equals tail". The better solution is to track the count separately, or use indices that never wrap around.
Fix 1: track count explicitly
Store the number of elements in the buffer as a separate field. Full is count == RING_BUF_SIZE,
empty is count == 0. Head and tail track positions only. No wasted slot.
typedef struct {
uint8_t buf[RING_BUF_SIZE];
uint8_t head;
uint8_t tail;
uint8_t count;
} RingBuffer_t;
bool RingBuf_Push(RingBuffer_t *rb, uint8_t element) {
if (rb->count == RING_BUF_SIZE) return false; /* full */
rb->buf[rb->head] = element;
rb->head = (rb->head + 1) % RING_BUF_SIZE;
rb->count++;
return true;
}
bool RingBuf_Pop(RingBuffer_t *rb, uint8_t *element) {
if (rb->count == 0) return false; /* empty */
*element = rb->buf[rb->tail];
rb->tail = (rb->tail + 1) % RING_BUF_SIZE;
rb->count--;
return true;
}
Cleaner semantics, no wasted slot. The cost is an extra element in the struct and one increment/decrement per operation which is negligible.
There is one new problem though: count is now modified in both the writer
and the reader. If one of them runs in an ISR, you have a race condition on
count. More on that in the ISR section below.
Bug 2: the modulo that breaks at speed
The naive implementation uses % RING_BUF_SIZE to wrap the index. This works
correctly for any buffer size. It's also slower than necessary because the %
operator compiles to a division on ARM when the operand is not a power of two.
On a Cortex-M4, an integer division takes 2–12 cycles. A ring buffer used inside a UART ISR running at 921600 baud fires every ~10µs. At 168 MHz that's 1680 cycles per element. Spending 12 of them on a modulo is not catastrophic, but it's also not necessary.
If the buffer size is a power of two, the modulo becomes a bitwise AND:
/* RING_BUF_SIZE must be a power of 2 and need to be enforced at compile time */
#define RING_BUF_SIZE 64u
#define RING_BUF_MASK (RING_BUF_SIZE - 1u)
_Static_assert((RING_BUF_SIZE & RING_BUF_MASK) == 0u,
"RING_BUF_SIZE must be a power of two");
/* Wrap with AND instead of % */
rb->head = (rb->head + 1u) & RING_BUF_MASK;
The _Static_assert is the important part. If someone changes RING_BUF_SIZE
to 100 without knowing about the power-of-two requirement, the build fails immediately
with a clear message instead of silently producing wrong wrap-around at runtime.
For any power of two N, x % N == x & (N-1). This is because powers
of two in binary are 1 followed by zeros (64 = 0b01000000), so N-1 is all ones below
that bit (63 = 0b00111111). ANDing with that mask keeps only the lower bits which is exactly
what modulo does for a power of two divisor. The compiler does this automatically when
the divisor is a compile-time constant power of two, but an explicit AND makes the intent
clear and prevents the bug if RING_BUF_SIZE is ever changed to a non-power-of-two.
Bug 3: the ISR race condition
This is the one that causes the most subtle failures in production. A ring buffer shared between an ISR and application code has a concurrency problem even with a single writer and single reader.
Consider the count-based implementation above. The UART ISR writes elements, the main loop
reads them. The count field is written by the ISR (increment on write) and
written by the main loop (decrement on read). On Cortex-M, a read-modify-write of an
8-bit variable in SRAM is not a single atomic instruction but three:
LDRB, ADD, STRB. If the ISR fires between the
load and the store, the ISR's write is overwritten by the application's stale value.
/* Main loop: rb->count-- */
LDRB r0, [rb + offset_count] ; load count (e.g. 5)
; <<< ISR fires here, sets count to 6
SUB r0, r0, #1 ; r0 = 4 (based on stale value of 5)
STRB r0, [rb + offset_count] ; store 4 ISR's increment is lost
The result is a count that is permanently one too low. The buffer reports itself as empty when it still has a element. Or worse, the count underflows.
The lock-free single-producer/single-consumer pattern
The cleanest fix for a single-writer/single-reader ring buffer on Cortex-M is to
eliminate the shared count field entirely and use the "sacrifice one slot"
approach with a twist: use indices that grow monotonically and never wrap around
within their type. The actual buffer index is derived with a mask.
/* Lock-free SPSC ring buffer, safe for one ISR writer, one main-loop reader
No shared mutable state between producer and consumer. */
#define RING_BUF_SIZE 64u
#define RING_BUF_MASK (RING_BUF_SIZE - 1u)
_Static_assert((RING_BUF_SIZE & RING_BUF_MASK) == 0u, "RING_BUF_SIZE must be power of two");
typedef struct {
volatile uint8_t buf[RING_BUF_SIZE];
volatile uint32_t head; /* written by producer (ISR) only */
volatile uint32_t tail; /* written by consumer (main) only */
} RingBuffer_t;
/* Called from ISR only */
bool RingBuf_Push(RingBuffer_t *rb, uint8_t element) {
uint32_t head = rb->head;
if ((head - rb->tail) >= RING_BUF_SIZE) return false; /* full */
rb->buf[head & RING_BUF_MASK] = element;
rb->head = head + 1u; /* release store, consumer sees new buf */
return true;
}
/* Called from main loop only */
bool RingBuf_Pop(RingBuffer_t *rb, uint8_t *element) {
uint32_t tail = rb->tail;
if (rb->head == tail) return false; /* empty */
*element = rb->buf[tail & RING_BUF_MASK];
rb->tail = tail + 1u; /* release store, producer sees freed slot */
return true;
}
uint32_t RingBuf_Count(const RingBuffer_t *rb) {
return rb->head - rb->tail;
}
Why this is correct without disabling interrupts:
headis written only by the ISR (producer). The main loop reads it but never writes it.tailis written only by the main loop (consumer). The ISR reads it but never writes it.- There is no shared mutable state between producer and consumer. Each side owns its index exclusively.
- The full check
(head - tail) >= RING_BUF_SIZEuses unsigned arithmetic. It works correctly even whenheadwraps around a 32-bit boundary after ~4 billion writes. - The
volatileon the indices ensures neither side caches the other's index in a register across the check.
The lock-free property breaks the moment you have two ISRs writing, or two tasks reading. For multiple producers or consumers you need a critical section (disable interrupts, or an RTOS mutex). Do not use this pattern for anything beyond one writer and one reader.
Putting it together
Here is the complete implementation as used in the Phase 1 UART driver. The header exposes only the API, the struct fields are visible (no opaque pointer, to avoid dynamic allocation) but the caller should never touch them directly.
/* RingBuffer.h */
#ifndef RING_BUFFER_H
#define RING_BUFFER_H
#include <stdint.h>
#include <stdbool.h>
#define RING_BUF_SIZE 256u /* must be power of 2 */
#define RING_BUF_MASK (RING_BUF_SIZE - 1U)
_Static_assert((RING_BUF_SIZE & RING_BUF_MASK) == 0u,
"RING_BUF_SIZE must be a power of two");
typedef struct {
volatile uint8_t buf[RING_BUF_SIZE];
volatile uint32_t head;
volatile uint32_t tail;
} RingBuffer_t;
void RingBuf_Init (RingBuffer_t *rb);
bool RingBuf_Push (RingBuffer_t *rb, uint8_t element);
bool RingBuf_Pop (RingBuffer_t *rb, uint8_t *element);
uint32_t RingBuf_Count (const RingBuffer_t *rb);
bool RingBuf_IsEmpty(const RingBuffer_t *rb);
bool RingBuf_IsFull (const RingBuffer_t *rb);
#endif /* RING_BUFFER_H */
/* RingBuffer.c */
#include "RingBuffer.h"
void RingBuf_Init(RingBuffer_t *rb) {
rb->head = 0u;
rb->tail = 0u;
}
bool RingBuf_Push(RingBuffer_t *rb, uint8_t element) {
uint32_t head = rb->head;
if ((head - rb->tail) >= RING_BUF_SIZE) return false;
rb->buf[head & RING_BUF_MASK] = element;
rb->head = head + 1u;
return true;
}
bool RingBuf_Pop(RingBuffer_t *rb, uint8_t *element) {
uint32_t tail = rb->tail;
if (rb->head == tail) return false;
*element = rb->buf[tail & RING_BUF_MASK];
rb->tail = tail + 1u;
return true;
}
uint32_t RingBuf_Count(const RingBuffer_t *rb) {
return rb->head - rb->tail;
}
bool RingBuf_IsEmpty(const RingBuffer_t *rb) {
return rb->head == rb->tail;
}
bool RingBuf_IsFull(const RingBuffer_t *rb) {
return (rb->head - rb->tail) >= RING_BUF_SIZE;
}
Takeaways
- The naive head/tail implementation wastes one slot. Use either a separate count or monotonically-growing indices with a bitmask to use all slots.
- Use a power-of-two buffer size and replace
% RING_BUF_SIZEwith& RING_BUF_MASK. Enforce the constraint with_Static_assert. - For single-producer/single-consumer (one ISR writer, one main-loop reader): the monotonic-index pattern is lock-free and correct without disabling interrupts as long as each index is owned by exactly one side.
- For multiple producers or consumers: disable interrupts around the critical section. The lock-free pattern does not generalise.