AVR USART Serial Communication in C: UBRR0 Baud Rate, printf and RX ISR

A serial link to a PC is the most useful debugging tool on a microcontroller with no screen. With it you can print sensor values, trace program flow and send commands from a terminal program. The ATmega328P has one USART (Universal Synchronous and Asynchronous serial Receiver and Transmitter), usually used in asynchronous mode as a UART.

This tutorial calculates the baud rate register value, writes polled transmit and receive functions, connects printf from avr-libc to the USART, and finally builds an interrupt-driven receiver with a ring buffer so that no incoming characters are lost.

Frame Format and Baud Rate

Asynchronous serial data has no clock line, so both ends must agree on the bit rate and frame format. The usual setting is 8N1: one start bit, eight data bits, no parity, one stop bit, giving ten bits per byte. At 9600 baud that is 960 bytes per second.

The USART derives its bit rate from the CPU clock using the 12-bit register UBRR0. In normal speed mode the formulas are:

UBRR0 = f_clk / (16 x BAUD) - 1          (normal mode)
UBRR0 = f_clk / (8 x BAUD) - 1           (double speed, U2X0 = 1)
Actual baud = f_clk / (16 x (UBRR0 + 1))

16 MHz, 9600 baud:  16 000 000 / 153 600 - 1 = 103.17 -> 103
                    actual = 9615 baud, error +0.2 %
Baud (16 MHz)U2X0UBRR0Error
96000103+0.2 %
38400025+0.2 %
57600134-0.8 %
11520008-3.5 %
115200116+2.1 %

Keep the error within about 2 % for reliable reception. At 16 MHz, 115200 baud is only usable in double speed mode, and even then it is marginal. This is why boards intended for high baud rates sometimes use crystals such as 14.7456 MHz, which divide exactly into standard baud rates.

Registers and Wiring

RegisterBitsUse
UBRR0H, UBRR0L (UBRR0)12-bit valueBaud rate divider
UCSR0ARXC0, UDRE0, U2X0Receive complete, data register empty, double speed
UCSR0BRXEN0, TXEN0, RXCIE0Enable receiver, transmitter and RX interrupt
UCSR0CUCSZ01, UCSZ00Both set gives 8-bit characters; default is 1 stop bit, no parity
UDR0-Write to transmit, read to receive

TXD is PD1 (DIP pin 3) and RXD is PD0 (pin 2). On an Arduino Uno these are already connected to the on-board USB-serial converter, so just open the COM port. For a bare chip, use a USB to TTL adapter such as CP2102 or CH340: adapter TX to AVR RXD, adapter RX to AVR TXD, and common ground.

Program: printf Output and Interrupt-Driven Receive

USART0 driver with printf redirection and RX ring buffer (ATmega328P, 16 MHz, 9600 8N1)
#define F_CPU 16000000UL
#define BAUD  9600UL

#include <avr/io.h>
#include <avr/interrupt.h>
#include <stdio.h>
#include <stdint.h>

#define UBRR_VALUE ((F_CPU / (16UL * BAUD)) - 1)
#define RX_SIZE    32                       /* must be a power of two */

static volatile uint8_t rx_buf[RX_SIZE];
static volatile uint8_t rx_head = 0, rx_tail = 0;

static void uart_init(void)
{
    UBRR0  = UBRR_VALUE;                       /* 103 for 9600 baud */
    UCSR0A = 0;                                /* normal speed */
    UCSR0B = (1 << RXEN0) | (1 << TXEN0) | (1 << RXCIE0);
    UCSR0C = (1 << UCSZ01) | (1 << UCSZ00);    /* 8N1 */
}

static void uart_putc(char c)
{
    while (!(UCSR0A & (1 << UDRE0)))           /* wait for empty buffer */
        ;
    UDR0 = c;
}

/* Called for every character printf produces */
static int uart_putchar(char c, FILE *stream)
{
    if (c == '\n')
        uart_putc('\r');                       /* terminals expect CR LF */
    uart_putc(c);
    return 0;
}

static FILE uart_out = FDEV_SETUP_STREAM(uart_putchar, NULL, _FDEV_SETUP_WRITE);

/* RX complete interrupt: store byte in ring buffer */
ISR(USART_RX_vect)
{
    uint8_t c = UDR0;                          /* reading clears RXC0 */
    uint8_t next = (rx_head + 1) & (RX_SIZE - 1);
    if (next != rx_tail) {                     /* drop byte if full */
        rx_buf[rx_head] = c;
        rx_head = next;
    }
}

/* Returns -1 if no data, otherwise the next byte */
static int uart_getc(void)
{
    if (rx_head == rx_tail)
        return -1;
    uint8_t c = rx_buf[rx_tail];
    rx_tail = (rx_tail + 1) & (RX_SIZE - 1);
    return c;
}

int main(void)
{
    uint16_t counter = 0;

    DDRB |= (1 << DDB5);
    uart_init();
    stdout = &uart_out;
    sei();

    printf("ATmega328P USART ready. Send 1 = LED on, 0 = LED off\n");

    while (1) {
        int c = uart_getc();
        if (c == '1') {
            PORTB |= (1 << PORTB5);
            printf("LED ON\n");
        } else if (c == '0') {
            PORTB &= ~(1 << PORTB5);
            printf("LED OFF\n");
        } else if (c == 'c') {
            printf("counter = %u\n", counter);
        }
        counter++;
    }
}

How It Works

uart_init() loads UBRR0 with 103, enables the receiver, transmitter and receive interrupt, and selects 8-bit frames. Writing the 16-bit name UBRR0 lets the compiler write UBRR0H and UBRR0L in the correct order.

To transmit, the code waits until UDRE0 shows the transmit buffer is empty and writes the byte to UDR0. The hardware shifts it out while the CPU continues. FDEV_SETUP_STREAM creates a stdio stream that calls uart_putchar() for every output character, and assigning it to stdout makes the normal printf work. The default avr-libc printf does not support %f; link with -Wl,-u,vfprintf -lprintf_flt -lm if you need floats, at the cost of several kilobytes of flash.

The receive ISR runs whenever a byte arrives, copies it into a 32-byte circular buffer and advances the head index. The main loop removes bytes at the tail. Because the buffer size is a power of two, wrapping is a cheap AND operation, and since each index is one byte, reading it is atomic. Even if the main loop is busy printing, characters arriving during that time are safely stored.

Troubleshooting

  • Garbage characters: the baud rate on the terminal differs, or F_CPU does not match the real clock. A chip still running at 1 MHz will produce rubbish at 9600 baud with UBRR0 = 103.
  • Nothing received on a bare chip: TX and RX swapped, or grounds not connected.
  • Uploading over the Uno bootloader fails while your terminal is open: close the terminal first, because only one program can hold the COM port.
  • Program resets when a character arrives: the RX interrupt is enabled but the ISR name is wrong. On the ATmega328P it is USART_RX_vect.

Summary

The AVR USART needs UBRR0 for baud rate, UCSR0B to enable TX, RX and interrupts, and UCSR0C for frame format. Check the baud error before choosing a speed, use UDRE0 to pace transmission, redirect printf through FDEV_SETUP_STREAM, and buffer received data in an ISR. This serial link will be used to display results in the remaining tutorials.