Hello! Welcome back to your interview preparation course.
In our last lesson, we built a PWM driver, which was an excellent exercise in configuring a timer for synchronous, precisely-timed digital output. Today, we pivot from creating signals to communicating data. We'll tackle one of the most fundamental and common tasks in embedded systems: handling asynchronous serial communication.
This lesson directly addresses the learning outcome: Develop an interrupt-driven UART driver using circular buffers for robust serial communication. For someone with your background in high-level communication stacks like CAN and SOME/IP, this is a fantastic opportunity to dive into the bare-metal foundations upon which those complex protocols are built. We'll see how to manage unpredictable data streams efficiently, a core skill for any driver developer.
1. The Challenge of Asynchronous Data
A UART receives data asynchronously—it can arrive at any time. The simplest way to handle this is polling: constantly checking a status flag in a loop to see if a byte has arrived.
// Simplified Polling Example
while (1) {
// Block CPU until a character is received
while ( (UART_STATUS_REGISTER & RX_DATA_READY_FLAG) == 0 ) {
// Wasting CPU cycles...
}
char data = UART_DATA_REGISTER;
process_data(data);
}
This approach is inefficient and brittle. The CPU is stuck in a busy-wait loop, unable to perform other tasks. If process_data() or another part of the main loop takes too long, you could miss incoming bytes, leading to data loss. This is unacceptable in a robust system.
The solution is to let the hardware notify us when data arrives using interrupts.
2. The Interrupt-Driven Architecture
In an interrupt-driven approach, the UART peripheral triggers an interrupt when:
- A new byte has been received and is ready to be read (e.g., Receive buffer not empty -
RXNE). - The transmit buffer is empty and ready to accept a new byte to send (e.g., Transmit buffer empty -
TXE).
This frees the main application to perform other tasks, only being briefly interrupted to quickly handle the data. However, this introduces a new challenge: the high-speed, unpredictable interrupt context needs to safely pass data to and from the slower, sequential main application loop. This is a classic producer-consumer problem.
The standard solution is a circular buffer (also called a ring buffer).

