Exercise 3 — UART Serial Communication
Every piece of embedded hardware you will ever debug will need to tell you what it is doing. UART (Universal Asynchronous Receiver-Transmitter) is the oldest, simplest, most universal communication interface in computing. It is on every microcontroller ever made. It is how Arduino's Serial.print() works. It is how a GPS module sends coordinates and how a GSM module accepts AT commands.
In this exercise you will configure UART from scratch — no libraries — and build a small command interpreter.
Prerequisites — What to Buy
This is the cheapest exercise in the series — if you already have an Arduino Uno and USB cable from Exercise 1, you need to buy nothing. The Arduino Uno has a dedicated USB-to-UART chip on board (usually a CH340G or ATmega16U2). When you plug the USB cable in, your PC sees it as a virtual serial port.
Components list
| # | Component | Qty | Approx. cost | How to identify / what to ask for |
|---|---|---|---|---|
| 1 | Arduino Uno R3 | 1 | ₹350–₹500 | Carry over from Exercise 1. The USB-B port doubles as the programming port and the UART serial port. |
| 2 | USB cable (Type A to Type B) | 1 | ₹50–₹100 | Carry over from Exercise 1. This is the same cable used to program the board — it also carries the serial data in this exercise. |
| 3 | Breadboard + jumper wires | — | — | Carry over from Exercise 1. Not strictly required for this exercise but useful if you extend it. |
Total new hardware cost: ₹0 if you completed Exercise 1.
Software you need (free)
| Software | Platform | Purpose | Where to get |
|---|---|---|---|
| Arduino IDE or AVR-GCC | Windows / Linux / Mac | Compile and upload the program | arduino.cc/en/software |
| PuTTY | Windows | Serial terminal — type commands, see responses | search "PuTTY download" — putty.org |
| screen or minicom | Linux / Mac | Serial terminal | Pre-installed on Linux; brew install minicom on Mac |
| VS Code Serial Monitor (optional) | Any | More comfortable GUI serial terminal | Extension: "Serial Monitor" by Microsoft |
What is a serial terminal? It is a simple program that opens the COM port, lets you type characters, and shows whatever the microcontroller sends back. Think of it as a chat window between your PC and the microcontroller. When your program does `UART_transmit('O'); UART_transmit('K');` you will see `OK` appear in the terminal window.
If you do NOT have an Arduino Uno
If you are working with a bare ATmega328P chip (not on a board), you need a separate USB-to-TTL serial adapter:
| Component | Cost | What to ask for |
|---|---|---|
| CP2102 USB-to-UART module | ₹80–₹150 | "CP2102 USB serial module" — has TX, RX, GND, VCC pins. Also works as a 3.3 V / 5 V power supply for the MCU. |
| Alternatively: CH340G module | ₹40–₹80 | Same function, lower cost. Ask for "CH340 USB TTL serial converter". |
Connect: PC-USB → adapter → TX to MCU RX, RX to MCU TX, GND to GND.
Where to buy
- Online: The CH340G or CP2102 USB-TTL module is on Amazon India, Robu.in, Evelta.com for under ₹150. Search "CP2102 USB serial module 5V".
- Local shop: Ask for "USB to TTL serial converter" or "USB serial adapter". Specify 5 V logic level (not 3.3 V only).
How UART Works — The Protocol
UART transmits one bit at a time on a single wire, at a fixed clock rate called the baud rate (bits per second). There is no shared clock wire — both sides agree on the rate beforehand.
The UART Frame
Baud Rate
The baud rate is the number of symbols (bits) per second. Common values: 9600, 19200, 38400, 57600, 115200 bps.
At 9600 baud:
- 1 bit = 1/9600 s ≈ 104 µs
- 1 byte (10 bits with start+stop) = 1.04 ms
- 1 KB of data takes ≈ 1 second
At 115200 baud (most common today):
- 1 bit = 8.68 µs
- 1 byte = 86.8 µs
- 1 KB ≈ 88 ms
AVR UART Hardware
The ATmega328P has one hardware UART (USART0). It handles start/stop bit generation, shifting bits in and out, and buffering — completely in hardware.
Baud Rate Calculation
UBRR = (F_CPU / (16 × BAUD)) − 1
At 16 MHz, 9600 baud: UBRR = (16,000,000 / 153,600) − 1 = 103 (0.16% error)
At 16 MHz, 115200 baud: UBRR = (16,000,000 / 1,843,200) − 1 = 7.68 ≈ 8 (3.7% error — acceptable)
A baud rate error above ~3–4% causes bit misalignment by the end of a byte frame and corrupts data. The ATmega's 16 MHz crystal gives low error at 9600 and acceptable error at 115200. Some baud rates (e.g., 19200) are more accurate than others at a given clock frequency — always check the datasheet table.
UART Initialisation and Transmit/Receive Functions
// UART driver for ATmega328P @ 16 MHz, 9600 baud
// 8 data bits, no parity, 1 stop bit (8N1) — the universal default
#include <avr/io.h>
#define BAUD 9600UL
#define UBRR_VAL ((F_CPU / (16UL * BAUD)) - 1)
void uart_init(void) {
UBRR0H = (uint8_t)(UBRR_VAL >> 8);
UBRR0L = (uint8_t)(UBRR_VAL);
UCSR0B = (1 << RXEN0) | (1 << TXEN0); // enable RX and TX
UCSR0C = (1 << UCSZ01) | (1 << UCSZ00); // 8-bit data, 1 stop bit
}
void uart_send_byte(uint8_t data) {
while (!(UCSR0A & (1 << UDRE0))); // wait until TX buffer empty
UDR0 = data;
}
uint8_t uart_recv_byte(void) {
while (!(UCSR0A & (1 << RXC0))); // wait until byte received
return UDR0;
}
void uart_send_string(const char *s) {
while (*s) uart_send_byte(*s++);
}
Program Flow — The Command Interpreter
Building the Program — Snippet by Snippet
Snippet 1 — Register Definitions
UART registers sit above 0x5F (extended I/O), so they need sts/lds. Name everything with .equ so the code is self-documenting.
; ── UART registers (extended I/O — use lds/sts) ─────────
.equ UCSR0A, 0xC0
.equ UCSR0B, 0xC1
.equ UCSR0C, 0xC2
.equ UBRR0L, 0xC4
.equ UBRR0H, 0xC5
.equ UDR0, 0xC6
; Bit positions
.equ RXC0, 7 ; UCSR0A: 1 = RX data ready to read
.equ UDRE0, 5 ; UCSR0A: 1 = TX buffer empty (ready to send)
.equ RXEN0, 4 ; UCSR0B: enable receiver
.equ TXEN0, 3 ; UCSR0B: enable transmitter
; ── GPIO ────────────────────────────────────────────────
.equ DDRB, 0x04
.equ PORTB, 0x05
.equ LED, 5 ; PB5 = Arduino pin 13 (built-in LED)
Snippet 2 — UART Initialisation
Three things to configure: baud rate (via UBRR), enable RX+TX (UCSR0B), and frame format — 8 data bits, no parity, 1 stop bit (UCSR0C).
main:
LDI R16, HIGH(RAMEND) ; stack pointer setup
OUT SPH, R16
LDI R16, LOW(RAMEND)
OUT SPL, R16
; Baud rate: 9600 at 16 MHz → UBRR = 103 = 0x0067
LDI R16, 0x00
STS UBRR0H, R16
LDI R16, 103
STS UBRR0L, R16
; Enable receiver AND transmitter
LDI R16, (1<<RXEN0)|(1<<TXEN0)
STS UCSR0B, R16
; 8-bit frame: UCSZ01=1, UCSZ00=1
LDI R16, 0x06
STS UCSR0C, R16
SBI DDRB, LED ; LED pin as output
Formula: UBRR = (F_CPU / (16 × baud)) − 1 = (16,000,000 / 153,600) − 1 = 103. This is a fixed number — no calculation at runtime, just a constant loaded into UBRR.
Snippet 3 — Transmit and Receive Subroutines
These two are the foundation of everything else. Every time you send or receive, you poll a flag in UCSR0A first.
; ── uart_tx — send one byte ─────────────────────────────
; In: r24 = byte to send Clobbers: r18
uart_tx:
LDS R18, UCSR0A
SBRS R18, UDRE0 ; skip if UDR0 is empty (ready)
RJMP uart_tx ; loop until ready
STS UDR0, R24
RET
; ── uart_rx — receive one byte ──────────────────────────
; Out: r24 = byte received Clobbers: r18
uart_rx:
LDS R18, UCSR0A
SBRS R18, RXC0 ; skip if data waiting
RJMP uart_rx ; loop until data arrives
LDS R24, UDR0
RET
SBRS = Skip if Bit in Register is Set. The bit we check (UDRE0 for TX, RXC0 for RX) is inside UCSR0A, which we first load into a general-purpose register with LDS. After SBRS skips the RJMP, execution falls through to the actual send/receive.
Snippet 4 — Print a Flash String
Rather than pushing each character individually, we write a subroutine that walks through a null-terminated string in Flash using LPM Z+.
; ── print_str — print null-terminated string from Flash ─
; In: Z = byte address of string (label << 1)
print_str:
LPM R24, Z+ ; load byte from Flash, advance Z
TST R24 ; is it the null terminator?
BREQ ps_done
RCALL uart_tx
RJMP print_str
ps_done:
RET
Usage: load Z with LDI ZH, hi(msg<<1) / LDI ZL, lo(msg<<1), then RCALL print_str. Each call walks until it finds the 0x00 byte you put at the end of the string with .byte 0.
Snippet 5 — Receive a Line into SRAM Buffer
We collect incoming characters into a 32-byte buffer in SRAM. X register (R27:R26) serves as the write pointer. We act on the buffer when Enter (\r) arrives.
; X register = buffer pointer (r27:r26)
; R19 = character count (so we know if buffer is empty)
LDI XH, HIGH(cmd_buf) ; point X at the buffer in SRAM
LDI XL, LOW(cmd_buf)
LDI R19, 0 ; character count = 0
rx_loop:
RCALL uart_rx ; wait for and receive one byte → r24
CPI R24, '\r'
BREQ process_cmd ; Enter pressed — process what we have
CPI R24, 8 ; backspace (ASCII 8)?
BREQ do_backspace
CPI R24, 127 ; DEL key also acts as backspace
BREQ do_backspace
CPI R19, 31 ; buffer full? (keep one slot for null)
BRGE rx_loop ; yes — ignore extra characters
ST X+, R24 ; store character in buffer, advance X
INC R19 ; count++
RCALL uart_tx ; echo character back to terminal
RJMP rx_loop
do_backspace:
TST R19
BREQ rx_loop ; nothing to delete
LD R24, -X ; decrement X, load (but we discard the byte)
DEC R19
LDI R24, 8
RCALL uart_tx ; send BS to move cursor left
LDI R24, ' '
RCALL uart_tx ; overwrite character with space
LDI R24, 8
RCALL uart_tx ; move cursor left again
RJMP rx_loop
ST X+, R24 stores R24 at the address in X then increments X. LD R24, -X pre-decrements X then loads — this is how we implement backspace without keeping a separate pointer.
Snippet 6 — The Command Interpreter
After Enter arrives, we compare the buffer against known commands. Assembly has no strcmp — we write a byte-by-byte comparison subroutine.
process_cmd:
LDI R24, '\r' ; move terminal to new line
RCALL uart_tx
LDI R24, '\n'
RCALL uart_tx
; Null-terminate the buffer
LDI R24, 0
ST X, R24
; Compare against "on"
LDI ZH, HIGH(str_on<<1)
LDI ZL, LOW(str_on<<1)
LDI XH, HIGH(cmd_buf)
LDI XL, LOW(cmd_buf)
RCALL str_eq
BRNE try_off
SBI PORTB, LED
LDI ZH, HIGH(msg_on<<1)
LDI ZL, LOW(msg_on<<1)
RCALL print_str
RJMP new_prompt
try_off:
LDI ZH, HIGH(str_off<<1)
LDI ZL, LOW(str_off<<1)
LDI XH, HIGH(cmd_buf)
LDI XL, LOW(cmd_buf)
RCALL str_eq
BRNE try_hi
CBI PORTB, LED
LDI ZH, HIGH(msg_off<<1)
LDI ZL, LOW(msg_off<<1)
RCALL print_str
RJMP new_prompt
try_hi:
; ... (pattern repeats for each command)
new_prompt:
LDI ZH, HIGH(msg_prompt<<1)
LDI ZL, LOW(msg_prompt<<1)
RCALL print_str
LDI XH, HIGH(cmd_buf) ; reset buffer pointer
LDI XL, LOW(cmd_buf)
LDI R19, 0
RJMP rx_loop
; ── str_eq — compare Flash string (Z) against SRAM buffer (X) ──
; Out: Z flag set if equal, clear if not Clobbers: r20, r21, r24
str_eq:
LPM R20, Z+ ; byte from Flash
LD R21, X+ ; byte from SRAM
CP R20, R21
BRNE not_equal
TST R20 ; both were null → strings equal
BRNE str_eq
RET ; Z flag set (equal)
not_equal:
LDI R24, 1 ; force Z flag clear
TST R24
RET
Complete Program
; ═══════════════════════════════════════════════════════
; uart_shell.S — UART Command Interpreter
; ATmega328P @ 16 MHz, 9600 8N1
; Commands: on, off, hi, ver
; LED on PB5 (Arduino pin 13)
; ═══════════════════════════════════════════════════════
.equ UCSR0A, 0xC0
.equ UCSR0B, 0xC1
.equ UCSR0C, 0xC2
.equ UBRR0L, 0xC4
.equ UBRR0H, 0xC5
.equ UDR0, 0xC6
.equ RXC0, 7
.equ UDRE0, 5
.equ RXEN0, 4
.equ TXEN0, 3
.equ DDRB, 0x04
.equ PORTB, 0x05
.equ LED, 5
.org 0x0000
RJMP main
.org 0x0034
main:
LDI R16, HIGH(RAMEND)
OUT SPH, R16
LDI R16, LOW(RAMEND)
OUT SPL, R16
CLR R16
STS UBRR0H, R16
LDI R16, 103
STS UBRR0L, R16
LDI R16, (1<<RXEN0)|(1<<TXEN0)
STS UCSR0B, R16
LDI R16, 0x06
STS UCSR0C, R16
SBI DDRB, LED
LDI ZH, HIGH(msg_banner<<1)
LDI ZL, LOW(msg_banner<<1)
RCALL print_str
LDI XH, HIGH(cmd_buf)
LDI XL, LOW(cmd_buf)
LDI R19, 0
rx_loop:
RCALL uart_rx
CPI R24, '\r'
BREQ process_cmd
CPI R24, 8
BREQ do_bs
CPI R24, 127
BREQ do_bs
CPI R19, 31
BRGE rx_loop
ST X+, R24
INC R19
RCALL uart_tx
RJMP rx_loop
do_bs:
TST R19
BREQ rx_loop
LD R24, -X
DEC R19
LDI R24, 8
RCALL uart_tx
LDI R24, ' '
RCALL uart_tx
LDI R24, 8
RCALL uart_tx
RJMP rx_loop
process_cmd:
LDI R24, '\r'
RCALL uart_tx
LDI R24, '\n'
RCALL uart_tx
LDI R24, 0
ST X, R24
; "on"
LDI ZH, HIGH(str_on<<1)
LDI ZL, LOW(str_on<<1)
LDI XH, HIGH(cmd_buf)
LDI XL, LOW(cmd_buf)
RCALL str_eq
BRNE try_off
SBI PORTB, LED
LDI ZH, HIGH(msg_on<<1)
LDI ZL, LOW(msg_on<<1)
RCALL print_str
RJMP new_prompt
try_off:
LDI ZH, HIGH(str_off<<1)
LDI ZL, LOW(str_off<<1)
LDI XH, HIGH(cmd_buf)
LDI XL, LOW(cmd_buf)
RCALL str_eq
BRNE try_hi
CBI PORTB, LED
LDI ZH, HIGH(msg_off<<1)
LDI ZL, LOW(msg_off<<1)
RCALL print_str
RJMP new_prompt
try_hi:
LDI ZH, HIGH(str_hi<<1)
LDI ZL, LOW(str_hi<<1)
LDI XH, HIGH(cmd_buf)
LDI XL, LOW(cmd_buf)
RCALL str_eq
BRNE try_ver
LDI ZH, HIGH(msg_hi<<1)
LDI ZL, LOW(msg_hi<<1)
RCALL print_str
RJMP new_prompt
try_ver:
LDI ZH, HIGH(str_ver<<1)
LDI ZL, LOW(str_ver<<1)
LDI XH, HIGH(cmd_buf)
LDI XL, LOW(cmd_buf)
RCALL str_eq
BRNE unknown
LDI ZH, HIGH(msg_ver<<1)
LDI ZL, LOW(msg_ver<<1)
RCALL print_str
RJMP new_prompt
unknown:
TST R19
BREQ new_prompt
LDI ZH, HIGH(msg_unknown<<1)
LDI ZL, LOW(msg_unknown<<1)
RCALL print_str
new_prompt:
LDI ZH, HIGH(msg_prompt<<1)
LDI ZL, LOW(msg_prompt<<1)
RCALL print_str
LDI XH, HIGH(cmd_buf)
LDI XL, LOW(cmd_buf)
LDI R19, 0
RJMP rx_loop
; ── uart_tx ─────────────────────────────────────────────
uart_tx:
LDS R18, UCSR0A
SBRS R18, UDRE0
RJMP uart_tx
STS UDR0, R24
RET
; ── uart_rx ─────────────────────────────────────────────
uart_rx:
LDS R18, UCSR0A
SBRS R18, RXC0
RJMP uart_rx
LDS R24, UDR0
RET
; ── print_str ───────────────────────────────────────────
print_str:
LPM R24, Z+
TST R24
BREQ ps_done
RCALL uart_tx
RJMP print_str
ps_done:
RET
; ── str_eq: compare Flash (Z) vs SRAM (X) ───────────────
; Equal → Z flag set Not equal → Z flag clear
str_eq:
LPM R20, Z+
LD R21, X+
CP R20, R21
BRNE se_no
TST R20
BRNE str_eq
RET
se_no:
LDI R20, 1
TST R20
RET
; ── Flash strings ───────────────────────────────────────
msg_banner:
.ascii "\r\n================================\r\n"
.ascii " AVR Command Shell v1.0\r\n"
.ascii " Commands: on, off, hi, ver\r\n"
.ascii "================================\r\n> "
.byte 0
str_on: .ascii "on" .byte 0
str_off: .ascii "off" .byte 0
str_hi: .ascii "hi" .byte 0
str_ver: .ascii "ver" .byte 0
msg_on: .ascii "LED is ON\r\n" .byte 0
msg_off: .ascii "LED is OFF\r\n" .byte 0
msg_hi: .ascii "Hello from ATmega328P!\r\n" .byte 0
msg_ver: .ascii "Firmware v1.0 | AVR @ 16 MHz\r\n" .byte 0
msg_unknown: .ascii "Unknown command\r\n" .byte 0
msg_prompt: .ascii "> " .byte 0
; ── SRAM buffer (32 bytes) ──────────────────────────────
.dseg
cmd_buf: .byte 32
Connecting to a Terminal
[!TIP] On Arduino Uno, PD0/PD1 (RX/TX) are shared with the USB-UART chip. If you are uploading a sketch while something is connected to these pins, the upload will fail. Disconnect external devices from PD0/PD1 before flashing.
How Data Moves — Interrupt-Driven vs Polling
The program above uses polling — it sits in uart_rx() waiting for a byte. This blocks the CPU entirely. For real applications, you use interrupts:
Interrupt-driven UART uses a ring buffer (circular queue) in SRAM. The ISR writes incoming bytes into the buffer; the main loop reads from it. This pattern is used in virtually every production embedded system.
Extension Challenges
Challenge A — Printf over UART
Redirect stdout to UART by implementing a custom uart_putchar() and linking it into avr-libc's FILE stream. Then printf("ADC value: %d\r\n", adc_result) works normally.
Challenge B — ADC Reading Reporter
Read the internal temperature sensor (ATmega328P ADC channel 8) and send the temperature over UART every second. Format the output as CSV: "time_ms,temp_raw,temp_celsius\r\n" — importable directly into Excel or Python.
Challenge C — UART Bootloader Concept
The Arduino bootloader is a small program in the top 2KB of Flash that listens on UART at startup. If it receives the STK500 programming protocol, it writes new firmware to Flash using the SPM instruction. If no data arrives within 1 second, it jumps to your application. Study the optiboot source code — it is under 500 lines of C and teaches you Flash self-programming, watchdog timers, and the AVR reset vector mechanism.
Verification Checklist
Before you call the UART shell working, verify these items:
- The PC terminal is set to 9600 baud, 8 data bits, no parity, 1 stop bit, and no flow control.
- Reset prints the banner and prompt exactly once.
- Typed characters echo back immediately.
- Pressing Enter after
onturns PB5 on and repliesLED is ON. - Pressing Enter after
offturns PB5 off and repliesLED is OFF. - Backspace removes one buffered character without corrupting the prompt.
- Unknown commands produce a controlled error message and return to the prompt.
Common failure symptoms:
- Garbled text: baud rate, CPU clock, or terminal frame settings do not match.
- Nothing prints: TX is not enabled, UDR0 is written before UDRE0 is set, or the wrong COM port is open.
- Upload fails on Arduino Uno: external wiring is loading PD0 or PD1 during programming.
- Command never completes: terminal sends
\nonly while firmware waits for\r; configure CR or handle both.
Explained Solution
The UART peripheral shifts one byte at a time using the frame format configured in UCSR0C. UBRR0 = 103 gives 9600 baud at a 16 MHz clock in normal asynchronous mode. The transmit routine waits for UDRE0 before writing UDR0, and the receive routine waits for RXC0 before reading UDR0. The shell stores characters in an SRAM buffer, handles backspace by moving the X pointer backward, null-terminates the buffer on Enter, and compares it against Flash-resident command strings. GPIO action is intentionally small: on sets PB5 and off clears PB5.
Summary
Further Reading
- Microchip ATmega328P Datasheet - USART0 registers, baud-rate generation, and GPIO.
- AVR Instruction Set Manual -
LDS,STS,SBRS,LPM, and pointer instructions. - Optiboot Bootloader - compact UART bootloader implementation for AVR boards.
Next Exercise: Exercise 4 — EEPROM: Persistent Memory →