An I2C to UART bridge (like the NXP SC16IS750 or SC16IS752) converts a synchronous, multi-drop I2C bus into an asynchronous, point-to-point UART serial port. You need this hardware when your microcontroller—such as an ESP32 or ATmega2560—runs out of native hardware UARTs for GPS modules, cellular modems, or DMX512 lighting, but still has a free I2C bus. Rather than relying on unstable software serial emulation (which drops bytes at high baud rates), an I2C-to-UART IC provides a dedicated hardware FIFO buffer, handling the timing-critical bit-banging on the silicon level while your MCU simply reads and writes bytes over I2C.

I2C vs UART Bus Mechanics and Physical Layer

Before wiring a bridge, you must understand the physical layer differences between the two protocols. I2C is an open-drain, synchronous bus that requires external pull-up resistors to function. UART is a push-pull, asynchronous protocol that drives lines actively high and low. Bridging them requires an IC that translates the open-drain I2C clock/data signals into the rigid timing windows of UART start, data, parity, and stop bits.

Table 1: I2C vs UART Bus Mechanics
Feature I2C (Inter-Integrated Circuit) UART (Universal Asynchronous Receiver-Transmitter)
Wires 2 (SDA, SCL) + Ground 2 (TX, RX) + Ground
Topology Multi-drop bus (up to 127 devices) Point-to-point (1 TX to 1 RX)
Clocking Synchronous (shared SCL line) Asynchronous (local baud rate generators)
Max Speed 100 kHz (Standard), 400 kHz (Fast), 1 MHz (Fast+) Typically 115,200 bps; up to 5 Mbps on modern ICs
Addressing 7-bit or 10-bit hardware addressing None (hardware wire routing only)
Max Distance ~1 meter (limited by bus capacitance) ~15 meters at 9600 bps (RS-232/TTL limits)
Physical Drive Open-drain (requires pull-up resistors) Push-pull (active high/low drivers)
Callout Tip: The Pull-Up Requirement
Because I2C is open-drain, the SDA and SCL lines will float if not pulled high. For a 3.3V ESP32 running at 400 kHz, use 4.7 kΩ pull-up resistors to 3.3V. If your bus capacitance exceeds 200 pF (common when adding long wires or multiple breakout boards), drop to 2.2 kΩ to sharpen the rise times. Never connect 5V pull-ups to a 3.3V MCU I2C bus; you will fry the GPIO pins.

Sizing the Bridge: Distance, Speed, and Device Count

Choosing between native UART, an I2C-to-UART bridge, or an RS-485 transceiver depends entirely on your physical constraints. Use native UART when you have the pins available and need maximum throughput with zero I2C overhead. Use an I2C-to-UART bridge when you are pin-constrained on the MCU but the target serial device is within a few centimeters (intra-board). If you need to run serial data across a room or between buildings, abandon TTL UART entirely and use an RS-485 transceiver (like the MAX485) driven by your bridge or native UART.

The NXP SC16IS750 is the industry-standard single-channel bridge. It features a 64-byte TX and 64-byte RX FIFO, meaning your ESP32 can be busy handling WiFi interrupts for milliseconds without dropping incoming serial bytes.

Table 2: NXP SC16IS750 Spec Sheet Highlights
Parameter Specification Practical Implication
I2C Speed Up to 400 kHz (Fast-mode) Max I2C bus throughput is ~400 kbps; leaves headroom for 115.2k UART.
UART Baud Rate Up to 5 Mbps (with 14.7456 MHz crystal) Easily handles standard 9600, 57600, and 115200 baud rates.
FIFO Depth 64 bytes (TX and RX) Prevents data loss during MCU context switches or WiFi stack delays.
I2C Address Configurable via A0 and A1 pins Base address 0x48; allows up to 4 SC16IS750s on one I2C bus (16 total UARTs).
Operating Voltage 2.5V to 3.6V (VCC) Native 3.3V logic; perfect for ESP32, ESP8266, and Raspberry Pi.

Classic Failures: Pull-ups, Address Clashes, and Baud Drift

When an I2C-to-UART bridge fails to communicate, the issue is almost always at the physical layer or in the register initialization sequence. Here is how to diagnose the three most common bench failures.

1. Missing or Undersized Pull-ups (The NACK Loop)

If your I2C scanner returns no devices, or you get random NACKs (Not Acknowledged) under load, check your pull-ups. Open-drain lines rely on the resistor to pull the voltage high. If the resistor is too large (e.g., 10 kΩ on a heavily loaded bus), the rise time exceeds the I2C specification, and the SC16IS750 misinterprets the clock edges. Fix: Verify 4.7 kΩ resistors are physically present on the SDA and SCL lines. Measure the idle bus voltage with a multimeter; it must read exactly VCC (3.3V).

2. Address Clashes and Pin Strapping

The SC16IS750 uses two hardware pins (A0 and A1) to set the lower two bits of its 7-bit I2C address. If you leave these pins floating, the internal logic will drift, causing the chip to respond to ghost addresses or ignore the MCU entirely. Fix: Explicitly tie A0 and A1 to GND for the base address (0x48), or to VCC to shift the address. Never leave CMOS configuration pins floating.

