Embedded C track

Lesson 22 · Part 4: Applications & Patterns

Ring Buffers & Interrupt-Driven UART Reception

Shared application patterns

Part 4: Applications & Patterns7 min readFree lesson

In Chapter 18, we learned that interrupts should be kept "short." However, when data floods in via UART, a receive interrupt (RXNE) is triggered for every byte. If you try to handle all these bytes within the ISR, the ISR becomes long, and if the main loop is slow, the next byte arrives and overwrites the previous byte, causing loss. The solution is that the ISR should only put the received byte into a buffer, and the actual processing is handled by the idle main loop, dividing the work.

The key data structure for this division of labor is the ring buffer (circular buffer). If it is designed so that the producer (ISR) and consumer (main) only touch different indices, data can be safely exchanged without turning off interrupts. In this chapter, we implement a ring buffer as a hardware-independent module, verify it on a PC, and then look at how it is combined with the actual UART RX interrupt.

Learning Objectives

  • Explain why data loss occurs in interrupt-based reception.
  • Understand the head/tail FIFO structure of the ring buffer.
  • Implement Empty/Full judgment and power-of-two masking.
  • Understand the safety and volatile of a single Producer/Consumer (SPSC) environment.
  • Learn the pattern of combining a ring buffer with UART RX interrupts.

Target Environment

  • Compiler: PC + gcc or μVision (pure logic).
  • Device: None (hardware-independent). On an actual board, the producer is the USART RX ISR.
  • Verification: Check expected outputs using PC gcc (pc_test.c).

Key Concepts

1) The Dilemma of Interrupt Reception

RX interrupt occurs for each byte. If the ISR is long or the main program doesn't fetch that byte in time, the next byte will overwrite the receive register, causing data loss. "Receive quickly and store it somewhere, process it later" is necessary.

2) Ring Buffer Structure

A fixed-size array is used in a circular manner. head (write position) and tail (read position) wrap back to 0 when they reach the end. The first data in is the first data out (FIFO).

[ . . A B C . . . ]
^tail   ^head      pop is at the tail, push is at the head

3) Empty/Full Determination

  • Empty state: head == tail.
  • Full state: ((head + 1) & MASK) == tail.

To determine fullness, one slot is left empty, so if the size is N, the actual capacity is N-1. If the size is a power of 2, it can wrap quickly using & (N-1) instead of % N.

#define RB_SIZE 8u
#define RB_MASK (RB_SIZE - 1u)
next = (head + 1) & RB_MASK;   /*Automatic wrap from 8 → 0*/

4) SPSC Safety and volatile

The producer (ISR) writes only to head, and the consumer (main) writes only to tail. Since they update different variables, in a single consumer-single producer (SPSC) setup, it is safe without locks or disabling interrupts. However, since ISR and main share head/tail-is, it should be declared as volatile volatile to prevent compiler caching.

5) Combining with UART RX Interrupt (Concept)

/*USART1 receive interrupt: just put the received byte into the buffer (keep it short!)*/
void USART1_IRQHandler(void)
{
    if (USART1->SR & USART_SR_RXNE) {
        uint8_t b = (uint8_t)USART1->DR;   /*Reading clears RXNE*/
        rb_push(&rx_rb, b);                /*Producer: only touches head*/
    }
}

int main(void)
{
    /*... USART1 + RXNEIE interrupt setup ...*/
    uint8_t c;
    while (1) {
        if (rb_pop(&rx_rb, &c)) {          /*Consumer: only touches tail*/
            /*Process the received byte c leisurely*/
        }
    }
}

RX interrupt operates when USART1->CR1 is enabled with USART_CR1_RXNEIE (receive interrupt enable) and NVIC_EnableIRQ(USART1_IRQn) is called. Processing is done by the main program.

Examples

Example 1 — ringbuffer.h / ringbuffer.c: Ring Buffer Module

#ifndef RINGBUFFER_H
#define RINGBUFFER_H

#include <stdint.h>

/*
 *Circular buffer (ring buffer) — a FIFO connecting interrupt reception and main processing.
 *The producer (ISR) only touches the head with push, and the consumer (main) only touches the tail with pop.
 *Therefore, it is safe in a single producer/single consumer (SPSC) without turning off interrupts.
 *
 *The size must be a power of 2 so that the index can be wrapped with (& MASK).
 *With the 'leave one slot empty' method, the actual capacity is RB_SIZE - 1.
 */

#define RB_SIZE  8u            /*Power of 2*/

typedef struct {
    uint8_t  buf[RB_SIZE];
    volatile uint16_t head;    /*Next position to write (producer)*/
    volatile uint16_t tail;    /*Next position to read (consumer)*/
} ringbuffer_t;

void     rb_init(ringbuffer_t *rb);
uint8_t  rb_is_empty(const ringbuffer_t *rb);
uint8_t  rb_is_full(const ringbuffer_t *rb);
uint8_t  rb_push(ringbuffer_t *rb, uint8_t data);   /*Success 1, full 0*/
uint8_t  rb_pop(ringbuffer_t *rb, uint8_t *out);     /*Success 1, empty 0*/
uint16_t rb_count(const ringbuffer_t *rb);

#endif /* RINGBUFFER_H */
#include "ringbuffer.h"

#define RB_MASK  (RB_SIZE - 1u)

void rb_init(ringbuffer_t *rb)
{
    rb->head = 0u;
    rb->tail = 0u;
}

uint8_t rb_is_empty(const ringbuffer_t *rb)
{
    return (rb->head == rb->tail) ? 1u : 0u;
}

uint8_t rb_is_full(const ringbuffer_t *rb)
{
    return (((rb->head + 1u) & RB_MASK) == rb->tail) ? 1u : 0u;
}

uint8_t rb_push(ringbuffer_t *rb, uint8_t data)
{
    uint16_t next = (uint16_t)((rb->head + 1u) & RB_MASK);
    if (next == rb->tail) {
        return 0u;                  /*Full — Rejected to prevent data loss*/
    }
    rb->buf[rb->head] = data;
    rb->head = next;
    return 1u;
}

uint8_t rb_pop(ringbuffer_t *rb, uint8_t *out)
{
    if (rb->head == rb->tail) {
        return 0u;                  /*Empty*/
    }
    *out = rb->buf[rb->tail];
    rb->tail = (uint16_t)((rb->tail + 1u) & RB_MASK);
    return 1u;
}

uint16_t rb_count(const ringbuffer_t *rb)
{
    return (uint16_t)((rb->head - rb->tail) & RB_MASK);
}

Example 2 — pc_test.c: FIFO / Fullness Verification

#include <stdio.h>
#include <stdint.h>
#include "ringbuffer.h"

/*
 *Lesson 22 PC Verification — Ring Buffer FIFO Operation.
 *Check insertion/removal, rejection when full (capacity RB_SIZE-1=7), and FIFO order.
 *Build: gcc pc_test.c ringbuffer.c -o pc_test
 */

int main(void)
{
    ringbuffer_t rb;
    uint8_t c;
    char x;

    rb_init(&rb);

    /*1) Insert 3 items*/
    rb_push(&rb, (uint8_t)'A');
    rb_push(&rb, (uint8_t)'B');
    rb_push(&rb, (uint8_t)'C');
    printf("after push A,B,C: count=%u empty=%u full=%u\n",
           (unsigned)rb_count(&rb), (unsigned)rb_is_empty(&rb),
           (unsigned)rb_is_full(&rb));

    /*2) Remove one item (A comes first as per FIFO)*/
    rb_pop(&rb, &c);
    printf("pop -> %c, count=%u\n", c, (unsigned)rb_count(&rb));

    /*3) Insert until full (capacity 7)*/
    for (x = 'D'; x <= 'L'; x++) {
        uint8_t ok = rb_push(&rb, (uint8_t)x);
        printf("push %c -> %s (count=%u)\n",
               x, ok ? "ok" : "FULL", (unsigned)rb_count(&rb));
    }

    /*4) Remove all — they come out in the order they went in*/
    printf("drain: ");
    while (rb_pop(&rb, &c)) {
        putchar((int)c);
    }
    putchar('\n');

    return 0;
}

