UART communication protocols define how asynchronous serial data is framed, but the protocol itself does not dictate the physical voltage on the wire. The direct answer for your next build: for point-to-point bench wiring under 1 meter, use raw 3.3V/5V TTL UART; for runs over 1 meter or electrically noisy environments, wrap the UART frames in an RS-485 differential physical layer using an SP3485 transceiver.
Many hobbyists confuse the logical UART frame (start bit, 8 data bits, parity, stop bit) with the physical layer (TTL, RS-232, RS-485). Understanding this distinction is the difference between a reliable sensor network and a bus that drops packets every time a relay clicks.
The Physical Layer: TTL, RS-232, and RS-485
Before wiring your microcontrollers, you must match the physical voltage levels. Sending 5V TTL into an RS-232 port will not work, and sending +/- 12V RS-232 into a 3.3V ESP32 will instantly destroy the GPIO pin.
- Raw TTL (0V to 3.3V/5V): The default on almost all modern microcontrollers (Arduino, ESP32, STM32). Logic 0 is 0V, Logic 1 is VCC. Maximum reliable distance is roughly 1 meter (3 feet) due to capacitive coupling and EMI susceptibility.
- RS-232 (+/- 12V): Legacy PC serial ports. Logic 0 is +3V to +15V, Logic 1 is -3V to -15V. Requires a charge pump IC like the MAX232. Rarely used in new embedded designs except for legacy industrial equipment.
- RS-485 (Differential): Uses two wires (A and B) to transmit the difference in voltage. Highly immune to common-mode noise. Supports distances up to 1,200 meters (4,000 feet) and allows multi-drop bus topologies (up to 32 or 256 nodes, depending on the transceiver). Use a 3.3V transceiver like the SP3485 or MAX3485 for modern 3.3V MCUs.
For a deep dive into the electrical characteristics of differential signaling, the SparkFun Serial Communication Guide provides an excellent baseline on how logic levels translate across these standards.
Bus Mechanics and Wiring Rules
UART is inherently simple, but it lacks the built-in bus management features of I2C or SPI. Here are the hard mechanical limits of the protocol.
| Parameter | Raw TTL UART | RS-485 (via UART) |
|---|---|---|
| Wires Required | 3 (TX, RX, GND) | 4 (A, B, VCC, GND) or 3 if isolated |
| Max Speed | 1 Mbps (short runs) | 10 Mbps (short), 115 kbps (long) |
| Addressing | None (Point-to-Point only) | None (Requires software polling like Modbus) |
| Max Distance | ~1 meter (3 ft) | ~1,200 meters (4,000 ft) |
| Topology | Point-to-Point | Multi-drop (Daisy chain / Bus) |
The Pull-Up Rule and Common Ground
A frequent bench mistake is leaving the RX line floating. The TX line is push-pull (driven high and low by the sending MCU) and needs no pull-up. However, the RX line is a high-impedance input. If the transmitting device is powered off or booting, the RX line floats. Electromagnetic interference will induce phantom start bits, filling your MCU's hardware UART buffer with 0x00 or 0xFF garbage. Always place a 10kΩ pull-up resistor from the RX pin to VCC to hold it in the idle (HIGH) state.
Furthermore, a common ground is mandatory for TTL and RS-232. Without a shared GND reference, the voltage difference between the two MCU grounds will exceed the logic threshold, resulting in dead communication or, worse, ground loop currents that fry the GPIO pins.
Minimal Working Exchange: ESP32 to Arduino Nano
This example demonstrates a robust point-to-point TTL UART link. We use the ESP32's hardware UART2 and the Arduino Nano's SoftwareSerial to avoid conflicting with the Nano's USB programming port.
Wiring Table
| ESP32 DevKit Pin | Arduino Nano Pin | Notes |
|---|---|---|
| GPIO 17 (TX2) | D10 (Software RX) | TX always connects to RX |
| GPIO 16 (RX2) | D11 (Software TX) | Include 10k pull-up to 5V on D10 |
| GND | GND | Mandatory common ground |
ESP32 Transmitter Code (Hardware Serial2)
// ESP32 Transmitter
#include <HardwareSerial.h>
// Use UART2 on ESP32 (GPIO 16 = RX, GPIO 17 = TX)
HardwareSerial MySerial(2);
void setup() {
// Initialize at 9600 baud.
// Serial config: SERIAL_8N1 (8 data bits, no parity, 1 stop bit)
MySerial.begin(9600, SERIAL_8N1, 16, 17);
}
void loop() {
MySerial.println("PING: Sensor data ready");
delay(1000);
}
Arduino Nano Receiver Code (SoftwareSerial)
// Arduino Nano Receiver
#include <SoftwareSerial.h>
// SoftwareSerial on Nano: RX = D10, TX = D11
SoftwareSerial MySerial(10, 11);
void setup() {
Serial.begin(115200); // USB debug monitor
MySerial.begin(9600); // Match ESP32 baud rate
}
void loop() {
if (MySerial.available()) {
String incoming = MySerial.readStringUntil('\n');
Serial.print("Received: ");
Serial.println(incoming);
}
}
For more details on timing limitations of bit-banged serial on AVR chips, refer to the official Arduino SoftwareSerial Documentation.
Debugging the Classic UART Failures
When the bus fails, it almost always comes down to one of three physical or timing issues. Here is how to diagnose them.
1. The Baud Rate Mismatch (AVR Clock Error)
Symptom: You receive garbled characters like ÿ, ??, or random Wingdings instead of your text.
The Fix: Baud rates are derived from the MCU's system clock. On a 16MHz Arduino Uno/Nano, requesting 115,200 baud results in an actual baud rate of 111,111 due to integer division in the hardware prescaler. This is a -3.5% error. Over short wires, the receiver tolerates this. Over long wires or through optoisolators, the timing drift causes bit-sampling errors.
Action: Drop the baud rate to 76,800 or 57,600 for long-distance AVR runs. The math for 76,800 on a 16MHz crystal yields a near-perfect 0.0% error.
2. The Multi-Drop Address Clash
Symptom: You wired three Arduinos to one ESP32 TX line, and data is corrupted or the bus locks up.
The Fix: UART has no hardware addressing and no collision detection. If two devices transmit on the same wire simultaneously, the voltages collide (a 5V logic 1 fighting a 0V logic 0), causing a short circuit through the GPIO pins and corrupting the data. If you need multiple devices, you must use RS-485 transceivers with a Master-Slave software polling protocol (like Modbus RTU), ensuring only one slave enables its transmit driver at a time.
3. Sniffing the Bus with a Logic Analyzer
When Serial.print() debugging fails, you need to see the raw bits. Do not guess; measure.
- Connect a generic 24MHz 8-channel USB logic analyzer (approx. $12 on Amazon) to the TX and RX lines.
- Open PulseView (the open-source sigrok GUI) or Saleae Logic 2.
- Set the sample rate to at least 10x your baud rate (e.g., 1 MHz sampling for 115,200 baud).
- Add the "UART" protocol decoder, set the baud rate, and specify the bit order (LSB first).
- Trigger on the falling edge of the start bit. You will instantly see if the idle state is wrong, if the baud rate is drifting, or if noise is inducing phantom start bits.
Decision Tree: Which UART Variant to Pick
Stop debating the protocol and follow this decision path to select the exact physical layer for your project.
| Condition | Required Physical Layer | Concrete Part / Action |
|---|---|---|
| Distance < 1m, Point-to-Point, same ground | Raw 3.3V/5V TTL | Direct GPIO wiring. Add 10kΩ pull-up on RX. |
| Distance < 1m, but different ground potentials | Isolated TTL | Use an ADuM1201 digital isolator. |
| Distance > 1m, or high EMI (motors/relays nearby) | RS-485 Differential | Use SP3485 (3.3V) or MAX485 (5V) transceivers. |
| Connecting to a legacy PC serial port | RS-232 | Use a MAX3232 charge pump IC. |
| Multiple nodes (>2) on one bus | RS-485 + Software Polling | Use RS-485 transceivers + implement Modbus RTU library. |






