Table of Contents
KEY TAKEAWAYS
- A well-structured UART driver separates hardware access (registers) from the public API, making it portable across MCU families
- The driver uses a ring buffer for non-blocking, interrupt-driven transmit and receive operations
- Error handling (framing, overrun, parity) must be built into the ISR — ignored errors lead to silent data corruption
- A complete driver includes: initialization, blocking/non-blocking read/write, line-buffered input, and printf redirection
Driver Architecture
A production-quality UART driver is more than a putchar() function. It must handle concurrent transmit and receive, buffer management, error detection, and provide a clean API that the rest of your application can use without knowing anything about registers. For general principles of embedded driver design, see Driver Interface Design in C and Hardware Abstraction Layer.
Our driver will have three layers:
- Hardware layer — Register definitions, direct hardware access (only this layer changes when porting to a different MCU)
- Driver layer — Ring buffers, ISR, initialization, error handling
- API layer — Public functions your application calls:
uart_init(),uart_write(),uart_read(),uart_printf() - Communication Interfaces: UART, SPI, and I2C
- Polling vs Interrupts in Embedded Systems
- Registers in Microcontrollers
The Header File: uart.h
The header defines the public interface. Application code only needs to include this file — it never touches registers directly. For best practices on header file organization, see Separate Header Files in C.
/*
* uart.h — UART Driver Public Interface
*
* Supports multiple UART instances (UART0, UART1, etc.)
* Interrupt-driven with configurable buffer sizes.
*/
#ifndef UART_H
#define UART_H
#include <stdint.h>
#include <stddef.h>
/* Parity options */
typedef enum {
UART_PARITY_NONE,
UART_PARITY_EVEN,
UART_PARITY_ODD
} uart_parity_t;
/* Stop bit options */
typedef enum {
UART_STOP_1,
UART_STOP_2
} uart_stop_bits_t;
/* Flow control */
typedef enum {
UART_FLOW_NONE,
UART_FLOW_RTS_CTS
} uart_flow_t;
/* Configuration structure */
typedef struct {
uint32_t baud_rate;
uint8_t data_bits; /* 7, 8, or 9 */
uart_parity_t parity;
uart_stop_bits_t stop_bits;
uart_flow_t flow_control;
} uart_config_t;
/* Error codes */
typedef enum {
UART_OK = 0,
UART_ERR_INVALID_PORT,
UART_ERR_INVALID_CONFIG,
UART_ERR_BUFFER_FULL,
UART_ERR_BUFFER_EMPTY,
UART_ERR_TIMEOUT,
UART_ERR_FRAMING,
UART_ERR_OVERRUN,
UART_ERR_PARITY
} uart_error_t;
/* Error statistics */
typedef struct {
uint32_t rx_bytes;
uint32_t tx_bytes;
uint32_t framing_errors;
uint32_t overrun_errors;
uint32_t parity_errors;
uint32_t buffer_overflows;
} uart_stats_t;
/* UART port identifiers */
typedef enum {
UART_PORT_1 = 0,
UART_PORT_2,
UART_PORT_MAX
} uart_port_t;
/* Initialize a UART port with the given configuration */
uart_error_t uart_init(uart_port_t port, const uart_config_t *config);
/* Deinitialize a UART port (disable peripheral, free resources) */
void uart_deinit(uart_port_t port);
/* Write data (non-blocking, returns immediately, data sent via interrupt) */
uart_error_t uart_write(uart_port_t port, const uint8_t *data, uint16_t len);
/* Write a string (convenience wrapper) */
uart_error_t uart_write_string(uart_port_t port, const char *str);
/* Read available data from the receive buffer (non-blocking) */
uint16_t uart_read(uart_port_t port, uint8_t *data, uint16_t max_len);
/* Read a single byte (blocking with timeout, returns -1 on timeout) */
int uart_getchar(uart_port_t port, uint32_t timeout_ms);
/* Check how many bytes are available in the receive buffer */
uint16_t uart_available(uart_port_t port);
/* Flush the transmit buffer (block until all data is sent) */
void uart_flush_tx(uart_port_t port);
/* Get error statistics */
uart_stats_t uart_get_stats(uart_port_t port);
/* Reset error statistics */
void uart_reset_stats(uart_port_t port);
/* Read a line (blocks until newline or buffer full or timeout) */
uint16_t uart_read_line(uart_port_t port, char *buf, uint16_t max_len,
uint32_t timeout_ms);
#endif /* UART_H */The Implementation: uart.c
The implementation file contains the ring buffer, ISR, and all public functions. Here is the complete driver:
/*
* uart.c — UART Driver Implementation
*/
#include "uart.h"
/* ── Ring Buffer ────────────────────────────────────────── */
#define UART_BUF_SIZE 256 /* Must be power of 2 */
#define BUF_MASK (UART_BUF_SIZE - 1)
typedef struct {
uint8_t data[UART_BUF_SIZE];
volatile uint16_t head;
volatile uint16_t tail;
} ringbuf_t;
static void rb_init(ringbuf_t *rb) { rb->head = rb->tail = 0; }
static int rb_empty(const ringbuf_t *rb) { return rb->head == rb->tail; }
static int rb_full(const ringbuf_t *rb) { return ((rb->head+1) & BUF_MASK) == rb->tail; }
static int rb_push(ringbuf_t *rb, uint8_t byte) {
if (rb_full(rb)) return -1;
rb->data[rb->head] = byte;
rb->head = (rb->head + 1) & BUF_MASK;
return 0;
}
static int rb_pop(ringbuf_t *rb, uint8_t *byte) {
if (rb_empty(rb)) return -1;
*byte = rb->data[rb->tail];
rb->tail = (rb->tail + 1) & BUF_MASK;
return 0;
}
static uint16_t rb_count(const ringbuf_t *rb) {
return (rb->head - rb->tail) & BUF_MASK;
}
/* ── Per-port State ──────────────────────────────────────── */
typedef struct {
volatile uint32_t *SR; /* Status Register */
volatile uint32_t *DR; /* Data Register */
volatile uint32_t *BRR; /* Baud Rate Register */
volatile uint32_t *CR1; /* Control Register 1 */
uint32_t pclk; /* Peripheral clock frequency */
} uart_hw_t;
typedef struct {
uart_hw_t hw;
ringbuf_t rx_buf;
ringbuf_t tx_buf;
uart_stats_t stats;
int initialized;
} uart_state_t;
static uart_state_t uart_ports[UART_PORT_MAX];
/* Hardware definitions (STM32-style, adapt for your MCU) */
static const uart_hw_t hw_defs[UART_PORT_MAX] = {
[UART_PORT_1] = {
.SR = (volatile uint32_t *)0x40011000,
.DR = (volatile uint32_t *)0x40011004,
.BRR = (volatile uint32_t *)0x40011008,
.CR1 = (volatile uint32_t *)0x4001100C,
.pclk = 72000000 /* APB2 clock for USART1 */
},
[UART_PORT_2] = {
.SR = (volatile uint32_t *)0x40004400,
.DR = (volatile uint32_t *)0x40004404,
.BRR = (volatile uint32_t *)0x40004408,
.CR1 = (volatile uint32_t *)0x4000440C,
.pclk = 36000000 /* APB1 clock for USART2 */
},
};
/* ── Initialization ──────────────────────────────────────── */
uart_error_t uart_init(uart_port_t port, const uart_config_t *config)
{
if (port >= UART_PORT_MAX) return UART_ERR_INVALID_PORT;
if (config->data_bits < 7 || config->data_bits > 9) return UART_ERR_INVALID_CONFIG;
uart_state_t *u = &uart_ports[port];
u->hw = hw_defs[port];
rb_init(&u->rx_buf);
rb_init(&u->tx_buf);
u->stats = (uart_stats_t){0};
/* Enable peripheral clock (MCU-specific, omitted for portability) */
/* enable_uart_clock(port); */
/* configure_uart_gpio(port); */
/* Disable UART during configuration */
*u->hw.CR1 = 0;
/* Set baud rate */
*u->hw.BRR = u->hw.pclk / config->baud_rate;
/* Build CR1 value */
uint32_t cr1 = (1U << 13) /* UE: USART Enable */
| (1U << 3) /* TE: Transmitter Enable */
| (1U << 2) /* RE: Receiver Enable */
| (1U << 5); /* RXNEIE: RX interrupt enable */
if (config->data_bits == 9) cr1 |= (1U << 12); /* M: 9-bit word */
if (config->parity != UART_PARITY_NONE) {
cr1 |= (1U << 10); /* PCE: Parity enable */
if (config->parity == UART_PARITY_ODD) cr1 |= (1U << 9); /* PS: Odd */
}
*u->hw.CR1 = cr1;
/* Enable NVIC interrupt (port-specific IRQ number) */
/* NVIC_EnableIRQ(uart_irq_numbers[port]); */
u->initialized = 1;
return UART_OK;
}
/* ── ISR (call from actual interrupt vector) ─────────────── */
void uart_isr_handler(uart_port_t port)
{
uart_state_t *u = &uart_ports[port];
uint32_t sr = *u->hw.SR;
/* Handle errors FIRST (read SR then DR clears flags) */
if (sr & (1U << 1)) { u->stats.framing_errors++; }
if (sr & (1U << 3)) { u->stats.overrun_errors++; }
if (sr & (1U << 0)) { u->stats.parity_errors++; }
/* RX: byte received */
if (sr & (1U << 5)) {
uint8_t byte = (uint8_t)(*u->hw.DR & 0xFF);
if (rb_push(&u->rx_buf, byte) != 0) {
u->stats.buffer_overflows++;
} else {
u->stats.rx_bytes++;
}
}
/* TX: ready for next byte */
if ((sr & (1U << 7)) && (*u->hw.CR1 & (1U << 7))) {
uint8_t byte;
if (rb_pop(&u->tx_buf, &byte) == 0) {
*u->hw.DR = byte;
u->stats.tx_bytes++;
} else {
*u->hw.CR1 &= ~(1U << 7); /* Disable TXE interrupt */
}
}
}
/* Actual ISR vectors call the generic handler: */
void USART1_IRQHandler(void) { uart_isr_handler(UART_PORT_1); }
void USART2_IRQHandler(void) { uart_isr_handler(UART_PORT_2); }
/* ── Public API ──────────────────────────────────────────── */
uart_error_t uart_write(uart_port_t port, const uint8_t *data, uint16_t len)
{
uart_state_t *u = &uart_ports[port];
for (uint16_t i = 0; i < len; i++) {
while (rb_full(&u->tx_buf))
; /* Block until space available */
rb_push(&u->tx_buf, data[i]);
}
/* Enable TXE interrupt to start transmission */
*u->hw.CR1 |= (1U << 7);
return UART_OK;
}
uart_error_t uart_write_string(uart_port_t port, const char *str)
{
return uart_write(port, (const uint8_t *)str, strlen(str));
}
uint16_t uart_read(uart_port_t port, uint8_t *data, uint16_t max_len)
{
uart_state_t *u = &uart_ports[port];
uint16_t count = 0;
while (count < max_len && rb_pop(&u->rx_buf, &data[count]) == 0) {
count++;
}
return count;
}
int uart_getchar(uart_port_t port, uint32_t timeout_ms)
{
uart_state_t *u = &uart_ports[port];
uint32_t start = get_tick_ms();
while (rb_empty(&u->rx_buf)) {
if ((get_tick_ms() - start) >= timeout_ms) return -1;
}
uint8_t byte;
rb_pop(&u->rx_buf, &byte);
return (int)byte;
}
uint16_t uart_available(uart_port_t port)
{
return rb_count(&uart_ports[port].rx_buf);
}
void uart_flush_tx(uart_port_t port)
{
uart_state_t *u = &uart_ports[port];
while (!rb_empty(&u->tx_buf))
;
/* Wait for last byte to finish transmitting (TC flag) */
while (!(*u->hw.SR & (1U << 6)))
;
}
uint16_t uart_read_line(uart_port_t port, char *buf, uint16_t max_len,
uint32_t timeout_ms)
{
uint16_t count = 0;
uint32_t start = get_tick_ms();
while (count < max_len - 1) {
int ch = uart_getchar(port, timeout_ms);
if (ch < 0) break; /* Timeout */
if (ch == 'r' || ch == 'n') {
if (count > 0) break; /* End of line */
continue; /* Skip leading newlines */
}
buf[count++] = (char)ch;
/* Update remaining timeout */
uint32_t elapsed = get_tick_ms() - start;
if (elapsed >= timeout_ms) break;
timeout_ms -= elapsed;
start = get_tick_ms();
}
buf[count] = '';
return count;
}
uart_stats_t uart_get_stats(uart_port_t port)
{
return uart_ports[port].stats;
}
void uart_reset_stats(uart_port_t port)
{
uart_ports[port].stats = (uart_stats_t){0};
}Usage Example: Command-Line Interface over UART
A very common embedded pattern — a simple command parser over UART for runtime configuration and debugging:
#include "uart.h"
#include <string.h>
#include <stdio.h>
void process_command(const char *cmd)
{
if (strcmp(cmd, "status") == 0) {
uart_stats_t stats = uart_get_stats(UART_PORT_1);
char buf[128];
snprintf(buf, sizeof(buf),
"RX: %lu TX: %lu FE: %lu ORE: %lurn",
stats.rx_bytes, stats.tx_bytes,
stats.framing_errors, stats.overrun_errors);
uart_write_string(UART_PORT_1, buf);
} else if (strcmp(cmd, "reset") == 0) {
uart_write_string(UART_PORT_1, "Resetting...rn");
uart_flush_tx(UART_PORT_1);
software_reset();
} else if (strcmp(cmd, "help") == 0) {
uart_write_string(UART_PORT_1,
"Commands: status, reset, helprn");
} else {
uart_write_string(UART_PORT_1, "Unknown command: ");
uart_write_string(UART_PORT_1, cmd);
uart_write_string(UART_PORT_1, "rn");
}
}
int main(void)
{
uart_config_t cfg = {
.baud_rate = 115200,
.data_bits = 8,
.parity = UART_PARITY_NONE,
.stop_bits = UART_STOP_1,
.flow_control = UART_FLOW_NONE
};
uart_init(UART_PORT_1, &cfg);
uart_write_string(UART_PORT_1, "System ready. Type 'help' for commands.rn> ");
char line[64];
while (1) {
uint16_t len = uart_read_line(UART_PORT_1, line, sizeof(line), 100);
if (len > 0) {
process_command(line);
uart_write_string(UART_PORT_1, "> ");
}
/* Other tasks here... */
}
}Testing: Loopback Test
The simplest UART test: connect TX to RX (physically wire the pins together). Every byte you transmit should be received back. This tests the entire driver chain — initialization, TX interrupt, ring buffer, RX interrupt.
int uart_loopback_test(uart_port_t port)
{
const uint8_t test_data[] = {0x00, 0x55, 0xAA, 0xFF, 'H', 'e', 'l', 'l', 'o'};
const int test_len = sizeof(test_data);
uint8_t rx_data[sizeof(test_data)];
int errors = 0;
/* Send test data */
uart_write(port, test_data, test_len);
/* Wait a bit for transmission + loopback reception */
delay_ms(10);
/* Read back */
int rx_len = uart_read(port, rx_data, test_len);
if (rx_len != test_len) {
printf("FAIL: sent %d bytes, received %d\n", test_len, rx_len);
return -1;
}
for (int i = 0; i < test_len; i++) {
if (rx_data[i] != test_data[i]) {
printf("FAIL: byte %d: sent 0x%02X, got 0x%02X\n",
i, test_data[i], rx_data[i]);
errors++;
}
}
if (errors == 0) {
printf("PASS: loopback test (%d bytes)\n", test_len);
}
return errors;
}Making It Portable
To port this driver to a different MCU family, you only need to change:
- The register addresses in
hw_defs[] - The clock enable and GPIO configuration functions
- The ISR vector names
- The bit positions if the status/control registers differ
The ring buffer, the public API, and the application code remain completely unchanged. This is the power of separating hardware abstraction from driver logic — see Module Design in Embedded C and Separation of Concerns for the design principles behind this approach.
Summary
A well-structured UART driver is one of the first things you should build for any new embedded project. It provides your primary debugging channel and serves as a template for other peripheral drivers. The pattern shown here — configuration struct, ring buffer, interrupt-driven I/O, clean public API — applies equally to SPI, I2C, and other communication drivers.
For the protocol fundamentals behind this driver, read UART Protocol Deep Dive. To learn about common communication problems, see Debugging Communication Protocols. For error handling design patterns, see Error Handling Patterns in C.
📖 Related: Writing an SPI Driver in Embedded C: Complete Implementation
Related on this site
- For the underlying protocol theory (framing, baud rate, parity, flow control), see UART protocol deep dive.
- For the broader pattern of how a driver layer is structured — register access up to kernel-style API — see device drivers development introduction.
- The most common UART use case is printf-style logging — see logging and trace techniques in embedded C for patterns that scale beyond ad-hoc printf.

Vivek Bhageria — Lead Firmware R&D Engineer, 12+ years. Ex-Bosch (automotive powertrain), MusicTribe (real-time audio), medical devices. M.Tech BITS Pilani. I write at NerdyElectronics — practical, register-level embedded systems for engineers who want to understand what’s actually happening under the hood.



