Table of Contents
KEY TAKEAWAYS
- UART is an asynchronous protocol — no clock line — so both sides must agree on the baud rate within 2-3% for reliable communication
- A UART frame consists of a start bit (always 0), 5-9 data bits, optional parity bit, and 1-2 stop bits (always 1)
- The baud rate divisor is calculated as: BRR = peripheral_clock / (16 × baud_rate) for 16x oversampling
- Hardware flow control (RTS/CTS) prevents data loss when the receiver cannot process data fast enough
📘 Writing UART drivers is much easier with strong C fundamentals under your belt. My complete Master C & Embedded C course takes you from zero to hardware-ready code — free on YouTube (53 videos), or guided on Udemy with quizzes, certificate and my Q&A support.
What Makes UART Special
UART (Universal Asynchronous Receiver/Transmitter) is the most widely used serial communication protocol in embedded systems. It connects microcontrollers to GPS modules, Bluetooth adapters, GSM modems, sensors, and — most importantly — to your development PC for debugging via serial terminal. If you are building any embedded project, you will use UART. For a comparison with other protocols, see Communication Interfaces: UART, SPI, I2C.
The word “asynchronous” is the key. Unlike SPI and I2C, UART has no clock line. The transmitter and receiver must independently agree on when to sample each bit. This is done through the baud rate — a shared timing contract. Get it wrong by even a few percent and you get garbled data.
UART vs RS-232 vs TTL
These terms are often confused. They refer to different voltage standards that carry the same UART protocol:
- TTL UART (3.3V or 5V) — What microcontrollers use directly. Logic 1 = VDD (3.3V or 5V), Logic 0 = 0V. This is what comes out of your MCU’s TX/RX pins.
- RS-232 — An older standard for long-distance communication. Logic 1 = -3V to -15V, Logic 0 = +3V to +15V (inverted!). Used on legacy PC serial ports. Requires a level converter (like MAX232) to interface with a microcontroller.
- USB-to-UART — Most modern development uses a USB-to-UART bridge chip (CP2102, CH340, FTDI FT232). This chip converts USB on the PC side to TTL UART on the MCU side.
If you connect a 3.3V MCU directly to an RS-232 port, the ±12V signals will damage the MCU. Always check voltage levels. See Level Shifting Between 3.3V and 5V for voltage interfacing.
The UART Frame — Bit by Bit
When UART is idle (no data being sent), the line is held at logic HIGH (1). A transmission begins with a START bit — the line drops to LOW (0) for exactly one bit period. This falling edge is how the receiver detects “data is coming.”
A complete UART frame looks like this:
IDLE ─────┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌─────── IDLE
│START│D0│ │D1│ │D2│ │D3│ │D4│ │D5│ │D6│ │D7│ │PAR│ │STOP│
└─────┘ └──┘ └─────┘ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘
HIGH = 1 ───── ───────
LOW = 0 ────── ──── ────── ──── ──── ──── ──── ──── ──── ──── ────
│ │ │ │
│<─── Start bit (always 0) ───────── Data bits (LSB first) ──│PAR │STOP│
│ │ │ │The frame components:
- Start bit — Always 0. Lasts exactly one bit period. Signals the beginning of a frame.
- Data bits — 5 to 9 bits, transmitted LSB (Least Significant Bit) first. The most common configuration is 8 data bits.
- Parity bit (optional) — A single error-detection bit. Even parity makes the total number of 1s even; odd parity makes it odd. Detects single-bit errors but cannot correct them. Many modern systems skip parity and use higher-level checksums instead.
- Stop bit(s) — Always 1. Can be 1, 1.5, or 2 bit periods. Guarantees the line returns to idle before the next frame. One stop bit is most common.
The most common configuration is 8N1: 8 data bits, No parity, 1 stop bit. This gives 10 bits total per byte (1 start + 8 data + 1 stop). At 115200 baud, each byte takes 10/115200 = 86.8 µs, giving an effective throughput of 11,520 bytes per second.
Baud Rate: The Timing Contract
The baud rate defines how long each bit lasts. At 9600 baud, each bit is 1/9600 = 104.17 µs. At 115200 baud, each bit is 1/115200 = 8.68 µs. Both transmitter and receiver must use the same baud rate.
The UART hardware generates the bit timing from the peripheral clock using a baud rate divider register (BRR):
/*
* Baud Rate Divisor Calculation
*
* For 16x oversampling (standard):
* BRR = f_PCLK / (16 × baud_rate)
*
* For 8x oversampling (higher baud rates):
* BRR = f_PCLK / (8 × baud_rate)
*
* Example: f_PCLK = 72 MHz, baud = 115200
* BRR = 72,000,000 / (16 × 115,200) = 39.0625
*
* The integer part (39) goes into BRR[15:4]
* The fractional part (0.0625 × 16 = 1) goes into BRR[3:0]
* BRR = (39 << 4) | 1 = 0x271
*
* Actual baud rate = 72,000,000 / (16 × 39.0625) = 115,200.0 (0% error)
*/
/* Calculate BRR value with rounding for best accuracy */
uint16_t calculate_brr(uint32_t pclk, uint32_t baud)
{
/* Multiply by 100 to get 2 decimal places of fraction */
uint32_t int_div = (25 * pclk) / (4 * baud); /* 25 * pclk / (4 * baud) */
uint32_t mantissa = int_div / 100;
uint32_t fraction = int_div - (mantissa * 100);
/* Convert fraction to 4-bit value (0-15) with rounding */
fraction = ((fraction * 16) + 50) / 100;
return (uint16_t)((mantissa << 4) | (fraction & 0x0F));
}Baud Rate Error — Why It Matters
Because the divider must be an integer (or has limited fractional bits), the actual baud rate may not exactly match the desired rate. The error is:
Error (%) = ((actual_baud - desired_baud) / desired_baud) × 100
Example: f_PCLK = 8 MHz, desired baud = 115200
BRR = 8,000,000 / (16 × 115,200) = 4.34
Rounded BRR = 4
Actual baud = 8,000,000 / (16 × 4) = 125,000
Error = (125,000 - 115,200) / 115,200 × 100 = 8.5% ← TOO HIGH!A baud rate error above 2-3% will cause framing errors. In the example above, an 8 MHz clock cannot accurately produce 115200 baud. You would need to either use a different clock frequency (like 7.3728 MHz, which divides evenly into common baud rates) or use a lower baud rate like 9600. This is why crystal oscillator selection matters for UART-heavy applications.
How the Receiver Synchronizes
With no clock line, the receiver must figure out exactly when each bit starts. It does this through oversampling. In 16x oversampling mode, the UART hardware samples the input 16 times per bit period. Here is the process:
- The receiver detects the falling edge of the start bit
- It counts 8 sample clocks (half a bit period) to reach the center of the start bit
- It verifies the start bit is still 0 (filters out noise glitches)
- It then samples each data bit at the center (sample 8 of 16) for best noise immunity
- Some implementations use majority voting — sampling at positions 7, 8, and 9, taking the majority value — for additional noise rejection
This means the actual sampling point drifts slightly if there is a baud rate mismatch. By the last data bit (bit 7 in 8N1), the accumulated timing error determines whether the bit is sampled correctly. With a 3% error, the sampling point shifts by 0.03 × 8 = 0.24 bit periods — right at the edge of reliability.
Flow Control
When the transmitter sends data faster than the receiver can process it, data is lost. Flow control mechanisms prevent this.
Hardware Flow Control (RTS/CTS)
Two additional signal lines control data flow:
- RTS (Request To Send) — The receiver asserts this to say “I am ready for data.” The receiver drives this line.
- CTS (Clear To Send) — The transmitter checks this before sending. If CTS is deasserted, the transmitter pauses. The transmitter reads this line.
The flow is: Device A wants to send → checks if Device B’s RTS (connected to A’s CTS) is active → if yes, sends data → if no, waits. When B’s receive buffer is nearly full, B deasserts RTS, and A stops sending.
Software Flow Control (XON/XOFF)
Uses special characters in the data stream: XOFF (0x13, Ctrl+S) means “stop sending” and XON (0x11, Ctrl+Q) means “resume sending.” No extra wires needed, but you cannot use these character values in your data. Rarely used in modern embedded systems.
Complete UART Implementation in C
Here is a complete register-level UART implementation with polling, interrupt-driven reception, and a ring buffer. This code is for a typical ARM Cortex-M microcontroller but the concepts apply to any architecture.
Polling-Based UART
#include <stdint.h>
/* USART register definitions */
#define USART1_BASE 0x40011000UL
#define USART1_SR (*(volatile uint32_t *)(USART1_BASE + 0x00))
#define USART1_DR (*(volatile uint32_t *)(USART1_BASE + 0x04))
#define USART1_BRR (*(volatile uint32_t *)(USART1_BASE + 0x08))
#define USART1_CR1 (*(volatile uint32_t *)(USART1_BASE + 0x0C))
#define USART1_CR2 (*(volatile uint32_t *)(USART1_BASE + 0x10))
#define USART1_CR3 (*(volatile uint32_t *)(USART1_BASE + 0x14))
/* Status register flags */
#define USART_SR_TXE (1U << 7) /* Transmit Data Register Empty */
#define USART_SR_TC (1U << 6) /* Transmission Complete */
#define USART_SR_RXNE (1U << 5) /* Read Data Register Not Empty */
#define USART_SR_ORE (1U << 3) /* Overrun Error */
#define USART_SR_FE (1U << 1) /* Framing Error */
#define USART_SR_PE (1U << 0) /* Parity Error */
/* Control register 1 flags */
#define USART_CR1_UE (1U << 13)
#define USART_CR1_TE (1U << 3)
#define USART_CR1_RE (1U << 2)
#define USART_CR1_RXNEIE (1U << 5)
/* RCC and GPIO for enabling clocks and configuring pins */
#define RCC_APB2ENR (*(volatile uint32_t *)(0x40021000UL + 0x18))
#define GPIOA_CRH (*(volatile uint32_t *)(0x40010800UL + 0x04))
void uart1_init(uint32_t pclk, uint32_t baud)
{
/* Enable clocks: USART1 + GPIOA + Alternate Function */
RCC_APB2ENR |= (1U << 14) | (1U << 2) | (1U << 0);
/* Configure PA9 (TX) as Alternate Function Push-Pull, 50 MHz
* PA9 is controlled by CRH bits [7:4]: MODE=11 (50MHz), CNF=10 (AF PP) */
GPIOA_CRH &= ~(0xFU << 4);
GPIOA_CRH |= (0xBU << 4); /* 1011: AF push-pull, 50 MHz */
/* Configure PA10 (RX) as Floating Input
* PA10 is controlled by CRH bits [11:8]: MODE=00 (input), CNF=01 (floating) */
GPIOA_CRH &= ~(0xFU << 8);
GPIOA_CRH |= (0x4U << 8); /* 0100: floating input */
/* Set baud rate */
USART1_BRR = pclk / baud;
/* Enable USART: TX + RX + USART Enable */
USART1_CR1 = USART_CR1_UE | USART_CR1_TE | USART_CR1_RE;
}
void uart1_putchar(char c)
{
while (!(USART1_SR & USART_SR_TXE)) /* Wait for TX buffer empty */
;
USART1_DR = (uint32_t)c;
}
char uart1_getchar(void)
{
while (!(USART1_SR & USART_SR_RXNE)) /* Wait for data available */
;
return (char)(USART1_DR & 0xFF);
}
void uart1_send_string(const char *str)
{
while (*str) {
uart1_putchar(*str++);
}
}
/* Getchar with timeout (returns -1 on timeout) */
int uart1_getchar_timeout(uint32_t timeout_ms)
{
uint32_t start = get_tick_ms(); /* Your systick-based millisecond counter */
while (!(USART1_SR & USART_SR_RXNE)) {
if ((get_tick_ms() - start) >= timeout_ms) {
return -1; /* Timeout */
}
}
return (int)(USART1_DR & 0xFF);
}Ring Buffer for Interrupt-Driven Reception
Polling works but blocks the CPU while waiting for data. In real applications, you want interrupt-driven UART with a ring (circular) buffer. The interrupt fires when a byte arrives, stores it in the buffer, and the main loop reads from the buffer at its own pace.
#include <stdint.h>
/* Ring buffer implementation */
#define RING_BUF_SIZE 256 /* Must be a power of 2 for fast modulo */
typedef struct {
uint8_t buffer[RING_BUF_SIZE];
volatile uint16_t head; /* Write index (ISR writes here) */
volatile uint16_t tail; /* Read index (main loop reads here) */
} ring_buffer_t;
void rb_init(ring_buffer_t *rb)
{
rb->head = 0;
rb->tail = 0;
}
int rb_is_empty(const ring_buffer_t *rb)
{
return rb->head == rb->tail;
}
int rb_is_full(const ring_buffer_t *rb)
{
return ((rb->head + 1) & (RING_BUF_SIZE - 1)) == rb->tail;
}
int rb_push(ring_buffer_t *rb, uint8_t byte)
{
if (rb_is_full(rb)) return -1; /* Buffer full */
rb->buffer[rb->head] = byte;
rb->head = (rb->head + 1) & (RING_BUF_SIZE - 1);
return 0;
}
int rb_pop(ring_buffer_t *rb, uint8_t *byte)
{
if (rb_is_empty(rb)) return -1; /* Buffer empty */
*byte = rb->buffer[rb->tail];
rb->tail = (rb->tail + 1) & (RING_BUF_SIZE - 1);
return 0;
}
uint16_t rb_available(const ring_buffer_t *rb)
{
return (rb->head - rb->tail) & (RING_BUF_SIZE - 1);
}
/* Global ring buffers for UART RX and TX */
static ring_buffer_t uart1_rx_buf;
static ring_buffer_t uart1_tx_buf;
void uart1_init_interrupt(uint32_t pclk, uint32_t baud)
{
rb_init(&uart1_rx_buf);
rb_init(&uart1_tx_buf);
/* Basic UART init (same as polling version above) */
uart1_init(pclk, baud);
/* Enable RXNE interrupt */
USART1_CR1 |= USART_CR1_RXNEIE;
/* Enable USART1 interrupt in NVIC (IRQ 37 on many STM32) */
/* NVIC_EnableIRQ(USART1_IRQn); */
*(volatile uint32_t *)(0xE000E104) |= (1U << (37 - 32));
}
/* UART1 Interrupt Service Routine */
void USART1_IRQHandler(void)
{
uint32_t sr = USART1_SR;
/* RX: data received */
if (sr & USART_SR_RXNE) {
uint8_t data = (uint8_t)(USART1_DR & 0xFF);
rb_push(&uart1_rx_buf, data); /* Store in ring buffer */
}
/* TX: transmit buffer empty, send next byte */
if ((sr & USART_SR_TXE) && (USART1_CR1 & (1U << 7))) {
uint8_t data;
if (rb_pop(&uart1_tx_buf, &data) == 0) {
USART1_DR = data;
} else {
/* No more data to send — disable TXE interrupt */
USART1_CR1 &= ~(1U << 7);
}
}
/* Handle errors */
if (sr & (USART_SR_ORE | USART_SR_FE | USART_SR_PE)) {
/* Read DR to clear error flags */
(void)USART1_DR;
}
}
/* Non-blocking read from ring buffer */
int uart1_read(uint8_t *data, uint16_t max_len)
{
uint16_t count = 0;
while (count < max_len && rb_pop(&uart1_rx_buf, &data[count]) == 0) {
count++;
}
return count;
}
/* Non-blocking write via ring buffer + interrupt */
int uart1_write(const uint8_t *data, uint16_t len)
{
for (uint16_t i = 0; i < len; i++) {
while (rb_is_full(&uart1_tx_buf))
; /* Wait if buffer is full */
rb_push(&uart1_tx_buf, data[i]);
}
/* Enable TXE interrupt to start sending */
USART1_CR1 |= (1U << 7);
return len;
}Common UART Problems and Solutions
Garbled Data
If you see random characters instead of your expected data, it is almost always a baud rate mismatch. The transmitted bytes look correct at the sender’s baud rate but the receiver interprets the bit timings differently, decoding different values. Solution: verify both sides use exactly the same baud rate. Use an oscilloscope or logic analyzer to measure the actual bit period on the TX line.
No Data at All
Check these in order:
- TX and RX must be crossed: MCU TX → PC RX, MCU RX → PC TX
- The UART peripheral clock must be enabled in the RCC
- The TX pin must be configured as alternate function output, not GPIO
- The UART must be enabled (UE bit) AFTER configuring baud rate and pins
- Ground must be connected between the two devices
Intermittent Framing Errors
Framing errors mean the stop bit was not detected where expected. Causes:
- Baud rate error accumulating over multiple bytes in a burst
- Electrical noise on the line (especially on long wires)
- Ground bounce in noisy environments
C code to check for and handle errors:
typedef struct {
uint32_t framing_errors;
uint32_t overrun_errors;
uint32_t parity_errors;
uint32_t total_bytes;
} uart_stats_t;
static uart_stats_t uart1_stats = {0};
void uart1_check_errors(void)
{
uint32_t sr = USART1_SR;
if (sr & USART_SR_FE) {
uart1_stats.framing_errors++;
(void)USART1_DR; /* Clear flag by reading DR */
}
if (sr & USART_SR_ORE) {
uart1_stats.overrun_errors++;
(void)USART1_DR;
}
if (sr & USART_SR_PE) {
uart1_stats.parity_errors++;
(void)USART1_DR;
}
}
void uart1_print_stats(void)
{
char buf[64];
snprintf(buf, sizeof(buf), "RX: %lu bytes, FE: %lu, ORE: %lu, PE: %lurn",
uart1_stats.total_bytes,
uart1_stats.framing_errors,
uart1_stats.overrun_errors,
uart1_stats.parity_errors);
uart1_send_string(buf);
}Printf over UART
Redirecting printf() to UART is extremely useful for debugging. On GCC-based toolchains, you implement the _write system call:
#include <errno.h>
#include <sys/stat.h>
/* Redirect printf to UART1 */
int _write(int fd, char *ptr, int len)
{
(void)fd; /* Ignore file descriptor — always go to UART */
for (int i = 0; i < len; i++) {
if (ptr[i] == 'n') {
uart1_putchar('r'); /* Add carriage return for terminals */
}
uart1_putchar(ptr[i]);
}
return len;
}
/* Now you can use printf normally: */
/* printf("ADC value: %d, voltage: %.2fVn", raw, voltage); */Note: printf() pulls in the C standard library, which adds significant code size (10-50 KB). For resource-constrained MCUs, write your own lightweight integer-to-string functions instead. For more on toolchain topics, see Mastering GCC and The Compilation Process.
Summary
UART is deceptively simple in concept but has many subtleties that cause problems in practice. The key points to remember:
- Both sides must agree on baud rate, data bits, parity, and stop bits
- Baud rate error must be below 2-3% for reliable communication
- Use interrupt-driven reception with a ring buffer for real applications
- Always handle error flags (framing, overrun, parity)
- TX crosses to RX — the most common wiring mistake
- Registers in Microcontrollers
- GPIO in Embedded Systems
- Endianness: Big Endian vs Little Endian
Next, read Writing a UART Driver in Embedded C for a complete, production-quality driver implementation. For debugging UART problems, see Debugging Communication Protocols. For a broader comparison of serial protocols, see Communication Interfaces: UART, SPI, I2C.
Related on this site
- For the matching driver implementation — register setup, IRQ handling, ring buffer — see writing a UART driver in embedded C.
- For an AVR-specific worked example using UART, see serial programming of AVR microcontrollers.
- When the bytes received don’t match what was sent, see debugging communication protocols for the troubleshooting workflow.

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.



