phase 1 embedded C buf structures

The ring buffer mistakes most tutorials make

The ring buffer is the most common buf structure in embedded firmware. Most tutorial implementations have at least one subtle bug. Here are the three that come up most often: the full/empty ambiguity, the assumption that the buffer size can be arbitrary, and unsafe access from an ISR.

Mohamed Ali Bessaidi 9 min read Phase 1, Week 3

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.

The full/empty ambiguity

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.

The power-of-two trick explained

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:

This only works for single-producer / single-consumer

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

← previous
Why your embedded code breaks at -O2