Verification on a PC

gcc pc_test.c ringbuffer.c -o pc_test && ./pc_test

Expected output (stdout):

after push A,B,C: count=3 empty=0 full=0
pop -> A, count=2
push D -> ok (count=3)
push E -> ok (count=4)
push F -> ok (count=5)
push G -> ok (count=6)
push H -> ok (count=7)
push I -> FULL (count=7)
push J -> FULL (count=7)
push K -> FULL (count=7)
push L -> FULL (count=7)
drain: BCDEFGH

Check the FIFO operation where the capacity is 7 (size 8 − empty 1), and since A was removed first, the remaining data starts from B and comes out in the BCDEFGH order.

Common Mistakes

Q. It works fine on PC even without attaching at-the-head/tail volatile.

A. On a PC (single thread), it may appear fine, but on actual embedded systems where ISR and main share data, the compiler may cache the value and not see updates. Always attach volatile.

Q. When I set the size to 10, the wrap behaves strangely.

A. & (N-1) masking only works correctly when N is a power of 2. For 10, use % 10 or choose a power of 2 like 8 or 16.

Q. The buffer is full, but push overwrites.

A. With push using ((head+1)&MASK)==tail, it should reject (return 0). Otherwise, it may overtake the tail and overwrite data that has not been read yet.

Q. Can ISR pop while main push?

A. This SPSC safety holds only when "producer uses head only, consumer uses tail only." If roles are mixed or there are multiple producers, additional protection like interrupt blocking is needed.

Summary

  • Receiving interrupts is safe if ISR puts data into the buffer, and the main retrieves and processes it.
  • The ring buffer is a FIFO that operates with with-head/tail and is determined by the Empty/Full index.
  • If the size is set to a power of 2, it wraps quickly with & MASK (capacity is N−1).
  • In SPSC, separating head/tail + volatile is safe without locks.
  • The UART RX ISR only uses rb_push, and the main processes it after rb_pop.

Try it yourself

Solve the assignment below. The reference solution is shown below.

Example files

The runnable files used in this lesson. Copy them into your own project and experiment.

src/app_ringbuffer/pc_test.cC
#include <stdio.h>
#include <stdint.h>
#include "ringbuffer.h"

/*
 *Lesson 22 PC Verification — Ring Buffer FIFO Operation.
 *Check insertion/removal, rejection when full (capacity RB_SIZE-1=7), and FIFO order.
 *Build: gcc pc_test.c ringbuffer.c -o pc_test
 */

int main(void)
{
    ringbuffer_t rb;
    uint8_t c;
    char x;

    rb_init(&rb);

    /*1) Insert 3 items*/
    rb_push(&rb, (uint8_t)'A');
    rb_push(&rb, (uint8_t)'B');
    rb_push(&rb, (uint8_t)'C');
    printf("after push A,B,C: count=%u empty=%u full=%u\n",
           (unsigned)rb_count(&rb), (unsigned)rb_is_empty(&rb),
           (unsigned)rb_is_full(&rb));

    /*2) Remove one item (A comes first as per FIFO)*/
    rb_pop(&rb, &c);
    printf("pop -> %c, count=%u\n", c, (unsigned)rb_count(&rb));

    /*3) Insert until full (capacity 7)*/
    for (x = 'D'; x <= 'L'; x++) {
        uint8_t ok = rb_push(&rb, (uint8_t)x);
        printf("push %c -> %s (count=%u)\n",
               x, ok ? "ok" : "FULL", (unsigned)rb_count(&rb));
    }

    /*4) Remove all — they come out in the order they went in*/
    printf("drain: ");
    while (rb_pop(&rb, &c)) {
        putchar((int)c);
    }
    putchar('\n');

    return 0;
}
src/app_ringbuffer/ringbuffer.cC
#include "ringbuffer.h"

