Skip to content

UART Serial Communication

UART Serial Communication hero image
Modified:
Published:

Serial communication is how your microcontroller talks to the outside world. In this lesson you will configure the ATmega328P USART peripheral from scratch, calculating baud rates from register values, setting frame formats, and implementing both blocking and interrupt-driven transmit/receive. The project is a temperature logger: an NTC thermistor feeds the ADC, the firmware converts the reading to degrees Celsius using the Steinhart-Hart equation, and streams timestamped CSV lines over UART for capture and plotting on a PC. #UART #Serial #DataLogging

What We Are Building

Temperature Logger with CSV Export

A continuous temperature logger that samples an NTC thermistor once per second, converts the ADC reading to Celsius, and transmits CSV-formatted data (timestamp, raw ADC, temperature) over UART at 9600 baud. On the PC side, you capture the serial stream to a file and plot it with Python, a spreadsheet, or any tool that reads CSV. A ring buffer handles transmit buffering so the main loop never blocks on UART.

Project specifications:

ParameterValue
MCUATmega328P (on Arduino Nano or Uno)
USARTUSART0, 9600 baud, 8N1
Sensor10K NTC thermistor (B=3950)
ADC channelADC0 (PC0, Arduino A0)
Sample rate1 Hz
Output formatCSV: timestamp_ms, adc_raw, temp_c
TX buffer64-byte ring buffer

Parts for This Lesson

RefComponentQuantityNotes
1Arduino Nano or Uno (ATmega328P)1From previous lessons
2Breadboard1From previous lessons
310K NTC thermistor1Glass bead or epoxy type
410K ohm resistor1Voltage divider partner
5Jumper wires~4Male-to-male

USART Register Overview



A UART frame at 8N1 consists of a start bit (always low), eight data bits (LSB first), and a stop bit (always high). The idle state is high, so the receiver detects the falling edge of the start bit to begin sampling each bit at the center of its period.

UART 8N1 frame format (one byte):
Idle Start D0 D1 D2 D3 D4 D5 D6 D7 Stop Idle
____ ___ ___ ___ ___ ___ _______
| | | | | | | | |
|_____| |___| |_______| | | |
^ ^
start stop
bit bit
Bit period = 1 / baud_rate
At 9600 baud: ~104 us per bit
Total frame: 10 bits = ~1.04 ms

The ATmega328P has one USART peripheral (USART0) controlled by five registers. UDR0 is the data register for both transmit and receive. UCSR0A holds status flags (transmit complete, receive complete, data register empty). UCSR0B enables the transmitter, receiver, and interrupts. UCSR0C sets the frame format. UBRR0 (split into UBRR0H and UBRR0L) sets the baud rate.

RegisterPurpose
UDR0Transmit/Receive data buffer
UCSR0AStatus: RXC0, TXC0, UDRE0, frame error, parity error
UCSR0BEnable: RXEN0, TXEN0, RXCIE0, TXCIE0, UDRIE0
UCSR0CFrame format: UCSZ (data bits), UPM (parity), USBS (stop bits)
UBRR0H/LBaud rate register (12-bit)

Baud Rate Calculation

The baud rate is derived from the system clock and the UBRR value. In normal speed mode (U2X0 = 0), the formula is: UBRR = (F_CPU / (16 * BAUD)) - 1. For 16 MHz and 9600 baud: UBRR = (16000000 / (16 * 9600)) - 1 = 103. The actual baud rate with UBRR=103 is 9615, giving an error of 0.16%, which is well within the acceptable range.

Baud RateUBRR (16 MHz)Actual BaudError
240041624000.0%
960010396150.2%
1920051192310.2%
3840025384620.2%
5760016588242.1%
11520081111113.5%

UART Initialization



#define F_CPU 16000000UL
#define BAUD 9600
#define UBRR_VAL ((F_CPU / (16UL * BAUD)) - 1)
static void uart_init(void)
{
/* Set baud rate */
UBRR0H = (uint8_t)(UBRR_VAL >> 8);
UBRR0L = (uint8_t)(UBRR_VAL);
/* Enable transmitter and receiver */
UCSR0B = (1 << TXEN0) | (1 << RXEN0);
/* Frame format: 8 data bits, no parity, 1 stop bit (8N1) */
UCSR0C = (1 << UCSZ01) | (1 << UCSZ00);
}

Blocking Transmit and Receive



static void uart_putc(char c)
{
while (!(UCSR0A & (1 << UDRE0))); /* Wait for empty buffer */
UDR0 = c;
}
static char uart_getc(void)
{
while (!(UCSR0A & (1 << RXC0))); /* Wait for data */
return UDR0;
}
static void uart_puts(const char *s)
{
while (*s) uart_putc(*s++);
}

Ring Buffer for Non-Blocking TX



A ring buffer (circular buffer) decouples the producer (main code adding characters) from the consumer (UART hardware sending them).

Ring buffer (64 bytes):
+---+---+---+---+---+---+---+---+
| A | B | C | D | | | | |
+---+---+---+---+---+---+---+---+
^ ^
tail head
(ISR reads (main writes
next byte) next byte)
After ISR sends 'A' and main writes 'E':
+---+---+---+---+---+---+---+---+
| | B | C | D | E | | | |
+---+---+---+---+---+---+---+---+
^ ^
tail head
Wraps around when head or tail
reaches the end of the array.

The main code writes to the buffer and enables the UDRE interrupt. The ISR sends one byte at a time and disables itself when the buffer is empty. This prevents the main loop from stalling while long strings are transmitted.

#define TX_BUF_SIZE 64
static volatile char tx_buf[TX_BUF_SIZE];
static volatile uint8_t tx_head = 0;
static volatile uint8_t tx_tail = 0;
static void tx_enqueue(char c)
{
uint8_t next = (tx_head + 1) % TX_BUF_SIZE;
while (next == tx_tail); /* Wait if buffer full */
tx_buf[tx_head] = c;
tx_head = next;
UCSR0B |= (1 << UDRIE0); /* Enable UDRE interrupt */
}
ISR(USART_UDRE_vect)
{
if (tx_head != tx_tail) {
UDR0 = tx_buf[tx_tail];
tx_tail = (tx_tail + 1) % TX_BUF_SIZE;
} else {
UCSR0B &= ~(1 << UDRIE0); /* Buffer empty, disable interrupt */
}
}
static void tx_puts(const char *s)
{
while (*s) tx_enqueue(*s++);
}

NTC Thermistor Circuit



The thermistor and fixed resistor form a voltage divider whose output voltage changes with temperature. The ADC reads this voltage and the firmware converts it to degrees Celsius.

VCC (5V)
|
[NTC] 10K at 25C
| (resistance drops
+--- as temp rises)
|
ADC0 --+---> to ATmega328P PC0
|
[R1] 10K fixed
|
GND

The NTC thermistor forms a voltage divider with a fixed 10K resistor. The thermistor connects from the ADC input to VCC, and the fixed resistor connects from the ADC input to ground. As temperature increases, the thermistor resistance decreases, and the voltage at the ADC pin drops. The Steinhart-Hart equation (simplified B-parameter version) converts resistance to temperature.

Steinhart-Hart B-Parameter Equation

1/T = 1/T0 + (1/B) * ln(R/R0)

Where T0 = 298.15 K (25 C), R0 = 10000 ohms, and B = 3950 (from the thermistor datasheet).

From the ADC reading:

R_therm = R_fixed * (1023 - adc) / adc

Complete Firmware