This diagram illustrates how a circular buffer works. Data is written at the 'Head' and read from the 'Tail'. When a pointer reaches the end of the array, it wraps around to the beginning, creating a continuous loop. This structure is ideal for buffering data between two processes running at different speeds, like an ISR and the main loop.
3. The Heart of the Driver: The Interrupt-Safe Circular Buffer
A circular buffer is more than just an array with two indices. When used with interrupts, it must be implemented carefully to prevent race conditions. The key is to design it as a lock-free, single-producer, single-consumer (SPSC) queue.
- For the Receive (RX) Buffer: The UART ISR is the only producer (it writes to the buffer and updates the
headindex). The main application is the only consumer (it reads from the buffer and updates thetailindex). - For the Transmit (TX) Buffer: The main application is the only producer (it writes to the buffer and updates the
headindex). The UART ISR is the only consumer (it reads from the buffer and updates thetailindex).
Because the head and tail indices are each modified by only one context, we can often avoid complex locking mechanisms like disabling interrupts for every buffer access.
Microcontrollers: Interrupt-safe ring buffers
The following article from 'Downtown Doug Brown' is an excellent and widely-cited explanation of this concept. It clearly lays out why polling is inefficient and how to build a truly interrupt-safe circular buffer.
Please read through the article, focusing on these key parts: Introduction: Understand the motivation for moving from polled I/O to interrupt-driven I/O. Ring Buffer Structure: Pay close attention to the section that begins with 'Anyway, we’ve determined that you need three things:'. Note the critical importance of the volatile keyword and why using a power-of-two buffer size is efficient. Code and Logic: Study the add() and remove() function examples. The accompanying bullet points are the most important part—they explain the specific ordering of operations that guarantees interrupt safety. Lock-Free Guarantee: Read the section beginning 'By doing both of the above...' to solidify your understanding of why this design works without needing to disable interrupts for every read/write.
The article emphasizes two crucial points for interrupt safety in an SPSC queue:
- Separate control: The ISR only ever modifies one index (
headfor RX,tailfor TX), and the main loop only ever modifies the other. - Careful update order: You update the data in the buffer before you update the index that makes that data visible to the other context.
Test your understanding!
Why must the buffer, head, and tail variables be declared as volatile? What kind of bug might you see if you forget this qualifier?
Show answer
The volatile keyword tells the compiler that a variable's value can change at any time, in ways not apparent to the code immediately surrounding it (e.g., by an ISR or hardware). This prevents the compiler from making optimizations that could break the logic.
For example, without volatile, the compiler might see the main loop checking if (rx_buffer_head != rx_buffer_tail) and, if it sees no code within the main loop that changes rx_buffer_head, it might decide to cache the value of rx_buffer_head in a CPU register. An interrupt could then occur, receive a new character, and update rx_buffer_head in memory, but the main loop's cached register value would be stale. The main loop would never see the new character, and your program would appear to be stuck or missing data.
4. Implementation Walkthrough
Now let's see how these concepts come together in a full driver implementation. We will look at a professional-grade TTY (serial driver) module.
Lesson 18. ttys: UART Driver and Standard I/O Integration Module (with Interrupt Handler)
The following video from Gene Schroeder Tech provides a deep dive into a complete, interrupt-driven UART driver. It's a fantastic real-world example that goes beyond a simple demo.
Watch these key segments to see the theory put into practice: Circular Buffer Design (15:11 - 17:54): This section explains the dual circular buffer design (one for transmit, one for receive) and the role of the 'put' and 'get' indices. This is a great visual reinforcement of the concepts from the article. Overall Architecture (17:54 - 20:27): Pay close attention to the diagram showing the data flow. It beautifully illustrates how the interrupt handlers, circular buffers, and application-level functions all interact. The Interrupt Handler (30:01 - 34:34): This is the most important part. The author walks through the C code for the interrupt handler step-by-step. Observe how it first reads the status register to determine the cause of the interrupt (e.g., RX character received, TX ready), and then takes the appropriate action with the circular buffers.
Driver Implementation Steps:
Synthesizing from the resources, here is the logical flow to build your driver:
- Initialization (
UART_Init)- Enable clocks for the UART peripheral and the corresponding GPIO pins.
- Configure GPIO pins to their Alternate Function (AF) for UART TX/RX.
- Configure UART parameters: baud rate, word length (8-bit), stop bits (1), no parity. This involves writing to the Baud Rate Register (
BRR) and Control Registers (CR1,CR2,CR3). - Initialize the RX and TX circular buffers by setting their
headandtailindices to 0. - Enable the "Receive Register Not Empty" interrupt (
RXNEIE) in the UART's control register. - Enable the UART's global interrupt in the Nested Vectored Interrupt Controller (NVIC).
- Enable the UART peripheral itself.

