If you need reliable ESP32 code for LCD 16x2 character displays, skip the legacy LiquidCrystal_I2C library. The modern standard is the LiquidCrystal_PCF8574 library by Mathertel, which properly handles the ESP32's hardware I2C peripheral without hanging the RTOS. This guide targets the ESP32 DevKit V1 (38-pin variant) driving a standard HD44780-based 1602 LCD equipped with a PCF8574 I2C backpack.

Writing code for an LCD on an ESP32 is only 20% of the battle; the other 80% is managing the 3.3V logic of the ESP32 against the 5V requirements of the LCD backpack. Below is the exact bill of materials, the safe wiring topology, and the complete, error-handled C++ code to get your display running.

I2C Backpack Silicon & Bill of Materials

Not all I2C backpacks are identical. The silicon on the back of your LCD dictates the I2C base address and the maximum bus speed. Cheap clone boards often swap the PCF8574 for a PCF8574A without updating the silkscreen, which is the leading cause of 'LCD not found' errors.

Table 1: I2C Backpack IC Comparison & Address Mapping
Backpack IC Base I2C Address Address Range (with jumpers) Max I2C Clock Typical 2026 Price
PCF8574 (NXP/TI) 0x20 0x20 - 0x27 100 kHz $1.20 (module)
PCF8574A (NXP/TI) 0x38 0x38 - 0x3F 100 kHz $1.20 (module)
MCP23008 (Microchip) 0x20 0x20 - 0x27 1.7 MHz $2.50 (module)
Adafruit I2C/SPI Backpack 0x20 (MCP23008) 0x20 - 0x27 1.7 MHz $6.95 (module)

Exact Bill of Materials (BOM)

  • Microcontroller: ESP32 DevKit V1 (38-pin) — ~$6.00
  • Display: 16x2 Character LCD (HD44780 controller) with PCF8574 backpack — ~$4.50
  • Level Shifter: BSS138 Bidirectional Logic Level Converter (4-channel) — ~$1.50 (Critical for 3.3V/5V interfacing)
  • Wiring: 22 AWG solid core jumper wires
  • Power: 5V 2A USB-C power supply (do not rely on PC USB ports for backlight current)

ESP32 Pin Mapping & The 3.3V/5V Trap

The most common hardware failure in ESP32 LCD projects is frying the GPIO pins. The ESP32 operates at 3.3V logic. The PCF8574 backpack requires 5V to drive the LCD backlight and achieve proper contrast. However, cheap backpacks include 4.7kΩ pull-up resistors tied to the 5V VCC line. If you connect the ESP32 directly to the backpack's SDA/SCL pins, you will back-feed 5V into the ESP32's 3.3V GPIOs, eventually degrading or destroying the silicon.

The Fix: Use a BSS138 logic level shifter, or physically remove the pull-up resistors on the backpack and add 4.7kΩ pull-ups tied to the ESP32's 3.3V pin.

Table 2: Safe Pin Mapping (ESP32 to Level Shifter to LCD)
ESP32 DevKit V1 (38-pin) BSS138 Level Shifter (Low Side) BSS138 Level Shifter (High Side) PCF8574 Backpack (5V)
GPIO 21 (Default SDA) LV1 HV1 SDA
GPIO 22 (Default SCL) LV2 HV2 SCL
3V3 Pin LV (VCC) - -
5V (VIN) Pin - HV (VCC) VCC
GND GND (Low) GND (High) GND
Callout Tip: If you are using an official Adafruit I2C/SPI Character LCD Backpack, it features built-in level shifting and a dedicated 5V boost converter for the backlight. You can wire it directly to the ESP32's 3.3V SDA/SCL pins without an external BSS138 module.

Complete Compilable ESP32 Code for LCD

This code targets the ESP32 Arduino Core (v2.0.x or v3.0.x). It uses the LiquidCrystal_PCF8574 library by Mathertel. Unlike older libraries, this one includes proper I2C bus recovery and doesn't rely on hardcoded timing delays that conflict with the ESP32's FreeRTOS tick rates.

Prerequisite: Install the LiquidCrystal_PCF8574 library via the Arduino IDE Library Manager.

#include <Wire.h>
#include <LiquidCrystal_PCF8574.h>

// --- PIN DEFINITIONS ---
#define I2C_SDA 21
#define I2C_SCL 22

// --- LCD CONFIGURATION ---
// 0x27 is standard for PCF8574. Use 0x3F if your board has a PCF8574A chip.
#define LCD_I2C_ADDR 0x27 
#define LCD_COLS 16
#define LCD_ROWS 2

// Initialize the library with the I2C address
LiquidCrystal_PCF8574 lcd(LCD_I2C_ADDR);

// Custom character array (5x8 pixels) - Thermometer icon
byte thermometer[8] = {
  B00100,
  B01010,
  B01010,
  B01010,
  B01110,
  B11111,
  B11111,
  B01110
};

