Ring Buffer for UART in C: Lock-Free Interrupt-Safe FIFO Design

A UART receives bytes whenever the other side decides to send them. Most microcontroller UARTs hold only one received byte, or a small hardware FIFO, so if the program does not read a byte before the next one arrives, data is lost and an overrun flag is set. At 115200 baud with 8N1 framing, a new byte can arrive roughly every 87 microseconds (10 bits per byte divided by 115200 bits per second). Your main loop cannot promise to check the UART that often.

The standard solution is to let the receive interrupt drop each byte into a ring buffer (also called a circular buffer) and let the main loop take bytes out whenever it is ready. This article builds a ring buffer that is safe to share between one ISR and the main loop without disabling interrupts.

How a Ring Buffer Works

A ring buffer is a fixed array with two indexes. The head is where the producer writes the next byte. The tail is where the consumer reads the next byte. Both move forward and wrap to the start when they reach the end, so the array behaves like a loop.

size = 8, head = 6, tail = 2  -> 4 bytes waiting

index:  0   1   2   3   4   5   6   7
       [ ] [ ] [A] [B] [C] [D] [ ] [ ]
                ^tail           ^head

empty when head == tail
full  when head - tail == size  (free-running indexes, see below)

A subtle problem is telling full from empty, since in both cases the wrapped indexes are equal. Two common fixes exist: keep one slot always unused, or let the indexes run freely and only wrap them when accessing the array. This design uses free-running unsigned indexes with a power-of-two size, which uses every slot and needs no division.

Design Choices That Make It Interrupt-Safe

ChoiceReason
Size is a power of twoindex & (SIZE - 1) replaces the slower modulo operation, and free-running unsigned indexes wrap correctly
Only the producer writes headThe ISR owns head, the main loop owns tail, so neither overwrites the other's index
Indexes and data are volatileEach side re-reads the other side's index, and the compiler cannot move the data write after the index write
Data written before indexThe consumer only sees a byte after it is fully stored
Index type read atomicallyUse a width the CPU reads in one instruction: 8 bits on AVR, up to 32 bits on Cortex-M

This is the single-producer, single-consumer pattern. It needs no critical section as long as there is exactly one writer and one reader. If two tasks or two ISRs push into the same buffer, you need a lock or a critical section.

The Code

The program below compiles with any C99 compiler. The function uart_rx_isr would be called from the real UART receive interrupt with the byte read from the data register; here main calls it to simulate incoming data, including a burst that overflows the buffer.

Power-of-two ring buffer with free-running indexes, used for UART receive (PC simulation)
#include <stdio.h>
#include <stdint.h>
#include <stdbool.h>

#define RB_SIZE 16u                      /* must be a power of two */
#if (RB_SIZE & (RB_SIZE - 1u)) != 0
#error "RB_SIZE must be a power of two"
#endif

typedef struct {
    volatile uint8_t  buf[RB_SIZE];      /* volatile: keeps data write before index write */
    volatile uint16_t head;              /* written only by the producer */
    volatile uint16_t tail;              /* written only by the consumer */
    volatile uint16_t dropped;           /* bytes lost because buffer was full */
} ringbuf_t;

static uint16_t rb_count(const ringbuf_t *rb)
{
    return (uint16_t)(rb->head - rb->tail);   /* wrap-safe */
}

/* Producer side: call from the ISR */
static bool rb_put(ringbuf_t *rb, uint8_t byte)
{
    if (rb_count(rb) >= RB_SIZE) {
        rb->dropped++;
        return false;                     /* full: drop newest byte */
    }
    rb->buf[rb->head & (RB_SIZE - 1u)] = byte;  /* data first ...  */
    rb->head++;                                 /* ... then publish */
    return true;
}

/* Consumer side: call from main loop or a task */
static bool rb_get(ringbuf_t *rb, uint8_t *byte)
{
    if (rb->head == rb->tail) {
        return false;                     /* empty */
    }
    *byte = rb->buf[rb->tail & (RB_SIZE - 1u)];
    rb->tail++;
    return true;
}

static ringbuf_t uart_rx;

/* On hardware: void USART1_IRQHandler(void) { uint8_t b = (uint8_t)USART1->DR; ... } */
static void uart_rx_isr(uint8_t received)
{
    (void)rb_put(&uart_rx, received);
}

int main(void)
{
    const char *msg = "LED ON\r\n";
    uint8_t c;

    for (const char *p = msg; *p; p++) {
        uart_rx_isr((uint8_t)*p);         /* bytes arriving one by one */
    }

    printf("waiting: %u bytes\n", (unsigned)rb_count(&uart_rx));
    printf("received: ");
    while (rb_get(&uart_rx, &c)) {
        if (c >= 32 && c < 127) putchar(c); else printf("<%02X>", c);
    }
    putchar('\n');

    for (int i = 0; i < 20; i++) {        /* burst larger than the buffer */
        uart_rx_isr((uint8_t)('a' + i));
    }
    printf("after burst: %u stored, %u dropped\n",
           (unsigned)rb_count(&uart_rx), (unsigned)uart_rx.dropped);
    return 0;
}
waiting: 8 bytes
received: LED ON<0D><0A>
after burst: 16 stored, 4 dropped

How the Code Works

The indexes are 16-bit and never reset; they simply keep counting and wrap at 65536. Because 65536 is a multiple of the power-of-two buffer size, the masked position head & (RB_SIZE - 1) stays correct across that wrap, and head - tail cast to 16 bits always gives the number of stored bytes. On an 8-bit AVR, make the indexes uint8_t so each side reads the other's index in a single instruction; that limits the buffer to 128 bytes in this design, since the count must fit in the index type.

The #error check stops the build if someone changes the size to a value that is not a power of two. When the buffer is full, the newest byte is dropped and counted. Some applications prefer to overwrite the oldest data instead, for example a log of recent sensor readings, but for command input dropping and reporting is safer because a corrupted command can be detected.

Using It for Transmit and Choosing a Size

The same structure works in reverse for transmission. The main loop is the producer: it puts bytes into a TX buffer and enables the UART's transmit-register-empty interrupt. The ISR is the consumer: it takes one byte per interrupt and writes it to the data register, and disables the interrupt when the buffer is empty. printf can then return immediately instead of waiting for every byte to leave.

Size the receive buffer for the longest burst the main loop might fail to service. Multiply the byte rate by the worst-case gap between reads: at 115200 baud about 11 520 bytes per second arrive, so a 20 ms stall needs room for about 230 bytes, and a 256-byte buffer is a reasonable choice. Measure the real worst-case gap rather than guessing.

Common Mistakes

  • Using % with a non-power-of-two size and free-running indexes, which breaks when the index wraps.
  • Letting the ISR modify tail, for example to drop old data, which breaks the single-writer rule.
  • Updating the index before storing the byte, so the consumer can read a stale value.
  • Forgetting to clear the UART overrun error flag. On many parts an unhandled overrun stops further receive interrupts.
  • Using a 16-bit index on an 8-bit AVR without protection, giving occasional torn reads.

Summary

A ring buffer decouples bursty UART traffic from the main loop. With a power-of-two size, free-running unsigned indexes, one writer per index and data stored before the index is published, an ISR and the main loop can share it without disabling interrupts. Size it from the data rate and the longest service gap, count dropped bytes, and use a second buffer for interrupt-driven transmit.