Home / Reference / Tools / Ring Buffer Visualizer for Embedded Systems

Ring Buffer Visualizer for Embedded Systems

pcbway

A ring buffer in embedded systems is a fixed-size memory area used to temporarily store data. You will often find one between a fast peripheral and a slower part of the program. A UART interrupt, for example, can place received bytes into a ring buffer while the main application processes those bytes whenever it has time.

A ring buffer is also called a circular buffer. The memory itself is still an ordinary C array. What makes it circular is the way its indexes return to zero after reaching the final array element.

This tutorial explains how an embedded ring buffer works, how its head and tail pointers move, and how you can implement one in C. You can also use the interactive visualizer below to see each operation happen.

Interactive Embedded Ring Buffer Visualizer

Enter a character, decimal byte, or hexadecimal value and click Write Byte. The value is stored at the current head position. Click Read Byte to remove the oldest value from the tail position.

Watch what happens when either pointer reaches the final slot. Instead of moving outside the array, it wraps around to index zero.

Ring Buffer

Count-based FIFO storage for embedded UART bytes.

HEAD: next write position TAIL: next read position
UART RX idle.
Head: 0 Tail: 0 Used: 0 / 8 State: EMPTY

Buffer is empty. Write a byte to begin.

Equivalent Embedded C
#define BUFFER_SIZE 8

typedef struct
{
    uint8_t data[BUFFER_SIZE];
    volatile uint8_t head;
    volatile uint8_t tail;
    volatile uint8_t count;
} RingBuffer;

bool ring_buffer_write(RingBuffer *rb, uint8_t value)
{
    if (rb->count >= BUFFER_SIZE)
    {
        return false;
    }

    rb->data[rb->head] = value;
    rb->head = (rb->head + 1U) % BUFFER_SIZE;
    rb->count++;

    return true;
}

bool ring_buffer_read(RingBuffer *rb, uint8_t *value)
{
    if (rb->count == 0U)
    {
        return false;
    }

    *value = rb->data[rb->tail];
    rb->tail = (rb->tail + 1U) % BUFFER_SIZE;
    rb->count--;

    return true;
}

What Is a Ring Buffer in Embedded Systems?

A ring buffer is a first-in, first-out or FIFO data structure built using a fixed-size array. The first value written into the buffer is normally the first value read from it.

Consider this eight-byte array:

uint8_t buffer[8];

A normal queue implementation might remove the first value and then shift every remaining value toward index zero. That approach works, but repeatedly moving data wastes processor time.

A ring buffer does not shift its contents. Instead, it keeps track of two indexes:

  • Head — the position where the next value will be written
  • Tail — the position where the next value will be read

When a value is written, the head advances. When a value is read, the tail advances. The data remains in the same physical array slots until those slots are reused.

This makes ring-buffer writes and reads fast and predictable. Both operations normally take constant time because no existing elements have to be moved.

Why Ring Buffers Are Useful in Embedded Applications

Microcontrollers often deal with events that do not happen at exactly the same speed as the main application.

For example, a UART peripheral may receive a byte while the processor is:

  • Updating a display
  • Reading a sensor
  • Writing data to flash memory
  • Processing a communication packet
  • Executing another part of the main loop

The UART receive interrupt should not perform a large amount of processing. It should normally retrieve the received byte quickly, store it somewhere safe, and then return.

A ring buffer provides that temporary storage:

UART peripheral → RX interrupt → Ring buffer → Main application

The interrupt acts as the producer because it adds bytes. The main application acts as the consumer because it removes and processes them.

The same idea can be used for:

  • UART receive and transmit queues
  • ADC sample storage
  • Sensor-data collection
  • Logging systems
  • CAN and SPI message queues
  • Audio samples
  • Keyboard or keypad input
  • Data moving between interrupt handlers and the main loop

How the Head Pointer Works

The head points to the array position where the next byte will be stored.

Suppose the head is initially at index zero:

head = 0;

Writing the character 'A' stores it in buffer[0]. The head then moves to index one.

buffer[head] = 'A';
head++;

If the next value is 'B', it is stored in buffer[1], after which the head moves to index two.

The head always identifies the next available write position. It does not normally point to the most recently written value.

How the Tail Pointer Works

The tail points to the oldest unread value.

If the tail is at index zero, reading from the buffer returns the value stored in buffer[0]. The tail then advances to index one.

value = buffer[tail];
tail++;

The contents do not need to be shifted after the read. The application simply considers that slot available again.

Some implementations clear a slot after reading it because doing so makes debugging and visualization easier. Clearing the slot is not normally required for the ring-buffer algorithm itself.