#define RB_MASK  (RB_SIZE - 1u)

void rb_init(ringbuffer_t *rb)
{
    rb->head = 0u;
    rb->tail = 0u;
}

uint8_t rb_is_empty(const ringbuffer_t *rb)
{
    return (rb->head == rb->tail) ? 1u : 0u;
}

uint8_t rb_is_full(const ringbuffer_t *rb)
{
    return (((rb->head + 1u) & RB_MASK) == rb->tail) ? 1u : 0u;
}

uint8_t rb_push(ringbuffer_t *rb, uint8_t data)
{
    uint16_t next = (uint16_t)((rb->head + 1u) & RB_MASK);
    if (next == rb->tail) {
        return 0u;                  /*Full — Rejected to prevent data loss*/
    }
    rb->buf[rb->head] = data;
    rb->head = next;
    return 1u;
}

uint8_t rb_pop(ringbuffer_t *rb, uint8_t *out)
{
    if (rb->head == rb->tail) {
        return 0u;                  /*Empty*/
    }
    *out = rb->buf[rb->tail];
    rb->tail = (uint16_t)((rb->tail + 1u) & RB_MASK);
    return 1u;
}

uint16_t rb_count(const ringbuffer_t *rb)
{
    return (uint16_t)((rb->head - rb->tail) & RB_MASK);
}
src/app_ringbuffer/ringbuffer.hC header
#ifndef RINGBUFFER_H
#define RINGBUFFER_H

#include <stdint.h>

/*
 *Circular buffer (ring buffer) — a FIFO connecting interrupt reception and main processing.
 *The producer (ISR) only touches the head with push, and the consumer (main) only touches the tail with pop.
 *Therefore, it is safe in a single producer/single consumer (SPSC) without turning off interrupts.
 *
 *The size must be a power of 2 so that the index can be wrapped with (& MASK).
 *With the 'leave one slot empty' method, the actual capacity is RB_SIZE - 1.
 */

#define RB_SIZE  8u            /*Power of 2*/

typedef struct {
    uint8_t  buf[RB_SIZE];
    volatile uint16_t head;    /*Next position to write (producer)*/
    volatile uint16_t tail;    /*Next position to read (consumer)*/
} ringbuffer_t;

void     rb_init(ringbuffer_t *rb);
uint8_t  rb_is_empty(const ringbuffer_t *rb);
uint8_t  rb_is_full(const ringbuffer_t *rb);
uint8_t  rb_push(ringbuffer_t *rb, uint8_t data);   /*Success 1, full 0*/
uint8_t  rb_pop(ringbuffer_t *rb, uint8_t *out);     /*Success 1, empty 0*/
uint16_t rb_count(const ringbuffer_t *rb);

#endif /* RINGBUFFER_H */

Assignment

Both problems are pure logic (PC gcc).

gcc main.c -o prog && ./prog

Problem 1 — Overwrite Ring Buffer

  • File name: homework_01/main.c
  • Key concept: discard the oldest data when full, wrap
  • Platform: Common (PC gcc)

Requirements

  • Create a ring buffer of size 4 (capacity 3). When pushing into a full buffer, discard the oldest value (indicate that value) and insert the new value (log buffer policy).
  • Push A~E in order, then drain everything.
  • The format must match the expected output character by character.

Expected output

push A -> ok
push B -> ok
push C -> ok
push D -> overwrite (dropped A)
push E -> overwrite (dropped B)
drain: CDE

Hint

  • Full check: ((head+1)&MASK)==tail. If full, advance tail by one to discard the oldest value.
  • Size 4 is a power of 2, so wrap with & 3.

Problem 2 — Assemble Lines from Incoming Stream

  • File name: homework_02/main.c
  • Key concept: line buffer, accumulate until delimiter (\n) (UART command reception pattern)
  • Platform: Common (PC gcc)