3. Baud Rate Drift and FIFO Overruns

UART relies on both sides agreeing on the exact timing of a bit. The SC16IS750 generates this timing using an external crystal oscillator. If you use a cheap breakout board with a 5% tolerance ceramic resonator instead of a quartz crystal, your 115,200 baud might actually be 118,000 baud, causing framing errors on the receiving device. Furthermore, if your MCU doesn't read the bridge's Receive Holding Register (RHR) fast enough, the 64-byte FIFO overflows. Fix: Sniff the UART TX line with a logic analyzer to verify the actual bit width. In code, monitor the Line Status Register (LSR, address 0x05) for the Overrun Error (OE) bit.

Wiring and Minimal Working Exchange (ESP32 to SC16IS750)

Below is a complete, dependency-free implementation using the Arduino Wire library. This bypasses third-party libraries that often break across ESP32 core updates, teaching you exactly how to manipulate the bridge's internal registers.

Table 3: ESP32 to SC16IS750 Pin Mapping
SC16IS750 Pin ESP32 DevKit Pin Notes
VCC 3V3 Do not use 5V.
GND GND Common ground is mandatory.
SDA GPIO 21 Requires 4.7kΩ pull-up to 3V3.
SCL GPIO 22 Requires 4.7kΩ pull-up to 3V3.
A0 / A1 GND Sets I2C address to 0x48.
TX (UART out) Target Device RX Direct connection to peripheral.
RX (UART in) Target Device TX Direct connection from peripheral.

Minimal Working Code: Register Configuration and TX/RX

To set the baud rate on the SC16IS750, you must set the Divisor Latch Access Bit (DLAB) in the Line Control Register (LCR). This remaps registers 0x00 and 0x01 from the FIFO buffers to the baud rate divisor latches. Assuming a standard 1.8432 MHz crystal on the breakout board, a divisor of 12 yields exactly 9600 baud.

#include <Wire.h>

#define BRIDGE_ADDR 0x48

// Register Addresses
#define REG_RHR 0x00 // Receive Holding Register (Read)
#define REG_THR 0x00 // Transmit Holding Register (Write)
#define REG_LCR 0x03 // Line Control Register
#define REG_LSR 0x05 // Line Status Register

void writeRegister(uint8_t reg, uint8_t val) {
  Wire.beginTransmission(BRIDGE_ADDR);
  Wire.write(reg);
  Wire.write(val);
  Wire.endTransmission();
}

uint8_t readRegister(uint8_t reg) {
  Wire.beginTransmission(BRIDGE_ADDR);
  Wire.write(reg);
  Wire.endTransmission(false);
  Wire.requestFrom(BRIDGE_ADDR, 1);
  return Wire.read();
}

void setup() {
  Serial.begin(115200);
  Wire.begin(21, 22); // ESP32 default I2C pins
  Wire.setClock(400000); // 400kHz Fast-mode

  // 1. Reset the chip (Optional but recommended)
  writeRegister(0x0E, 0x08); // Software reset via IOControl register
  delay(10);

  // 2. Enable FIFO and clear RX/TX buffers
  writeRegister(0x02, 0x07); // FCR: Enable FIFO, clear RX and TX

  // 3. Set Baud Rate to 9600 (Assuming 1.8432 MHz Crystal)
  writeRegister(REG_LCR, 0x80); // Set DLAB = 1 to access divisor latches
  writeRegister(0x00, 0x0C);    // DLL = 12 (1.8432M / (16 * 9600))
  writeRegister(0x01, 0x00);    // DLM = 0
  writeRegister(REG_LCR, 0x03); // Clear DLAB, set 8 data bits, no parity, 1 stop bit

  Serial.println("SC16IS750 initialized at 9600 baud.");
}

void loop() {
  // Transmit a byte to the UART peripheral
  writeRegister(REG_THR, 'H');
  writeRegister(REG_THR, 'i');
  writeRegister(REG_THR, '\n');
  
  // Check if data is available in the RX FIFO
  uint8_t lsr = readRegister(REG_LSR);
  if (lsr & 0x01) { // Data Ready bit
    uint8_t rxByte = readRegister(REG_RHR);
    Serial.print("Received from UART: ");
    Serial.println((char)rxByte);
  }
  
  delay(1000);
}
Debugging Warning: I2C Bus Lockups
If your ESP32 randomly freezes on Wire.endTransmission(), the I2C bus has locked up—usually because the SC16IS750 was reset mid-transaction and is holding the SDA line low. Always implement a watchdog timer in production firmware, and ensure your breakout board has a dedicated hardware reset pin (RST) tied to a GPIO so you can hard-reset the bridge without power-cycling the entire system.

By offloading serial timing to the SC16IS750, you free your ESP32's main CPU cores to handle TLS encryption, WebSocket parsing, or sensor fusion without the constant interrupt overhead of software serial. Verify your physical pull-ups, strap your address pins, and let the silicon handle the bit-banging.