*This is how you would typically enable the peripheral's interrupt channel in a graphical configuration tool like STM32CubeIDE. This configures the NVIC to route the UART's interrupt requests to the CPU.*
2. The Interrupt Service Routine (USARTx_IRQHandler)
This function is the engine of the driver. It must handle multiple interrupt sources.
```c
void USART2_IRQHandler(void) {
// Check if the RXNE flag is set (data received)
if ( (USART2->SR & USART_SR_RXNE) != 0 ) {
// Read data from data register (this clears the RXNE flag)
uint8_t received_char = USART2->DR;
// Calculate next head position
uint16_t next_head = (rx_buffer.head + 1) % RX_BUFFER_SIZE;
// Check for buffer overflow
if (next_head != rx_buffer.tail) {
rx_buffer.buffer[rx_buffer.head] = received_char;
rx_buffer.head = next_head;
} else {
// Overrun: handle error (e.g., increment a counter)
}
}
// Check if the TXE flag is set (transmitter ready for more data)
if ( (USART2->SR & USART_SR_TXE) != 0 ) {
// Check if there is data in the TX buffer
if (tx_buffer.head != tx_buffer.tail) {
// Put data in the data register (this clears the TXE flag)
USART2->DR = tx_buffer.buffer[tx_buffer.tail];
tx_buffer.tail = (tx_buffer.tail + 1) % TX_BUFFER_SIZE;
} else {
// Buffer is empty, so disable the TXE interrupt
// to prevent it from continuously firing.
USART2->CR1 &= ~USART_CR1_TXEIE;
}
}
}
```
3. Application API Functions
* int UART_Read(void): This function checks if the RX buffer has data (head != tail), and if so, consumes one byte by reading from the buffer and advancing the tail.
* void UART_Write(const char* str): This function is the producer for the TX side. It loops through the input string, adds each character to the TX buffer (advancing the head), and finally, it must enable the TXE interrupt (TXEIE). This "kicks off" the transmission process, which will then run to completion in the background via the ISR.
Interview Preparation
Your extensive experience with communication protocols will make these questions a great way to demonstrate your deep understanding of the underlying mechanics.
-
Question: "Walk me through your design for a robust, interrupt-driven UART driver. What data structure is key, and why is it superior to a simple array?"
- Your Answer: "The core of the design is a pair of circular buffers, one for transmit and one for receive. This structure is ideal because it decouples the asynchronous, high-priority interrupt context from the main application loop. Unlike a simple array that requires complex management of indices and shifting data, a circular buffer is a natural FIFO queue. By implementing it as a single-producer, single-consumer queue where the ISR and main loop only modify their own respective pointers (
headortail), I can create a lock-free system that is highly efficient and avoids race conditions."
- Your Answer: "The core of the design is a pair of circular buffers, one for transmit and one for receive. This structure is ideal because it decouples the asynchronous, high-priority interrupt context from the main application loop. Unlike a simple array that requires complex management of indices and shifting data, a circular buffer is a natural FIFO queue. By implementing it as a single-producer, single-consumer queue where the ISR and main loop only modify their own respective pointers (
-
Question: "When implementing the transmit functionality, when and why do you enable and disable the 'Transmit Empty' (TXE) interrupt?"
- Your Answer: "This is a critical part of the state management. The TXE interrupt is only enabled within the
UART_WriteAPI function, right after the application has placed data into the transmit buffer. This initiates the transmission. The ISR then takes over, sending data byte-by-byte. Once the ISR finds the transmit buffer is empty (head == tail), it immediately disables the TXE interrupt. If it didn't, the interrupt would fire continuously as the hardware transmitter remains empty, starving the CPU. It's a classic 'kick-off and shut-down' mechanism."
- Your Answer: "This is a critical part of the state management. The TXE interrupt is only enabled within the
-
Question: "What is a UART overrun error, and how would you handle it in your driver?"
- Your Answer: "An overrun error occurs when the UART hardware receives a new byte before the previous one has been read from the data register by the CPU. In an interrupt-driven driver, this means a new byte arrived before the ISR for the last byte could even execute and read the data register. My ISR would detect this hardware flag. The driver's policy would dictate the action: the simplest approach is to discard the new byte that caused the overrun and increment an error counter. The application can periodically check this counter to know if the communication link is unreliable or if the system is too heavily loaded to service interrupts in time."
Conclusion
Today, we've constructed a complete mental model of an interrupt-driven UART driver, a cornerstone of embedded software. This moves beyond simply configuring a peripheral into the realm of managing concurrency, data flow, and state—hallmarks of advanced driver development.
Key Takeaways:
- Polling is inefficient. Interrupts are the robust solution for handling asynchronous events.
- Circular buffers are the ideal data structure for bridging the gap between the ISR and the main application.
- A single-producer, single-consumer (SPSC) design allows for a lock-free, highly efficient, and interrupt-safe implementation.
- The
volatilekeyword is non-negotiable for shared data structures to prevent erroneous compiler optimizations. - Properly managing interrupt enable bits (like for the TXE interrupt) is crucial for controlling the driver's state.
Next Lesson Preview:
We'll continue our exploration of communication peripherals by developing a polling-based SPI master driver. This will provide a great contrast to today's lesson. While polling is often unsuitable for asynchronous protocols like UART, we'll see situations where it can be a simple and effective choice for synchronous, master-controlled protocols like SPI.