Requirements

  • Read the character stream "go\nstop\nhi\n" one character at a time, and whenever encountering \n, output the line collected so far.
  • Format LINE: <line> must match the expected output character by character (newline character is not included in the line).

Expected output

LINE: go
LINE: stop
LINE: hi

Hint

  • Accumulate characters in the line buffer, and when \n is found, null-terminate and output, resetting the index to 0.
  • This pattern forms the basis for interpreting UART-received commands line by line in episode 24.

Check your answers

After solving it yourself, compare it with the reference solution below. The solution files include comments on key points and common mistakes.

Show the reference solution

Try the assignment yourself first — comparing your attempt with a working solution is where most of the learning happens.

homework_01/main.cC
#include <stdio.h>
#include <stdint.h>

/*
 *Answer 1 — Overwriting (overwrite) ring buffer
 *
 *Key Points:
 *- Instead of rejecting when full, discard the oldest value (tail position) and insert the new value.
 *Common policy in log buffers that "keep only the most recent N entries."
 *- When discarding, advance the tail by one and then write the new value at the head.
 *
 *Common mistake:
 *- Case when the tail advancement is missed when full, causing the head to overtake the tail.
 *- Case when size is not a power of 2, breaking & MASK wrap.
 */

#define SZ    4u
#define MASK  (SZ - 1u)

static uint8_t  buf[SZ];
static uint16_t head = 0u, tail = 0u;

static int is_full(void)
{
    return (((head + 1u) & MASK) == tail) ? 1 : 0;
}

/*If overwritten, put the discarded character in *dropped, otherwise put 0.*/
static void push_ow(uint8_t d, uint8_t *dropped)
{
    if (is_full()) {
        *dropped = buf[tail];
        tail = (tail + 1u) & MASK;     /*Discard the oldest one*/
    } else {
        *dropped = 0u;
    }
    buf[head] = d;
    head = (head + 1u) & MASK;
}

static int pop(uint8_t *out)
{
    if (head == tail) {
        return 0;
    }
    *out = buf[tail];
    tail = (tail + 1u) & MASK;
    return 1;
}

int main(void)
{
    char x;
    uint8_t dropped, c;

    for (x = 'A'; x <= 'E'; x++) {
        push_ow((uint8_t)x, &dropped);
        if (dropped) {
            printf("push %c -> overwrite (dropped %c)\n", x, dropped);
        } else {
            printf("push %c -> ok\n", x);
        }
    }

    printf("drain: ");
    while (pop(&c)) {
        putchar((int)c);
    }
    putchar('\n');

    return 0;
}
homework_02/main.cC
#include <stdio.h>

/*
 *Answer 2 — Line-by-line assembly from the receiving stream
 *
 *Key Points:
 *- Accumulate characters in a line buffer, and when '\n' is encountered, finalize it as one line for processing.
 *- Do not include the newline in the line, and reset the index to 0 after processing.
 *- Forms the basis for interpreting commands received via UART line by line in episode 24.
 *
 *Common mistake:
 *- If null termination ('\0') is omitted, remnants of the previous line may be output.
 *- Overflow can occur if buffer boundary checks are not performed.
 */

int main(void)
{
    const char *stream = "go\nstop\nhi\n";
    char line[32];
    int idx = 0;
    const char *p;

    for (p = stream; *p != '\0'; p++) {
        char ch = *p;
        if (ch == '\n') {
            line[idx] = '\0';                  /*Line Finalization*/
            printf("LINE: %s\n", line);
            idx = 0;                           /*Prepare for Next Line*/
        } else if (idx < (int)sizeof(line) - 1) {
            line[idx++] = ch;                  /*Accumulate Characters (Boundary Check)*/
        }
    }

    return 0;
}

Learn this lesson with a mentor

Work through the Embedded C track on your own, or join a guided cohort at our Pitampura campus and online.

Call NowEnroll Now