Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 27

Circular Buffer and UART Interrupts for Continuous Data Streams

Recommended resources

Hello! Let's continue our exploration of UART communication.

In our last lesson, you built a professional-grade, interrupt-driven UART driver. You now have a system where the CPU is completely decoupled from the byte-by-byte mechanics of serial I/O, using circular buffers for both transmission and reception. Your main loop can perform its tasks, and the ISRs handle the data flow in the background.

Currently, your application reads data one byte at a time from the receive buffer. This is fine for simple echo, but real-world applications communicate with messages or commands, like "GET_TEMP" or "SET_SPEED=100". These messages are continuous streams of bytes. Today, we'll bridge the gap between receiving individual bytes and understanding complete messages.

This lesson focuses on the learning outcome: Integrate a circular buffer with UART interrupts to handle continuous data streams. We will build a mechanism that uses our existing interrupt-driven circular buffer to detect and extract complete, line-based commands, preparing us for the command-line interface you will build next.

1. The Challenge: From a Stream of Bytes to Framed Messages

Your current system works like this:

  • ISR (Producer): A UART RXNE interrupt occurs. The ISR reads the byte and writes it into the rx_buffer circular buffer. This repeats for every incoming byte.
  • Main Loop (Consumer): The main loop periodically checks if rx_buffer has data. If it does, it reads one byte and processes it.

The problem is that the main loop has no context. If the PC sends "LED_ON\n", the main loop will see 'L', then 'E', then 'D', and so on. It has no inherent way of knowing that these bytes belong together as a single command that ends with the newline character (\n).

To solve this, we need a simple protocol. The most common protocol for text-based commands is to use a special delimiter character to mark the end of a message. We will use the newline character (\n) for this purpose. Our goal is to read data from the circular buffer not byte-by-byte, but line-by-line.

Ring/Circular Buffer for UART Rx
This diagram shows the core data structure we are using. The ISR acts as the 'write' pointer, adding data as it arrives. The main application acts as the 'read' pointer, consuming data. Our task is to make the consumer smart enough to look for a newline character between the read and write pointers.

2. A Robust Pattern for Message Framing

We will implement a clean and robust pattern that keeps our ISR as fast as possible, a critical principle in embedded systems.

  1. ISR (Byte Producer & Line Signaler): The ISR's primary job remains the same: quickly get the byte from the UART data register and put it into the circular buffer. We will add one tiny piece of logic: if the byte received is a newline character (\n), it will set a global flag, for instance, volatile uint8_t g_line_received;.
  2. Main Application (Line Consumer): The main loop will now ignore individual bytes and will only look for the g_line_received flag. When this flag is set, it knows that at least one complete line is waiting in the circular buffer. It can then enter a function to read all bytes from the buffer up to and including the \n, assemble them into a local string, and process the command.

This approach maintains a beautiful separation of concerns:

  • ISR: Handles the hardware timing, avoids data loss.
  • Main Loop: Handles the application logic and message parsing at its own pace.

3. Deep Dive: The Interrupt-Safe Circular Buffer

Before we implement the new logic, let's take a moment to ensure our foundation—the circular buffer—is perfectly understood. The properties of this data structure are what make our interrupt-driven design possible and safe from the race conditions that plague concurrent systems.

The following video provides an excellent, detailed explanation of building an interrupt-safe ring buffer from scratch. It perfectly explains the "single producer, single consumer" model that we are using (ISR is the producer, main loop is the consumer) and why it prevents data corruption without needing complex locks.

Interrupt Safe Ring Buffer :: Bare Metal Programming Series 6

Watch this video to get a deep, bare-metal perspective on the design and implementation of the circular buffer. Pay close attention to how the separate read and write indices are the key to avoiding race conditions, and to the practical advice on choosing a buffer size.

Watch from the explanation of the ring buffer concept (11:06) through the demonstration and buffer size calculation (54:19). This covers the conceptual model, C implementation details, integration with a UART driver, and practical considerations.


4. Hands-On: Implementing Line-Based Reception

Now, let's modify the code from the previous lesson to implement our line-framing logic.

Step 1: Add a Global "Line Received" Flag

In your usart.c or main.c, declare a new global volatile flag. It must be volatile because it is modified by an ISR and read by the main loop, preventing the compiler from making incorrect optimizations.

// In usart.c or a new command_parser.c



#include <stdint.h>

// This flag will be set by the ISR and cleared by the main loop.
volatile uint8_t g_line_received = 0;

Step 2: Modify the UART Receive ISR

Update your usart1_rx_interrupt_handler() function. The core logic of writing to the ring buffer remains, but we add the check for the newline character.

// In your usart interrupt handler file (e.g., usart_irq.c)

extern ring_buffer_t rx_buffer; // Your existing receive ring buffer
extern volatile uint8_t g_line_received;

void usart1_rx_interrupt_handler(void)
{
    // Check if the RXNE flag is the cause of the interrupt
    if ((USART1->SR & USART_SR_RXNE) != 0)
    {
        // Reading the data register clears the RXNE flag
        uint8_t received_byte = USART1->DR;

        // Write the byte to our ring buffer (as before)
        ring_buffer_write(&rx_buffer, received_byte);

        // NEW LOGIC: Check if the byte is our delimiter
        if (received_byte == '\n')
        {
            // If we are not already processing a line, signal that a new one is ready.
            g_line_received = 1;
        }
    }
}

Note: Your USART1_IRQHandler in stm32f1xx_it.c should remain unchanged, simply calling this handler function.

Step 3: Create a Line-Reading Function for the Main Loop

This is the core of our new functionality. This function will be called from main() when the g_line_received flag is set. Its job is to pull bytes from the circular buffer and assemble them into a standard C string.

// In usart.c or command_parser.c




#include "ring_buffer.h"

// Reads a line from the circular buffer into a provided linear buffer.
// Returns the number of bytes read.
uint32_t uart_read_line(char* line_buffer, uint32_t buffer_size)
{
    uint32_t bytes_read = 0;
    
    // Ensure we have space for the null terminator
    if (buffer_size == 0) return 0;

    // Read from the ring buffer until a newline or the buffer is full
    while (bytes_read < (buffer_size - 1))
    {
        uint8_t byte_from_ring;
        if (ring_buffer_read(&rx_buffer, &byte_from_ring))
        {
            line_buffer[bytes_read] = (char)byte_from_ring;
            bytes_read++;

            // Stop reading at the newline character
            if (byte_from_ring == '\n')
            {
                break;
            }
        }
        else
        {
            // Ring buffer is empty, but we haven't found a newline.
            // This can happen if the main loop is very fast.
            // We just wait for more data to arrive.
            break;
        }
    }

    // Null-terminate the string
    line_buffer[bytes_read] = '\0';

    return bytes_read;
}

Step 4: Update the Main Loop

Finally, let's tie everything together in main.c. The main loop is now much cleaner and operates on a higher level of abstraction—it thinks in terms of "lines", not "bytes".

// In main.c



#include "usart.h" // Your UART driver
#include "ring_buffer.h" // Your ring buffer
#include <string.h> // For strcmp

// Assume rx_buffer and UART are initialized as in the previous lesson
extern volatile uint8_t g_line_received;




#define LINE_BUFFER_SIZE 64

int main(void)
{
    // ... all your system initializations ...
    
    char line_buffer[LINE_BUFFER_SIZE];

    // Use non-blocking send to print a welcome message
    uart_send_non_blocking((uint8_t*)"System Ready.\r\n", 15);

    while (1)
    {
        // Check if the ISR has signaled a complete line is ready
        if (g_line_received)
        {
            // Clear the flag immediately to allow the ISR to signal a new line
            g_line_received = 0;
            
            // Read the line from the circular buffer
            uart_read_line(line_buffer, LINE_BUFFER_SIZE);

            // Now, process the complete command string
            // For now, let's just echo it back with a prefix
            uart_send_non_blocking((uint8_t*)"Received: ", 10);
            uart_send_non_blocking((uint8_t*)line_buffer, strlen(line_buffer));
        }

        // The CPU is free to do other tasks here, like blinking an LED.
        // For example: my_other_super_important_task();
    }
}

5. Test Your Implementation

  1. Compile and flash your updated code to the Blue Pill board.
  2. Open a serial terminal (like PuTTY, Tera Term, or the built-in VS Code terminal).
  3. Configure it for the correct COM port and baud rate (e.g., 115200).
  4. Type a command, for example, hello_world, and press Enter.
  5. You should see the message echoed back: Received: hello_world\r\n (depending on your terminal settings for Enter, you may see \r, \n, or both).
  6. Try typing very quickly or pasting a long string. The interrupt-driven buffer should handle the stream without losing data, and your application will process it line by line.

The following article demonstrates a very similar implementation pattern. While it uses a slightly different ring buffer implementation, the core concept of the ISR setting a flag on a delimiter and the main loop processing the line is identical.

Continuous Transfers with Ring Buffers

This article, 'Bare Metal STM32 Programming (Part 10)', discusses the same problem and arrives at a similar solution. It's a great reference to see another professional's take on this common embedded challenge.

Read the section 'Continuous Transfers with Ring Buffers'. Notice how it defines a ringbuf struct, uses a macro for writing, and implements a ringbuf_read function. Most importantly, see how the ISR writes to the buffer and sets a newline flag, and how the main loop waits for this flag before reading out the entire buffered line.


Conclusion

You have successfully integrated your circular buffer and UART interrupts to handle continuous data streams in a robust and efficient way. This is a fundamental technique for any embedded system that communicates via a serial protocol.

Key Takeaways:

  • Protocols are essential: Using a delimiter like \n allows you to frame meaningful messages from a raw byte stream.
  • Keep ISRs minimal: The ISR's only new job is to set a flag, a very fast operation. All string assembly and processing is left to the main application loop.
  • The main loop operates on a higher abstraction: Your application now thinks in terms of "lines" or "commands" instead of raw bytes, making your code cleaner and easier to manage.
  • This producer-consumer pattern with a flag is a cornerstone of real-time embedded programming for decoupling tasks with different timing requirements.

Preview of the Next Lesson:
Your system can now receive complete commands as strings. In the next lesson, "Implement a command-line interface (CLI) that parses strings received over UART," you will take the line_buffer string we just created and use functions like strcmp and sscanf to parse it, recognize different commands, extract arguments, and finally control your MCU's behavior based on user input.