Universal Asynchronous Receiver-Transmitter (UART) serial communication is the foundational bedrock of embedded debugging and point-to-point microcontroller data exchange. Unlike synchronous protocols, UART relies on two dedicated wires (TX and RX) and a pre-agreed timing rate (baud) rather than a shared clock line. If you need to connect an ESP32 to a GPS module, link an Arduino to a Raspberry Pi, or read raw NMEA sentences from a sensor, UART is your default tool.
The direct answer to 'how does it work' is simple: the transmitter pulls its TX line low to signal a start bit, shifts out 8 data bits at the agreed baud rate, and pulls the line high for a stop bit. The receiver samples the line mid-bit to reconstruct the byte. But while the theory is trivial, the physical layer is where hobbyists fry boards and lose hours to garbage characters. Let's look at the exact electrical requirements.
The Physical Layer: Wiring UART and Voltage Limits
UART is a physical interface, not just a software concept. The most common mistake in embedded UART is ignoring logic level differences. An Arduino Uno or Nano operates at 5V logic, while an ESP32, Raspberry Pi Pico, or STM32 operates at 3.3V logic. Feeding a 5V TX line directly into a 3.3V ESP32 RX pin will eventually degrade or destroy the ESP32's GPIO silicon due to overvoltage on the internal protection diodes.
Unlike I2C, standard UART lines are driven by push-pull output stages and idle in a HIGH state. You do not need external pull-up resistors for a standard point-to-point connection on a breadboard. However, if you are running UART over long cables (over 1 meter), the parasitic capacitance of the wire will round off the sharp edges of your digital squares, causing bit errors at high baud rates.
| Parameter | 3.3V Logic (ESP32/Pico) | 5V Logic (Arduino Nano) | Notes / Edge Cases |
|---|---|---|---|
| Logic High (Idle) | 2.4V to 3.3V | 3.5V to 5.0V | Line must be high when no data is sent. |
| Logic Low (Start Bit) | 0V to 0.8V | 0V to 1.5V | Transmitter actively pulls line to GND. |
| Max Reliable Baud (Breadboard) | 115,200 bps | 115,200 bps | Higher rates suffer from stray capacitance. |
| Max Cable Length @ 9600 | ~15 meters (unshielded) | ~15 meters (unshielded) | Use RS-485 transceivers for >20m runs. |
| Max Cable Length @ 115200 | ~1 meter | ~1 meter | Signal degradation causes framing errors. |
Bus Mechanics: Where UART Fits in the Embedded Stack
When designing a sensor network, you must choose between UART, I2C, and SPI. Each protocol makes distinct trade-offs regarding wire count, speed, addressing, and topology. UART is strictly a point-to-point (or multi-drop with hardware transceivers) protocol. It has no native addressing scheme, meaning you cannot daisy-chain multiple standard UART devices on the same two wires without a hardware multiplexer.
| Protocol | Wires Required | Typical Speed | Addressing | Topology & Distance |
|---|---|---|---|---|
| UART | 2 (TX, RX) + GND | 9600 to 115,200 bps | None (Point-to-Point) | 1-to-1. Up to 15m at low baud. |
| I2C | 2 (SDA, SCL) + GND | 100 kHz to 3.4 MHz | 7-bit or 10-bit I2C address | Multi-master bus. <1 meter on PCB. |
| SPI | 4 (MOSI, MISO, SCK, CS) | 1 MHz to 50+ MHz | Hardware Chip Select (CS) | 1 Master, many Slaves. <30cm. |
Which protocol fits your project?
Choose UART when you are communicating with off-board modules (GPS, cellular modems, Bluetooth HC-05), debugging via a serial console, or bridging two different microcontroller families. Choose I2C when you need to connect multiple low-speed sensors (BME280, MPU6050) on the same PCB without using a dozen GPIO pins. Choose SPI when you need raw throughput, such as driving an TFT LCD display or reading high-speed ADCs.
The Classic Failures: Baud Mismatches, Crossed Lines, and Sniffing
Because UART lacks a clock line and native error correction, the physical and timing layers are unforgiving. Let's address the three classic protocol failures often confused across I2C and UART:
- Baud Mismatch (The UART Killer): If the transmitter sends at 115,200 bps and the receiver listens at 9600 bps, the receiver will sample the start bit, immediately misinterpret the fast data bits as a solid LOW signal, and throw a framing error. You will see garbage characters like
ÿ,?, or\x00in your serial monitor. Always verify both devices are initialized to the exact same baud rate. - Address Clash vs. Crossed Lines: I2C fails due to address clashes (two sensors sharing address 0x76). UART has no addresses. The UART equivalent of an address clash is crossed TX/RX lines. TX must always connect to RX, and RX to TX. If you connect TX to TX, both devices drive the line high/low simultaneously, causing a short circuit through the GPIO pins and resulting in dead silence.
- Missing Pull-up vs. Floating Lines: I2C requires external pull-up resistors because it uses open-drain drivers; without them, the bus floats and fails. UART uses push-pull drivers, so missing pull-ups are rarely the issue. However, if a UART RX pin is left disconnected (floating) during boot, electromagnetic noise can trigger phantom start bits, causing the microcontroller to lock up or print gibberish. Tie unused RX pins to VCC via a 10kΩ resistor.
Minimal Working Exchange: ESP32 to Arduino Nano
Below is a complete, bench-tested setup for sending a telemetry string from an ESP32 to an Arduino Nano. We use HardwareSerial on the ESP32 (which has three hardware UARTs) and SoftwareSerial on the Nano to avoid conflicting with the Nano's USB-to-serial chip used for programming.
| ESP32 DevKit V1 | Level Shifter (LV/HV) | Arduino Nano |
|---|---|---|
| GPIO 17 (TX2) | LV1 -> HV1 | D10 (Software RX) |
| GPIO 16 (RX2) | LV2 <- HV2 | D11 (Software TX) |
| GND | Common GND | GND |
| 3V3 Pin | LV Power | 5V Pin -> HV Power |
ESP32 Code (Transmitter):
// ESP32 Transmitter - Hardware Serial 2
#include <HardwareSerial.h>
HardwareSerial MySerial(2); // Use UART2
void setup() {
// GPIO 16 is RX2, GPIO 17 is TX2 on standard ESP32 DevKit
MySerial.begin(9600, SERIAL_8N1, 16, 17);
Serial.begin(115200); // USB debug console
}
void loop() {
String payload = "FLUX:TEMP=24.5,HUM=45\n";
MySerial.print(payload);
Serial.println("Sent telemetry packet.");
delay(1000);
}
Arduino Nano Code (Receiver):
// Arduino Nano Receiver - Software Serial
#include <SoftwareSerial.h>
// RX on D10, TX on D11
SoftwareSerial MySerial(10, 11);
void setup() {
Serial.begin(115200); // USB debug console
MySerial.begin(9600); // Match ESP32 baud rate exactly
}
void loop() {
if (MySerial.available() > 0) {
String incoming = MySerial.readStringUntil('\n');
if (incoming.length() > 0) {
Serial.print("Received: ");
Serial.println(incoming);
}
}
}
For further reading on the electrical characteristics of asynchronous serial lines and RS-232 legacy standards, refer to the UART communication guide on All About Circuits. If you are scaling this up to industrial distances, look into MAX485 transceivers to convert your UART signals to differential RS-485, which rejects common-mode noise over runs exceeding 1,000 meters.