void setup() {
  Serial.begin(115200);
  delay(500); // Allow serial monitor to connect
  Serial.println("ESP32 LCD Boot Sequence...");

  // 1. Initialize I2C with explicit pins and 100kHz clock
  Wire.begin(I2C_SDA, I2C_SCL, 100000);

  // 2. Error Handling: Verify I2C Device Presence
  Wire.beginTransmission(LCD_I2C_ADDR);
  byte i2c_error = Wire.endTransmission();
  
  if (i2c_error != 0) {
    Serial.print("FATAL: I2C device not found at 0x");
    Serial.println(LCD_I2C_ADDR, HEX);
    Serial.println("Check wiring, pull-ups, or try address 0x3F.");
    // Blink onboard LED to indicate hardware fault
    pinMode(2, OUTPUT);
    while(1) { digitalWrite(2, !digitalRead(2)); delay(100); }
  }

  // 3. Initialize LCD
  lcd.begin(LCD_COLS, LCD_ROWS);
  lcd.setBacklight(255); // Turn on backlight (0-255)
  
  // 4. Load custom character into CGRAM slot 0
  lcd.createChar(0, thermometer);

  // 5. Print initial UI
  lcd.clear();
  lcd.setCursor(0, 0);
  lcd.write(0); // Print custom thermometer char
  lcd.print(" System Ready");
  lcd.setCursor(0, 1);
  lcd.print("Temp: ");
}

void loop() {
  // Simulate sensor reading
  float tempC = analogRead(34) * 0.15; // Mock data from GPIO 34
  
  lcd.setCursor(6, 1);
  lcd.print(tempC, 1);
  lcd.print("C   "); // Trailing spaces to clear old digits
  
  delay(500); // Update twice per second
}

Debugging I2C Timeouts & Blank Screens

When the ESP32 fails to communicate with the LCD, the Arduino core will throw a specific hardware-level error in the Serial Monitor. The most common exact error string is:

E (1452) I2C: i2c_master_cmd_begin(1112): RCV_FIFO timeout

This RCV_FIFO timeout means the ESP32's I2C peripheral sent the address byte, but the SDA line never transitioned low for the ACKnowledge (ACK) bit. The bus is either physically disconnected, held high by a 5V pull-up conflict, or addressing the wrong chip.

The First Three Things to Check

  1. Verify the I2C Address (0x27 vs 0x3F): Run a basic I2C scanner sketch. If your backpack uses the PCF8574A silicon, the address is almost always 0x3F. Update the #define LCD_I2C_ADDR in the code above to match the scanner output.
  2. Adjust the Contrast Potentiometer (V0): A blank screen with the backlight on usually means the LCD is receiving data, but the liquid crystals aren't biasing correctly. Use a small Phillips screwdriver to turn the blue trimpot on the backpack. Turn it until you see dark blocks appear, then back it off slightly until the text is crisp.
  3. Multimeter the Logic Levels: Set your multimeter to DC Voltage. Measure the SDA and SCL lines at the ESP32 pins. They should idle at ~3.3V. If you measure 4.8V - 5.0V, your level shifter is bypassed or missing, and the backpack's 5V pull-ups are back-feeding the ESP32. Disconnect immediately to prevent GPIO damage.

Extending and Simplifying the Build

Depending on your project constraints, you may want to scale this hardware up for a commercial prototype or down for a quick weekend build.

How to Simplify (The Quick Build)

If you want to eliminate the breadboard and BSS138 level shifter entirely, purchase a Grove I2C LCD or an Adafruit Character LCD with STEMMA QT. These modules feature integrated 3.3V voltage regulators, built-in logic level shifting, and Qwiic/STEMMA connectors. You simply plug a 4-pin JST cable directly into an ESP32 breakout board. The code remains identical, though the I2C address will change to match the manufacturer's default (usually 0x3E or 0x3F).

How to Extend (Advanced Features)

  • Custom Bitmaps: The HD44780 controller allows up to 8 custom 5x8 pixel characters stored in CGRAM. Use the LCD Character Creator tool to generate the byte arrays for battery icons, WiFi symbols, or arrows, and load them using lcd.createChar() as shown in the code block.
  • Multiple Displays: The I2C bus supports multiple devices. If you need a 40x4 LCD (which actually uses two HD44780 controllers internally) or want to daisy-chain a 16x2 and a 20x4 display, ensure they have different I2C addresses. You can change the address on a PCF8574 backpack by soldering the A0, A1, and A2 jumper pads on the PCB.
  • RTOS Task Isolation: For complex ESP32 projects, move the lcd.print() calls into a dedicated FreeRTOS task pinned to Core 0. I2C transactions take milliseconds; running them in the main loop() alongside WiFi stacks can cause micro-stutters in your sensor sampling rates.

By respecting the 3.3V logic boundaries of the ESP32 and using a modern, RTOS-aware library, your LCD integration will survive long-term deployment without bus lockups or silicon degradation.