This thread is a draft—feel free to chime in if you think the analogy could be improved!
Please note that this guide is written specifically for the ATmega328P.
The costycnc tool is designed for those holding a microcontroller for the very first time or wanting to look under the hood — people who want to build something simple and practical or learn how the hardware works. This tool is not a rival to Arduino, but a companion; it can be considered a first stepping stone toward Arduino, or running in parallel to help you understand concepts better. This tutorial is meant for this, not to debate high-level university concepts.
Let's keep it fun and accessible!
Every code listing has its own URL that opens a preloaded, precompiled, ready-to-run experiment.
Think of Arduino like your car: it has a main ECU (the CPU) and several auxiliary control units. One of these vital auxiliary units is the USART.
In another tutorial you will see another vital auxiliary unit: the TIMER.
Every modern car has a diagnostic connector where a mechanic can plug in a scan tool to exchange information with the ECU. Inside the ATmega328P, that communication module is called USART.
In this tutorial, however, we're not going to talk about serial protocols, strings, terminal software, or high-level communication.
Instead, we'll open up the USART module itself and see how it really works inside the microcontroller.
Our goal is simple: to understand how a single byte is received and how a single byte is transmitted.
Once that journey becomes clear, everything else—words, strings, commands, and communication protocols—is simply many bytes making the same trip, one after another.
Let's open the USART module and see what happens under the hood.
Imagine Arduino's memory as a large cabinet full of drawers .
Each drawer represents a Register. Each drawer is divided into exactly 8 compartments (our Bits), numbered from 0 to 7. Each compartment has a small switch that can only be in two positions: OPEN (0) or CLOSE (1).
When a switch is CLOSE (1), it means that specific function is active or that indicator light is on. When it's OPEN (0), the function is disabled or the light is off.
To make the USART module (our communication port) work, the ATmega328P reserves a series of specific drawers for us. Let's see what they are and what they do:
1. The Mail Carrier's Drawer: UDR0
![]()
Real physical address: 0xC6
Engineers have assigned to this drawer at physical address 0xC6 a specific name, a textual "alias": UDR0 (which stands for USART Data Register 0).
This is the most dynamic drawer of all and is where the actual data exchange happens. In reality, behind the same handle, there are two secret compartments:
- When you write to the drawer: the microcontroller takes that byte and sends it out (transmission).
- When you read from the drawer: you are collecting the byte that just arrived from outside (reception).
2. The Control Panel Drawers: UCSR0A, UCSR0B, and UCSR0C
These three drawers manage the status indicators and switches for configuring communication.
The Indicator Drawer: UCSR0A
![]()
Real physical address: 0xC0
Engineers have assigned to this drawer at physical address 0xC0 the name UCSR0A (USART Control and Status Register A).
This register mainly contains indicator lights (status bits) that turn on and off by themselves depending on what happens during communication.
The two most important compartments in this drawer are:

Bit 7, called RXC0 (USART Receive Complete): This switch automatically moves to 1 (CLOSE) when the chip receives a complete byte from the outside. The light turns on to tell you: "There's a new message ready to read in the mail carrier's drawer (UDR0)". When you retrieve the data, the light returns to 0 (OPEN).

Bit 5, called UDRE0 (USART Data Register Empty): This switch automatically sets to 1 (CLOSE) when the mail carrier's drawer (UDR0) is empty. It's the green light that tells you: "The drawer is free, you can place new data to send". When you insert a new byte to transmit, the light immediately returns to 0 (OPEN).
The Command Drawer: UCSR0B

Real physical address: 0xC1
Engineers have assigned to this drawer at physical address 0xC1 the name UCSR0B (USART Control and Status Register B).
Unlike the first one, this register mainly contains control buttons (configuration bits) that you decide how to set to activate or deactivate the main communication functions.
The three most important compartments in this drawer are:

Bit 7, called RXCIE0 (USART Receive Complete Interrupt Enable): This switch activates the automatic alarm for incoming messages. If you set it to 1 (CLOSE), you connect the receive indicator (RXC0) to a doorbell. As soon as data arrives, the chip interrupts what it's doing to tell you to read it. If it's 0 (OPEN), the doorbell is off.

Bit 4, called RXEN0 (Receiver Enable): This is the master switch for the receiving antenna. If you set it to 1 (CLOSE), you power the receive circuit and the chip starts listening on the RX pin. If you leave it at 0 (OPEN), reception is completely off and the chip will ignore any incoming messages.

