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:

WirePurpose
TXTransmit — data goes out
RXReceive — data comes in
GNDGround — 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 RateUse CaseBits/secApprox. Bytes/sec
9600GPS modules, slow sensors9,600~960
115200Debug output, general use115,200~11,520
921600High-speed telemetry, bulk data921,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: U produces 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:

FeatureUART ModeUSART Synchronous Mode
Clock wireNoYes (CK pin)
Baud agreementBoth sides configureMaster provides clock
Use caseGeneral purposeRare — 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.