UART — Serial Communication
UART is probably the first communication protocol you will ever use in embedded systems. It is how your microcontroller talks to your computer over a serial terminal. It is how GPS modules spit out coordinates. It is how two boards chat with each other over a pair of wires. And the beautiful thing is — it is dead simple.
How UART Works
UART stands for Universal Asynchronous Receiver/Transmitter. The key word is "asynchronous" — there is no shared clock wire. Both sides just agree ahead of time on how fast they will talk, and then they trust each other to keep time.
The wiring could not be simpler:
| Wire | Purpose |
|---|---|
| TX | Transmit — data goes out |
| RX | Receive — data comes in |
| GND | Ground — shared voltage reference |
That is it. Two data wires and a ground. TX on one device connects to RX on the other, and vice versa. The wires are crossed — this trips up everyone at least once, so do not feel bad when it happens to you.
Device A Device B
-------- --------
TX ------------> RX
RX <------------ TX
GND -------------- GND
The UART Frame
When a UART line is idle, it sits HIGH (at 3.3V). To send a byte, the transmitter pulls the line LOW for one bit period — that is the start bit, a wake-up call that says "data incoming." Then it sends the 8 data bits, least significant bit first. Finally, it sends a stop bit (HIGH for one bit period) to mark the end of the frame.
Idle ──┐ ┌──┐ ┌──┐ ┌──┐ ┌───── Idle
│ │ │ │ │ ... │ │ │
└───┘ └──┘ └─────┘ └──┘
Start D0 D1 ... D7 Stop
There is no address, no handshake, no error checking built in. It is a fire-and-forget protocol. You transmit a byte and hope the other side was listening. (Spoiler: in practice, it works remarkably well.)
Baud Rates
Since there is no clock wire, both devices must agree on the baud rate — the number of bits transmitted per second. If one side sends at 115200 baud and the other listens at 9600, you get garbage.
| Baud Rate | Use Case | Bits/sec | Approx. Bytes/sec |
|---|---|---|---|
| 9600 | GPS modules, slow sensors | 9,600 | ~960 |
| 115200 | Debug output, general use | 115,200 | ~11,520 |
| 921600 | High-speed telemetry, bulk data | 921,600 | ~92,160 |
💡 Fun Fact: The baud rate 9600 dates back to the Bell 212A modem from 1976. GPS modules still default to 9600 baud almost 50 years later. Legacy is a powerful force in embedded systems.
The Three Most Common UART Bugs
You will hit at least one of these in your first week. Probably all three.
1. TX and RX are swapped. You connected TX to TX and RX to RX. Remember: they cross. TX on your board goes to RX on the other device.
2. Baud rate mismatch. You see garbage characters in your serial terminal. The data is arriving, but the receiver is interpreting the bit timing incorrectly. Double-check both sides are set to the same baud rate.
3. Missing GND connection. You connected TX and RX but forgot the ground wire. Without a shared voltage reference, the receiver has no idea what "HIGH" and "LOW" mean. Always connect GND.
🧠 Think About It: If you see the character
U(0x55, binary 01010101) transmitted correctly but other characters are garbled, what might that tell you about the baud rate? Hint:Uproduces a perfectly alternating bit pattern that looks "correct" even at wrong baud rates.
UART in Embassy
Embassy makes UART wonderfully straightforward. You create a UART peripheral, give it the pins and a DMA channel, and start reading and writing.
Basic Setup and Transmit
use embassy_stm32::usart::{Config, Uart}; use embassy_stm32::bind_interrupts; bind_interrupts!(struct Irqs { USART2 => embassy_stm32::usart::InterruptHandler<embassy_stm32::peripherals::USART2>; }); #[embassy_executor::main] async fn main(_spawner: Spawner) { let p = embassy_stm32::init(Default::default()); let mut config = Config::default(); config.baudrate = 115_200; let mut uart = Uart::new( p.USART2, // peripheral p.PA3, // RX pin p.PA2, // TX pin Irqs, p.DMA1_CH6, // TX DMA p.DMA1_CH5, // RX DMA config, ).unwrap(); // Send a message uart.write(b"Hello from STM32!\r\n").await.unwrap(); }
Reading Data
For receiving, UART has an interesting challenge — you often do not know how many bytes are coming. Embassy provides read_until_idle, which reads bytes until the line goes quiet for a moment. This is perfect for protocols like NMEA (GPS) where messages arrive as complete lines.
#![allow(unused)] fn main() { let mut buf = [0u8; 256]; loop { // Read until the line goes idle (no more data arriving) match uart.read_until_idle(&mut buf).await { Ok(n) => { // buf[..n] contains the received bytes defmt::info!("Received {} bytes: {:?}", n, &buf[..n]); } Err(e) => { defmt::error!("UART read error: {:?}", e); } } } }
Splitting TX and RX
Often you want one task transmitting telemetry while another task processes incoming data. Embassy lets you split a UART into separate TX and RX halves:
#![allow(unused)] fn main() { let (mut tx, mut rx) = uart.split(); // Now tx and rx can be moved into separate tasks spawner.spawn(reader_task(rx)).unwrap(); spawner.spawn(writer_task(tx)).unwrap(); }
Practical: Reading GPS NMEA Sentences
GPS modules are the classic UART peripheral. They continuously spit out NMEA sentences — ASCII text lines that start with $ and end with \r\n. Here is what they look like:
$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,47.0,M,,*47
$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A
#![allow(unused)] fn main() { #[embassy_executor::task] async fn gps_task(mut rx: UartRx<'static, embassy_stm32::mode::Async>) { let mut buf = [0u8; 256]; loop { match rx.read_until_idle(&mut buf).await { Ok(n) => { let data = &buf[..n]; // Check if this is a GGA sentence (contains position fix) if data.starts_with(b"$GPGGA") || data.starts_with(b"$GNGGA") { defmt::info!("Position fix: {=[u8]:a}", data); } } Err(_) => { defmt::warn!("GPS read error, continuing..."); } } } } }
💡 Fun Fact: NMEA stands for National Marine Electronics Association. The protocol was originally designed for ships, which is why even your tiny drone GPS module speaks in sentences that start with
$GP(GPS),$GL(GLONASS), or$GN(multi-constellation).
Sending Telemetry
UART is also great for sending data out. Here is a pattern for periodic telemetry:
#![allow(unused)] fn main() { use core::fmt::Write; use heapless::String; #[embassy_executor::task] async fn telemetry_task(mut tx: UartTx<'static, embassy_stm32::mode::Async>) { let mut ticker = Ticker::every(Duration::from_hz(10)); // 10 Hz loop { ticker.next().await; let voltage = read_battery_voltage().await; let temp = read_temperature().await; let mut msg: String<128> = String::new(); write!(msg, "V:{:.2},T:{:.1}\r\n", voltage, temp).unwrap(); tx.write(msg.as_bytes()).await.unwrap(); } } }
UART vs USART
You will notice the STM32 peripheral is called USART, not UART. The "S" stands for Synchronous. USART can operate in two modes:
| Feature | UART Mode | USART Synchronous Mode |
|---|---|---|
| Clock wire | No | Yes (CK pin) |
| Baud agreement | Both sides configure | Master provides clock |
| Use case | General purpose | Rare — mostly SPI-like comms |
In practice, you will almost always use USART in asynchronous (UART) mode. The synchronous mode exists but is rarely needed — if you want a clocked protocol, SPI (next chapter) is a better choice.
Summary
UART is the "hello world" of embedded communication. Two wires, no clock, simple framing. It is the protocol you will use for debugging, for GPS, for talking to Bluetooth modules, and for a hundred other things. Its simplicity is its superpower — when something is not working, there are only three wires to check.
In the next chapter, we will look at SPI — a faster, clocked protocol that trades simplicity for speed.