#define F_CPU 16000000UL
#define BAUD 9600
#define UBRR_VAL ((F_CPU / (16UL * BAUD)) - 1)
#include <avr/io.h>
#include <avr/interrupt.h>
#include <util/delay.h>
#include <util/atomic.h>
#include <stdlib.h>
#include <math.h>
/* --- UART (blocking for simplicity) --- */
static void uart_init(void)
{
UBRR0H = (uint8_t)(UBRR_VAL >> 8);
UBRR0L = (uint8_t)(UBRR_VAL);
UCSR0B = (1 << TXEN0);
UCSR0C = (1 << UCSZ01) | (1 << UCSZ00);
}
static void uart_putc(char c)
{
while (!(UCSR0A & (1 << UDRE0)));
UDR0 = c;
}
static void uart_puts(const char *s)
{
while (*s) uart_putc(*s++);
}
static void uart_putu16(uint16_t val)
{
char buf[6];
utoa(val, buf, 10);
uart_puts(buf);
}
static void uart_putu32(uint32_t val)
{
char buf[11];
ultoa(val, buf, 10);
uart_puts(buf);
}
/* --- ADC --- */
static void adc_init(void)
{
ADMUX = (1 << REFS0); /* AVcc reference, channel 0 */
ADCSRA = (1 << ADEN) | (1 << ADPS2) | (1 << ADPS1) | (1 << ADPS0);
/* ADC enabled, prescaler 128 (125 kHz ADC clock) */
}
static uint16_t adc_read(void)
{
ADCSRA |= (1 << ADSC); /* Start conversion */
while (ADCSRA & (1 << ADSC)); /* Wait for completion */
return ADC;
}
/* --- Timer for timestamps --- */
volatile uint32_t ms_ticks = 0;
ISR(TIMER1_COMPA_vect)
{
ms_ticks++;
}
static void timer1_init(void)
{
TCCR1A = 0;
TCCR1B = (1 << WGM12) | (1 << CS11) | (1 << CS10);
OCR1A = 249;
TIMSK1 = (1 << OCIE1A);
}
static uint32_t get_ms(void)
{
uint32_t val;
ATOMIC_BLOCK(ATOMIC_RESTORESTATE) {
val = ms_ticks;
}
return val;
}
/* --- Temperature conversion --- */
#define B_COEFF 3950.0
#define T0_K 298.15
#define R0 10000.0
#define R_FIXED 10000.0
static int16_t adc_to_temp_x10(uint16_t adc_val)
{
if (adc_val == 0) adc_val = 1; /* Avoid division by zero */
double r_therm = R_FIXED * (1023.0 - adc_val) / adc_val;
double t_kelvin = 1.0 / ((1.0 / T0_K) + (log(r_therm / R0) / B_COEFF));
double t_celsius = t_kelvin - 273.15;
return (int16_t)(t_celsius * 10); /* Fixed-point: 25.3C = 253 */
}
/* --- Main --- */
int main(void)
{
uart_init();
adc_init();
timer1_init();
sei();
/* CSV header */
uart_puts("timestamp_ms,adc_raw,temp_c\r\n");
uint32_t next_sample = 0;
while (1) {
uint32_t now = get_ms();
if (now >= next_sample) {
next_sample = now + 1000; /* 1 Hz sample rate */
uint16_t adc_val = adc_read();
int16_t temp_x10 = adc_to_temp_x10(adc_val);
/* timestamp */
uart_putu32(now);
uart_putc(',');
/* raw ADC */
uart_putu16(adc_val);
uart_putc(',');
/* temperature with one decimal place */
int16_t whole = temp_x10 / 10;
int16_t frac = temp_x10 % 10;
if (frac < 0) frac = -frac;
if (temp_x10 < 0 && whole == 0) uart_putc('-');
char buf[8];
itoa(whole, buf, 10);
uart_puts(buf);
uart_putc('.');
itoa(frac, buf, 10);
uart_puts(buf);
uart_puts("\r\n");
}
}
}

Capturing and Plotting on the PC



Terminal window
# Capture to file (Ctrl+C to stop)
cat /dev/ttyUSB0 > temperature_log.csv
# Or use screen
screen /dev/ttyUSB0 9600

Exercises



  1. Implement the ring-buffer based TX from the code earlier in this lesson. Verify that the main loop no longer blocks during UART transmission by toggling an LED and observing that it does not stutter.
  2. Add RX capability: send single-character commands from the PC to change the sample rate. For example, “1” for 1 Hz, “5” for 5 Hz, “0” to pause.
  3. Redirect printf to UART by implementing a custom uart_putchar function and assigning it with fdevopen. Then use printf("%.1f\r\n", temp) instead of manual string formatting.
  4. Calculate and print a running average of the last 10 temperature readings alongside the instantaneous value.

Summary



You now know how to configure the ATmega328P USART from register level: baud rate calculation, frame format, blocking and interrupt-driven I/O, and ring buffers for non-blocking transmission. The temperature logger demonstrates a complete data pipeline from analog sensor through ADC to formatted serial output. UART is the simplest and most universal communication interface in embedded systems, and it will serve as your primary debugging tool in every lesson that follows.

Comments

Loading comments...


© 2021-2026 SiliconWit®. All rights reserved.