Pointer Wraparound

The circular behavior appears when a pointer reaches the end of the array.

For an eight-element buffer, the valid indexes are zero through seven. If the head is at index seven, its next position must be index zero.

This can be written using the modulo operator:

head = (head + 1U) % BUFFER_SIZE;

With BUFFER_SIZE equal to eight:

(7 + 1) % 8 = 0

The same operation is used for the tail:

tail = (tail + 1U) % BUFFER_SIZE;

No data is physically moved from the end of the array back to the beginning. Only the index wraps around.

Detecting an Empty or Full Ring Buffer

A ring buffer must be able to determine whether another read or write is allowed.

The interactive visualizer uses a count-based implementation. In addition to the head and tail, it stores the number of elements currently inside the buffer.

uint8_t head;
uint8_t tail;
uint8_t count;

The buffer is empty when:

count == 0

It is full when:

count == BUFFER_SIZE

This method allows every array element to be used. An eight-element array can hold eight values.

Another common method avoids the count variable and reserves one array slot. In that version:

  • head == tail means the buffer is empty.
  • The buffer is full when advancing the head would make it equal to the tail.

That approach is especially useful for a single-producer, single-consumer ring buffer because the producer can own the head while the consumer owns the tail. I will return to that version later in the tutorial.

Basic Count-Based Ring Buffer in C

The following implementation matches the behavior of the visualizer. It stores eight bytes and rejects new data when the buffer is full.

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

#define BUFFER_SIZE 8U

typedef struct
{
    uint8_t data[BUFFER_SIZE];
    uint8_t head;
    uint8_t tail;
    uint8_t count;
} RingBuffer;

Initializing the Buffer

Initialization places both pointers at index zero and sets the stored count to zero.

void ring_buffer_init(RingBuffer *rb)
{
    rb->head = 0U;
    rb->tail = 0U;
    rb->count = 0U;
}

The actual data array does not have to be cleared because no slot is considered valid while the count is zero.

Writing a Byte

bool ring_buffer_write(RingBuffer *rb, uint8_t value)
{
    if (rb->count >= BUFFER_SIZE)
    {
        return false;
    }

    rb->data[rb->head] = value;
    rb->head = (rb->head + 1U) % BUFFER_SIZE;
    rb->count++;

    return true;
}

The function first checks whether the buffer is full. If space is available, the value is written at the head position. The head advances, and the element count increases.

The function returns false when no more data can be accepted. The caller can use this result to record an overflow, increment an error counter, or take another appropriate action.

Reading a Byte

bool ring_buffer_read(RingBuffer *rb, uint8_t *value)
{
    if (rb->count == 0U)
    {
        return false;
    }

    *value = rb->data[rb->tail];
    rb->tail = (rb->tail + 1U) % BUFFER_SIZE;
    rb->count--;

    return true;
}

The read function checks for an empty buffer before accessing the array. It returns the oldest byte, advances the tail, and decreases the count.

Checking the Current State

bool ring_buffer_is_empty(const RingBuffer *rb)
{
    return rb->count == 0U;
}

bool ring_buffer_is_full(const RingBuffer *rb)
{
    return rb->count == BUFFER_SIZE;
}

uint8_t ring_buffer_used(const RingBuffer *rb)
{
    return rb->count;
}

uint8_t ring_buffer_free(const RingBuffer *rb)
{
    return BUFFER_SIZE - rb->count;
}

A Simple Ring Buffer Example

Here is how the buffer can be used from a normal C program:

RingBuffer rx_buffer;
uint8_t received_byte;

int main(void)
{
    ring_buffer_init(&rx_buffer);

    ring_buffer_write(&rx_buffer, 'A');
    ring_buffer_write(&rx_buffer, 'B');
    ring_buffer_write(&rx_buffer, 'C');

    if (ring_buffer_read(&rx_buffer, &received_byte))
    {
        /* received_byte contains 'A' */
    }

    if (ring_buffer_read(&rx_buffer, &received_byte))
    {
        /* received_byte contains 'B' */
    }

    while (1)
    {
        /* Main application */
    }
}

Even if the head wraps around while more values are written, the tail continues to return them in FIFO order.

Using a Ring Buffer for UART Reception

A UART receive ring buffer is one of the most common embedded applications.

The general flow is:

  1. The UART peripheral receives a byte.
  2. The receive interrupt runs.
  3. The interrupt reads the UART data register.
  4. The byte is written into the ring buffer.
  5. The interrupt returns.
  6. The main loop later reads and processes the buffered byte.

A simplified interrupt handler might look like this:

void UART_RX_IRQHandler(void)
{
    uint8_t value = uart_read_data_register();

    if (!ring_buffer_write(&rx_buffer, value))
    {
        uart_rx_overflow = true;
    }
}

The main loop can process the data without waiting inside the interrupt:

int main(void)
{
    uint8_t value;

    ring_buffer_init(&rx_buffer);
    uart_init();

    while (1)
    {
        while (ring_buffer_read(&rx_buffer, &value))
        {
            process_received_byte(value);
        }

        run_other_application_tasks();
    }
}

This arrangement lets the interrupt capture incoming data quickly while the main application performs the more time-consuming work.

A Concurrency Warning About the Count Variable

The count-based implementation is easy to understand and works well for the visualizer. However, notice what happens in the UART example:

  • The interrupt increases count.
  • The main loop decreases count.

Both execution contexts modify the same variable. An interrupt occurring during a read-modify-write operation may produce a race condition, depending on the microcontroller, variable width, compiler, and generated instructions.

Adding volatile prevents the compiler from treating a shared variable as if it never changes unexpectedly. It does not automatically make an operation atomic, and it does not by itself solve every concurrency problem.

You can protect a count-based implementation with a short critical section. Another option is to use a single-producer, single-consumer design where the interrupt modifies only the head and the main loop modifies only the tail.

Single-Producer, Single-Consumer UART Ring Buffer

For a typical UART receive buffer, there is one producer and one consumer:

  • The UART RX interrupt is the only code that changes the head.
  • The main loop is the only code that changes the tail.

The implementation below reserves one array slot so that head == tail can unambiguously represent an empty buffer.

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

#define UART_BUFFER_SIZE 16U
#define UART_BUFFER_MASK (UART_BUFFER_SIZE - 1U)

typedef struct
{
    uint8_t data[UART_BUFFER_SIZE];
    volatile uint16_t head;
    volatile uint16_t tail;
} UartRingBuffer;

Because the masking optimization is used here, UART_BUFFER_SIZE must be a power of two.

Writing from the UART Interrupt

bool uart_ring_buffer_write_isr(
    UartRingBuffer *rb,
    uint8_t value)
{
    uint16_t head = rb->head;
    uint16_t next =
        (head + 1U) & UART_BUFFER_MASK;

    if (next == rb->tail)
    {
        return false;
    }

    rb->data[head] = value;
    rb->head = next;

    return true;
}

Reading from the Main Loop

bool uart_ring_buffer_read(
    UartRingBuffer *rb,
    uint8_t *value)
{
    uint16_t tail = rb->tail;

    if (tail == rb->head)
    {
        return false;
    }

    *value = rb->data[tail];
    rb->tail =
        (tail + 1U) & UART_BUFFER_MASK;

    return true;
}

This arrangement avoids a shared count variable. The producer publishes a new head only after writing the byte, while the consumer advances the tail only after retrieving it.

You should still check the requirements of your specific architecture. Index reads and writes should be naturally aligned and atomic for the chosen index type. Systems with multiple cores, DMA, an RTOS, cache coherency concerns, or more than one producer or consumer may require memory barriers, critical sections, or an operating-system synchronization primitive.

Modulo Versus Bit Masking

The general wraparound operation uses modulo:

next = (index + 1U) % BUFFER_SIZE;

This works for any valid buffer size.

If the size is a power of two, the index can instead be wrapped using a mask:

next = (index + 1U) & (BUFFER_SIZE - 1U);

For example, valid indexes for a 16-byte buffer are zero through 15. The value BUFFER_SIZE - 1 is therefore 0x0F.

Modern compilers can often optimize a constant modulo operation automatically. I would therefore prioritize clear and correct code first, then inspect the generated code if performance is critical.

Ring Buffer Overflow and Underflow

Overflow

An overflow occurs when the producer attempts to write while no space is available.

The buffer can respond in different ways:

  • Reject the new value
  • Overwrite the oldest unread value
  • Set an overflow flag
  • Increment a lost-byte counter
  • Apply flow control when the protocol supports it

The visualizer rejects the new byte because this makes the loss of data obvious. Whether that is the correct behavior in a real project depends on the application.

For a command interface, losing an old or new character may invalidate the entire command. For continuous sensor history, overwriting the oldest sample may be acceptable.

Underflow

An underflow occurs when the consumer attempts to read an empty buffer.

A well-designed read function should detect this before accessing the array. Returning a Boolean result is useful because every possible byte value, including zero, may be valid data.

uint8_t value;

if (ring_buffer_read(&rx_buffer, &value))
{
    process_received_byte(value);
}
else
{
    /* No data currently available */
}

Choosing the Ring Buffer Size

The correct buffer size depends on how quickly data arrives and how long the consumer may be unable to process it.

A useful starting estimate is:

N_{\min} \ge R_{\text{bytes}}T_{\text{delay}} + M

Where:

  • Nmin is the minimum number of required slots.
  • Rbytes is the incoming data rate in bytes per second.
  • Tdelay is the longest expected consumer delay in seconds.
  • M is an additional safety margin.

For example, an 8-N-1 UART connection at 115200 baud uses approximately ten transmitted bits for every data byte: one start bit, eight data bits, and one stop bit.

R_{\text{bytes}} \approx \frac{115200}{10} = 11520\text{ bytes/s}

If the main application can be delayed for 5 milliseconds:

N_{\min} \ge 11520 \times 0.005 = 57.6\text{ bytes}

A 64-byte buffer is the smallest convenient power-of-two choice, but it leaves almost no margin. A 128-byte buffer may be more appropriate if RAM permits and occasional longer delays are possible.

If the implementation reserves one slot, remember that a 64-element array stores only 63 bytes.

Ring Buffer Versus an Ordinary Linear Buffer

Feature Ring Buffer Linear Buffer with Shifting
Memory allocation Fixed Usually fixed
Write operation Advances head Adds at the end
Read operation Advances tail May shift remaining values
Wraparound Yes No
Read and write time Normally constant May increase when shifting
Common embedded use UART, ADC, logging and streaming Small packets and simple temporary storage

Common Ring Buffer Mistakes

Confusing the Head with the Last Written Position

In the implementation used here, the head is the next write position. After writing to index three, the head normally advances to index four.

Failing to Distinguish Full from Empty

If the head and tail can be equal in both states, another piece of information is required. Use a count, reserve one slot, or store a separate full flag.

Using an Index That Is Too Small

The index type must be able to represent every valid buffer position. An eight-bit index is sufficient for buffers up to 256 elements, but larger buffers require a wider type.

Assuming Volatile Makes Everything Thread-Safe

volatile affects compiler optimization. It does not automatically protect a multi-step update from interrupts, multiple tasks, or another processor core.

Doing Too Much Work Inside the Interrupt

The receive interrupt should normally store the byte and return. Packet parsing, command execution, display updates, and other longer operations are better handled outside the ISR.

Ignoring Overflow

Even a correctly implemented ring buffer can overflow. Always decide how the application should detect and respond to lost data.

Frequently Asked Questions

Is a ring buffer the same as a circular queue?

The terms are often used interchangeably. Both normally describe FIFO storage that reuses a fixed array by wrapping its indexes. Ring buffer is particularly common terminology in embedded systems, drivers, communications, and streaming applications.

Does a ring buffer dynamically allocate memory?

It does not have to. Embedded implementations usually use a statically allocated array so memory usage is known at compile time and no heap allocation is required.

Can a ring buffer store structures instead of bytes?

Yes. The array can contain sensor samples, CAN frames, event structures, pointers, or other fixed-size objects.

typedef struct
{
    uint32_t timestamp;
    int16_t temperature;
    uint16_t pressure;
} SensorSample;

SensorSample sample_buffer[16];

Should a full ring buffer reject or overwrite data?

It depends on the application. Communication buffers often reject new data and report an overflow. History buffers may intentionally overwrite the oldest entry so they always contain the most recent samples.

Can DMA use a ring buffer?

Yes, although the design becomes more hardware-specific. Many microcontrollers support circular DMA modes that repeatedly fill a memory region. The application must then track which portion of the region has been written and which portion has already been processed.

Can I use this implementation with an RTOS?

You can, but a ring buffer shared by multiple tasks may require a mutex, critical section, semaphore, or RTOS queue. A lock-free single-producer, single-consumer implementation should not automatically be treated as safe for multiple producers or consumers.

Conclusion

A ring buffer gives embedded applications an efficient way to temporarily store streaming data without shifting array elements or allocating more memory at runtime.

The basic idea is simple:

  • The head identifies the next write position.
  • The tail identifies the next read position.
  • Both pointers return to zero after reaching the end.
  • Writes must detect a full buffer.
  • Reads must detect an empty buffer.

The count-based approach used by the visualizer is useful for learning because the full and empty conditions are easy to see. For a real UART interrupt and main-loop arrangement, a single-producer, single-consumer implementation with separate head and tail ownership can reduce shared-state problems.

Try filling the visualizer, reading a few bytes, and then writing more values. The most important moment is when the head crosses the final index and returns to zero while the unread values remain in the correct FIFO order. Once that behavior is clear, the C implementation becomes much easier to understand.