Bit 3, called TXEN0 (Transmitter Enable): This is the master switch for the transmission engine. If you set it to 1 (CLOSE), you activate the transmit circuit and the chip takes control of the TX pin to send data. If it's set to 0 (OPEN), transmission is off and you cannot send anything to the outside.
The Rule Drawer: UCSR0C
Real physical address: 0xC2
Engineers have assigned to this drawer at physical address 0xC2 the name UCSR0C (USART Control and Status Register C).
This register contains the buttons to set the message format (the rules of the game) that both the chip and the external device must follow to understand each other.
The common configuration is: 8 data bits, No parity, 1 stop bit (8N1)

3. The Speedometer Drawers: UBRR0H and UBRR0L
To complete the speedometer drawer metaphor, we need to understand how engineers convert the desired speed (e.g., 9600 baud) into the binary number to insert into the compartments.
The speed calculation is based on a precise mathematical formula provided by the chip manufacturer, linked to Arduino's heartbeat (the 16 MHz quartz crystal).
The Speedometer Formula
The standard formula in normal mode (with bit U2X0 at 0) is as follows:
UBRR0 = (f_osc / (16 × Baud Rate)) - 1
Where:
- f_osc is Arduino's clock frequency, i.e., 16,000,000 Hz (16 MHz)
- Baud Rate is the desired speed, in our case 9600
1. Calculating the number for 9600 baud
Let's substitute the numbers into the chip's formula:
UBRR0 = (16,000,000 / (16 × 9600)) - 1
UBRR0 = (16,000,000 / 153,600) - 1
UBRR0 = 104.166 - 1 = 103.166
The drawer compartments can only hold whole numbers, so we round the result to 103.
The "Magic 16" Rule: Oversampling
Why is there a fixed 16 in the baud rate formula?
It is called Oversampling (16x mode). The microcontroller does not just check the wire once per bit. If it did, electrical noise could easily ruin the data.
Under the hood, the hardware engine splits every single bit slot (our 104.17 μs) into 16 internal slices:
- The Slices: The chip samples the voltage at the 7th, 8th, and 9th slice (right in the middle of the bit where the signal is stable).
- The Vote: It takes a majority vote. If at least 2 out of 3 samples are high, the bit is a
1. Otherwise, it is a0.The number 16 in the formula simply tells the chip to slow down its internal clock by 16 to achieve this high-precision filtering.
2. Conversion and distribution into drawers
The number 103 in binary is 01100111.
Since the number 103 is small (less than 255), it can fit comfortably in a single 8-compartment drawer without overflowing.
Consequently, the upper drawer UBRR0H will remain completely empty (all switches at 0).
The entire value will be placed in the lower drawer UBRR0L.
Here's how the two speedometer drawers look when set to 9600 baud:
The upper drawer: UBRR0H
![]()
Real physical address: 0xC5
Engineers have assigned to this drawer at physical address 0xC5 the task of holding the high bits. For the speed of 9600 baud, this drawer is not touched.
All compartments (Bit 7-0): Remain set to 0 (OPEN).
The lower drawer: UBRR0L
![]()
Real physical address: 0xC4
Engineers have assigned to this drawer at physical address 0xC4 the task of holding the fundamental part of the speed number. To travel at 9600 baud, we insert the decimal value 103.
Final Result: To set the speed to 9600 baud with a 16 MHz crystal, drawer UBRR0H goes to 0 and drawer UBRR0L goes to 103.
But how does the microcontroller know which drawer to open?
The microcontroller (MCU) does not know the names that engineers gave to the registers! The MCU only understands numbers.
Names like UDR0, UCSR0A, UBRR0L are textual aliases created by engineers for us humans, to make the code more readable and understandable.
Drawer Summary: from name to number
| Alias (name for us humans) | Address (number for the MCU) | What it contains |
|---|---|---|
| UDR0 | 0xC6 | The data to send or just received |
| UCSR0A | 0xC0 | Status indicators (RXC0, UDRE0, etc.) |
| UCSR0B | 0xC1 | Enable switches (RXEN0, TXEN0, RXCIE0) |
| UCSR0C | 0xC2 | Format rules (parity, stop bit, data size) |
| UBRR0H | 0xC5 | High part of the speedometer (for speeds > 255) |
| UBRR0L | 0xC4 | Low part of the speedometer |
Assembly Example with Physical Addresses
Let's do a practical example to send the character 'A' approximately every 2 seconds, using both names and numbers (for the MCU) and (for us humans - commented):
; ============================================================
; USART TRANSMIT 'A' EVERY ~2 SECONDS
; ATmega328P @ 16MHz, 9600 baud, 8N1
; ============================================================
.org 0x00
jmp init
.org 0x60
init:
; 1. Set USART CONFIGURATION AT 9600 BAUD, 8N1
ldi r16, 103 ; 9600 BAUD, 8N1 - 103 in binary = 01100111
sts 0xC4, r16 ; 0xC4 = UBRR0L
; 2. Set the rules (8 data, 1 stop, no parity)
ldi r16, 0b00000110 ; UCSZ01=1, UCSZ00=1
sts 0xC2, r16 ; 0xC2 = UCSR0C
; 3. Turn on transmitter
ldi r16, 0b00001000 ; TXEN0=1
sts 0xC1, r16 ; 0xC1 = UCSR0B
loop:
; === TRANSMIT A BYTE ===
; Wait for UDRE0 indicator to be on (drawer empty)
wait_tx:
lds r17, 0xC0 ; 0xC0 = UCSR0A
sbrs r17, 5 ; Check bit 5 (UDRE0)
rjmp wait_tx
; Place the data in the mail carrier's drawer
ldi r16, 0b01000001 ; BIN: 0b01000001 | DEC: 65 | HEX: 0x41 | CHAR: 'A'
sts 0xC6, r16 ; 0xC6 = UDR0
wait: ; Wait some seconds
inc r16
brne wait
inc r17
brne wait
inc r18
brne wait
rjmp loop
Now, let's test the code! Click this link: https://costycnc.github.io/avr-compiler-js/?code=arduino-forum/usart/usart.asm and the site will open with the code already pre-loaded and compiled. Just click one button from 'Upload to:' section, select the correct port, click connect, and the code will be uploaded automatically to your Arduino. The "A" caracter will be print to console (or not!
)
To test, you can use serialterminal.com, the Arduino IDE Serial Monitor, or another serial terminal.(select 9600 bauds)
Arduino short code equivalent:
void setup() {
Serial.begin(9600); // Inizializza USART a 9600 baud
}
void loop() {
Serial.print('A'); // Invia 'A'
delay(1000); // Pausa 1 secondo
}
Arduino code equivalent with register :
void setup() {
// Inizializzazione USART a 9600 baud, 8N1
UBRR0L = 103; // Baud rate low byte (9600 baud @ 16MHz)
UCSR0C = (1 << UCSZ01) | (1 << UCSZ00); // 8 data bits, 1 stop, no parity
UCSR0B = (1 << TXEN0); // Abilita trasmettitore
}
void loop() {
// Aspetta che il buffer di trasmissione sia vuoto
while (!(UCSR0A & (1 << UDRE0))); // UDRE0 = 1 quando il buffer è vuoto
// Invia il carattere 'A'
UDR0 = 'A';
// Pausa di 1 secondo
delay(1000);
}
We enable RX and the RX Complete Interrupt (Bit 7: RXCIE0 and Bit 4: RXEN0) in register 0xC1 (UCSR0B).
Now, when a character arrives and is ready to be read, the USART module asks the CPU to pause the main program and execute a jump to address 0x0024.
Since we only have 4 bytes of space at address 0x0024, we cannot fit the whole interrupt routine there. Instead, we place a jmp instruction that points to our custom Interrupt Service Routine (ISR), which I named costycnc.
Inside costycnc, the microcontrollor compares the received character with 'A'. If it matches, it toggles the D13 LED. At the end of the routine, we must use the reti instruction to allow the main program to resume its execution.
.org 0x0000
jmp RESET
.org 0x0024 ; USART RX Complete Interrupt vector
jmp costycnc
.org 0x0060
RESET:
sbi 0x04, 5 ; DDRB: Set PB5 as output
; 1. Configure Baud Rate to 9600 (for 16MHz clock)
ldi r16, 103
sts 0xC4, r16 ; Write to UBRR0L
; 2. Configure UCSR0C: 8 data bits, 1 stop bit, no parity
ldi r16, 0b00000110 ; Set UCSZ01 and UCSZ00 bits
sts 0xC2, r16 ; Write to UCSR0C
; 3. Enable Receiver and RX Interrupt
ldi r16, 0b10010000 ; Set RXCIE0 and RXEN0 bits
sts 0xC1, r16 ; Write to UCSR0B
sei ; Enable global interrupts
loop:
rjmp loop
costycnc:
lds r18, 0xC6 ; Read byte from UDR0
cpi r18, 'A' ; Is it 'A'?
brne jos
sbi 3,5 ; Toggle PB5 (Built-in LED) on match
jos:
reti
Now, let's test the code! Click this link: https://costycnc.github.io/avr-compiler-js/?code=arduino-forum/usart/usart-int.asm and the site will open with the code already pre-loaded and compiled. Just click one button from 'Upload to:' section, select the correct port, click connect, and the code will be uploaded automatically to your Arduino.
If want to test usart with a mix code Arduino Ide and avr asm you can use this code:
/*
===================================================================
1. ARDUINO CODE (.ino file)
Copy and paste this code into your main Arduino sketch tab.
===================================================================
extern "C" {
void init_usart_asm(); // Assembly function to configure the hardware registers
void send_char_asm(); // Assembly function to transmit the character
}
void setup() {
// Configure the serial interface ONLY with your Assembly code.
// Do not use "Serial.begin()" to avoid register conflicts!
init_usart_asm();
}
void loop() {
send_char_asm(); // Transmit the character 'A' continuously
delay(500); // Software delay to avoid clogging the buffer
}
===================================================================
2. ASSEMBLY CODE (.S file)
Click the "Export template" button to save this file
inside the same folder as your main .ino file.
===================================================================
*/
.global init_usart_asm
.global send_char_asm
; --- 1. Initial Configuration (Called only inside setup) ---
init_usart_asm:
ldi r16, 103 ; 9600 Baud rate for 16MHz clock
sts 0xC4, r16 ; 0xC4 = UBRR0L
ldi r16, 0b00000110 ; 8N1 frame format (8 data bits, 1 stop bit, no parity)
sts 0xC2, r16 ; 0xC2 = UCSR0C
ldi r16, 0b00001000 ; Enable the Transmitter only (TXEN0 = 1)
sts 0xC1, r16 ; 0xC1 = UCSR0B
ret ; Return back to Arduino's setup()
; --- 2. Single Byte Transmission (Called inside loop) ---
send_char_asm:
wait_tx:
lds r17, 0xC0 ; Read status register (0xC0 = UCSR0A)
sbrs r17, 5 ; Check bit 5 (UDRE0 indicator: is the drawer empty?)
rjmp wait_tx
ldi r16, 0b01000001 ; BIN: 0b01000001 | DEC: 65 | HEX: 0x41 | CHAR: 'A'
sts 0xC6, r16 ; Place the data in the mail carrier's drawer (0xC6 = UDR0)
ret ; Return back to Arduino's loop() to execute the delay
You can use costycnc tool https://costycnc.github.io/avr-compiler-js/?code=arduino-forum/external-c/usart/usart.S to save .S file in folder arduino same as this video:
Or with port prepared by arduino ide :
/*
===================================================================
1. ARDUINO CODE (.ino file)
Copy and paste this code into your main Arduino sketch tab.
===================================================================
extern "C" {
void send_char_asm(); // We only call the transmission function
}
void setup() {
Serial.begin(9600); // Arduino configures the hardware safely
}
void loop() {
asm volatile ("nop \n\t nop \n\t nop \n\t nop \n\t nop \n\t nop \n\t nop \n\t nop \n\t nop \n\t nop");
send_char_asm(); // Assembly inserts the byte directly into the register
asm volatile ("nop \n\t nop \n\t nop \n\t nop \n\t nop \n\t nop \n\t nop \n\t nop \n\t nop \n\t nop");
delay(1000);
}
===================================================================
2. ASSEMBLY CODE (.S file)
Click the "Export template" button to save this file
inside the same folder as your main .ino file.
===================================================================
*/
.global send_char_asm
send_char_asm:
wait_tx:
lds r17, 0xC0 ; Read the USART status (0xC0 = UCSR0A)
sbrs r17, 5 ; Wait until the transmission drawer is empty
rjmp wait_tx
ldi r16, 0b01000001 ; BIN: 0b01000001 | DEC: 65 | HEX: 0x41 | CHAR: 'A'
sts 0xC6, r16 ; Write directly into the UDR0 register
ret ; Happily return back to Arduino's loop()
I put 10 NOPs before and after send_char_asm() as a visual bookmark, so I can easily find the function inside the massive compiled Arduino code when looking at the disassembler.
You can use costycnc tool https://costycnc.github.io/avr-compiler-js/?code=arduino-forum/external-c/usart/usart-pre.S to save .S file in folder arduino same as this video:
After upload code to arduino open a serial terminal or test with this page:







