Skip to Content
Course content

249: Practice Exercise: Implementing a Ring Buffer for Producer-Consumer

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Imagine we're building a driver for a high-speed UART or a network packet logger. You've got data streaming in from the hardware via interrupts, and a main loop that processes that data whenever it has a free moment. If you block the interrupt to wait for the processor, you lose data. If you just overwrite the old data, you lose data. You need a staging area—a buffer—where the producer can dump bytes and the consumer can pick them up at its own pace.

The Cost of Shifting Data

When I first started writing these, I did what most people do: I used a simple array and a "read index." When the consumer read a piece of data, I'd just shift every remaining element in the array one position to the left to make room at the end. On a tiny 10-element buffer, you wouldn't even notice. But as soon as you scale that up to a 4KB buffer, you're suddenly calling memmove thousands of times a second. You're spending more CPU cycles moving bytes around in memory than you are actually processing them.

The naive approach treats the buffer like a queue in a textbook, but in C, moving memory is expensive. You're effectively turning a simple "get next item" operation into an O(n) nightmare. It's a classic case of choosing a data structure that looks right on a whiteboard but fails miserably in a performance-critical loop.

The Elegance of the Wrap-Around

The better way is the ring buffer. Instead of moving the data to fit the indices, we move the indices to fit the data. We maintain a head (where the producer writes) and a tail (where the consumer reads). When either index hits the end of the array, it simply wraps back around to zero. It transforms the linear array into a logical circle.

Now, every push and pop is O(1). No matter how large the buffer is, the cost to add or remove an element remains constant. I usually implement this using the modulo operator—index = (index + 1) % BUFFER_SIZE—though if you're really squeezed for performance and your buffer size is a power of two, you can use a bitwise AND for an even faster wrap.

The "Full vs. Empty" Ambiguity

Here is where most engineers trip up during the implementation. If you only track the head and tail, you'll notice a problem: when head == tail, the buffer could be completely empty, or it could be completely full. The state is ambiguous.

You have two real choices here. You can either maintain a separate count variable that tracks exactly how many elements are currently stored, or you can sacrifice one slot in the buffer. In the latter approach, you define "full" as (head + 1) % BUFFER_SIZE == tail. I personally prefer the count variable for readability; it makes the logic explicit and prevents that "why is my 1024-byte buffer only holding 1023 bytes?" confusion that happens when a teammate inherits your code.

typedef struct {
    uint8_t *buffer;
    size_t head;
    size_t tail;
    size_t max_len;
    size_t count;
} ring_buffer_t;

bool ring_buffer_push(ring_buffer_t *rb, uint8_t data) {
    if (rb->count == rb->max_len) return false; // Buffer overflow

    rb->buffer[rb->head] = data;
    rb->head = (rb->head + 1) % rb->max_len;
    rb->count++;
    return true;
}

bool ring_buffer_pop(ring_buffer_t *rb, uint8_t *data) {
    if (rb->count == 0) return false; // Buffer empty

    *data = rb->buffer[rb->tail];
    rb->tail = (rb->tail + 1) % rb->max_len;
    rb->count--;
    return true;
}

Notice that this structure decouples the producer from the consumer. The producer doesn't need to know who is reading the data or how fast they are doing it; it just needs to know if there is room to push. As long as your count updates are atomic or protected by a mutex in a multi-threaded environment, this pattern is the gold standard for streaming data in C.




📋 Practical Task

Exercise: Build a Thread-Safe Circular Event Logger

Implement a ring buffer system that simulates a system event logger. Your task is to create a producer-consumer setup with the following requirements:

  • Define a LogEvent struct containing an int event_id and a char message[32].
  • Implement a ring buffer specifically for these LogEvent structs with a fixed capacity of 16.
  • Write a producer function that generates 20 dummy events in a loop, attempting to push them into the buffer. If the buffer is full, the producer should print "Log Buffer Full - Dropping Event" and continue.
  • Write a consumer function that pops events from the buffer and prints them to the console. To simulate real-world processing, the consumer should be slower than the producer.
  • Ensure that the head, tail, and count are handled correctly so that no events are read twice and no memory is accessed out of bounds.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.