Loading header...

Exercise: Implement a Ring Buffer

Ring buffers are one of the most useful embedded data structures. They let a producer and consumer exchange data efficiently using a fixed block of memory. UART reception, logging, event queues, and DMA handoff paths all use the same core idea.

Learning Objectives

By the end of this exercise, you should be able to:

  • implement a byte-oriented circular buffer in C;
  • track head, tail, count, full, and empty conditions correctly;
  • test wraparound behavior instead of assuming it works;
  • prepare the design for later interrupt-driven use.

Prerequisites

  • Comfortable with fixed-width types, arrays, and pointers.
  • Basic understanding of producer-consumer behavior.
  • Ability to compile and run a small C test program on host or target.

Concrete Task

Implement a ring buffer with these operations:

  • initialize buffer state;
  • push one byte;
  • pop one byte;
  • report full, empty, and current count.

Use a power-of-two-free design first. Do not rely on bitmask tricks until the logic is correct.

Data Structure and API

#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>

#define RB_CAPACITY 16u

typedef struct {
    uint8_t data[RB_CAPACITY];
    size_t head;
    size_t tail;
    size_t count;
} ring_buffer_t;

void rb_init(ring_buffer_t *rb);
bool rb_is_empty(const ring_buffer_t *rb);
bool rb_is_full(const ring_buffer_t *rb);
bool rb_push(ring_buffer_t *rb, uint8_t value);
bool rb_pop(ring_buffer_t *rb, uint8_t *out_value);

Complete Implementation

#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>

#define RB_CAPACITY 16u

typedef struct {
    uint8_t data[RB_CAPACITY];
    size_t head;
    size_t tail;
    size_t count;
} ring_buffer_t;

void rb_init(ring_buffer_t *rb)
{
    if (rb == NULL) {
        return;
    }

    rb->head = 0u;
    rb->tail = 0u;
    rb->count = 0u;
}

bool rb_is_empty(const ring_buffer_t *rb)
{
    return (rb == NULL) || (rb->count == 0u);
}

bool rb_is_full(const ring_buffer_t *rb)
{
    return (rb != NULL) && (rb->count == RB_CAPACITY);
}

bool rb_push(ring_buffer_t *rb, uint8_t value)
{
    if ((rb == NULL) || rb_is_full(rb)) {
        return false;
    }

    rb->data[rb->head] = value;
    rb->head = (rb->head + 1u) % RB_CAPACITY;
    rb->count++;
    return true;
}

bool rb_pop(ring_buffer_t *rb, uint8_t *out_value)
{
    if ((rb == NULL) || (out_value == NULL) || rb_is_empty(rb)) {
        return false;
    }

    *out_value = rb->data[rb->tail];
    rb->tail = (rb->tail + 1u) % RB_CAPACITY;
    rb->count--;
    return true;
}

Minimal Verification Program

#include <assert.h>
#include <stdint.h>

int main(void)
{
    ring_buffer_t rb;
    uint8_t value = 0u;

    rb_init(&rb);
    assert(rb_is_empty(&rb));
    assert(!rb_is_full(&rb));

    for (uint8_t i = 0u; i < RB_CAPACITY; i++) {
        assert(rb_push(&rb, i));
    }

    assert(rb_is_full(&rb));
    assert(!rb_push(&rb, 99u));

    for (uint8_t i = 0u; i < RB_CAPACITY; i++) {
        assert(rb_pop(&rb, &value));
        assert(value == i);
    }

    assert(rb_is_empty(&rb));
    assert(!rb_pop(&rb, &value));

    for (uint8_t i = 0u; i < 8u; i++) {
        assert(rb_push(&rb, (uint8_t)(10u + i)));
    }
    for (uint8_t i = 0u; i < 4u; i++) {
        assert(rb_pop(&rb, &value));
    }
    for (uint8_t i = 0u; i < 8u; i++) {
        assert(rb_push(&rb, (uint8_t)(20u + i)));
    }
    assert(rb.count == 12u);
    for (uint8_t i = 0u; i < 4u; i++) {
        assert(rb_pop(&rb, &value));
        assert(value == (uint8_t)(14u + i));
    }
    for (uint8_t i = 0u; i < 8u; i++) {
        assert(rb_pop(&rb, &value));
        assert(value == (uint8_t)(20u + i));
    }
    assert(rb_is_empty(&rb));

    return 0;
}

This test intentionally exercises wraparound instead of checking only the simplest fill-then-empty case.

Build on a host machine with:

cc -std=c11 -Wall -Wextra -Wpedantic -O2 ring_buffer_test.c -o ring_buffer_test
./ring_buffer_test

Expected Behavior

  • Push succeeds until capacity is reached.
  • Push fails cleanly when full.
  • Pop returns bytes in first-in, first-out order.
  • After wraparound, the buffer still preserves order.
  • Empty and full states are unambiguous because count is tracked explicitly.

Verification Steps

  1. Compile the implementation and test program with warnings enabled.
  2. Run the test on host or target; all assertions should pass.
  3. Add temporary prints or debugger watches for head, tail, and count.
  4. Confirm head wraps from RB_CAPACITY - 1 back to 0.
  5. Confirm tail wraps independently.
  6. Confirm count never exceeds RB_CAPACITY.

Common Failure Symptoms

Symptom Likely cause
data comes out in wrong order head/tail update order wrong
full and empty look identical insufficient state tracking
wraparound corrupts entries modulo or index math error
pop succeeds on empty buffer missing state check
push overwrites unread data full-condition bug

Debugging Guidance

  • Draw the buffer on paper and trace head, tail, and count after each operation.
  • Test with a tiny capacity such as 4 to expose wraparound quickly.
  • Keep push and pop symmetric; asymmetry often reveals the bug.
  • If using an ISR later, remember that shared access may need atomic protection.
  • Add a watch expression for (head + capacity - tail) % capacity when debugging a count-free variant.

Extension Challenge

Extend the design for UART RX:

  • producer: ISR pushes received bytes;
  • consumer: main loop pops bytes and parses commands.

Then decide how to handle overflow:

  • drop newest byte;
  • drop oldest byte;
  • set an overflow flag and reject input until drained.

Document the policy explicitly.

Concise Explained Solution

The implementation works because the buffer stores data in a fixed array, head marks the next write position, tail marks the next read position, and count disambiguates full versus empty. Each push writes then advances head; each pop reads then advances tail. Modulo arithmetic wraps indices cleanly to the start of the array.

Common Mistakes

  • Trying to optimize with bitmasks before the logic is proven.
  • Forgetting that head == tail can mean either full or empty depending on design.
  • Omitting null-pointer checks in reusable utility code.
  • Testing only the no-wrap case.

Summary

This exercise builds a core embedded primitive: bounded FIFO storage with deterministic memory use. Once you can implement and verify a ring buffer confidently, interrupt-driven serial drivers and event queues become much easier to reason about.

Further Reading

Mind Map

mindmap root((Ring Buffer Exercise)) Goal Fixed byte FIFO Deterministic RAM Producer consumer handoff UART ready pattern State Data array Head next write Tail next read Count disambiguates Capacity is 16 bytes Operations Init clears indexes Push writes then advances Pop reads then advances Wrap by modulo capacity Full rejects write Verification Fill to full Reject extra push Drain in order Force wraparound Count never over capacity Debug checks Watch head Watch tail Watch count Try capacity four Review ISR atomicity Common mistakes Head tail swapped Overwrite unread byte Empty pop allowed Only no wrap tested