Skip to content
Home » Embedded Systems » Embedded C » Writing a UART Driver in Embedded C: From Registers to API

Writing a UART Driver in Embedded C: From Registers to API

Communication Protocols
Part 2 of 10View Full Path →

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:

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:

  1. The register addresses in hw_defs[]
  2. The clock enable and GPIO configuration functions
  3. The ISR vector names
  4. 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

Leave a Reply

Your email address will not be published. Required fields are marked *